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

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

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

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

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

No files matched your search

+3 -1
View File
@@ -15,6 +15,8 @@ import (
// IssueEditFormChoices supplies current issue values and selectable options for edit.
// Current values missing from the options stay selectable.
type IssueEditFormChoices struct {
// Heading names the edited issue above the form, such as "Editing alice/x#2 (Fix login)".
Heading string
Title, Body string
Assignees []string
Milestone *IssueMilestoneChoice
@@ -60,7 +62,7 @@ func (p *FormPrompter) EditIssue(ctx context.Context, choices IssueEditFormChoic
for _, label := range choices.Labels {
fields.labels = append(fields.labels, label.ID)
}
groups := fields.groups(choices.EditBody, choices.CanPush, withCurrent(choices.AvailableAssignees, choices.Assignees...), milestones, withCurrent(choices.AvailableLabels, choices.Labels...))
groups := fields.groups(choices.Heading, choices.EditBody, choices.CanPush, withCurrent(choices.AvailableAssignees, choices.Assignees...), milestones, withCurrent(choices.AvailableLabels, choices.Labels...))
if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil {
return IssueEditFormValues{}, err
}
+51 -5
View File
@@ -42,6 +42,8 @@ type IssueFormChoices struct {
Labels []IssueLabelChoice
CanPush bool
EditBody BodyEditor
// AskNext asks "What's next?" after the title and body, offering metadata only on request.
AskNext bool
}
// IssueFormValues holds interactively collected issue create fields.
@@ -87,6 +89,7 @@ func (p *FormPrompter) CreateIssue(ctx context.Context, choices IssueFormChoices
title, body = choices.Templates[selected].Title, choices.Templates[selected].Body
}
}
choices.AskNext = true
return p.collectIssueProperties(ctx, title, body, choices)
}
@@ -98,14 +101,25 @@ type issueFields struct {
labels []int64
}
// groups builds title/body and, when canPush, assignee/milestone/label/due-date fields. The labels
// name no resource, as issues and pull requests share them.
func (f *issueFields) groups(editBody BodyEditor, canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group {
// groups builds the content group, titled heading, and the metadata groups. The labels name no
// resource, as issues and pull requests share them.
func (f *issueFields) groups(heading string, editBody BodyEditor, canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group {
return append([]*huh.Group{f.content(heading, editBody)}, f.metadata(canPush, assignees, milestones, labels)...)
}
// content asks for the title and, without editBody, the body.
func (f *issueFields) content(heading string, editBody BodyEditor) *huh.Group {
fields := []huh.Field{titleField(&f.title)}
if editBody == nil {
fields = append(fields, bodyField(BodyTitle, &f.body))
}
groups := []*huh.Group{huh.NewGroup(fields...)}
return huh.NewGroup(fields...).Title(heading)
}
// metadata asks for assignees, a milestone, labels and a due date, which only people who can push
// may set.
func (f *issueFields) metadata(canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group {
var groups []*huh.Group
if !canPush {
return groups
}
@@ -161,11 +175,43 @@ func parseDueDate(value string) (*time.Time, error) {
return &parsed, nil
}
// What the create form does after the title and body.
const (
nextSubmit = iota
nextMetadata
nextCancel
)
// collectIssueProperties asks for the title and, without EditBody, the body, then the metadata. With
// AskNext it asks whether to submit, add metadata first, or cancel, like gh, in between. EditBody
// collects the body once the form is submitted.
func (p *FormPrompter) collectIssueProperties(ctx context.Context, title, body string, choices IssueFormChoices) (IssueFormValues, error) {
fields := issueFields{title: title, body: body}
if err := p.runForm(ctx, huh.NewForm(fields.groups(choices.EditBody, choices.CanPush, choices.Assignees, choices.Milestones, choices.Labels)...)); err != nil {
metadata := fields.metadata(choices.CanPush, choices.Assignees, choices.Milestones, choices.Labels)
groups := []*huh.Group{fields.content("", choices.EditBody)}
next := nextMetadata
if choices.AskNext {
options := []huh.Option[int]{huh.NewOption("Submit", nextSubmit)}
if len(metadata) > 0 {
options = append(options, huh.NewOption("Add metadata", nextMetadata))
}
next = nextSubmit
options = append(options, huh.NewOption("Cancel", nextCancel))
groups = append(groups, huh.NewGroup(huh.NewSelect[int]().Title("What's next?").Options(options...).Value(&next)))
} else {
groups, metadata = append(groups, metadata...), nil
}
if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil {
return IssueFormValues{}, err
}
switch {
case next == nextCancel:
return IssueFormValues{}, ErrCanceled
case next == nextMetadata && len(metadata) > 0:
if err := p.runForm(ctx, huh.NewForm(metadata...)); err != nil {
return IssueFormValues{}, err
}
}
values := IssueFormValues{Title: strings.TrimSpace(fields.title), Assignees: fields.assignees, Milestone: fields.milestone, Labels: fields.labels}
var err error
if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil {
+54
View File
@@ -6,8 +6,11 @@ package terminal
import (
"bytes"
"context"
"io"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
@@ -28,6 +31,57 @@ func TestCollectIssuePropertiesUsesEditBodyDraft(t *testing.T) {
require.NotEmpty(t, values.Draft.Path)
}
// lineReader returns one line per read, so that each accessible prompt reads only its answer.
type lineReader struct{ text string }
func (r *lineReader) Read(p []byte) (int, error) {
if r.text == "" {
return 0, io.EOF
}
line, _, _ := strings.Cut(r.text, "\n")
n := copy(p, line+"\n")
r.text = r.text[min(n, len(r.text)):]
return n, nil
}
func TestIssueFormAsksWhatsNextBeforeMetadata(t *testing.T) {
choices := IssueFormChoices{AskNext: true, CanPush: true, Assignees: []string{"alice"}, EditBody: noEdit}
for _, test := range []struct {
name, input string
canceled bool
}{
{"submit", "Title\n1\n", false},
{"cancel", "Title\n3\n", true},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("TERM", "dumb")
p, rendered := testPrompter(&lineReader{text: test.input})
values, err := p.collectIssueProperties(context.Background(), "", "", choices)
require.Contains(t, rendered.String(), "What's next?")
require.Contains(t, rendered.String(), "Add metadata")
require.NotContains(t, rendered.String(), "Assignees:")
if test.canceled {
require.ErrorIs(t, err, ErrCanceled)
return
}
require.NoError(t, err)
require.Equal(t, "Title", values.Title)
require.Empty(t, values.Assignees)
})
}
t.Setenv("TERM", "dumb")
p, rendered := testPrompter(&lineReader{text: "Title\n2\n1\n0\n2028-01-15\n"})
values, err := p.collectIssueProperties(context.Background(), "", "", choices)
require.NoError(t, err)
require.Contains(t, rendered.String(), "Assignees:")
require.Equal(t, []string{"alice"}, values.Assignees)
require.Equal(t, "2028-01-15", values.Deadline.Format(time.DateOnly))
p, rendered = accessiblePrompter(t, "Title\n")
_, err = p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit})
require.NoError(t, err)
require.NotContains(t, rendered.String(), "What's next?")
}
func TestIssueFormMarksTitleRequired(t *testing.T) {
p, rendered := accessiblePrompter(t, "Title\n")
_, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit})
+1 -4
View File
@@ -101,7 +101,7 @@ func (p *FormPrompter) CreatePull(ctx context.Context, choices PullFormChoices)
}
switch next {
case pullMetadata:
groups := fields.groups(noBody, true, choices.Assignees, choices.Milestones, choices.Labels)[1:]
groups := fields.metadata(true, choices.Assignees, choices.Milestones, choices.Labels)
if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil {
return values, err
}
@@ -124,9 +124,6 @@ func (p *FormPrompter) CreatePull(ctx context.Context, choices PullFormChoices)
return values, nil
}
// noBody keeps the body out of issueFields.groups, which the pull request form asks separately.
func noBody(context.Context, string) (Draft, error) { return Draft{}, nil }
// pullBranchFields are the branches of a new pull request.
type pullBranchFields struct {
base, headOwner, headBranch, topic string