Files
gitea-tea/internal/terminal/pull_form.go
T
bircni 0b6232087e 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>
2026-10-12 00:15:53 +02:00

185 lines
7.0 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"context"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"charm.land/huh/v2"
)
// PullFormChoices supplies defaults and selectable options for interactive PR create.
type PullFormChoices struct {
// Repo is the base repository, named in the form's header.
Repo string
BaseOwner, Base, HeadOwner, HeadBranch string
// Title and Body seed the form; Title applies only while HeadBranch is kept.
Title, Body string
AllowMaintainerEdits bool
Assignees []string
Milestones []IssueMilestoneChoice
Labels []IssueLabelChoice
// CanPush offers metadata, which needs push access; without it, changing branches offers AGit.
CanPush bool
EditBody BodyEditor
}
// PullFormValues holds interactively collected pull request create fields.
type PullFormValues struct {
IssueFormValues
Agit bool
Base, Head, Topic string
AllowMaintainerEdits bool
// AsDraft submits the pull request as a draft.
AsDraft bool
}
// PullForm collects interactive pull create input.
type PullForm interface {
CreatePull(context.Context, PullFormChoices) (PullFormValues, error)
}
// pullNext is what the user does after writing a pull request's title and body.
type pullNext int
const (
pullSubmit pullNext = iota
pullSubmitDraft
pullMetadata
pullBranches
pullCancel
)
// CreatePull runs the interactive pull create form on stderr, like gh pr create: the title and
// body of a pull request from the current branch, then "What's next?" to submit it, submit it as a
// draft, add metadata, change the branches or cancel. Branches are asked first when unknown.
func (p *FormPrompter) CreatePull(ctx context.Context, choices PullFormChoices) (PullFormValues, error) {
branches := pullBranchFields{base: choices.Base, headOwner: choices.HeadOwner, headBranch: choices.HeadBranch, allowEdits: choices.AllowMaintainerEdits}
branches.topic = branches.headBranch
if strings.TrimSpace(branches.base) == "" || strings.TrimSpace(branches.headBranch) == "" {
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(branches.fields(!choices.CanPush)...))); err != nil {
return PullFormValues{}, err
}
}
fmt.Fprintln(p.Streams.ErrOut, SafeLine(branches.header(choices.Repo, choices.BaseOwner)))
title := DefaultPullTitle(branches.headBranch)
if choices.Title != "" && branches.headBranch == choices.HeadBranch {
title = choices.Title
}
fields := issueFields{title: title, body: choices.Body}
content := []huh.Field{titleField(&fields.title)}
if choices.EditBody == nil {
content = append(content, bodyField(BodyTitle, &fields.body))
}
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(content...))); err != nil {
return PullFormValues{}, err
}
values := PullFormValues{}
var err error
if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil {
return values, err
}
for next := pullMetadata; next == pullMetadata || next == pullBranches; {
metadata := choices.CanPush && !branches.agit && (len(choices.Assignees)+len(choices.Milestones)+len(choices.Labels) > 0)
options := []huh.Option[pullNext]{huh.NewOption("Submit", pullSubmit)}
if !branches.agit {
options = append(options, huh.NewOption("Submit as draft", pullSubmitDraft))
}
if metadata {
options = append(options, huh.NewOption("Add metadata (assignees, labels, milestone, due date)", pullMetadata))
}
options = append(options, huh.NewOption("Change branches", pullBranches), huh.NewOption("Cancel", pullCancel))
next = pullSubmit
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewSelect[pullNext]().Title("What's next?").Options(options...).Value(&next)))); err != nil {
return values, err
}
switch next {
case pullMetadata:
groups := fields.metadata(true, choices.Assignees, choices.Milestones, choices.Labels)
if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil {
return values, err
}
case pullBranches:
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(branches.fields(!choices.CanPush)...))); err != nil {
return values, err
}
fmt.Fprintln(p.Streams.ErrOut, SafeLine(branches.header(choices.Repo, choices.BaseOwner)))
case pullCancel:
return values, ErrCanceled
}
values.AsDraft = next == pullSubmitDraft
}
values.Title, values.Assignees, values.Milestone, values.Labels = strings.TrimSpace(fields.title), fields.assignees, fields.milestone, fields.labels
if values.Deadline, err = parseDueDate(fields.deadline); err != nil {
return values, err
}
values.Agit, values.Base, values.Topic, values.AllowMaintainerEdits = branches.agit, strings.TrimSpace(branches.base), strings.TrimSpace(branches.topic), branches.allowEdits
values.Head = branches.head(choices.BaseOwner)
return values, nil
}
// pullBranchFields are the branches of a new pull request.
type pullBranchFields struct {
base, headOwner, headBranch, topic string
allowEdits, agit bool
}
// fields asks for the branches; offerAgit adds the choice of an AGit push for users who cannot
// push to the base repository.
func (b *pullBranchFields) fields(offerAgit bool) []huh.Field {
fields := []huh.Field{huh.NewInput().Title("Base branch (required):").Value(&b.base).Validate(huh.ValidateNotEmpty())}
if offerAgit {
fields = append(fields, huh.NewConfirm().Title("Push through AGit (refs/for) instead of a branch of your own?").Value(&b.agit))
}
return append(fields,
huh.NewInput().Title("Head repository owner:").Value(&b.headOwner),
huh.NewInput().Title("Head branch (required):").Value(&b.headBranch).Validate(huh.ValidateNotEmpty()),
huh.NewInput().Title("AGit topic (when pushing through AGit):").Value(&b.topic),
huh.NewConfirm().Title("Allow maintainers to push to the head branch?").Value(&b.allowEdits),
)
}
// head is the head of the pull request: BRANCH, OWNER:BRANCH for a head in another repository, or
// HEAD for an AGit push without a branch.
func (b *pullBranchFields) head(baseOwner string) string {
branch, owner := strings.TrimSpace(b.headBranch), strings.TrimSpace(b.headOwner)
switch {
case b.agit && branch == "":
return "HEAD"
case !b.agit && owner != "" && owner != baseOwner:
return owner + ":" + branch
}
return branch
}
// header introduces the pull request: "Creating pull request for feat/one into main in alice/x".
func (b *pullBranchFields) header(repo, baseOwner string) string {
text := fmt.Sprintf("Creating pull request for %s into %s", b.head(baseOwner), strings.TrimSpace(b.base))
if repo != "" {
text += " in " + repo
}
return text
}
// DefaultPullTitle turns a branch-like head into a readable pull title seed.
func DefaultPullTitle(head string) string {
if i := strings.LastIndex(head, ":"); i != -1 {
head = head[i+1:]
}
parts := strings.Fields(strings.ToLower(strings.NewReplacer("_", " ", "-", " ").Replace(head)))
if len(parts) == 0 {
return ""
}
for i, part := range parts {
r, size := utf8.DecodeRuneInString(part)
parts[i] = string(unicode.ToUpper(r)) + part[size:]
}
return strings.Join(parts, " ")
}