mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01: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
+789
-376
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user