// 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 cancelling 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 }