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
+787 -374

No files matched your search

+3 -10
View File
@@ -200,7 +200,7 @@ func TestRawJSONCommandsKeepJQToThemselves(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: --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)
err := Execute(context.Background(), deps, []string{"issue", "list", "--zzzzzz"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
@@ -251,7 +251,7 @@ func TestCommandHelpShowsRelevantFlagsOnly(t *testing.T) {
for _, want := range []string{
"\nUSAGE\n tea issue list [flags]\n",
"\nALIASES\n tea issue ls\n",
"\nFLAGS\n --assignee string ",
"\nFLAGS\n -a, --assignee string ",
"\nINHERITED FLAGS\n -R, --repo OWNER/REPO ",
"\n --json fields ",
"\n -q, --jq expression ",
@@ -323,7 +323,7 @@ func TestHelpTopicsAndUnknownTopics(t *testing.T) {
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)
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) {
@@ -424,17 +424,11 @@ const (
issue attachment list
issue attachment upload
issue attachment view
issue close
issue comment create
issue comment delete
issue comment edit
issue comment list
issue comment view
issue create
issue edit
issue list
issue reopen
issue view
label create
label delete
label edit
@@ -469,7 +463,6 @@ const (
time reset
`
longsToWrite = `
issue
issue attachment
issue comment
label
+2 -2
View File
@@ -85,8 +85,8 @@ func TestArgumentErrorsShowTheUsageLine(t *testing.T) {
args []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", "1", "2"}, "too many arguments\nUsage: tea issue view <number> [flags]"},
{[]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> | <url>} [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{"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
for i, r := range rest {
switch r {
case '<', '[':
case '<', '[', '{':
if depth == 0 {
start = i
}
depth++
case '>', ']':
case '>', ']', '}':
if depth--; depth != 0 {
continue
}
+7 -3
View File
@@ -9,6 +9,7 @@ import (
"slices"
"strings"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"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
// column, keyed by command path without the root. Their owners replace an entry with a listSpec.
var legacyColumns = map[string][]terminal.Column{
"issue list": numberedColumns,
}
var legacyColumns = map[string][]terminal.Column{}
// numberedColumns head the rows of issueRow and pullRow.
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 {
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()
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, "configured-pager", pager.command)
require.NotContains(t, pager.input, "\x1b[")
@@ -172,9 +172,8 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
args []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{"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"},
} {
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":""}}]`))
}))
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) {
deps, out, _ := serverDeps(t, server.URL)
args := []string{group, "list", "-R", "alice/project"}
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"+
"5\t支持中文路径 (support CJK paths)\topen\t\n", out.String())
require.Equal(t, want[0], out.String())
out.Reset()
deps.Streams.OutTTY, deps.Streams.Width = true, 50
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "ID TITLE STATE AUTHOR\n"+
"#6 A very long issue title that k… open alice\n"+
"#5 支持中文路径 (support CJK path… open\n", stripLinks(out.String()))
require.Equal(t, want[1], stripLinks(out.String()))
for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
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, []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
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "create", "-R", "alice/project", "--json=number,url", "--interactive"}))
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.Equal(t, []string{"alice", "bob"}, form.choices.Assignees)
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.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, "Body text", createPayload["body"])
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"},
}}
deps, out, _ := serverDeps(t, server.URL)
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "chosen-editor"})
deps.IssueForm = form
deps.Prompter = &scriptedPrompter{accept: true}
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
+50 -44
View File
@@ -5,7 +5,6 @@ package app
import (
"errors"
"fmt"
"strings"
"time"
@@ -15,35 +14,39 @@ import (
"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 {
var title, body, file string
var useEditor bool
var deadline string
var ref string
var milestone string
var removeMilestone bool
var title, body, file, deadline, ref, milestone string
var useEditor, removeMilestone bool
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
defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
numbers, err := resourceNumbers(args)
if err != nil {
return err
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
if !localFlagsChanged(cmd) {
if a.policy(snapshot).CanPrompt(a.deps.Streams) && a.deps.IssueEditForm != nil {
return a.editIssuesInteractively(cmd, numbers)
if !a.policy(snapshot).CanPrompt(a.deps.Streams) || a.deps.IssueEditForm == nil {
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
}
if useEditor && len(numbers) != 1 {
return a.editIssuesInteractively(cmd, target, numbers)
}
if useEditor && len(args) != 1 {
return clierr.Wrap(clierr.Usage, errors.New("--editor requires one issue"))
}
milestone, removeMilestone, err := milestoneFlag(cmd, milestone)
@@ -58,8 +61,7 @@ func (a *application) issueEditCommand() *cobra.Command {
opts.Ref = &ref
}
if cmd.Flags().Changed("deadline") {
opts.Deadline, opts.RemoveDeadline, err = parseDeadline(deadline)
if err != nil {
if opts.Deadline, opts.RemoveDeadline, err = parseDeadline(deadline); err != nil {
return err
}
}
@@ -70,8 +72,7 @@ func (a *application) issueEditCommand() *cobra.Command {
opts.Title = &title
}
if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") {
body, err = a.readBody(cmd, body, file)
if err != nil {
if body, err = a.readBody(cmd, body, file); err != nil {
return err
}
opts.Body = &body
@@ -80,9 +81,9 @@ func (a *application) issueEditCommand() *cobra.Command {
return err
}
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 {
return err
}
@@ -102,46 +103,54 @@ func (a *application) issueEditCommand() *cobra.Command {
if err != nil {
return err
}
draft, err = a.editDraft(cmd, current.Body)
if err != nil {
if draft, err = a.editDraft(cmd, current.Body); err != nil {
return err
}
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)
}, issueRow)
}, editedIssue(target))
}}
cmd.Flags().StringVar(&title, "title", "", "Issue title")
cmd.Flags().StringVar(&ref, "ref", "", "Referenced branch or tag; empty clears")
cmd.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp; empty clears")
cmd.Flags().StringVar(&body, "body", "", "Issue body (empty clears)")
cmd.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin")
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"})
flags := cmd.Flags()
flags.StringVarP(&title, "title", "t", "", "New title")
flags.StringVarP(&body, "body", "b", "", "New body; empty clears it")
flags.StringVarP(&file, "body-file", "F", "", "Read the body from `file`, or - for stdin")
flags.BoolVarP(&useEditor, "editor", "e", false, "Edit the current body in the configured editor")
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
}
func (a *application) editIssuesInteractively(cmd *cobra.Command, numbers []int64) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
// editedIssue words the done line of an issue edit.
func editedIssue(t target) func(services.Issue) string {
return func(item services.Issue) string {
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}
selectables, err := service.CreateSelectables(cmd.Context())
if err != nil {
return err
}
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)
if err != nil {
return item, err
}
choices := terminal.IssueEditFormChoices{
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,
EditBody: a.bodyEditor(cmd),
@@ -160,9 +169,6 @@ func (a *application) editIssuesInteractively(cmd *cobra.Command, numbers []int6
if err != nil {
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}
if selectables.CanPush {
// 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)
}, issueRow)
}, editedIssue(target))
}
+2 -1
View File
@@ -46,6 +46,7 @@ func TestIssueEditImplicitInteractiveForm(t *testing.T) {
deps, out, diagnostics := serverDeps(t, server.URL)
deps.IssueEditForm = issueEditFormFunc(func(_ context.Context, choices terminal.IssueEditFormChoices) (terminal.IssueEditFormValues, error) {
require.NotNil(t, choices.EditBody)
require.Equal(t, "Editing alice/project#5 (Old)", choices.Heading)
calls++
require.Equal(t, "Old", choices.Title)
require.Equal(t, "Old body", choices.Body)
@@ -64,7 +65,7 @@ func TestIssueEditImplicitInteractiveForm(t *testing.T) {
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.Equal(t, 1, calls)
require.Len(t, prompt.confirmations, 1)
require.Empty(t, prompt.confirmations)
require.Equal(t, "Edited", patchPayload["title"])
require.Equal(t, "New body", patchPayload["body"])
require.Equal(t, []any{"bob"}, patchPayload["assignees"])
+106 -11
View File
@@ -6,10 +6,13 @@ package app
import (
"context"
"net/http"
"regexp"
"strconv"
"testing"
"time"
"gitea.dev/tea/internal/clierr"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
@@ -88,22 +91,76 @@ func TestIssueLabelFiltersRejectUnknownRepositoryLabels(t *testing.T) {
}, listed)
}
func TestIssueListLinksNumbersOnColorTerminals(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"number":7,"title":"Short","state":"open","user":{"login":"alice"},"html_url":"https://example.org/i/7"},` +
`{"number":12,"title":"Evil\u001b]52;c;aGk=\u0007\r\nTitle\u202e","state":"closed","user":{"login":"bob"},"html_url":"https://example.org/i/12"}]`))
func TestIssueListTables(t *testing.T) {
var queries []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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()
deps, out, _ := serverDeps(t, server.URL)
args := []string{"issue", "list", "-R", "alice/project"}
deps, out, diagnostics := serverDeps(t, server.URL)
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.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, args))
require.Equal(t, "\x1b[90mID\x1b[0m \x1b[90mTITLE\x1b[0m \x1b[90mSTATE\x1b[0m \x1b[90mAUTHOR\x1b[0m\n"+
"\x1b]8;;https://example.org/i/7\x1b\\#7\x1b]8;;\x1b\\ Short \x1b[32mopen\x1b[0m alice\n"+
"\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())
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()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, append(args, "--author", "carol")))
require.Empty(t, out.String())
require.Equal(t, "no open issues match your filters in alice/project\n", diagnostics.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) {
@@ -181,3 +238,41 @@ func TestIssueMilestoneFiltersRejectUnknownMilestones(t *testing.T) {
}, listed)
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
import (
"cmp"
"context"
"errors"
"fmt"
"net/url"
"path/filepath"
"strconv"
"strings"
@@ -18,6 +20,335 @@ import (
"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) {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
@@ -28,9 +359,10 @@ func (a *application) createIssueInteractively(cmd *cobra.Command) (runErr error
if err != nil {
return err
}
choices := terminal.IssueFormChoices{
CanPush: selectables.CanPush, Assignees: selectables.Assignees,
EditBody: a.bodyEditor(cmd),
choices := terminal.IssueFormChoices{CanPush: selectables.CanPush, Assignees: selectables.Assignees}
// Without an editor the body is a field of the form, asked for right after the title.
if preferences, err := a.preferences(); err != nil || preferences.Editor != "" {
choices.EditBody = a.bodyEditor(cmd)
}
for _, template := range selectables.Templates {
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 {
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{
Title: values.Title, Body: values.Body, Assignees: values.Assignees, Milestone: values.Milestone, Labels: values.Labels, Deadline: values.Deadline,
})
if err != nil {
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.
@@ -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, ", "))
}
// 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
// "" 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) {
@@ -171,19 +555,33 @@ type metadataFlags struct {
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) {
cmd.Flags().StringSliceVar(&f.setLabels, "set-labels", nil, "Replace labels by exact name; repo:/org: disambiguate; empty clears")
cmd.Flags().StringSliceVar(&f.addLabels, "add-labels", nil, "Add labels by exact name; repo:/org: disambiguate")
cmd.Flags().StringSliceVar(&f.removeLabels, "remove-labels", nil, "Remove labels by exact name; repo:/org: disambiguate")
cmd.Flags().StringSliceVar(&f.setAssignees, "set-assignees", nil, "Replace assignees; an empty value clears")
cmd.Flags().StringSliceVar(&f.addAssignees, "add-assignees", nil, "Add assignees")
cmd.Flags().StringSliceVar(&f.removeAssignees, "remove-assignees", nil, "Remove assignees")
flags := cmd.Flags()
flags.StringSliceVar(&f.addLabels, "add-label", nil, "Add labels by `name`; prefix repo: or org: to disambiguate")
flags.StringSliceVar(&f.removeLabels, "remove-label", nil, "Remove labels by `name`")
flags.StringSliceVar(&f.setLabels, "set-labels", nil, "Replace all labels; an empty value clears them")
flags.StringSliceVar(&f.addAssignees, "add-assignee", nil, "Add assignees by `login`; @me for yourself")
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 {
for _, kind := range []string{"assignees", "labels"} {
if cmd.Flags().Changed("set-"+kind) && (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))
for _, kind := range []string{"assignee", "label"} {
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]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 {
@@ -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
// 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) {
t.expandMeAll(f.setAssignees)
t.expandMeAll(f.addAssignees)
t.expandMeAll(f.removeAssignees)
resolver := services.Labels{Client: t.client, Repository: t.Repo}
if cmd.Flags().Changed("set-labels") {
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
}
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.
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.
func (a *application) jsonResult(cmd *cobra.Command, item any) error {
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", "--json"}, "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", "1", "--no-input"}, "resource 1 is selected more than once"},
{[]string{"issue", "reopen", "1", "01", "--no-input"}, "resource 1 is selected more than once"},
{[]string{"issue", "close", "1", "bad"}, `invalid issue "bad": use a number, #number, OWNER/REPO#number or URL`},
{[]string{"issue", "close", "1", "#1", "--no-input"}, "#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", "text", "--json=title"}, "unknown JSON field \"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", "--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"}, "supply an edit flag"},
{[]string{"issue", "edit", "1", "--editor=false"}, "supply an edit flag"},
{[]string{"issue", "edit", "1", "--add-labels="}, "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"}, "nothing to edit"},
{[]string{"issue", "edit", "1", "--add-labels="}, "nothing to edit"},
{[]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-labels", "bug", "--add-labels", "help"}, "--set-labels cannot be combined with --add-labels or --remove-labels"},
{[]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-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", "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", "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", "--page", "0"}, `"--page" flag: must be a positive integer`},
{[]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.
const (
KindIssue = "issue"
KindPull = "pull"
KindPull = "pr"
)
// 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,
// and reports whether more issues follow.
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)
if err != nil {
return nil, false, err
return Page[Issue]{}, err
}
kind := "issues"
if opts.Kind != "" {
@@ -115,7 +121,7 @@ func (s Issues) List(ctx context.Context, opts ListOptions) ([]Issue, bool, erro
if opts.Before != nil {
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)
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)
@@ -123,9 +129,9 @@ func (s Issues) List(ctx context.Context, opts ListOptions) ([]Issue, bool, erro
return issues, headerOf(resp), err
})
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.
+3 -1
View File
@@ -15,6 +15,8 @@ import (
// IssueEditFormChoices supplies current issue values and selectable options for edit.
// Current values missing from the options stay selectable.
type IssueEditFormChoices struct {
// Heading names the edited issue above the form, such as "Editing alice/x#2 (Fix login)".
Heading string
Title, Body string
Assignees []string
Milestone *IssueMilestoneChoice
@@ -60,7 +62,7 @@ func (p *FormPrompter) EditIssue(ctx context.Context, choices IssueEditFormChoic
for _, label := range choices.Labels {
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 {
return IssueEditFormValues{}, err
}
+51 -5
View File
@@ -42,6 +42,8 @@ type IssueFormChoices struct {
Labels []IssueLabelChoice
CanPush bool
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.
@@ -87,6 +89,7 @@ func (p *FormPrompter) CreateIssue(ctx context.Context, choices IssueFormChoices
title, body = choices.Templates[selected].Title, choices.Templates[selected].Body
}
}
choices.AskNext = true
return p.collectIssueProperties(ctx, title, body, choices)
}
@@ -98,14 +101,25 @@ type issueFields struct {
labels []int64
}
// groups builds title/body and, when canPush, assignee/milestone/label/due-date fields. The labels
// name no resource, as issues and pull requests share them.
func (f *issueFields) groups(editBody BodyEditor, canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group {
// groups builds the content group, titled heading, and the metadata groups. The labels name no
// resource, as issues and pull requests share them.
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)}
if editBody == nil {
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 {
return groups
}
@@ -161,11 +175,43 @@ func parseDueDate(value string) (*time.Time, error) {
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) {
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
}
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}
var err error
if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil {
+54
View File
@@ -6,8 +6,11 @@ package terminal
import (
"bytes"
"context"
"io"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
@@ -28,6 +31,57 @@ func TestCollectIssuePropertiesUsesEditBodyDraft(t *testing.T) {
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) {
p, rendered := accessiblePrompter(t, "Title\n")
_, 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 {
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 {
return values, err
}
@@ -124,9 +124,6 @@ func (p *FormPrompter) CreatePull(ctx context.Context, choices PullFormChoices)
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.
type pullBranchFields struct {
base, headOwner, headBranch, topic string