Files
gitea-tea/internal/app/metadata.go
T
bircni 1f4b5f868f refactor(prompts): fold Asker into Prompter and share delete confirmations
FormPrompter is the only production prompter and implements the rich
prompt API, so Ask, AskText and ConfirmDefault become the Prompter
interface and the plain Input/Confirm fallbacks go away.

Confirmations that default to no derive their non-terminal wording
from the prompt, and single deletes share deleteConfirmed for the
prompt, the request and the done line.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 10:47:54 +02:00

393 lines
15 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"cmp"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
// milestoneArguments is the ARGUMENTS help of commands that take milestones.
const milestoneArguments = `A milestone can be supplied by its title, such as "v1.0", or by its ID. A number is an ID
first, then a title; --by-name matches it as a title only.`
// milestoneService returns the milestones of the repository a milestone command targets.
type milestoneService func(*cobra.Command) (services.Milestones, target, error)
func (a *application) milestoneCommand() *cobra.Command {
group := &cobra.Command{Use: "milestone", Short: "Manage milestones", Long: `Work with the milestones of a repository and the issues and pull requests in them.
Milestones are addressed by title or ID.`}
service := func(cmd *cobra.Command) (services.Milestones, target, error) {
t, err := a.resolve(cmd.Context(), true)
return services.Milestones{Client: t.client, Repository: t.Repo}, t, err
}
group.AddCommand(a.milestoneListCommand(service), a.milestoneViewCommand(service), a.milestoneCreateCommand(service), a.milestoneEditCommand(service),
a.milestoneDeleteCommand(service), a.milestoneStateCommand("close", service), a.milestoneStateCommand("reopen", service), a.milestoneIssueCommand())
return group
}
func (a *application) milestoneListCommand(service milestoneService) *cobra.Command {
var limit, page int
var state string
cmd := &cobra.Command{Use: "list", Short: "List milestones", Long: "List the milestones of a repository: open ones first, then by due date.", Example: `$ tea milestone list
$ tea milestone list --state all --json title,due_on,open_issues,closed_issues`, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
s, t, err := service(cmd)
if err != nil {
return err
}
items, err := s.ListPage(cmd.Context(), state, limit, page)
if err != nil {
return err
}
slices.SortStableFunc(items.Items, compareMilestones)
what := "milestones in " + t.Repo
if state != "all" {
what = state + " " + what
}
return writeTable(a, cmd, items.Items, listSpec[services.Milestone]{
Columns: []terminal.Column{{Header: "ID"}, {Header: "Title", Flex: true}, {Header: "State", PipedOnly: state == "open"}, {Header: "Due"}, {Header: "Progress"}},
Row: milestoneRow, Empty: "no " + what, Context: what,
}, servicePage(items, page))
}}
pageFlags(cmd, &limit, &page)
enumFlag(cmd, &state, "state", "s", "open", "Filter by state", "open", "closed", "all")
return cmd
}
// compareMilestones orders open milestones first, then by due date, those without one last.
func compareMilestones(x, y services.Milestone) int {
if x.State != y.State {
return cmp.Compare(boolOrder(x.State != "open"), boolOrder(y.State != "open"))
}
if x.Deadline == nil || y.Deadline == nil {
return cmp.Compare(boolOrder(x.Deadline == nil), boolOrder(y.Deadline == nil))
}
return terminal.ParseTime(*x.Deadline).Compare(terminal.ParseTime(*y.Deadline))
}
func boolOrder(b bool) int {
if b {
return 1
}
return 0
}
func milestoneRow(item services.Milestone) []terminal.Cell {
due := terminal.Cell{}
if item.Deadline != nil {
due.Time = terminal.ParseTime(*item.Deadline)
}
return []terminal.Cell{
{Text: strconv.FormatInt(item.ID, 10), Color: stateColors[item.State]},
{Text: item.Title},
{Text: item.State, Color: stateColors[item.State]},
due,
{Text: milestoneProgress(item)},
}
}
// milestoneProgress summarizes the closed issues of a milestone: "3/5 (60%)".
func milestoneProgress(item services.Milestone) string {
total := item.OpenIssues + item.ClosedIssues
if total == 0 {
return "0/0"
}
return fmt.Sprintf("%d/%d (%d%%)", item.ClosedIssues, total, item.ClosedIssues*100/total)
}
// milestoneName names a milestone in messages: `milestone "v1.0" in alice/x`.
func milestoneName(t target, item services.Milestone) string {
return fmt.Sprintf("milestone %q in %s", item.Title, t.Repo)
}
// milestoneURL is the web page of a milestone, which the API does not return.
func milestoneURL(t target, item services.Milestone) string {
return strings.TrimRight(t.Host, "/") + "/" + t.Repo + "/milestone/" + strconv.FormatInt(item.ID, 10)
}
func milestoneView(t target, now time.Time) func(services.Milestone) terminal.View {
return func(item services.Milestone) terminal.View {
total := item.OpenIssues + item.ClosedIssues
meta := []string{fmt.Sprintf("%d of %d closed", item.ClosedIssues, total)}
if total > 0 {
meta[0] += fmt.Sprintf(" (%d%%)", item.ClosedIssues*100/total)
}
due := ""
if item.Deadline != nil {
deadline := terminal.ParseTime(*item.Deadline)
due = terminal.Timestamp(deadline)
meta = append([]string{"due " + deadline.Format("Jan 2, 2006") + " (" + terminal.RelativeTime(now, deadline) + ")"}, meta...)
}
return terminal.View{
Title: item.Title, ID: "#" + strconv.FormatInt(item.ID, 10),
Badges: []terminal.Cell{{Text: titleCase(item.State), Color: stateColors[item.State]}}, Meta: strings.Join(meta, " • "),
Fields: []terminal.Field{
{Label: "State", Value: item.State, PipedOnly: true},
{Label: "Due", Value: due, PipedOnly: true},
{Label: "Progress", Value: milestoneProgress(item), PipedOnly: true},
},
Body: item.Description, Kind: "milestone", URL: milestoneURL(t, item),
}
}
}
func (a *application) milestoneViewCommand(service milestoneService) *cobra.Command {
var byName, web bool
cmd := &cobra.Command{Use: "view <milestone>", Short: "View a milestone", Example: `$ tea milestone view v1.0
$ tea milestone view 3 --web`, Annotations: map[string]string{"tea.arguments": milestoneArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
s, t, err := service(cmd)
if err != nil {
return err
}
item, err := findMilestone(cmd, s, args[0], byName)
if err != nil {
return err
}
if web {
return a.openBrowser(cmd, milestoneURL(t, item))
}
return writeView(a, cmd, item, milestoneView(t, a.deps.Now()))
}}
cmd.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the milestone in the browser")
cmd.ValidArgsFunction = a.completeMilestoneArgs(false)
return cmd
}
// milestoneFields are the fields of milestone create and edit.
type milestoneFields struct {
title, description, descriptionFile, deadline string
useEditor bool
}
func (f *milestoneFields) register(cmd *cobra.Command) {
flags := cmd.Flags()
flags.StringVarP(&f.title, "title", "t", "", "Milestone title")
flags.StringVarP(&f.description, "description", "d", "", "Milestone description")
flags.StringVarP(&f.descriptionFile, "description-file", "F", "", "Read the description from `file`, or - for stdin")
flags.BoolVarP(&f.useEditor, "editor", "e", false, "Write the description in the configured editor")
flags.StringVar(&f.deadline, "deadline", "", "Due `date`: YYYY-MM-DD, RFC3339, or Unix timestamp; empty clears it")
}
// milestoneOptions returns the fields that were passed, reading the description and checking the
// rest before any request. Callers run the editor for --editor.
func (a *application) milestoneOptions(cmd *cobra.Command, f *milestoneFields) (services.MilestoneOptions, error) {
var opts services.MilestoneOptions
if cmd.Flags().Changed("title") {
if strings.TrimSpace(f.title) == "" {
return opts, clierr.Wrap(clierr.Usage, errors.New("milestone title cannot be empty"))
}
opts.Title = &f.title
}
if cmd.Flags().Changed("deadline") {
date := time.Time{}
if f.deadline != "" {
parsed, _, err := parseDeadline(f.deadline)
if err != nil {
return opts, err
}
date = *parsed
}
normalized := date.Format(time.RFC3339)
opts.Deadline = &normalized
}
if cmd.Flags().Changed("description") || cmd.Flags().Changed("description-file") {
description, err := a.readTextSource(cmd, f.description, f.descriptionFile, "description", "description-file")
if err != nil {
return opts, err
}
opts.Description = &description
}
return opts, nil
}
// formOptions turns milestone form values into options.
func formOptions(values terminal.MilestoneFormValues) services.MilestoneOptions {
deadline := time.Time{}
if values.Deadline != nil {
deadline = *values.Deadline
}
normalized := deadline.Format(time.RFC3339)
return services.MilestoneOptions{Title: &values.Title, Description: &values.Description, Deadline: &normalized}
}
func (a *application) milestoneCreateCommand(service milestoneService) *cobra.Command {
var fields milestoneFields
var state string
cmd := &cobra.Command{Use: "create [<title>]", Aliases: []string{"new"}, Short: "Create a milestone", Long: `Create a milestone. Without flags on a terminal, a form asks for the title, description
and due date. The new milestone's URL is printed, so scripts can capture it.`, Example: `$ tea milestone create v1.0 --deadline 2026-12-31 --description "First release"
$ tea milestone create --title v2.0 --editor`, Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
if err := validateTextSources(cmd, "description", "description-file"); err != nil {
return err
}
if len(args) == 1 {
if cmd.Flags().Changed("title") {
return clierr.Wrap(clierr.Usage, errors.New("pass the title as an argument or --title, not both"))
}
if err := cmd.Flags().Set("title", args[0]); err != nil {
return err
}
}
prompt := a.canPrompt()
var opts services.MilestoneOptions
var draft terminal.Draft
var err error
defer finishDraft(&draft, &runErr)
if !localFlagsChanged(cmd) && prompt && a.deps.MilestoneForm != nil {
values, err := a.deps.MilestoneForm.EditMilestone(cmd.Context(), "", terminal.MilestoneFormValues{})
if err != nil {
return err
}
opts = formOptions(values)
} else {
if opts, err = a.milestoneOptions(cmd, &fields); err != nil {
return err
}
if _, err := a.requireInput(cmd, &fields.title, "title", "Milestone title", nil); err != nil {
return err
}
opts.Title = &fields.title
if fields.useEditor {
if draft, err = a.editDraft(cmd, ""); err != nil {
return err
}
opts.Description = &draft.Body
}
}
opts.State = &state
s, t, err := service(cmd)
if err != nil {
return err
}
item, err := s.Create(cmd.Context(), opts)
if err != nil {
return err
}
a.done(cmd, "Created %s", milestoneName(t, item))
return a.created(cmd, item, milestoneURL(t, item))
}}
fields.register(cmd)
enumFlag(cmd, &state, "state", "", "open", "State of the new milestone", "open", "closed")
return cmd
}
func (a *application) milestoneEditCommand(service milestoneService) *cobra.Command {
var fields milestoneFields
var byName bool
cmd := &cobra.Command{Use: "edit <milestone>", Short: "Edit a milestone", Long: `Edit the title, description or due date of a milestone. Without flags on a terminal, a form
starts from the current values.`, Example: `$ tea milestone edit v1.0 --title v1.0.0 --deadline 2027-01-31
$ tea milestone edit v1.0 --deadline=
$ tea milestone edit 3 --editor`, Annotations: map[string]string{"tea.arguments": milestoneArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
if err := validateTextSources(cmd, "description", "description-file"); err != nil {
return err
}
prompt := a.canPrompt()
form := !localFlagsChanged(cmd) && prompt && a.deps.MilestoneForm != nil
if !form && !localFlagsChanged(cmd) {
return errNothingToEdit
}
var opts services.MilestoneOptions
var draft terminal.Draft
var err error
defer finishDraft(&draft, &runErr)
if !form {
if opts, err = a.milestoneOptions(cmd, &fields); err != nil || (opts == (services.MilestoneOptions{}) && !fields.useEditor) {
return cmp.Or(err, errNothingToEdit)
}
}
s, t, err := service(cmd)
if err != nil {
return err
}
current, err := findMilestone(cmd, s, args[0], byName)
if err != nil {
return err
}
switch {
case form:
values := terminal.MilestoneFormValues{Title: current.Title, Description: current.Description}
if current.Deadline != nil {
deadline := terminal.ParseTime(*current.Deadline)
values.Deadline = &deadline
}
if values, err = a.deps.MilestoneForm.EditMilestone(cmd.Context(), "Editing "+milestoneName(t, current), values); err != nil {
return err
}
opts = formOptions(values)
case fields.useEditor:
if draft, err = a.editDraft(cmd, current.Description); err != nil {
return err
}
opts.Description = &draft.Body
}
item, err := s.Edit(cmd.Context(), current.ID, opts)
if err != nil {
return err
}
a.done(cmd, "Edited %s", milestoneName(t, item))
return a.jsonResult(cmd, item)
}}
fields.register(cmd)
cmd.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
cmd.ValidArgsFunction = a.completeMilestoneArgs(false)
return cmd
}
func (a *application) milestoneDeleteCommand(service milestoneService) *cobra.Command {
var byName bool
cmd := &cobra.Command{Use: "delete <milestone>", Short: "Delete a milestone", Long: "Delete a milestone. Its issues and pull requests stay, without a milestone.", Example: `$ tea milestone delete v0.9
$ tea milestone delete 7 --yes`, Annotations: map[string]string{"tea.confirm": "true", "tea.arguments": milestoneArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
s, t, err := service(cmd)
if err != nil {
return err
}
item, err := findMilestone(cmd, s, args[0], byName)
if err != nil {
return err
}
name := fmt.Sprintf("milestone %q (ID %d) from %s", item.Title, item.ID, t.Repo)
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + name + "?"}); err != nil {
return err
}
if err := s.Delete(cmd.Context(), item.ID); err != nil {
return err
}
a.done(cmd, "Deleted milestone %q from %s", item.Title, t.Repo)
return a.jsonResult(cmd, item)
}}
cmd.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
cmd.ValidArgsFunction = a.completeMilestoneArgs(false)
return cmd
}
// findMilestone resolves a milestone argument, a title or an ID.
func findMilestone(cmd *cobra.Command, s services.Milestones, raw string, byName bool) (services.Milestone, error) {
if strings.TrimSpace(raw) == "" {
return services.Milestone{}, clierr.Wrap(clierr.Usage, errors.New("milestone title or ID cannot be empty"))
}
return s.FindOne(cmd.Context(), raw, byName)
}
// completeMilestoneArgs completes milestone titles for the first argument, or for every argument
// when variadic.
func (a *application) completeMilestoneArgs(variadic bool) cobra.CompletionFunc {
complete := a.completeMilestones()
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) > 0 && !variadic {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return complete(cmd, args, toComplete)
}
}