Files
gitea-tea/internal/terminal/issue_form.go
T
bircni faea86cd94 refactor: add terminal output and forms package (#1124)
- `internal/terminal`: streams and the prompting policy, interactive forms (issue, PR, review, merge, milestone), editor / pager / browser launching, JSON and `--jq` output.
- An aligned table renderer and a record renderer for human output; both strip control and escape characters from server-supplied text so titles and bodies cannot inject terminal sequences.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1124
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
2026-10-11 17:12:23 +00:00

176 lines
5.7 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
}
// 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(label, i))
}
selected := -1
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
huh.NewSelect[int]().Title("Issue template:").Options(options...).Value(&selected),
))); err != nil {
return IssueFormValues{}, err
}
if selected >= 0 {
title, body = choices.Templates[selected].Title, choices.Templates[selected].Body
}
}
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 title/body and, when canPush, assignee/milestone/label/due-date fields.
func (f *issueFields) groups(editBody BodyEditor, canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group {
fields := []huh.Field{huh.NewInput().Title("Issue title (required):").Value(&f.title).Validate(huh.ValidateNotEmpty())}
if editBody == nil {
fields = append(fields, huh.NewText().Title("Issue description(markdown):").Value(&f.body))
}
groups := []*huh.Group{huh.NewGroup(fields...)}
if !canPush {
return groups
}
if len(assignees) > 0 {
groups = append(groups, huh.NewGroup(huh.NewMultiSelect[string]().Title("Assignees:").Options(huh.NewOptions(assignees...)...).Value(&f.assignees)))
}
if len(milestones) > 0 {
options := []huh.Option[int64]{huh.NewOption("[none]", int64(0))}
for _, milestone := range milestones {
options = append(options, huh.NewOption(milestone.Title, milestone.ID))
}
groups = append(groups, huh.NewGroup(huh.NewSelect[int64]().Title("Milestone:").Options(options...).Value(&f.milestone)))
}
if len(labels) > 0 {
options := make([]huh.Option[int64], 0, len(labels))
for _, label := range labels {
options = append(options, huh.NewOption(label.Name, label.ID))
}
groups = append(groups, huh.NewGroup(huh.NewMultiSelect[int64]().Title("Labels:").Options(options...).Value(&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
}
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 {
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 {
return values, err
}
values.Deadline, err = parseDueDate(fields.deadline)
return values, err
}