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

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

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

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

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

No files matched your search

+14 -9
View File
@@ -78,7 +78,7 @@ func TestIssueListRespectsColorNeverAndStillPages(t *testing.T) {
}))
defer server.Close()
deps, out, _, pager := pagedDeps(t, server.URL, nil, "color", "never", "pager", "configured-pager")
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "list", "-R", "alice/project", "--no-input"}))
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "list", "-R", "alice/project", "--state", "all", "--no-input"}))
require.Equal(t, 1, pager.calls)
require.Equal(t, "configured-pager", pager.command)
require.NotContains(t, pager.input, "\x1b[")
@@ -172,9 +172,8 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
args []string
want string
}{
{[]string{"milestone", "issue", "list", "3"}, "1 Issue 1 closed\n2 Issue 2 closed\n"},
{[]string{"issue", "close", "1", "2"}, "1 Issue 1 closed\n2 Issue 2 closed\n"},
{[]string{"branch", "list"}, "Showing 2 of 2 branches in alice/project\n\nNAME COMMIT PROTECTED UPDATED MESSAGE\nmain (default) abc\ndev def\n"},
{[]string{"milestone", "issue", "list", "3"}, "1 Issue 1 closed issue\n2 Issue 2 closed issue\n"},
{[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"},
} {
t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) {
@@ -224,21 +223,27 @@ func TestListsFitTheTerminalAndSplitWhenPiped(t *testing.T) {
`{"number":5,"title":"支持中文路径 (support CJK paths)","state":"open","user":{"login":""}}]`))
}))
defer server.Close()
for _, group := range []string{"issue"} {
for group, want := range map[string][2]string{
"issue": {
"6\tA very long issue title that keeps going and going to see how list output handles wrapping\t\topen\tissue\talice\t\n" +
"5\t支持中文路径 (support CJK paths)\t\topen\tissue\t\t\n",
"Showing 2 of 2 open issues in alice/project\n\n" +
"ID TITLE LABELS AUTHOR UPDATED\n" +
"#6 A very long issue ti… alice\n" +
"#5 支持中文路径 (suppor…\n",
},
} {
t.Run(group, func(t *testing.T) {
deps, out, _ := serverDeps(t, server.URL)
args := []string{group, "list", "-R", "alice/project"}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "6\tA very long issue title that keeps going and going to see how list output handles wrapping\topen\talice\n"+
"5\t支持中文路径 (support CJK paths)\topen\t\n", out.String())
require.Equal(t, want[0], out.String())
out.Reset()
deps.Streams.OutTTY, deps.Streams.Width = true, 50
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "ID TITLE STATE AUTHOR\n"+
"#6 A very long issue title that k… open alice\n"+
"#5 支持中文路径 (support CJK path… open\n", stripLinks(out.String()))
require.Equal(t, want[1], stripLinks(out.String()))
for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
require.LessOrEqual(t, runewidth.StringWidth(stripLinks(line)), 50, line)
}