Files
gitea-tea/internal/terminal/issue_form.go
T
2026-10-18 17:43:58 +02:00

235 lines
7.9 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"context"
"errors"
"strconv"
"strings"
"time"
"charm.land/huh/v2"
)
// IssueTemplateChoice seeds title/body when selected in an interactive create form.
type IssueTemplateChoice struct {
Name, About, Title, Body string
}
// IssueMilestoneChoice identifies an open milestone for interactive selection.
type IssueMilestoneChoice struct {
Title string
ID int64
}
// IssueLabelChoice identifies a repository label for interactive selection.
type IssueLabelChoice struct {
Name string
ID int64
}
// BodyEditor collects a markdown body through the shared draft editor instead of
// an inline text field. Callers must finish the returned Draft.
type BodyEditor func(context.Context, string) (Draft, error)
// IssueFormChoices supplies options for an interactive issue create form.
type IssueFormChoices struct {
Templates []IssueTemplateChoice
Assignees []string
Milestones []IssueMilestoneChoice
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.
type IssueFormValues struct {
Title, Body string
Assignees []string
Milestone int64
Labels []int64
Deadline *time.Time
Draft Draft
}
// IssueForm collects interactive issue create input.
type IssueForm interface {
CreateIssue(context.Context, IssueFormChoices) (IssueFormValues, error)
}
// CreateIssue runs the interactive issue create form on stderr.
func (p *FormPrompter) CreateIssue(ctx context.Context, choices IssueFormChoices) (IssueFormValues, error) {
title, body := "", ""
if len(choices.Templates) > 0 {
options := []huh.Option[int]{huh.NewOption("[none]", -1)}
for i, template := range choices.Templates {
label := template.Name
switch {
case template.Name != "" && template.About != "":
label = template.Name + " — " + template.About
case label == "":
label = template.About
}
if label == "" {
label = "template " + strconv.Itoa(i+1)
}
options = append(options, huh.NewOption(SafeLine(label), i))
}
selected := -1
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
huh.NewSelect[int]().Title("Issue template:").Options(options...).Height(min(len(options), 10)+2).Value(&selected),
))); err != nil {
return IssueFormValues{}, err
}
if selected >= 0 {
title, body = choices.Templates[selected].Title, choices.Templates[selected].Body
}
}
choices.AskNext = true
return p.collectIssueProperties(ctx, title, body, choices)
}
// issueFields holds the shared issue form inputs that create and edit bind to.
type issueFields struct {
title, body, deadline string
assignees []string
milestone int64
labels []int64
}
// 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))
}
return huh.NewGroup(fields...).Title(SafeLine(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
}
if len(assignees) > 0 {
groups = append(groups, huh.NewGroup(multiSelect("Assignees:", huh.NewOptions(assignees...), &f.assignees)))
}
if len(milestones) > 0 {
options := []huh.Option[int64]{huh.NewOption("[none]", int64(0))}
for _, milestone := range milestones {
options = append(options, huh.NewOption(SafeLine(milestone.Title), milestone.ID))
}
groups = append(groups, huh.NewGroup(huh.NewSelect[int64]().Title("Milestone:").Options(options...).Height(min(len(options), 10)+2).Value(&f.milestone)))
}
if len(labels) > 0 {
options := make([]huh.Option[int64], 0, len(labels))
for _, label := range labels {
options = append(options, huh.NewOption(SafeLine(label.Name), label.ID))
}
groups = append(groups, huh.NewGroup(multiSelect("Labels:", options, &f.labels)))
}
return append(groups, huh.NewGroup(dueDateInput("Due date:", &f.deadline)))
}
// editBody runs the draft editor after the form, keeping the draft on failure.
func editBody(ctx context.Context, edit BodyEditor, body string) (string, Draft, error) {
if edit == nil {
return body, Draft{}, nil
}
draft, err := edit(ctx, body)
if err != nil {
return body, draft, err
}
return draft.Body, draft, nil
}
func dueDateInput(title string, value *string) *huh.Input {
return huh.NewInput().Title(title).Placeholder("YYYY-MM-DD").Value(value).Validate(func(s string) error {
_, err := parseDueDate(s)
return err
})
}
// parseDueDate returns nil for an empty value.
func parseDueDate(value string) (*time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
parsed, err := time.Parse(time.DateOnly, value)
if err != nil {
return nil, errors.New("use YYYY-MM-DD")
}
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}
metadata := fields.metadata(choices.CanPush, choices.Assignees, choices.Milestones, choices.Labels)
content := fields.content("", choices.EditBody)
values := IssueFormValues{}
var err error
if !choices.AskNext {
if err := p.runForm(ctx, huh.NewForm(append([]*huh.Group{content}, metadata...)...)); err != nil {
return IssueFormValues{}, err
}
} else {
if err := p.runForm(ctx, huh.NewForm(content)); err != nil {
return IssueFormValues{}, err
}
// The body comes before "What's next?", so canceling still reports the draft.
if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil {
return values, err
}
options := []huh.Option[int]{huh.NewOption("Submit", nextSubmit)}
if len(metadata) > 0 {
options = append(options, huh.NewOption("Add metadata", nextMetadata))
}
options = append(options, huh.NewOption("Cancel", nextCancel))
for next := nextMetadata; next == nextMetadata; {
next = nextSubmit
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewSelect[int]().Title("What's next?").Options(options...).Value(&next)))); err != nil {
return values, err
}
switch next {
case nextCancel:
return values, ErrCanceled
case nextMetadata:
if err := p.runForm(ctx, huh.NewForm(metadata...)); err != nil {
return values, err
}
}
}
}
values.Title, values.Assignees, values.Milestone, values.Labels = strings.TrimSpace(fields.title), fields.assignees, fields.milestone, fields.labels
if !choices.AskNext {
if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil {
return values, err
}
}
values.Deadline, err = parseDueDate(fields.deadline)
return values, err
}