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 <xiaolunwen@gmail.com>
This commit is contained in:
Lunny Xiao 2026-08-21 10:22:53 -07:00
parent ee531914cd
commit bc164f28f6
9 changed files with 221 additions and 11 deletions

View file

@ -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{

View file

@ -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
}

View file

@ -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

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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 {

View file

@ -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
}

121
modules/task/project.go Normal file
View file

@ -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(),
}
}

View file

@ -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)
}