mirror of
https://gitea.com/gitea/tea.git
synced 2026-09-10 07:26:33 -04:00
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:
parent
ee531914cd
commit
bc164f28f6
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
2
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
121
modules/task/project.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
68
modules/task/project_test.go
Normal file
68
modules/task/project_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue