feat(issue): gh-style issue list, view, create, edit, close and reopen

issue list renders through the shared table: a context line, ID TITLE
LABELS [STATE] [KIND] AUTHOR UPDATED with relative times on a terminal,
the ID colored by state and linked, and a stable tab-separated column
set when piped; empty results say so on stderr. It gains -s -L -l -a -A
-m -S -w, @me, comma-separated label and milestone filters, and --web.

issue view, edit, close and reopen accept #N, OWNER/REPO#N and issue
URLs. Mutations report "✓ Created/Edited/Closed issue alice/x#5 (title)"
on a terminal; issue create prints the new issue's URL on stdout (also
as issue new), and close and reopen skip issues already in that state
with a ! line and take -c/--comment. issue create and edit gain the gh
short flags and singular --add-label, --remove-label, --add-assignee and
--remove-assignee (the plural names still work), and create gains --web.

The interactive create form asks for the title and body, then "What's
next?" (submit, add metadata or cancel), without a confirmation after
it; the edit form names the issue it edits. Every issue command has help
text with examples and completes its flags and issue numbers.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:15:53 +02:00
1 parent b892423545
commit 0b6232087e
18 files changed
+789 -376

No files matched your search

+3 -10
View File
@@ -200,7 +200,7 @@ func TestRawJSONCommandsKeepJQToThemselves(t *testing.T) {
func TestUnknownFlagsSuggestSimilarFlags(t *testing.T) { func TestUnknownFlagsSuggestSimilarFlags(t *testing.T) {
requireUsageBeforeRequests(t, "unknown flag: --lable; did you mean --label?", "issue", "list", "--lable", "bug") requireUsageBeforeRequests(t, "unknown flag: --lable; did you mean --label?", "issue", "list", "--lable", "bug")
requireUsageBeforeRequests(t, "unknown flag: --stat; did you mean --state?", "issue", "list", "--stat", "open") requireUsageBeforeRequests(t, "unknown flag: --stat; did you mean --state?", "issue", "list", "--stat", "open")
requireUsageBeforeRequests(t, "unknown flag: --add-label; did you mean --add-labels?", "issue", "edit", "1", "--add-label", "bug") requireUsageBeforeRequests(t, "unknown flag: --add-lable; did you mean --add-label?", "issue", "edit", "1", "--add-lable", "bug")
deps, _, _ := testDeps(t) deps, _, _ := testDeps(t)
err := Execute(context.Background(), deps, []string{"issue", "list", "--zzzzzz"}) err := Execute(context.Background(), deps, []string{"issue", "list", "--zzzzzz"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err)) require.Equal(t, clierr.Usage, clierr.ExitCode(err))
@@ -251,7 +251,7 @@ func TestCommandHelpShowsRelevantFlagsOnly(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"\nUSAGE\n tea issue list [flags]\n", "\nUSAGE\n tea issue list [flags]\n",
"\nALIASES\n tea issue ls\n", "\nALIASES\n tea issue ls\n",
"\nFLAGS\n --assignee string ", "\nFLAGS\n -a, --assignee string ",
"\nINHERITED FLAGS\n -R, --repo OWNER/REPO ", "\nINHERITED FLAGS\n -R, --repo OWNER/REPO ",
"\n --json fields ", "\n --json fields ",
"\n -q, --jq expression ", "\n -q, --jq expression ",
@@ -323,7 +323,7 @@ func TestHelpTopicsAndUnknownTopics(t *testing.T) {
values, _ := complete(t, deps, out, "help", "e") values, _ := complete(t, deps, out, "help", "e")
require.Equal(t, []string{"environment\tEnvironment variables and global flags", "exit-codes\tExit codes used by tea"}, values) require.Equal(t, []string{"environment\tEnvironment variables and global flags", "exit-codes\tExit codes used by tea"}, values)
values, _ = complete(t, deps, out, "help", "issue", "l") values, _ = complete(t, deps, out, "help", "issue", "l")
require.Equal(t, []string{"list\tList repository issues"}, values) require.Equal(t, []string{"list\tList issues in a repository"}, values)
} }
func TestVerbAndFlagAliases(t *testing.T) { func TestVerbAndFlagAliases(t *testing.T) {
@@ -424,17 +424,11 @@ const (
issue attachment list issue attachment list
issue attachment upload issue attachment upload
issue attachment view issue attachment view
issue close
issue comment create issue comment create
issue comment delete issue comment delete
issue comment edit issue comment edit
issue comment list issue comment list
issue comment view issue comment view
issue create
issue edit
issue list
issue reopen
issue view
label create label create
label delete label delete
label edit label edit
@@ -469,7 +463,6 @@ const (
time reset time reset
` `
longsToWrite = ` longsToWrite = `
issue
issue attachment issue attachment
issue comment issue comment
label label
+2 -2
View File
@@ -85,8 +85,8 @@ func TestArgumentErrorsShowTheUsageLine(t *testing.T) {
args []string args []string
want string want string
}{ }{
{[]string{"issue", "view"}, "missing issue number\nUsage: tea issue view <number> [flags]\nRun 'tea issue view --help' for more information."}, {[]string{"issue", "view"}, "missing issue number\nUsage: tea issue view {<number> | <url>} [flags]\nRun 'tea issue view --help' for examples."},
{[]string{"issue", "view", "1", "2"}, "too many arguments\nUsage: tea issue view <number> [flags]"}, {[]string{"issue", "view", "1", "2"}, "too many arguments\nUsage: tea issue view {<number> | <url>} [flags]"},
{[]string{"issue", "list", "extra"}, "unexpected argument \"extra\"\nUsage: tea issue list [flags]"}, {[]string{"issue", "list", "extra"}, "unexpected argument \"extra\"\nUsage: tea issue list [flags]"},
{[]string{"pr", "checkout"}, "missing pull request number\nUsage: tea pr checkout {<number> | <url> | <branch>} [flags]"}, {[]string{"pr", "checkout"}, "missing pull request number\nUsage: tea pr checkout {<number> | <url> | <branch>} [flags]"},
{[]string{"run", "view"}, "missing run ID\nUsage: tea run view [<run-id>] [flags]"}, {[]string{"run", "view"}, "missing run ID\nUsage: tea run view [<run-id>] [flags]"},
+2 -2
View File
@@ -604,12 +604,12 @@ func argSlots(use string) []argSlot {
depth, start := 0, 0 depth, start := 0, 0
for i, r := range rest { for i, r := range rest {
switch r { switch r {
case '<', '[': case '<', '[', '{':
if depth == 0 { if depth == 0 {
start = i start = i
} }
depth++ depth++
case '>', ']': case '>', ']', '}':
if depth--; depth != 0 { if depth--; depth != 0 {
continue continue
} }
+7 -3
View File
@@ -9,6 +9,7 @@ import (
"slices" "slices"
"strings" "strings"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings" "gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"github.com/mattn/go-runewidth" "github.com/mattn/go-runewidth"
@@ -252,9 +253,7 @@ func writeItem[T any](a *application, cmd *cobra.Command, item T, row func(T) []
// legacyColumns gives lists that still render through writeList a header row and a flexible // legacyColumns gives lists that still render through writeList a header row and a flexible
// column, keyed by command path without the root. Their owners replace an entry with a listSpec. // column, keyed by command path without the root. Their owners replace an entry with a listSpec.
var legacyColumns = map[string][]terminal.Column{ var legacyColumns = map[string][]terminal.Column{}
"issue list": numberedColumns,
}
// numberedColumns head the rows of issueRow and pullRow. // numberedColumns head the rows of issueRow and pullRow.
var numberedColumns = []terminal.Column{{Header: "ID", Prefix: "#"}, {Header: "Title", Flex: true}, {Header: "State"}, {Header: "Author"}} var numberedColumns = []terminal.Column{{Header: "ID", Prefix: "#"}, {Header: "Title", Flex: true}, {Header: "State"}, {Header: "Author"}}
@@ -352,3 +351,8 @@ func yesNo(value bool) string {
func shortSHA(sha string) string { func shortSHA(sha string) string {
return sha[:min(len(sha), 7)] return sha[:min(len(sha), 7)]
} }
// servicePage places a page of a service listing for writeTable.
func servicePage[T any](page services.Page[T], number int) listPage {
return listPage{Number: number, More: page.More, Total: page.Total, Counted: page.Counted}
}
+14 -9
View File
@@ -78,7 +78,7 @@ func TestIssueListRespectsColorNeverAndStillPages(t *testing.T) {
})) }))
defer server.Close() defer server.Close()
deps, out, _, pager := pagedDeps(t, server.URL, nil, "color", "never", "pager", "configured-pager") deps, out, _, pager := pagedDeps(t, server.URL, nil, "color", "never", "pager", "configured-pager")
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "list", "-R", "alice/project", "--no-input"})) require.NoError(t, Execute(context.Background(), deps, []string{"issue", "list", "-R", "alice/project", "--state", "all", "--no-input"}))
require.Equal(t, 1, pager.calls) require.Equal(t, 1, pager.calls)
require.Equal(t, "configured-pager", pager.command) require.Equal(t, "configured-pager", pager.command)
require.NotContains(t, pager.input, "\x1b[") require.NotContains(t, pager.input, "\x1b[")
@@ -172,9 +172,8 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
args []string args []string
want string want string
}{ }{
{[]string{"milestone", "issue", "list", "3"}, "1 Issue 1 closed\n2 Issue 2 closed\n"},
{[]string{"issue", "close", "1", "2"}, "1 Issue 1 closed\n2 Issue 2 closed\n"},
{[]string{"branch", "list"}, "Showing 2 of 2 branches in alice/project\n\nNAME COMMIT PROTECTED UPDATED MESSAGE\nmain (default) abc\ndev def\n"}, {[]string{"branch", "list"}, "Showing 2 of 2 branches in alice/project\n\nNAME COMMIT PROTECTED UPDATED MESSAGE\nmain (default) abc\ndev def\n"},
{[]string{"milestone", "issue", "list", "3"}, "1 Issue 1 closed issue\n2 Issue 2 closed issue\n"},
{[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"}, {[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"},
} { } {
t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) { t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) {
@@ -224,21 +223,27 @@ func TestListsFitTheTerminalAndSplitWhenPiped(t *testing.T) {
`{"number":5,"title":"支持中文路径 (support CJK paths)","state":"open","user":{"login":""}}]`)) `{"number":5,"title":"支持中文路径 (support CJK paths)","state":"open","user":{"login":""}}]`))
})) }))
defer server.Close() defer server.Close()
for _, group := range []string{"issue"} { for group, want := range map[string][2]string{
"issue": {
"6\tA very long issue title that keeps going and going to see how list output handles wrapping\t\topen\tissue\talice\t\n" +
"5\t支持中文路径 (support CJK paths)\t\topen\tissue\t\t\n",
"Showing 2 of 2 open issues in alice/project\n\n" +
"ID TITLE LABELS AUTHOR UPDATED\n" +
"#6 A very long issue ti… alice\n" +
"#5 支持中文路径 (suppor…\n",
},
} {
t.Run(group, func(t *testing.T) { t.Run(group, func(t *testing.T) {
deps, out, _ := serverDeps(t, server.URL) deps, out, _ := serverDeps(t, server.URL)
args := []string{group, "list", "-R", "alice/project"} args := []string{group, "list", "-R", "alice/project"}
require.NoError(t, Execute(context.Background(), deps, args)) require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "6\tA very long issue title that keeps going and going to see how list output handles wrapping\topen\talice\n"+ require.Equal(t, want[0], out.String())
"5\t支持中文路径 (support CJK paths)\topen\t\n", out.String())
out.Reset() out.Reset()
deps.Streams.OutTTY, deps.Streams.Width = true, 50 deps.Streams.OutTTY, deps.Streams.Width = true, 50
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"}) deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
require.NoError(t, Execute(context.Background(), deps, args)) require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "ID TITLE STATE AUTHOR\n"+ require.Equal(t, want[1], stripLinks(out.String()))
"#6 A very long issue title that k… open alice\n"+
"#5 支持中文路径 (support CJK path… open\n", stripLinks(out.String()))
for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
require.LessOrEqual(t, runewidth.StringWidth(stripLinks(line)), 50, line) require.LessOrEqual(t, runewidth.StringWidth(stripLinks(line)), 50, line)
} }
+50
View File
@@ -64,3 +64,53 @@ func TestIssueEditMergesAssigneesWithOneRead(t *testing.T) {
require.Equal(t, 1, reads) require.Equal(t, 1, reads)
require.Equal(t, []any{"alice", "carol"}, patched) require.Equal(t, []any{"alice", "carol"}, patched)
} }
func TestIssueMutationsReportDoneLines(t *testing.T) {
var changed []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "GET /api/v1/repos/alice/project/issues/5":
_, _ = w.Write([]byte(`{"number":5,"title":"Open one","state":"open"}`))
case "GET /api/v1/repos/alice/project/issues/6":
_, _ = w.Write([]byte(`{"number":6,"title":"Closed one","state":"closed"}`))
case "GET /api/v1/repos/alice/project/issues/14":
_, _ = w.Write([]byte(`{"number":14,"title":"Merged one","state":"closed","pull_request":{"merged":true}}`))
case "POST /api/v1/repos/alice/project/issues":
changed = append(changed, r.Method+" "+r.URL.Path)
_, _ = w.Write([]byte(`{"number":9,"title":"New","state":"open","html_url":"https://example.org/alice/project/issues/9"}`))
case "POST /api/v1/repos/alice/project/issues/5/comments":
changed = append(changed, r.Method+" "+r.URL.Path+" "+payloadField(t, r, "body"))
_, _ = w.Write([]byte(`{"id":1}`))
default:
changed = append(changed, r.Method+" "+r.URL.Path)
_, _ = w.Write([]byte(`{"number":5,"title":"Open one","state":"closed"}`))
}
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
run := func(tty bool, args ...string) {
t.Helper()
out.Reset()
diagnostics.Reset()
deps.Streams.OutTTY = tty
require.NoError(t, Execute(context.Background(), deps, append(args, "-R", "alice/project", "--no-input")))
}
run(true, "issue", "close", "5", "#6", "14", "-c", "Fixed in #7")
require.Empty(t, out.String())
require.Equal(t, "✓ Closed issue alice/project#5 (Open one)\n"+
"! Issue alice/project#6 (Closed one) is already closed\n"+
"! Pull request alice/project#14 (Merged one) is already merged\n", diagnostics.String())
require.Equal(t, []string{"POST /api/v1/repos/alice/project/issues/5/comments Fixed in #7", "PATCH /api/v1/repos/alice/project/issues/5"}, changed)
run(false, "issue", "reopen", "6")
require.Empty(t, out.String())
require.Empty(t, diagnostics.String())
run(true, "issue", "create", "--title", "New")
require.Equal(t, "https://example.org/alice/project/issues/9\n", out.String())
require.Equal(t, "✓ Created issue alice/project#9 (New)\n", diagnostics.String())
run(false, "issue", "new", "--title", "New")
require.Equal(t, "https://example.org/alice/project/issues/9\n", out.String())
require.Empty(t, diagnostics.String())
run(true, "issue", "edit", "5", "--title", "Open one")
require.Equal(t, "✓ Edited issue alice/project#5 (Open one)\n", diagnostics.String())
}
+3 -2
View File
@@ -49,13 +49,13 @@ func TestIssueCreateImplicitInteractiveForm(t *testing.T) {
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "create", "-R", "alice/project", "--json=number,url", "--interactive"})) require.NoError(t, Execute(context.Background(), deps, []string{"issue", "create", "-R", "alice/project", "--json=number,url", "--interactive"}))
require.Equal(t, 1, form.calls) require.Equal(t, 1, form.calls)
require.NotNil(t, form.choices.EditBody) require.Nil(t, form.choices.EditBody, "without an editor the form asks for the body")
require.True(t, form.choices.CanPush) require.True(t, form.choices.CanPush)
require.Equal(t, []string{"alice", "bob"}, form.choices.Assignees) require.Equal(t, []string{"alice", "bob"}, form.choices.Assignees)
require.Equal(t, []terminal.IssueMilestoneChoice{{Title: "Release", ID: 4}}, form.choices.Milestones) require.Equal(t, []terminal.IssueMilestoneChoice{{Title: "Release", ID: 4}}, form.choices.Milestones)
require.Equal(t, []terminal.IssueLabelChoice{{Name: "bug", ID: 2}, {Name: "docs", ID: 3}}, form.choices.Labels) require.Equal(t, []terminal.IssueLabelChoice{{Name: "bug", ID: 2}, {Name: "docs", ID: 3}}, form.choices.Labels)
require.Equal(t, []terminal.IssueTemplateChoice{{Name: "Bug", About: "Report", Title: "bug: ", Body: "## Steps"}}, form.choices.Templates) require.Equal(t, []terminal.IssueTemplateChoice{{Name: "Bug", About: "Report", Title: "bug: ", Body: "## Steps"}}, form.choices.Templates)
require.Len(t, prompt.confirmations, 1) require.Empty(t, prompt.confirmations)
require.Equal(t, "Interactive", createPayload["title"]) require.Equal(t, "Interactive", createPayload["title"])
require.Equal(t, "Body text", createPayload["body"]) require.Equal(t, "Body text", createPayload["body"])
require.Equal(t, []any{"alice"}, createPayload["assignees"]) require.Equal(t, []any{"alice"}, createPayload["assignees"])
@@ -79,6 +79,7 @@ func TestIssueCreateInteractiveDraftSurvivesSubmissionFailure(t *testing.T) {
Title: "Interactive", Body: "saved body", Draft: terminal.Draft{Path: path, Body: "saved body"}, Title: "Interactive", Body: "saved body", Draft: terminal.Draft{Path: path, Body: "saved body"},
}} }}
deps, out, _ := serverDeps(t, server.URL) deps, out, _ := serverDeps(t, server.URL)
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "chosen-editor"})
deps.IssueForm = form deps.IssueForm = form
deps.Prompter = &scriptedPrompter{accept: true} deps.Prompter = &scriptedPrompter{accept: true}
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
+51 -45
View File
@@ -5,7 +5,6 @@ package app
import ( import (
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@@ -15,35 +14,39 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// errNothingToEdit is the shared answer to edit commands without anything to change.
var errNothingToEdit = clierr.Wrap(clierr.Usage, errors.New("nothing to edit; pass a flag such as --title (see --help)"))
func (a *application) issueEditCommand() *cobra.Command { func (a *application) issueEditCommand() *cobra.Command {
var title, body, file string var title, body, file, deadline, ref, milestone string
var useEditor bool var useEditor, removeMilestone bool
var deadline string
var ref string
var milestone string
var removeMilestone bool
var metadata metadataFlags var metadata metadataFlags
cmd := &cobra.Command{Use: "edit <number>...", Short: "Edit issue content, assignees, and milestone", Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) (runErr error) { cmd := &cobra.Command{Use: "edit {<number> | <url>}...", Short: "Edit issues: title, body, labels, assignees, milestone, deadline", Long: `Edit one or more issues. Without flags on a terminal, a form edits one issue at a time,
starting from its current values. Label and assignee flags add to or remove from each issue's
current values; the --set flags replace them.`, Example: `$ tea issue edit 12 --title "Crash on start" --add-label bug
$ tea issue edit 12 13 --add-assignee @me --milestone v1.0
$ tea issue edit 12 --remove-label wontfix --remove-milestone
$ tea issue edit 12 --body-file notes.md`, Annotations: map[string]string{"tea.arguments": issueArguments}, Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var draft terminal.Draft var draft terminal.Draft
defer finishDraft(&draft, &runErr) defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil { if err := validateBodySources(cmd); err != nil {
return err return err
} }
numbers, err := resourceNumbers(args)
if err != nil {
return err
}
snapshot, err := a.deps.Settings.Load() snapshot, err := a.deps.Settings.Load()
if err != nil { if err != nil {
return err return err
} }
if !localFlagsChanged(cmd) { if !localFlagsChanged(cmd) {
if a.policy(snapshot).CanPrompt(a.deps.Streams) && a.deps.IssueEditForm != nil { if !a.policy(snapshot).CanPrompt(a.deps.Streams) || a.deps.IssueEditForm == nil {
return a.editIssuesInteractively(cmd, numbers) return errNothingToEdit
} }
return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea issue edit --help")) target, numbers, err := a.resolveRefs(cmd, args, issueRef)
if err != nil {
return err
}
return a.editIssuesInteractively(cmd, target, numbers)
} }
if useEditor && len(numbers) != 1 { if useEditor && len(args) != 1 {
return clierr.Wrap(clierr.Usage, errors.New("--editor requires one issue")) return clierr.Wrap(clierr.Usage, errors.New("--editor requires one issue"))
} }
milestone, removeMilestone, err := milestoneFlag(cmd, milestone) milestone, removeMilestone, err := milestoneFlag(cmd, milestone)
@@ -58,8 +61,7 @@ func (a *application) issueEditCommand() *cobra.Command {
opts.Ref = &ref opts.Ref = &ref
} }
if cmd.Flags().Changed("deadline") { if cmd.Flags().Changed("deadline") {
opts.Deadline, opts.RemoveDeadline, err = parseDeadline(deadline) if opts.Deadline, opts.RemoveDeadline, err = parseDeadline(deadline); err != nil {
if err != nil {
return err return err
} }
} }
@@ -70,8 +72,7 @@ func (a *application) issueEditCommand() *cobra.Command {
opts.Title = &title opts.Title = &title
} }
if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") { if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") {
body, err = a.readBody(cmd, body, file) if body, err = a.readBody(cmd, body, file); err != nil {
if err != nil {
return err return err
} }
opts.Body = &body opts.Body = &body
@@ -80,9 +81,9 @@ func (a *application) issueEditCommand() *cobra.Command {
return err return err
} }
if opts == (services.IssueEdit{}) && !useEditor && !metadata.changed(cmd) && milestone == "" { if opts == (services.IssueEdit{}) && !useEditor && !metadata.changed(cmd) && milestone == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea issue edit --help")) return errNothingToEdit
} }
target, err := a.resolve(cmd.Context(), true) target, numbers, err := a.resolveRefs(cmd, args, issueRef)
if err != nil { if err != nil {
return err return err
} }
@@ -102,47 +103,55 @@ func (a *application) issueEditCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
draft, err = a.editDraft(cmd, current.Body) if draft, err = a.editDraft(cmd, current.Body); err != nil {
if err != nil {
return err return err
} }
opts.Body = &draft.Body opts.Body = &draft.Body
} }
return batch(a, cmd, numbers, issueLabel, func(n int64) (services.Issue, error) { return batchDone(a, cmd, numbers, issueLabel, func(n int64) (services.Issue, error) {
return service.Edit(cmd.Context(), n, opts) return service.Edit(cmd.Context(), n, opts)
}, issueRow) }, editedIssue(target))
}} }}
cmd.Flags().StringVar(&title, "title", "", "Issue title") flags := cmd.Flags()
cmd.Flags().StringVar(&ref, "ref", "", "Referenced branch or tag; empty clears") flags.StringVarP(&title, "title", "t", "", "New title")
cmd.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp; empty clears") flags.StringVarP(&body, "body", "b", "", "New body; empty clears it")
cmd.Flags().StringVar(&body, "body", "", "Issue body (empty clears)") flags.StringVarP(&file, "body-file", "F", "", "Read the body from `file`, or - for stdin")
cmd.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin") flags.BoolVarP(&useEditor, "editor", "e", false, "Edit the current body in the configured editor")
cmd.Flags().BoolVar(&useEditor, "editor", false, "Edit the current issue body in the configured editor")
cmd.Flags().StringVarP(&milestone, "milestone", "m", "", "Move the issue to a milestone by `title` or ID")
cmd.Flags().BoolVar(&removeMilestone, "remove-milestone", false, "Remove the issue from its milestone")
flagAliases(cmd, map[string]string{"milestone-name": "milestone"})
metadata.register(cmd) metadata.register(cmd)
flags.StringVarP(&milestone, "milestone", "m", "", "Move the issue to a milestone by `title` or ID")
flags.BoolVar(&removeMilestone, "remove-milestone", false, "Remove the issue from its milestone")
flagAliases(cmd, map[string]string{"milestone-name": "milestone"})
flags.StringVar(&ref, "ref", "", "Branch or tag the issue refers to; empty clears it")
flags.StringVar(&deadline, "deadline", "", "Due `date`: YYYY-MM-DD, RFC3339, or Unix timestamp; empty clears it")
a.completeMetadataFlags(cmd)
_ = cmd.RegisterFlagCompletionFunc("milestone", a.completeMilestones())
_ = cmd.RegisterFlagCompletionFunc("ref", a.completeBranches())
cmd.ValidArgsFunction = a.completeIssueArgs("open", true)
return cmd return cmd
} }
func (a *application) editIssuesInteractively(cmd *cobra.Command, numbers []int64) error { // editedIssue words the done line of an issue edit.
target, err := a.resolve(cmd.Context(), true) func editedIssue(t target) func(services.Issue) string {
if err != nil { return func(item services.Issue) string {
return err return "Edited " + discussionNoun(item.Kind) + " " + refLabel(t.Repo, item.Number, item.Title)
} }
}
func (a *application) editIssuesInteractively(cmd *cobra.Command, target target, numbers []int64) error {
service := services.Issues{Client: target.client, Repository: target.Repo} service := services.Issues{Client: target.client, Repository: target.Repo}
selectables, err := service.CreateSelectables(cmd.Context()) selectables, err := service.CreateSelectables(cmd.Context())
if err != nil { if err != nil {
return err return err
} }
milestones, labels := milestoneChoices(selectables.Milestones), labelChoices(selectables.Labels) milestones, labels := milestoneChoices(selectables.Milestones), labelChoices(selectables.Labels)
return batch(a, cmd, numbers, issueLabel, func(n int64) (item services.Issue, err error) { return batchDone(a, cmd, numbers, issueLabel, func(n int64) (item services.Issue, err error) {
current, err := service.View(cmd.Context(), n) current, err := service.View(cmd.Context(), n)
if err != nil { if err != nil {
return item, err return item, err
} }
choices := terminal.IssueEditFormChoices{ choices := terminal.IssueEditFormChoices{
Title: current.Title, Body: current.Body, Assignees: current.Assignees, Heading: "Editing " + refLabel(target.Repo, n, current.Title),
Title: current.Title, Body: current.Body, Assignees: current.Assignees,
CanPush: selectables.CanPush, AvailableAssignees: selectables.Assignees, Milestones: milestones, AvailableLabels: labels, CanPush: selectables.CanPush, AvailableAssignees: selectables.Assignees, Milestones: milestones, AvailableLabels: labels,
EditBody: a.bodyEditor(cmd), EditBody: a.bodyEditor(cmd),
} }
@@ -160,9 +169,6 @@ func (a *application) editIssuesInteractively(cmd *cobra.Command, numbers []int6
if err != nil { if err != nil {
return item, abortBatch{err} return item, abortBatch{err}
} }
if err := a.confirm(cmd, fmt.Sprintf("Edit issue %s#%d: %s", target.Repo, n, values.Title)); err != nil {
return item, abortBatch{err}
}
opts := services.IssueEdit{Title: &values.Title, Body: &values.Body} opts := services.IssueEdit{Title: &values.Title, Body: &values.Body}
if selectables.CanPush { if selectables.CanPush {
// Non-nil slices: an empty selection must clear rather than be omitted. // Non-nil slices: an empty selection must clear rather than be omitted.
@@ -177,5 +183,5 @@ func (a *application) editIssuesInteractively(cmd *cobra.Command, numbers []int6
} }
} }
return service.Edit(cmd.Context(), n, opts) return service.Edit(cmd.Context(), n, opts)
}, issueRow) }, editedIssue(target))
} }
+2 -1
View File
@@ -46,6 +46,7 @@ func TestIssueEditImplicitInteractiveForm(t *testing.T) {
deps, out, diagnostics := serverDeps(t, server.URL) deps, out, diagnostics := serverDeps(t, server.URL)
deps.IssueEditForm = issueEditFormFunc(func(_ context.Context, choices terminal.IssueEditFormChoices) (terminal.IssueEditFormValues, error) { deps.IssueEditForm = issueEditFormFunc(func(_ context.Context, choices terminal.IssueEditFormChoices) (terminal.IssueEditFormValues, error) {
require.NotNil(t, choices.EditBody) require.NotNil(t, choices.EditBody)
require.Equal(t, "Editing alice/project#5 (Old)", choices.Heading)
calls++ calls++
require.Equal(t, "Old", choices.Title) require.Equal(t, "Old", choices.Title)
require.Equal(t, "Old body", choices.Body) require.Equal(t, "Old body", choices.Body)
@@ -64,7 +65,7 @@ func TestIssueEditImplicitInteractiveForm(t *testing.T) {
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "edit", "5", "-R", "alice/project", "--json=number,title", "--interactive"})) require.NoError(t, Execute(context.Background(), deps, []string{"issue", "edit", "5", "-R", "alice/project", "--json=number,title", "--interactive"}))
require.Equal(t, 1, calls) require.Equal(t, 1, calls)
require.Len(t, prompt.confirmations, 1) require.Empty(t, prompt.confirmations)
require.Equal(t, "Edited", patchPayload["title"]) require.Equal(t, "Edited", patchPayload["title"])
require.Equal(t, "New body", patchPayload["body"]) require.Equal(t, "New body", patchPayload["body"])
require.Equal(t, []any{"bob"}, patchPayload["assignees"]) require.Equal(t, []any{"bob"}, patchPayload["assignees"])
+107 -12
View File
@@ -6,10 +6,13 @@ package app
import ( import (
"context" "context"
"net/http" "net/http"
"regexp"
"strconv"
"testing" "testing"
"time" "time"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -88,22 +91,76 @@ func TestIssueLabelFiltersRejectUnknownRepositoryLabels(t *testing.T) {
}, listed) }, listed)
} }
func TestIssueListLinksNumbersOnColorTerminals(t *testing.T) { func TestIssueListTables(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { var queries []string
_, _ = w.Write([]byte(`[{"number":7,"title":"Short","state":"open","user":{"login":"alice"},"html_url":"https://example.org/i/7"},` + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
`{"number":12,"title":"Evil\u001b]52;c;aGk=\u0007\r\nTitle\u202e","state":"closed","user":{"login":"bob"},"html_url":"https://example.org/i/12"}]`)) queries = append(queries, r.URL.RawQuery)
if r.URL.Query().Get("created_by") == "carol" {
_, _ = w.Write([]byte(`[]`))
return
}
w.Header().Set("X-Total-Count", "12")
_, _ = w.Write([]byte(`[{"number":7,"title":"Short","state":"open","user":{"login":"alice"},"html_url":"https://example.org/i/7",` +
`"labels":[{"name":"bug"},{"name":"priority/high"}],"updated_at":"2026-10-11T18:00:00Z"},` +
`{"number":12,"title":"Evil\u001b]52;c;aGk=\u0007\r\nTitle\u202e","state":"closed","user":{"login":"bob"},"html_url":"https://example.org/i/12","updated_at":"2026-10-09T20:00:00Z"},` +
`{"number":14,"title":"Merged","state":"closed","pull_request":{"merged":true},"user":{"login":"bob"},"html_url":"https://example.org/p/14","updated_at":"2026-10-11T19:59:00Z"}]`))
})) }))
defer server.Close() defer server.Close()
deps, out, _ := serverDeps(t, server.URL) deps, out, diagnostics := serverDeps(t, server.URL)
args := []string{"issue", "list", "-R", "alice/project"} deps.Now = func() time.Time { return time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) }
args := []string{"issue", "list", "-R", "alice/project", "-L", "3"}
require.NoError(t, Execute(context.Background(), deps, args)) require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "7\tShort\topen\talice\n12\tEvil]52;c;aGk= Title\tclosed\tbob\n", out.String()) require.Equal(t, "7\tShort\tbug, priority/high\topen\tissue\talice\t2026-10-11T18:00:00Z\n"+
"12\tEvil]52;c;aGk= Title\t\tclosed\tissue\tbob\t2026-10-09T20:00:00Z\n"+
"14\tMerged\t\tmerged\tpr\tbob\t2026-10-11T19:59:00Z\n", out.String())
for _, test := range []struct {
args []string
want string
}{
{nil, "Showing 3 of 12 open issues in alice/project · next: --page 2\n\n" +
"ID TITLE LABELS AUTHOR UPDATED\n" +
"#7 Short bug, priority/high alice about 2 hours ago\n" +
"#12 Evil]52;c;aGk= Title bob about 2 days ago\n" +
"#14 Merged bob about 1 minute ago\n"},
{[]string{"--state", "all", "--kind", "all"}, "Showing 3 of 12 issues and pull requests in alice/project · next: --page 2\n\n" +
"ID TITLE LABELS STATE KIND AUTHOR UPDATED\n" +
"#7 Short bug, priority/high open issue alice about 2 hours ago\n" +
"#12 Evil]52;c;aGk= Title closed issue bob about 2 days ago\n" +
"#14 Merged merged pr bob about 1 minute ago\n"},
} {
out.Reset()
deps.Streams.OutTTY = true
require.NoError(t, Execute(context.Background(), deps, append(append(args, test.args...), "--no-input")))
require.Equal(t, test.want, stripLinks(stripColors(out.String())))
}
require.Contains(t, out.String(), "\x1b]8;;https://example.org/i/7\x1b\\\x1b[32m#7\x1b[0m\x1b]8;;\x1b\\")
require.Contains(t, out.String(), "\x1b[35mmerged\x1b[0m")
out.Reset() out.Reset()
deps.Streams.OutTTY = true diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, args)) require.NoError(t, Execute(context.Background(), deps, append(args, "--author", "carol")))
require.Equal(t, "\x1b[90mID\x1b[0m \x1b[90mTITLE\x1b[0m \x1b[90mSTATE\x1b[0m \x1b[90mAUTHOR\x1b[0m\n"+ require.Empty(t, out.String())
"\x1b]8;;https://example.org/i/7\x1b\\#7\x1b]8;;\x1b\\ Short \x1b[32mopen\x1b[0m alice\n"+ require.Equal(t, "no open issues match your filters in alice/project\n", diagnostics.String())
"\x1b]8;;https://example.org/i/12\x1b\\#12\x1b]8;;\x1b\\ Evil]52;c;aGk= Title \x1b[31mclosed\x1b[0m bob\n", out.String()) queries = nil
require.NoError(t, Execute(context.Background(), deps, append(args, "-A", "@me", "-a", "@me", "--json")))
require.Contains(t, queries[0], "assigned_by=alice")
require.Contains(t, queries[0], "created_by=alice")
}
func TestIssueListWebOpensTheFilteredPage(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
browser := &recordingBrowser{}
deps.Browser = browser
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "list", "-R", "alice/project", "-w", "-s", "closed", "-S", "login bug"}))
require.Equal(t, server.URL+"/alice/project/issues?q=login+bug&state=closed", browser.resource)
}
// stripColors removes SGR color sequences from rendered output.
func stripColors(s string) string {
return regexp.MustCompile("\x1b\\[[0-9;]*m").ReplaceAllString(s, "")
} }
func TestIssueListKindFilter(t *testing.T) { func TestIssueListKindFilter(t *testing.T) {
@@ -181,3 +238,41 @@ func TestIssueMilestoneFiltersRejectUnknownMilestones(t *testing.T) {
}, listed) }, listed)
require.Contains(t, diagnostics.String(), "pass --repo to check them") require.Contains(t, diagnostics.String(), "pass --repo to check them")
} }
func TestIssueCommandsComplete(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project/issues":
_, _ = w.Write([]byte(`[{"number":4,"title":"Crash","state":` + strconv.Quote(r.URL.Query().Get("state")) + `}]`))
case "/api/v1/repos/alice/project/labels":
_, _ = w.Write([]byte(`[{"id":1,"name":"bug"}]`))
case "/api/v1/repos/alice/project/milestones":
_, _ = w.Write([]byte(`[{"id":3,"title":"v1.0"}]`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Getenv = serverEnv(server.URL, map[string]string{"TEA_REPO": "alice/project"})
for _, test := range []struct {
args []string
want []string
}{
{[]string{"issue", "list", "--state", ""}, []string{"open", "closed", "all"}},
{[]string{"issue", "list", "-s", "c"}, []string{"closed"}},
{[]string{"issue", "list", "--kind", ""}, []string{"issues", "pulls", "all"}},
{[]string{"issue", "list", "--label", ""}, []string{"bug"}},
{[]string{"issue", "list", "--milestone", "v1.0,"}, []string{}},
{[]string{"issue", "create", "-m", ""}, []string{"v1.0"}},
{[]string{"issue", "edit", "4", "--add-label", ""}, []string{"bug"}},
{[]string{"issue", "view", ""}, []string{"4\tCrash"}},
{[]string{"issue", "view", "4", ""}, []string{}},
{[]string{"issue", "close", "5", ""}, []string{"4\tCrash"}},
{[]string{"issue", "reopen", ""}, []string{"4\tCrash"}},
} {
values, directive := complete(t, deps, out, test.args...)
require.Equal(t, test.want, values, test.args)
require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive&cobra.ShellCompDirectiveNoFileComp, test.args)
}
}
+418 -260
View File
@@ -4,9 +4,11 @@
package app package app
import ( import (
"cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/url"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -18,6 +20,335 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// issueArguments is the ARGUMENTS help of commands that take issue references.
const issueArguments = `An issue can be supplied as argument in any of the following formats:
- by number, e.g. "123" or "#123"
- by URL, e.g. "https://gitea.com/OWNER/REPO/issues/123"
- by repository and number, e.g. "OWNER/REPO#123"`
func (a *application) issueCommand() *cobra.Command {
group := &cobra.Command{Use: "issue", Short: "Manage issues", Long: `Work with the issues of a repository: list, view and create them, edit, close or reopen
one or more, and comment on them. Gitea lists pull requests among issues, so most issue
commands also accept a pull request number.`}
group.AddCommand(a.issueListCommand(), a.issueViewCommand(), a.issueCreateCommand(), a.issueEditCommand(),
a.issueStateCommand("close"), a.issueStateCommand("reopen"), a.issueAttachmentCommand(issueRef), a.commentCommand(issueRef))
return group
}
func (a *application) issueListCommand() *cobra.Command {
opts := services.ListOptions{Page: 1}
var labels []string
var updated timeRange
var web bool
cmd := &cobra.Command{Use: "list", Short: "List issues in a repository", Long: `List the issues of a repository, newest first. Filters combine: an issue must match all of them.
Labels and milestones must exist in the repository.`, Example: `$ tea issue list --label bug --state all
$ tea issue list --assignee @me --json number,title
# Issues and pull requests in a milestone, as tab-separated values
$ tea issue list --kind all --milestone v1.0 | cut -f1,2
$ tea issue list --search "login" --web`, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
var err error
if opts.Since, opts.Before, err = updated.parse(a.deps.Now()); err != nil {
return err
}
if err := requireNames("label", "", labels); err != nil {
return err
}
milestones, err := nameFilter("milestone", opts.Milestones)
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
if errors.Is(err, errRepositoryRequired) {
return clierr.Wrap(clierr.Usage, errors.New("repository required: use --repo owner/name, or tea search issue for instance-wide results"))
}
return err
}
opts.Assignee, opts.Author, opts.Mentions = target.expandMe(opts.Assignee), target.expandMe(opts.Author), target.expandMe(opts.Mentions)
if web {
return a.openBrowser(cmd, issuesURL(target, opts))
}
filtered := opts.Search+opts.Assignee+opts.Author+opts.Mentions != "" || len(labels)+len(milestones) > 0 || opts.Since != nil || opts.Before != nil
if opts.Labels, opts.Milestones, err = issueFilters(cmd, target, labels, milestones); err != nil {
return err
}
page, err := (services.Issues{Client: target.client, Repository: target.Repo}).ListPage(cmd.Context(), opts)
if err != nil {
return err
}
return writeTable(a, cmd, page.Items, issueListSpec(target.Repo, opts.State, opts.Kind, filtered), servicePage(page, opts.Page))
}}
enumFlag(cmd, &opts.State, "state", "s", "open", "Filter by state", "open", "closed", "all")
enumFlag(cmd, &opts.Kind, "kind", "", "issues", "List issues, pull requests or both", "issues", "pulls", "all")
pageFlags(cmd, &opts.Limit, &opts.Page)
flags := cmd.Flags()
flags.StringVarP(&opts.Search, "search", "S", "", "Search issue titles and bodies")
flags.StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee; @me for yourself")
flags.StringVarP(&opts.Author, "author", "A", "", "Filter by author; @me for yourself")
flags.StringVar(&opts.Mentions, "mentions", "", "Filter by mentioned user; @me for yourself")
flags.StringSliceVarP(&labels, "label", "l", nil, "Filter by label `names`")
flags.StringVarP(&opts.Milestones, "milestone", "m", "", "Filter by milestone `titles` or IDs, comma-separated")
updated.register(cmd, "since", "Updated since a date, timestamp, or duration ago", "until", "Updated before a date, timestamp, or duration ago")
flags.BoolVarP(&web, "web", "w", false, "Open the issue list in the browser")
_ = cmd.RegisterFlagCompletionFunc("label", a.completeLabels())
_ = cmd.RegisterFlagCompletionFunc("milestone", commaSeparated(a.completeMilestones()))
for _, name := range []string{"assignee", "author", "mentions"} {
_ = cmd.RegisterFlagCompletionFunc(name, a.completeUsers())
}
return cmd
}
// issuesURL is the web page of the repository's issues or pull requests in state, searched for
// the --search text.
func issuesURL(t target, opts services.ListOptions) string {
section := "issues"
if opts.Kind == "pulls" {
section = "pulls"
}
query := url.Values{"state": {opts.State}}
if opts.Search != "" {
query.Set("q", opts.Search)
}
return strings.TrimRight(t.Host, "/") + "/" + t.Repo + "/" + section + "?" + query.Encode()
}
// issueListSpec describes issue tables: STATE shows on a terminal unless only open issues are
// listed, and KIND when issues and pull requests are; piped tables always have both.
func issueListSpec(repo, state, kind string, filtered bool) listSpec[services.Issue] {
what := map[string]string{"issues": "issues", "pulls": "pull requests", "all": "issues and pull requests"}[kind]
if state != "all" {
what = state + " " + what
}
empty := "no " + what
if filtered {
empty += " match your filters"
}
return listSpec[services.Issue]{
Columns: []terminal.Column{
{Header: "ID", Prefix: "#"},
{Header: "Title", Flex: true},
{Header: "Labels", Flex: true},
{Header: "State", PipedOnly: state == "open"},
{Header: "Kind", PipedOnly: kind != "all"},
{Header: "Author"},
{Header: "Updated"},
},
Row: issueRow,
Empty: empty + " in " + repo,
Context: what + " in " + repo,
}
}
func issueRow(item services.Issue) []terminal.Cell {
labels := make([]string, len(item.Labels))
for i, label := range item.Labels {
labels[i] = label.Name
}
return []terminal.Cell{
{Text: strconv.FormatInt(item.Number, 10), Link: item.URL, Color: stateColors[item.State]},
{Text: item.Title},
{Text: strings.Join(labels, ", ")},
{Text: item.State, Color: stateColors[item.State]},
{Text: item.Kind},
{Text: item.Author},
{Time: terminal.ParseTime(item.UpdatedAt)},
}
}
func (a *application) issueViewCommand() *cobra.Command {
var web, comments bool
cmd := &cobra.Command{Use: "view {<number> | <url>}", Short: "View an issue", Long: `Display the title, body and other information about an issue. With --comments, the
conversation follows the body.`, Example: `$ tea issue view 12
$ tea issue view 12 --comments
$ tea issue view https://gitea.com/OWNER/REPO/issues/12 --web`, Annotations: map[string]string{"tea.arguments": issueArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
target, n, err := a.resolveRef(cmd, args[0], issueRef)
if err != nil {
return err
}
service := services.Issues{Client: target.client, Repository: target.Repo}
item, err := service.View(cmd.Context(), n)
if err != nil {
return err
}
if web {
return a.openBrowser(cmd, item.URL)
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
dependencies, err := service.Dependencies(cmd.Context(), n)
if err != nil {
return err
}
return a.writeDiscussion(cmd, target, issueView(item, target.Repo, dependencies), comments)
}}
cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the issue in the browser")
cmd.Flags().BoolVarP(&comments, "comments", "c", false, "View issue comments")
cmd.ValidArgsFunction = a.completeIssueArgs("open", false)
return cmd
}
// completeIssueArgs completes issue numbers in state for the first argument, or for every
// argument when variadic.
func (a *application) completeIssueArgs(state string, variadic bool) cobra.CompletionFunc {
complete := a.completeIssues(state)
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) > 0 && !variadic {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return complete(cmd, args, toComplete)
}
}
func (a *application) issueCreateCommand() *cobra.Command {
var title, body, bodyFile, deadline, templateName, ref, milestone string
var useEditor, web bool
var assignees, labelNames, attachmentPaths []string
cmd := &cobra.Command{Use: "create", Aliases: []string{"new"}, Short: "Create an issue", Long: `Create an issue in a repository. Without flags on a terminal, a form asks for the title and
body, then optionally for assignees, a milestone, labels and a due date. The new issue's URL
is printed, so scripts can capture it.`, Example: `$ tea issue create
$ tea issue create --title "Crash on start" --body "Steps to reproduce…" --label bug --milestone v1.0
$ tea issue create -t "Update docs" -F notes.md -a @me
# Start from a repository issue template, or continue in the browser
$ tea issue create --template "Bug report"
$ tea issue create --title "Idea" --web`, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) (runErr error) {
var draft terminal.Draft
defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
milestone, _, err := milestoneFlag(cmd, milestone)
if err != nil {
return err
}
if err := requireNames("label", "", labelNames); err != nil {
return err
}
if err := requireNames("assignee", "", assignees); err != nil {
return err
}
if cmd.Flags().Changed("template") && strings.TrimSpace(templateName) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--template cannot be empty"))
}
files, err := openRegularFiles(attachmentPaths)
defer closeFiles(files)
if err != nil {
return err
}
var due *time.Time
if cmd.Flags().Changed("deadline") {
if due, _, err = parseDeadline(deadline); err != nil {
return err
}
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
if !localFlagsChanged(cmd) && a.policy(snapshot).CanPrompt(a.deps.Streams) && a.deps.IssueForm != nil {
return a.createIssueInteractively(cmd)
}
var target target
var templateBody string
if cmd.Flags().Changed("template") || web {
if target, err = a.resolve(cmd.Context(), true); err != nil {
return err
}
}
if cmd.Flags().Changed("template") {
template, err := issueTemplate(cmd.Context(), services.Issues{Client: target.client, Repository: target.Repo}, strings.TrimSpace(templateName))
if err != nil {
return err
}
if !cmd.Flags().Changed("title") {
title = template.Title
}
templateBody = issueTemplateBody(template)
}
if web {
if body, err = a.readBody(cmd, body, bodyFile); err != nil {
return err
}
query := url.Values{"title": {title}, "body": {cmp.Or(body, templateBody)}}
return a.openBrowser(cmd, strings.TrimRight(target.Host, "/")+"/"+target.Repo+"/issues/new?"+query.Encode())
}
if bodyFile == "-" && title == "" {
return clierr.Wrap(clierr.Usage, errors.New("--body-file - requires --title; stdin cannot also supply a prompt"))
}
if _, err := a.requireInput(cmd, &title, "title", "Title", nil); err != nil {
return err
}
if target.client == nil {
if target, err = a.resolve(cmd.Context(), true); err != nil {
return err
}
}
service := services.Issues{Client: target.client, Repository: target.Repo}
switch {
case useEditor:
draft, err = a.editDraft(cmd, templateBody)
body = draft.Body
case cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file"):
body, err = a.readBody(cmd, body, bodyFile)
default:
body = templateBody
}
if err != nil {
return err
}
var milestoneValue int64
if milestone != "" {
if milestoneValue, err = milestoneID(cmd, target, milestone); err != nil {
return err
}
}
labelIDs, err := (services.Labels{Client: target.client, Repository: target.Repo}).ResolveIssueNames(cmd.Context(), labelNames)
if err != nil {
return err
}
target.expandMeAll(assignees)
item, err := service.Create(cmd.Context(), services.IssueOptions{Title: title, Body: body, Deadline: due, Ref: ref, Assignees: assignees, Milestone: milestoneValue, Labels: labelIDs})
if err != nil {
return err
}
var failures []error
for _, file := range files {
if _, err := service.UploadAttachment(cmd.Context(), item.Number, filepath.Base(file.Name()), file); err != nil {
failures = append(failures, fmt.Errorf("issue %d created; attachment %s upload failed: %w", item.Number, filepath.Base(file.Name()), err))
}
}
return errors.Join(append(failures, a.created(cmd, target, item))...)
}}
flags := cmd.Flags()
flags.StringVarP(&title, "title", "t", "", "Issue title")
flags.StringVarP(&body, "body", "b", "", "Issue body")
flags.StringVarP(&bodyFile, "body-file", "F", "", "Read body from `file`, or - for stdin")
flags.BoolVarP(&useEditor, "editor", "e", false, "Compose the issue body in the configured editor")
flags.StringSliceVarP(&labelNames, "label", "l", nil, "Add labels by `name`; prefix repo: or org: to disambiguate")
flags.StringSliceVarP(&assignees, "assignee", "a", nil, "Assign people by their `login`; @me for yourself")
flags.StringVarP(&milestone, "milestone", "m", "", "Add the issue to a milestone by `title` or ID")
flagAliases(cmd, map[string]string{"milestone-name": "milestone"})
flags.StringVarP(&templateName, "template", "T", "", "Start from the repository issue template with this `name`")
flags.StringVar(&ref, "ref", "", "Branch or tag the issue refers to")
flags.StringVar(&deadline, "deadline", "", "Due `date`: YYYY-MM-DD, RFC3339, or Unix timestamp")
flags.StringArrayVar(&attachmentPaths, "attachment", nil, "`File` to attach after creation (repeatable)")
flags.BoolVarP(&web, "web", "w", false, "Continue in the browser with the new-issue page")
_ = cmd.RegisterFlagCompletionFunc("label", a.completeLabels())
_ = cmd.RegisterFlagCompletionFunc("assignee", a.completeUsers())
_ = cmd.RegisterFlagCompletionFunc("milestone", a.completeMilestones())
_ = cmd.RegisterFlagCompletionFunc("ref", a.completeBranches())
return cmd
}
// created reports a new issue: as JSON, or its URL on stdout after a ✓ line for people.
func (a *application) created(cmd *cobra.Command, t target, item services.Issue) error {
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
a.done(cmd, "Created issue %s", refLabel(t.Repo, item.Number, item.Title))
return a.printURL(cmd, item.URL)
}
func (a *application) createIssueInteractively(cmd *cobra.Command) (runErr error) { func (a *application) createIssueInteractively(cmd *cobra.Command) (runErr error) {
target, err := a.resolve(cmd.Context(), true) target, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
@@ -28,9 +359,10 @@ func (a *application) createIssueInteractively(cmd *cobra.Command) (runErr error
if err != nil { if err != nil {
return err return err
} }
choices := terminal.IssueFormChoices{ choices := terminal.IssueFormChoices{CanPush: selectables.CanPush, Assignees: selectables.Assignees}
CanPush: selectables.CanPush, Assignees: selectables.Assignees, // Without an editor the body is a field of the form, asked for right after the title.
EditBody: a.bodyEditor(cmd), if preferences, err := a.preferences(); err != nil || preferences.Editor != "" {
choices.EditBody = a.bodyEditor(cmd)
} }
for _, template := range selectables.Templates { for _, template := range selectables.Templates {
choices.Templates = append(choices.Templates, terminal.IssueTemplateChoice{Name: template.Name, About: template.About, Title: template.Title, Body: issueTemplateBody(template)}) choices.Templates = append(choices.Templates, terminal.IssueTemplateChoice{Name: template.Name, About: template.About, Title: template.Title, Body: issueTemplateBody(template)})
@@ -41,16 +373,13 @@ func (a *application) createIssueInteractively(cmd *cobra.Command) (runErr error
if err != nil { if err != nil {
return err return err
} }
if err := a.confirm(cmd, "Create issue on "+target.Repo+": "+values.Title); err != nil {
return err
}
item, err := service.Create(cmd.Context(), services.IssueOptions{ item, err := service.Create(cmd.Context(), services.IssueOptions{
Title: values.Title, Body: values.Body, Assignees: values.Assignees, Milestone: values.Milestone, Labels: values.Labels, Deadline: values.Deadline, Title: values.Title, Body: values.Body, Assignees: values.Assignees, Milestone: values.Milestone, Labels: values.Labels, Deadline: values.Deadline,
}) })
if err != nil { if err != nil {
return err return err
} }
return writeRecord(a, cmd, item, issueRecord) return a.created(cmd, target, item)
} }
// issueTemplateBody seeds a body from markdown content, or from the description of form templates. // issueTemplateBody seeds a body from markdown content, or from the description of form templates.
@@ -80,6 +409,61 @@ func issueTemplate(ctx context.Context, service services.Issues, name string) (s
return services.IssueTemplate{}, fmt.Errorf("issue template %q does not exist; available: %s", name, strings.Join(names, ", ")) return services.IssueTemplate{}, fmt.Errorf("issue template %q does not exist; available: %s", name, strings.Join(names, ", "))
} }
// issueStateCommand closes or reopens issues, reporting those already in that state instead of
// changing them.
func (a *application) issueStateCommand(verb string) *cobra.Command {
var comment string
state, past, completeState := "closed", "Closed", "open"
if verb == "reopen" {
state, past, completeState = "open", "Reopened", "closed"
}
cmd := &cobra.Command{Use: verb + " {<number> | <url>}...", Short: titleCase(verb) + " issues", Example: fmt.Sprintf(`$ tea issue %[1]s 12
$ tea issue %[1]s 12 13 --comment "Done in #14"`, verb), Annotations: map[string]string{"tea.arguments": issueArguments}, Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
target, numbers, err := a.resolveRefs(cmd, args, issueRef)
if err != nil {
return err
}
service := services.Issues{Client: target.client, Repository: target.Repo}
comments := services.Comments{Client: target.client, Repository: target.Repo}
results, failures := applyBatch(cmd, numbers, issueLabel, func(n int64) (services.Issue, error) {
current, err := service.View(cmd.Context(), n)
if err != nil {
return current, err
}
if current.State == state || (state == "closed" && current.State == "merged") {
a.warn(cmd, "%s %s is already %s", titleCase(discussionNoun(current.Kind)), refLabel(target.Repo, n, current.Title), current.State)
return current, nil
}
if comment != "" {
if _, err := comments.Create(cmd.Context(), n, comment); err != nil {
return current, err
}
}
item, err := service.SetState(cmd.Context(), n, state)
if err == nil {
a.done(cmd, "%s %s %s", past, discussionNoun(item.Kind), refLabel(target.Repo, n, item.Title))
}
return item, err
}, func(services.Issue) {})
var err2 error
if a.options.json != "" {
err2 = writeBatchJSON(a, cmd, len(numbers), results)
}
return errors.Join(append(failures, err2)...)
}}
cmd.Flags().StringVarP(&comment, "comment", "c", "", "Leave a comment before the issue is "+state)
cmd.ValidArgsFunction = a.completeIssueArgs(completeState, true)
return cmd
}
// discussionNoun names an issue of kind in messages: "issue" or "pull request".
func discussionNoun(kind string) string {
if kind == services.KindPull {
return "pull request"
}
return "issue"
}
// milestoneFlag reads -m/--milestone, a milestone title or ID, before any request: value is // milestoneFlag reads -m/--milestone, a milestone title or ID, before any request: value is
// "" when the flag is absent, and remove reports 0 or --remove-milestone, which clear it. // "" when the flag is absent, and remove reports 0 or --remove-milestone, which clear it.
func milestoneFlag(cmd *cobra.Command, value string) (selected string, remove bool, err error) { func milestoneFlag(cmd *cobra.Command, value string) (selected string, remove bool, err error) {
@@ -171,19 +555,33 @@ type metadataFlags struct {
setAssignees, addAssignees, removeAssignees []string setAssignees, addAssignees, removeAssignees []string
} }
// register adds the flags under their gh names, --add-label and so on, and keeps the plural
// names working (D8).
func (f *metadataFlags) register(cmd *cobra.Command) { func (f *metadataFlags) register(cmd *cobra.Command) {
cmd.Flags().StringSliceVar(&f.setLabels, "set-labels", nil, "Replace labels by exact name; repo:/org: disambiguate; empty clears") flags := cmd.Flags()
cmd.Flags().StringSliceVar(&f.addLabels, "add-labels", nil, "Add labels by exact name; repo:/org: disambiguate") flags.StringSliceVar(&f.addLabels, "add-label", nil, "Add labels by `name`; prefix repo: or org: to disambiguate")
cmd.Flags().StringSliceVar(&f.removeLabels, "remove-labels", nil, "Remove labels by exact name; repo:/org: disambiguate") flags.StringSliceVar(&f.removeLabels, "remove-label", nil, "Remove labels by `name`")
cmd.Flags().StringSliceVar(&f.setAssignees, "set-assignees", nil, "Replace assignees; an empty value clears") flags.StringSliceVar(&f.setLabels, "set-labels", nil, "Replace all labels; an empty value clears them")
cmd.Flags().StringSliceVar(&f.addAssignees, "add-assignees", nil, "Add assignees") flags.StringSliceVar(&f.addAssignees, "add-assignee", nil, "Add assignees by `login`; @me for yourself")
cmd.Flags().StringSliceVar(&f.removeAssignees, "remove-assignees", nil, "Remove assignees") flags.StringSliceVar(&f.removeAssignees, "remove-assignee", nil, "Remove assignees by `login`; @me for yourself")
flags.StringSliceVar(&f.setAssignees, "set-assignees", nil, "Replace all assignees; an empty value clears them")
flagAliases(cmd, map[string]string{"add-labels": "add-label", "remove-labels": "remove-label", "add-assignees": "add-assignee", "remove-assignees": "remove-assignee"})
}
// completeMetadataFlags completes the label and assignee flags of metadataFlags.
func (a *application) completeMetadataFlags(cmd *cobra.Command) {
for _, name := range []string{"add-label", "remove-label", "set-labels"} {
_ = cmd.RegisterFlagCompletionFunc(name, a.completeLabels())
}
for _, name := range []string{"add-assignee", "remove-assignee", "set-assignees"} {
_ = cmd.RegisterFlagCompletionFunc(name, a.completeUsers())
}
} }
func (f *metadataFlags) validate(cmd *cobra.Command) error { func (f *metadataFlags) validate(cmd *cobra.Command) error {
for _, kind := range []string{"assignees", "labels"} { for _, kind := range []string{"assignee", "label"} {
if cmd.Flags().Changed("set-"+kind) && (cmd.Flags().Changed("add-"+kind) || cmd.Flags().Changed("remove-"+kind)) { if cmd.Flags().Changed("set-"+kind+"s") && (cmd.Flags().Changed("add-"+kind) || cmd.Flags().Changed("remove-"+kind)) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--set-%[1]s cannot be combined with --add-%[1]s or --remove-%[1]s", kind)) return clierr.Wrap(clierr.Usage, fmt.Errorf("--set-%[1]ss cannot be combined with --add-%[1]s or --remove-%[1]s", kind))
} }
} }
if err := requireNames("assignee", "; use --set-assignees= to clear", f.setAssignees, f.addAssignees, f.removeAssignees); err != nil { if err := requireNames("assignee", "; use --set-assignees= to clear", f.setAssignees, f.addAssignees, f.removeAssignees); err != nil {
@@ -203,8 +601,11 @@ func (f *metadataFlags) incremental() bool {
} }
// resolve maps set flags to replacements, and add and remove flags to changes that the service // resolve maps set flags to replacements, and add and remove flags to changes that the service
// merges with each item's current state. // merges with each item's current state. @me names the account tea acts as.
func (f *metadataFlags) resolve(cmd *cobra.Command, t target) (labels *[]int64, assignees *[]string, changes *services.MetadataChanges, err error) { func (f *metadataFlags) resolve(cmd *cobra.Command, t target) (labels *[]int64, assignees *[]string, changes *services.MetadataChanges, err error) {
t.expandMeAll(f.setAssignees)
t.expandMeAll(f.addAssignees)
t.expandMeAll(f.removeAssignees)
resolver := services.Labels{Client: t.client, Repository: t.Repo} resolver := services.Labels{Client: t.client, Repository: t.Repo}
if cmd.Flags().Changed("set-labels") { if cmd.Flags().Changed("set-labels") {
ids, err := resolver.ResolveIssueNames(cmd.Context(), f.setLabels) ids, err := resolver.ResolveIssueNames(cmd.Context(), f.setLabels)
@@ -229,246 +630,3 @@ func (f *metadataFlags) resolve(cmd *cobra.Command, t target) (labels *[]int64,
} }
return labels, assignees, changes, nil return labels, assignees, changes, nil
} }
func issueRecord(item services.Issue) terminal.Record {
return numberedRecord(item.Number, item.Title, item.URL)
}
func issueRow(item services.Issue) []terminal.Cell {
return []terminal.Cell{{Text: strconv.FormatInt(item.Number, 10), Link: item.URL}, {Text: item.Title}, {Text: item.State, Color: stateColors[item.State]}, {Text: item.Author}}
}
func (a *application) issueCommand() *cobra.Command {
group := &cobra.Command{Use: "issue", Short: "Work with repository issues"}
opts := services.ListOptions{State: "open", Limit: 30, Page: 1}
var updated timeRange
list := &cobra.Command{Use: "list", Short: "List repository issues", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
if err := oneOf("state", opts.State, "open", "closed", "all"); err != nil {
return err
}
if err := oneOf("kind", opts.Kind, "issues", "pulls", "all"); err != nil {
return err
}
var err error
if opts.Since, opts.Before, err = updated.parse(a.deps.Now()); err != nil {
return err
}
labelNames, err := labelFilter(opts.Labels)
if err != nil {
return err
}
milestones, err := nameFilter("milestone", opts.Milestones)
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
if errors.Is(err, errRepositoryRequired) {
return clierr.Wrap(clierr.Usage, errors.New("repository required: use --repo owner/name, or tea search issue for instance-wide results"))
}
return err
}
if opts.Labels, opts.Milestones, err = issueFilters(cmd, target, labelNames, milestones); err != nil {
return err
}
items, more, err := (services.Issues{Client: target.client, Repository: target.Repo}).List(cmd.Context(), opts)
if err != nil {
return err
}
if err := writeList(a, cmd, items, issueRow); err != nil {
return err
}
return a.morePagesHint(cmd, more, opts.Page)
}}
list.Flags().StringVar(&opts.State, "state", "open", "Issue state: open, closed, all")
list.Flags().StringVar(&opts.Kind, "kind", "issues", "Resource kind: issues, pulls, or all")
pageFlags(list, &opts.Limit, &opts.Page)
list.Flags().StringVar(&opts.Search, "search", "", "Search issue titles and bodies")
list.Flags().StringVar(&opts.Assignee, "assignee", "", "Filter by assigned username")
list.Flags().StringVar(&opts.Labels, "label", "", "Filter by comma-separated label names")
list.Flags().StringVar(&opts.Milestones, "milestone", "", "Filter by comma-separated milestone names or IDs")
list.Flags().StringVar(&opts.Author, "author", "", "Filter by author username")
list.Flags().StringVar(&opts.Mentions, "mentions", "", "Filter by mentioned username")
updated.register(list, "since", "Updated since a date, timestamp, or duration ago", "until", "Updated before a date, timestamp, or duration ago")
group.AddCommand(list)
var web, comments bool
view := &cobra.Command{Use: "view <number>", Short: "View a repository issue", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Issues{Client: target.client, Repository: target.Repo}
item, err := service.View(cmd.Context(), n)
if err != nil {
return err
}
if web {
return a.openBrowser(cmd, item.URL)
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
dependencies, err := service.Dependencies(cmd.Context(), n)
if err != nil {
return err
}
return a.writeDiscussion(cmd, target, issueView(item, target.Repo, dependencies), comments)
}}
view.Flags().BoolVar(&web, "web", false, "Open the issue in a browser")
view.Flags().BoolVarP(&comments, "comments", "c", false, "View issue comments")
group.AddCommand(view)
var title, body, bodyFile, deadline, templateName string
var useEditor bool
var ref string
var assignees, labelNames, attachmentPaths []string
var milestone string
create := &cobra.Command{Use: "create", Short: "Create a repository issue", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) (runErr error) {
var draft terminal.Draft
defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
milestone, _, err := milestoneFlag(cmd, milestone)
if err != nil {
return err
}
if err := requireNames("label", "", labelNames); err != nil {
return err
}
if err := requireNames("assignee", "", assignees); err != nil {
return err
}
if cmd.Flags().Changed("template") && strings.TrimSpace(templateName) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--template cannot be empty"))
}
files, err := openRegularFiles(attachmentPaths)
defer closeFiles(files)
if err != nil {
return err
}
var due *time.Time
if cmd.Flags().Changed("deadline") {
due, _, err = parseDeadline(deadline)
if err != nil {
return err
}
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
policy := a.policy(snapshot)
if !localFlagsChanged(cmd) && policy.CanPrompt(a.deps.Streams) && a.deps.IssueForm != nil {
return a.createIssueInteractively(cmd)
}
var resolved target
var templateBody string
if cmd.Flags().Changed("template") {
if resolved, err = a.resolve(cmd.Context(), true); err != nil {
return err
}
template, err := issueTemplate(cmd.Context(), services.Issues{Client: resolved.client, Repository: resolved.Repo}, strings.TrimSpace(templateName))
if err != nil {
return err
}
if !cmd.Flags().Changed("title") {
title = template.Title
}
templateBody = issueTemplateBody(template)
}
if bodyFile == "-" && title == "" {
return clierr.Wrap(clierr.Usage, errors.New("--body-file - requires --title; stdin cannot also supply a prompt"))
}
prompted, err := a.require(cmd, &title, "title")
if err != nil {
return err
}
switch {
case useEditor:
draft, err = a.editDraft(cmd, templateBody)
body = draft.Body
case cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file"):
body, err = a.readBody(cmd, body, bodyFile)
default:
body = templateBody
}
if err != nil {
return err
}
target := resolved
if target.client == nil {
if target, err = a.resolve(cmd.Context(), true); err != nil {
return err
}
}
var milestoneValue int64
if milestone != "" {
if milestoneValue, err = milestoneID(cmd, target, milestone); err != nil {
return err
}
}
labelIDs, err := (services.Labels{Client: target.client, Repository: target.Repo}).ResolveIssueNames(cmd.Context(), labelNames)
if err != nil {
return err
}
if prompted {
if err := a.confirm(cmd, "Create issue on "+target.Repo+": "+title); err != nil {
return err
}
}
service := services.Issues{Client: target.client, Repository: target.Repo}
item, err := service.Create(cmd.Context(), services.IssueOptions{Title: title, Body: body, Deadline: due, Ref: ref, Assignees: assignees, Milestone: milestoneValue, Labels: labelIDs})
if err != nil {
return err
}
var failures []error
for _, file := range files {
if _, err := service.UploadAttachment(cmd.Context(), item.Number, filepath.Base(file.Name()), file); err != nil {
failures = append(failures, fmt.Errorf("issue %d created; attachment %s upload failed: %w", item.Number, filepath.Base(file.Name()), err))
}
}
if err := writeRecord(a, cmd, item, issueRecord); err != nil {
return err
}
return errors.Join(failures...)
}}
create.Flags().StringVar(&title, "title", "", "Issue title")
create.Flags().StringVar(&ref, "ref", "", "Referenced branch or tag")
create.Flags().StringSliceVar(&labelNames, "label", nil, "Apply labels by exact name; use repo: or org: to disambiguate")
create.Flags().StringSliceVar(&assignees, "assignee", nil, "Assign users by username")
create.Flags().StringVarP(&milestone, "milestone", "m", "", "Add the issue to a milestone by `title` or ID")
flagAliases(create, map[string]string{"milestone-name": "milestone"})
create.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp")
create.Flags().StringVar(&body, "body", "", "Issue body")
create.Flags().StringVar(&bodyFile, "body-file", "", "Read body from a file, or - for stdin")
create.Flags().BoolVar(&useEditor, "editor", false, "Compose the issue body in the configured editor")
create.Flags().StringVar(&templateName, "template", "", "Seed title and body from the repository issue template with this name")
create.Flags().StringArrayVar(&attachmentPaths, "attachment", nil, "File to attach after creation (repeatable)")
group.AddCommand(create)
for _, action := range []string{"close", "reopen"} {
group.AddCommand(&cobra.Command{Use: action + " <number>...", Short: titleCase(action) + " repository issues", Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
numbers, err := resourceNumbers(args)
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
state := "closed"
if action == "reopen" {
state = "open"
}
service := services.Issues{Client: target.client, Repository: target.Repo}
return batch(a, cmd, numbers, issueLabel, func(n int64) (services.Issue, error) {
return service.SetState(cmd.Context(), n, state)
}, issueRow)
}})
}
group.AddCommand(a.issueAttachmentCommand(issueRef), a.commentCommand(issueRef), a.issueEditCommand())
return group
}
-5
View File
@@ -23,11 +23,6 @@ var visibilities = []string{"public", "limited", "private"}
// visibilityColors color the visibility of organizations and accounts. // visibilityColors color the visibility of organizations and accounts.
var visibilityColors = map[string]string{"public": "green", "limited": "yellow", "private": "red"} var visibilityColors = map[string]string{"public": "green", "limited": "yellow", "private": "red"}
// servicePage places a page of a service listing for writeTable.
func servicePage[T any](page services.Page[T], number int) listPage {
return listPage{Number: number, More: page.More, Total: page.Total, Counted: page.Counted}
}
// jsonResult writes the result of a mutation when --json asks for it; people get a done line instead. // jsonResult writes the result of a mutation when --json asks for it; people get a done line instead.
func (a *application) jsonResult(cmd *cobra.Command, item any) error { func (a *application) jsonResult(cmd *cobra.Command, item any) error {
if a.options.json == "" { if a.options.json == "" {
+10 -10
View File
@@ -43,9 +43,9 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"issue", "attachment", "download", "1", "1"}, "supply --output-file, or --output-file - for stdout"}, {[]string{"issue", "attachment", "download", "1", "1"}, "supply --output-file, or --output-file - for stdout"},
{[]string{"issue", "attachment", "download", "1", "1", "--json"}, "attachment downloads are binary"}, {[]string{"issue", "attachment", "download", "1", "1", "--json"}, "attachment downloads are binary"},
{[]string{"issue", "attachment", "download", "1", "1", "--output-file", "-", "--json=id"}, "attachment downloads are binary"}, {[]string{"issue", "attachment", "download", "1", "1", "--output-file", "-", "--json=id"}, "attachment downloads are binary"},
{[]string{"issue", "close", "1", "bad"}, "invalid resource number \"bad\""}, {[]string{"issue", "close", "1", "bad"}, `invalid issue "bad": use a number, #number, OWNER/REPO#number or URL`},
{[]string{"issue", "close", "1", "1", "--no-input"}, "resource 1 is selected more than once"}, {[]string{"issue", "close", "1", "#1", "--no-input"}, "#1 is selected more than once"},
{[]string{"issue", "reopen", "1", "01", "--no-input"}, "resource 1 is selected more than once"}, {[]string{"issue", "reopen", "1", "01", "--no-input"}, "#1 is selected more than once"},
{[]string{"issue", "comment", "create", "1", "--body="}, "comment body cannot be empty"}, {[]string{"issue", "comment", "create", "1", "--body="}, "comment body cannot be empty"},
{[]string{"issue", "comment", "create", "1", "--body", "text", "--json=title"}, "unknown JSON field \"title\""}, {[]string{"issue", "comment", "create", "1", "--body", "text", "--json=title"}, "unknown JSON field \"title\""},
{[]string{"issue", "create", "--no-input"}, "missing required input: supply --title"}, {[]string{"issue", "create", "--no-input"}, "missing required input: supply --title"},
@@ -57,16 +57,16 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"issue", "create", "--title", "x", "--attachment", "."}, ". is not a regular file"}, {[]string{"issue", "create", "--title", "x", "--attachment", "."}, ". is not a regular file"},
{[]string{"issue", "create", "--milestone-name="}, "--milestone needs a title or ID"}, {[]string{"issue", "create", "--milestone-name="}, "--milestone needs a title or ID"},
{[]string{"issue", "edit", "1", "--milestone", "v1", "--remove-milestone"}, "--milestone and --remove-milestone cannot be combined"}, {[]string{"issue", "edit", "1", "--milestone", "v1", "--remove-milestone"}, "--milestone and --remove-milestone cannot be combined"},
{[]string{"issue", "edit", "1"}, "supply an edit flag"}, {[]string{"issue", "edit", "1"}, "nothing to edit; pass a flag such as --title (see --help)"},
{[]string{"issue", "edit", "1", "--editor=false"}, "supply an edit flag"}, {[]string{"issue", "edit", "1", "--editor=false"}, "nothing to edit"},
{[]string{"issue", "edit", "1", "--add-labels="}, "supply an edit flag"}, {[]string{"issue", "edit", "1", "--add-labels="}, "nothing to edit"},
{[]string{"issue", "edit", "1", "--title="}, "issue title cannot be empty"}, {[]string{"issue", "edit", "1", "--title="}, "issue title cannot be empty"},
{[]string{"issue", "edit", "1", "--set-assignees", "alice", "--add-assignees", "bob"}, "--set-assignees cannot be combined with --add-assignees or --remove-assignees"}, {[]string{"issue", "edit", "1", "--set-assignees", "alice", "--add-assignees", "bob"}, "--set-assignees cannot be combined with --add-assignee or --remove-assignee"},
{[]string{"issue", "edit", "1", "--set-labels", "bug", "--add-labels", "help"}, "--set-labels cannot be combined with --add-labels or --remove-labels"}, {[]string{"issue", "edit", "1", "--set-labels", "bug", "--add-label", "help"}, "--set-labels cannot be combined with --add-label or --remove-label"},
{[]string{"issue", "edit", "1", "--add-assignees", " "}, "assignee names cannot be empty"}, {[]string{"issue", "edit", "1", "--add-assignees", " "}, "assignee names cannot be empty"},
{[]string{"issue", "edit", "1", "invalid", "--title", "Updated"}, "invalid resource number \"invalid\""}, {[]string{"issue", "edit", "1", "invalid", "--title", "Updated"}, `invalid issue "invalid"`},
{[]string{"issue", "edit", "1", "2", "--editor"}, "--editor requires one issue"}, {[]string{"issue", "edit", "1", "2", "--editor"}, "--editor requires one issue"},
{[]string{"issue", "edit", "1", "01", "--title", "Updated", "--no-input"}, "resource 1 is selected more than once"}, {[]string{"issue", "edit", "1", "01", "--title", "Updated", "--no-input"}, "#1 is selected more than once"},
{[]string{"issue", "list", "--limit", "0"}, `"-L, --limit" flag: must be 1..1000`}, {[]string{"issue", "list", "--limit", "0"}, `"-L, --limit" flag: must be 1..1000`},
{[]string{"issue", "list", "--page", "0"}, `"--page" flag: must be a positive integer`}, {[]string{"issue", "list", "--page", "0"}, `"--page" flag: must be a positive integer`},
{[]string{"issue", "list", "--label", "bug, "}, "label names cannot be empty"}, {[]string{"issue", "list", "--label", "bug, "}, "label names cannot be empty"},
+11 -5
View File
@@ -36,7 +36,7 @@ type Issue struct {
// Kinds of Issue: Gitea lists pull requests among issues. // Kinds of Issue: Gitea lists pull requests among issues.
const ( const (
KindIssue = "issue" KindIssue = "issue"
KindPull = "pull" KindPull = "pr"
) )
// discussionFrom converts an issue or a pull request listed among issues; the state of a merged // discussionFrom converts an issue or a pull request listed among issues; the state of a merged
@@ -96,9 +96,15 @@ func (s Issues) endpoint() string { return "repos/" + s.Repository + "/issues" }
// List returns a page of issues, even when the server caps its page size below the limit, // List returns a page of issues, even when the server caps its page size below the limit,
// and reports whether more issues follow. // and reports whether more issues follow.
func (s Issues) List(ctx context.Context, opts ListOptions) ([]Issue, bool, error) { func (s Issues) List(ctx context.Context, opts ListOptions) ([]Issue, bool, error) {
page, err := s.ListPage(ctx, opts)
return page.Items, page.More, err
}
// ListPage is List with the number of issues that match, as the server counts them.
func (s Issues) ListPage(ctx context.Context, opts ListOptions) (Page[Issue], error) {
owner, repo, err := splitRepository(s.Repository) owner, repo, err := splitRepository(s.Repository)
if err != nil { if err != nil {
return nil, false, err return Page[Issue]{}, err
} }
kind := "issues" kind := "issues"
if opts.Kind != "" { if opts.Kind != "" {
@@ -115,7 +121,7 @@ func (s Issues) List(ctx context.Context, opts ListOptions) ([]Issue, bool, erro
if opts.Before != nil { if opts.Before != nil {
query.Before = *opts.Before query.Before = *opts.Before
} }
issues, more, err := listWindow(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.Issue, http.Header, error) { issues, err := listPage(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.Issue, http.Header, error) {
query.ListOptions = listOptions(limit, page) query.ListOptions = listOptions(limit, page)
issues, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Issue, *gitea.Response, error) { issues, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Issue, *gitea.Response, error) {
return c.Issues.ListRepoIssues(ctx, owner, repo, query) return c.Issues.ListRepoIssues(ctx, owner, repo, query)
@@ -123,9 +129,9 @@ func (s Issues) List(ctx context.Context, opts ListOptions) ([]Issue, bool, erro
return issues, headerOf(resp), err return issues, headerOf(resp), err
}) })
if err != nil { if err != nil {
return nil, false, err return Page[Issue]{}, err
} }
return convertAll(issues, discussionFrom), more, nil return Page[Issue]{Items: convertAll(issues.Items, discussionFrom), More: issues.More, Total: issues.Total, Counted: issues.Counted}, nil
} }
// filterValues splits a comma-separated filter, which the SDK joins again. // filterValues splits a comma-separated filter, which the SDK joins again.
+3 -1
View File
@@ -15,6 +15,8 @@ import (
// IssueEditFormChoices supplies current issue values and selectable options for edit. // IssueEditFormChoices supplies current issue values and selectable options for edit.
// Current values missing from the options stay selectable. // Current values missing from the options stay selectable.
type IssueEditFormChoices struct { type IssueEditFormChoices struct {
// Heading names the edited issue above the form, such as "Editing alice/x#2 (Fix login)".
Heading string
Title, Body string Title, Body string
Assignees []string Assignees []string
Milestone *IssueMilestoneChoice Milestone *IssueMilestoneChoice
@@ -60,7 +62,7 @@ func (p *FormPrompter) EditIssue(ctx context.Context, choices IssueEditFormChoic
for _, label := range choices.Labels { for _, label := range choices.Labels {
fields.labels = append(fields.labels, label.ID) fields.labels = append(fields.labels, label.ID)
} }
groups := fields.groups(choices.EditBody, choices.CanPush, withCurrent(choices.AvailableAssignees, choices.Assignees...), milestones, withCurrent(choices.AvailableLabels, choices.Labels...)) groups := fields.groups(choices.Heading, choices.EditBody, choices.CanPush, withCurrent(choices.AvailableAssignees, choices.Assignees...), milestones, withCurrent(choices.AvailableLabels, choices.Labels...))
if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil { if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil {
return IssueEditFormValues{}, err return IssueEditFormValues{}, err
} }
+51 -5
View File
@@ -42,6 +42,8 @@ type IssueFormChoices struct {
Labels []IssueLabelChoice Labels []IssueLabelChoice
CanPush bool CanPush bool
EditBody BodyEditor EditBody BodyEditor
// AskNext asks "What's next?" after the title and body, offering metadata only on request.
AskNext bool
} }
// IssueFormValues holds interactively collected issue create fields. // IssueFormValues holds interactively collected issue create fields.
@@ -87,6 +89,7 @@ func (p *FormPrompter) CreateIssue(ctx context.Context, choices IssueFormChoices
title, body = choices.Templates[selected].Title, choices.Templates[selected].Body title, body = choices.Templates[selected].Title, choices.Templates[selected].Body
} }
} }
choices.AskNext = true
return p.collectIssueProperties(ctx, title, body, choices) return p.collectIssueProperties(ctx, title, body, choices)
} }
@@ -98,14 +101,25 @@ type issueFields struct {
labels []int64 labels []int64
} }
// groups builds title/body and, when canPush, assignee/milestone/label/due-date fields. The labels // groups builds the content group, titled heading, and the metadata groups. The labels name no
// name no resource, as issues and pull requests share them. // resource, as issues and pull requests share them.
func (f *issueFields) groups(editBody BodyEditor, canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group { func (f *issueFields) groups(heading string, editBody BodyEditor, canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group {
return append([]*huh.Group{f.content(heading, editBody)}, f.metadata(canPush, assignees, milestones, labels)...)
}
// content asks for the title and, without editBody, the body.
func (f *issueFields) content(heading string, editBody BodyEditor) *huh.Group {
fields := []huh.Field{titleField(&f.title)} fields := []huh.Field{titleField(&f.title)}
if editBody == nil { if editBody == nil {
fields = append(fields, bodyField(BodyTitle, &f.body)) fields = append(fields, bodyField(BodyTitle, &f.body))
} }
groups := []*huh.Group{huh.NewGroup(fields...)} return huh.NewGroup(fields...).Title(heading)
}
// metadata asks for assignees, a milestone, labels and a due date, which only people who can push
// may set.
func (f *issueFields) metadata(canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group {
var groups []*huh.Group
if !canPush { if !canPush {
return groups return groups
} }
@@ -161,11 +175,43 @@ func parseDueDate(value string) (*time.Time, error) {
return &parsed, nil return &parsed, nil
} }
// What the create form does after the title and body.
const (
nextSubmit = iota
nextMetadata
nextCancel
)
// collectIssueProperties asks for the title and, without EditBody, the body, then the metadata. With
// AskNext it asks whether to submit, add metadata first, or cancel, like gh, in between. EditBody
// collects the body once the form is submitted.
func (p *FormPrompter) collectIssueProperties(ctx context.Context, title, body string, choices IssueFormChoices) (IssueFormValues, error) { func (p *FormPrompter) collectIssueProperties(ctx context.Context, title, body string, choices IssueFormChoices) (IssueFormValues, error) {
fields := issueFields{title: title, body: body} fields := issueFields{title: title, body: body}
if err := p.runForm(ctx, huh.NewForm(fields.groups(choices.EditBody, choices.CanPush, choices.Assignees, choices.Milestones, choices.Labels)...)); err != nil { metadata := fields.metadata(choices.CanPush, choices.Assignees, choices.Milestones, choices.Labels)
groups := []*huh.Group{fields.content("", choices.EditBody)}
next := nextMetadata
if choices.AskNext {
options := []huh.Option[int]{huh.NewOption("Submit", nextSubmit)}
if len(metadata) > 0 {
options = append(options, huh.NewOption("Add metadata", nextMetadata))
}
next = nextSubmit
options = append(options, huh.NewOption("Cancel", nextCancel))
groups = append(groups, huh.NewGroup(huh.NewSelect[int]().Title("What's next?").Options(options...).Value(&next)))
} else {
groups, metadata = append(groups, metadata...), nil
}
if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil {
return IssueFormValues{}, err return IssueFormValues{}, err
} }
switch {
case next == nextCancel:
return IssueFormValues{}, ErrCanceled
case next == nextMetadata && len(metadata) > 0:
if err := p.runForm(ctx, huh.NewForm(metadata...)); err != nil {
return IssueFormValues{}, err
}
}
values := IssueFormValues{Title: strings.TrimSpace(fields.title), Assignees: fields.assignees, Milestone: fields.milestone, Labels: fields.labels} values := IssueFormValues{Title: strings.TrimSpace(fields.title), Assignees: fields.assignees, Milestone: fields.milestone, Labels: fields.labels}
var err error var err error
if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil { if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil {
+54
View File
@@ -6,8 +6,11 @@ package terminal
import ( import (
"bytes" "bytes"
"context" "context"
"io"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -28,6 +31,57 @@ func TestCollectIssuePropertiesUsesEditBodyDraft(t *testing.T) {
require.NotEmpty(t, values.Draft.Path) require.NotEmpty(t, values.Draft.Path)
} }
// lineReader returns one line per read, so that each accessible prompt reads only its answer.
type lineReader struct{ text string }
func (r *lineReader) Read(p []byte) (int, error) {
if r.text == "" {
return 0, io.EOF
}
line, _, _ := strings.Cut(r.text, "\n")
n := copy(p, line+"\n")
r.text = r.text[min(n, len(r.text)):]
return n, nil
}
func TestIssueFormAsksWhatsNextBeforeMetadata(t *testing.T) {
choices := IssueFormChoices{AskNext: true, CanPush: true, Assignees: []string{"alice"}, EditBody: noEdit}
for _, test := range []struct {
name, input string
canceled bool
}{
{"submit", "Title\n1\n", false},
{"cancel", "Title\n3\n", true},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("TERM", "dumb")
p, rendered := testPrompter(&lineReader{text: test.input})
values, err := p.collectIssueProperties(context.Background(), "", "", choices)
require.Contains(t, rendered.String(), "What's next?")
require.Contains(t, rendered.String(), "Add metadata")
require.NotContains(t, rendered.String(), "Assignees:")
if test.canceled {
require.ErrorIs(t, err, ErrCanceled)
return
}
require.NoError(t, err)
require.Equal(t, "Title", values.Title)
require.Empty(t, values.Assignees)
})
}
t.Setenv("TERM", "dumb")
p, rendered := testPrompter(&lineReader{text: "Title\n2\n1\n0\n2028-01-15\n"})
values, err := p.collectIssueProperties(context.Background(), "", "", choices)
require.NoError(t, err)
require.Contains(t, rendered.String(), "Assignees:")
require.Equal(t, []string{"alice"}, values.Assignees)
require.Equal(t, "2028-01-15", values.Deadline.Format(time.DateOnly))
p, rendered = accessiblePrompter(t, "Title\n")
_, err = p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit})
require.NoError(t, err)
require.NotContains(t, rendered.String(), "What's next?")
}
func TestIssueFormMarksTitleRequired(t *testing.T) { func TestIssueFormMarksTitleRequired(t *testing.T) {
p, rendered := accessiblePrompter(t, "Title\n") p, rendered := accessiblePrompter(t, "Title\n")
_, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit}) _, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit})
+1 -4
View File
@@ -101,7 +101,7 @@ func (p *FormPrompter) CreatePull(ctx context.Context, choices PullFormChoices)
} }
switch next { switch next {
case pullMetadata: case pullMetadata:
groups := fields.groups(noBody, true, choices.Assignees, choices.Milestones, choices.Labels)[1:] groups := fields.metadata(true, choices.Assignees, choices.Milestones, choices.Labels)
if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil { if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil {
return values, err return values, err
} }
@@ -124,9 +124,6 @@ func (p *FormPrompter) CreatePull(ctx context.Context, choices PullFormChoices)
return values, nil return values, nil
} }
// noBody keeps the body out of issueFields.groups, which the pull request form asks separately.
func noBody(context.Context, string) (Draft, error) { return Draft{}, nil }
// pullBranchFields are the branches of a new pull request. // pullBranchFields are the branches of a new pull request.
type pullBranchFields struct { type pullBranchFields struct {
base, headOwner, headBranch, topic string base, headOwner, headBranch, topic string