mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
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:
18 files changed
+787
-374
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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]"},
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+50
-44
@@ -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
|
||||||
}
|
}
|
||||||
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"))
|
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,46 +103,54 @@ 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{
|
||||||
|
Heading: "Editing " + refLabel(target.Repo, n, current.Title),
|
||||||
Title: current.Title, Body: current.Body, Assignees: current.Assignees,
|
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))
|
||||||
}
|
}
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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()
|
out.Reset()
|
||||||
deps.Streams.OutTTY = true
|
deps.Streams.OutTTY = true
|
||||||
require.NoError(t, Execute(context.Background(), deps, args))
|
require.NoError(t, Execute(context.Background(), deps, append(append(args, test.args...), "--no-input")))
|
||||||
require.Equal(t, "\x1b[90mID\x1b[0m \x1b[90mTITLE\x1b[0m \x1b[90mSTATE\x1b[0m \x1b[90mAUTHOR\x1b[0m\n"+
|
require.Equal(t, test.want, stripLinks(stripColors(out.String())))
|
||||||
"\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.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) {
|
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
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user