From bc164f28f690e9d9a4f9a7613c3342484d89018b Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 21 Aug 2026 10:22:53 -0700 Subject: [PATCH] feat(issues): add --project flag to assign new issues to a board Add a --project/-p flag to `tea issues create` so newly created issues can be placed on a project board. The flag accepts either a project ID or title and adds the issue to the project default column. This uses the project APIs recently added to the Gitea SDK. Signed-off-by: Lunny Xiao --- cmd/flags/issue_pr.go | 7 ++ cmd/issues/create.go | 15 +++- docs/CLI.md | 2 + go.mod | 2 +- go.sum | 4 +- modules/interact/issue_create.go | 3 +- modules/task/issue_create.go | 10 +-- modules/task/project.go | 121 +++++++++++++++++++++++++++++++ modules/task/project_test.go | 68 +++++++++++++++++ 9 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 modules/task/project.go create mode 100644 modules/task/project_test.go diff --git a/cmd/flags/issue_pr.go b/cmd/flags/issue_pr.go index 65d96da2..1d48ba8c 100644 --- a/cmd/flags/issue_pr.go +++ b/cmd/flags/issue_pr.go @@ -131,6 +131,13 @@ var IssuePRCreateFlags = append([]cli.Flag{ }, }, issuePRFlags...) +// ProjectFlag provides a flag to specify the project board for a new issue. +var ProjectFlag = &cli.StringFlag{ + Name: "project", + Aliases: []string{"p"}, + Usage: "Project board to add the issue to (name or ID)", +} + // GetIssuePRCreateFlags parses all IssuePREditFlags func GetIssuePRCreateFlags(requestCtx stdctx.Context, ctx *context.TeaContext) (*gitea.CreateIssueOption, error) { opts := gitea.CreateIssueOption{ diff --git a/cmd/issues/create.go b/cmd/issues/create.go index 8b0d7d53..e866abb9 100644 --- a/cmd/issues/create.go +++ b/cmd/issues/create.go @@ -22,7 +22,7 @@ var CmdIssuesCreate = cli.Command{ Description: `Create an issue on repository`, ArgsUsage: " ", // command does not accept arguments Action: runIssuesCreate, - Flags: flags.IssuePRCreateFlags, + Flags: append([]cli.Flag{flags.ProjectFlag}, flags.IssuePRCreateFlags...), } func runIssuesCreate(requestCtx stdctx.Context, cmd *cli.Command) error { @@ -47,9 +47,20 @@ func runIssuesCreate(requestCtx stdctx.Context, cmd *cli.Command) error { return err } - return task.CreateIssue(requestCtx, ctx.Login, + issue, err := task.CreateIssue(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, *opts, ) + if err != nil { + return err + } + + if project := ctx.String("project"); project != "" { + if err := task.AddIssueToProject(requestCtx, ctx.Login.Client(), ctx.Owner, ctx.Repo, issue.ID, project); err != nil { + return err + } + } + + return nil } diff --git a/docs/CLI.md b/docs/CLI.md index 3f74a38e..5719afd8 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -227,6 +227,8 @@ Create an issue on repository **--milestone, -m**="": Milestone to assign +**--project, -p**="": Project board to add the issue to (name or ID) + **--referenced-version, -v**="": commit-hash or tag name to assign **--remote, -R**="": Discover Gitea login from remote. Optional diff --git a/go.mod b/go.mod index fba7910c..01d88a57 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( charm.land/lipgloss/v2 v2.0.5 code.gitea.io/gitea-vet v0.2.3 gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c - gitea.dev/sdk v1.2.0 + gitea.dev/sdk v1.2.1-0.20260820141827-7238a3ea11f7 github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.0 diff --git a/go.sum b/go.sum index 59d211db..e8ffc9ce 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI= -gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo= -gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE= +gitea.dev/sdk v1.2.1-0.20260820141827-7238a3ea11f7 h1:OyukyA7jhu49oxZsMydLXgsQSq7eo2jMt+SxnTA6r2g= +gitea.dev/sdk v1.2.1-0.20260820141827-7238a3ea11f7/go.mod h1:McaA1Vd+POnguz48vHfgD9jWmeInt10eH/bKaCIVGDA= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= diff --git a/modules/interact/issue_create.go b/modules/interact/issue_create.go index 3dee358e..05a3cd1f 100644 --- a/modules/interact/issue_create.go +++ b/modules/interact/issue_create.go @@ -34,7 +34,8 @@ func CreateIssue(ctx context.Context, login *config.Login, owner, repo string) e return err } - return task.CreateIssue(ctx, login, owner, repo, opts) + _, err = task.CreateIssue(ctx, login, owner, repo, opts) + return err } func promptIssueProperties(ctx context.Context, login *config.Login, owner, repo string, o *gitea.CreateIssueOption) error { diff --git a/modules/task/issue_create.go b/modules/task/issue_create.go index 35b9c245..48f37c3a 100644 --- a/modules/task/issue_create.go +++ b/modules/task/issue_create.go @@ -13,21 +13,21 @@ import ( "gitea.dev/tea/modules/print" ) -// CreateIssue creates an issue in the given repo and prints the result -func CreateIssue(requestCtx stdctx.Context, rlogin *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) error { +// CreateIssue creates an issue in the given repo and prints the result. +func CreateIssue(requestCtx stdctx.Context, rlogin *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) (*gitea.Issue, error) { // title is required if len(opts.Title) == 0 { - return fmt.Errorf("title is required") + return nil, fmt.Errorf("title is required") } issue, _, err := rlogin.Client().Issues.CreateIssue(requestCtx, repoOwner, repoName, opts) if err != nil { - return fmt.Errorf("could not create issue: %s", err) + return nil, fmt.Errorf("could not create issue: %s", err) } print.IssueDetails(issue, nil) fmt.Println(issue.HTMLURL) - return nil + return issue, nil } diff --git a/modules/task/project.go b/modules/task/project.go new file mode 100644 index 00000000..5f5d94dc --- /dev/null +++ b/modules/task/project.go @@ -0,0 +1,121 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package task + +import ( + stdctx "context" + "fmt" + "net/http" + "strconv" + "strings" + + gitea "gitea.dev/sdk" +) + +// AddIssueToProject adds an issue to the default column of a project. +// The project argument may be either a project ID or a project title. +func AddIssueToProject(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, issueID int64, project string) error { + scope, projectID, err := resolveProject(requestCtx, client, owner, repo, project) + if err != nil { + return err + } + + column, err := defaultProjectColumn(requestCtx, client, scope, projectID) + if err != nil { + return fmt.Errorf("could not find default column for project '%s': %w", project, err) + } + + if _, err := client.Projects.AddIssueToProjectColumn(requestCtx, scope, projectID, column.ID, issueID); err != nil { + return fmt.Errorf("could not add issue to project '%s': %w", project, err) + } + return nil +} + +func defaultProjectColumn(requestCtx stdctx.Context, client *gitea.Client, scope gitea.ProjectScope, projectID int64) (*gitea.ProjectColumn, error) { + var fallback *gitea.ProjectColumn + page := 1 + for { + columns, resp, err := client.Projects.ListProjectColumns(requestCtx, scope, projectID, gitea.ListOptions{ + Page: page, PageSize: 50, + }) + if err != nil { + return nil, err + } + for _, column := range columns { + if column.Default { + return column, nil + } + if fallback == nil || column.Sorting < fallback.Sorting { + fallback = column + } + } + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage + } + if fallback == nil { + return nil, fmt.Errorf("project has no columns") + } + return fallback, nil +} + +func resolveProject(requestCtx stdctx.Context, client *gitea.Client, owner, repo, project string) (gitea.ProjectScope, int64, error) { + project = strings.TrimSpace(project) + if id, err := strconv.ParseInt(project, 10, 64); err == nil { + for _, scope := range projectScopes(owner, repo) { + p, resp, err := client.Projects.GetProject(requestCtx, scope, id) + if err == nil { + return scope, p.ID, nil + } + if resp != nil && resp.StatusCode != http.StatusNotFound { + return gitea.ProjectScope{}, 0, fmt.Errorf("could not get project '%s': %w", project, err) + } + } + return gitea.ProjectScope{}, 0, fmt.Errorf("project with ID '%s' not found", project) + } + + for _, scope := range projectScopes(owner, repo) { + projectID, found, err := findProjectID(requestCtx, client, scope, project) + if err != nil { + return gitea.ProjectScope{}, 0, err + } + if found { + return scope, projectID, nil + } + } + return gitea.ProjectScope{}, 0, fmt.Errorf("project '%s' not found", project) +} + +func findProjectID(requestCtx stdctx.Context, client *gitea.Client, scope gitea.ProjectScope, title string) (int64, bool, error) { + page := 1 + for { + projects, resp, err := client.Projects.ListProjects(requestCtx, scope, gitea.ListProjectsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return 0, false, nil + } + return 0, false, fmt.Errorf("could not list projects: %w", err) + } + for _, project := range projects { + if strings.EqualFold(strings.TrimSpace(project.Title), title) { + return project.ID, true, nil + } + } + if resp == nil || resp.NextPage == 0 { + return 0, false, nil + } + page = resp.NextPage + } +} + +func projectScopes(owner, repo string) []gitea.ProjectScope { + return []gitea.ProjectScope{ + gitea.RepoProjectScope(owner, repo), + gitea.OrgProjectScope(owner), + gitea.CurrentUserProjectScope(), + } +} diff --git a/modules/task/project_test.go b/modules/task/project_test.go new file mode 100644 index 00000000..a90be97d --- /dev/null +++ b/modules/task/project_test.go @@ -0,0 +1,68 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gitea "gitea.dev/sdk" + "github.com/stretchr/testify/require" +) + +func TestAddIssueToProject_ByID(t *testing.T) { + var addIssuePath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/repo/projects/42": + _, _ = w.Write([]byte(`{"id":42,"title":"Roadmap"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/repo/projects/42/columns": + _, _ = w.Write([]byte(`[ + {"id":1,"title":"Backlog","default":false,"sorting":2}, + {"id":7,"title":"Done","default":true,"sorting":5} + ]`)) + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/repos/owner/repo/projects/42/columns/"): + addIssuePath = r.URL.Path + w.WriteHeader(http.StatusCreated) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client, err := gitea.NewClient(server.URL, gitea.SetGiteaVersion("")) + require.NoError(t, err) + + require.NoError(t, AddIssueToProject(context.Background(), client, "owner", "repo", 99, "42")) + require.Equal(t, "/api/v1/repos/owner/repo/projects/42/columns/7/issues/99", addIssuePath) +} + +func TestAddIssueToProject_ByName(t *testing.T) { + var projectListPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/repo/projects": + projectListPath = r.URL.Path + _, _ = w.Write([]byte(`[{"id":7,"title":"Roadmap"}]`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/repo/projects/7/columns": + _, _ = w.Write([]byte(`[{"id":3,"title":"Backlog","default":true,"sorting":0}]`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/owner/repo/projects/7/columns/3/issues/99": + w.WriteHeader(http.StatusCreated) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client, err := gitea.NewClient(server.URL, gitea.SetGiteaVersion("")) + require.NoError(t, err) + + require.NoError(t, AddIssueToProject(context.Background(), client, "owner", "repo", 99, "roadmap")) + require.Equal(t, "/api/v1/repos/owner/repo/projects", projectListPath) +}