Files
gitea-tea/internal/app/labels.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

366 lines
14 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
// labelScope returns the labels a label command manages: the repository's, or those of --org.
type labelScope func(*cobra.Command) (services.Labels, error)
// labelColors are the colors of new labels created without --color, from Gitea's default labels.
var labelColors = []string{"ee0701", "fbca04", "84b6eb", "cc317c", "c2e0c6", "0e8a16", "1d76db", "5319e7", "d4c5f9", "e99695", "bfd4f2", "006b75"}
// labelArguments is the ARGUMENTS help of commands that take labels.
const labelArguments = `A label can be supplied by its name, such as "bug" or "kind/feature", or by its ID. A
number is an ID first, then a name.`
func (a *application) labelCommand() *cobra.Command {
group := &cobra.Command{Use: "label", Short: "Manage labels", Long: `Work with the labels of a repository or, with --org, of an organization. Labels are
addressed by name or ID.`}
var org string
group.PersistentFlags().StringVar(&org, "org", "", "Manage the labels of an `organization` instead of a repository")
_ = group.RegisterFlagCompletionFunc("org", a.completeOrgs())
scope := func(cmd *cobra.Command) (services.Labels, error) {
if org != "" && a.options.repo != "" {
return services.Labels{}, clierr.Wrap(clierr.Usage, errors.New("--org and --repo are mutually exclusive"))
}
t, err := a.resolve(cmd.Context(), org == "")
return services.Labels{Client: t.client, Repository: t.Repo, Organization: org}, err
}
complete := a.completeScopedLabels(&org)
group.AddCommand(a.labelListCommand(scope), a.labelCreateCommand(scope), a.labelEditCommand(scope, complete), a.labelDeleteCommand(scope, complete),
a.labelImportCommand(scope), a.labelExportCommand(scope))
return group
}
func (a *application) labelListCommand(scope labelScope) *cobra.Command {
var limit, page int
cmd := &cobra.Command{Use: "list", Short: "List labels", Example: `$ tea label list
$ tea label list --org acme
$ tea label list --json name,color`, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
s, err := scope(cmd)
if err != nil {
return err
}
items, err := s.ListPage(cmd.Context(), limit, page)
if err != nil {
return err
}
return writeTable(a, cmd, items.Items, listSpec[services.Label]{
Columns: []terminal.Column{{Header: "ID"}, {Header: "Name"}, {Header: "Color", Prefix: "● "}, {Header: "Description", Flex: true}, {Header: "Flags"}},
Row: labelRow, Empty: "no labels in " + s.Scope(), Context: "labels in " + s.Scope(),
}, servicePage(items, page))
}}
pageFlags(cmd, &limit, &page)
return cmd
}
func labelRow(item services.Label) []terminal.Cell {
var flags []string
if item.Exclusive {
flags = append(flags, "exclusive")
}
if item.Archived {
flags = append(flags, "archived")
}
return []terminal.Cell{
{Text: strconv.FormatInt(item.ID, 10)},
{Text: item.Name},
{Text: item.Color, Color: item.Color},
{Text: item.Description},
{Text: strings.Join(flags, ", ")},
}
}
// labelName names a label in messages: `label "bug" in alice/x`.
func labelName(s services.Labels, item services.Label) string {
return fmt.Sprintf("label %q in %s", item.Name, s.Scope())
}
// labelFields are the fields of label create and edit.
type labelFields struct {
name, color, description string
exclusive, archived bool
}
func (f *labelFields) register(cmd *cobra.Command, nameUsage string) {
flags := cmd.Flags()
flags.StringVarP(&f.color, "color", "c", "", "Color as six hexadecimal `digits`, such as d73a4a")
flags.StringVarP(&f.description, "description", "d", "", "Description of the label")
flags.BoolVar(&f.exclusive, "exclusive", false, "Allow only one label of its scope, such as priority/high, per issue")
flags.BoolVar(&f.archived, "archived", false, "Archive the label so that it cannot be applied anymore")
flags.StringVarP(&f.name, "name", "n", "", nameUsage)
}
// options returns the fields that were passed, checking them before any request.
func (f *labelFields) options(cmd *cobra.Command) (services.LabelOptions, error) {
var opts services.LabelOptions
if cmd.Flags().Changed("name") {
if strings.TrimSpace(f.name) == "" {
return opts, clierr.Wrap(clierr.Usage, errors.New("label name cannot be empty"))
}
opts.Name = &f.name
}
if cmd.Flags().Changed("color") {
f.color = strings.TrimPrefix(f.color, "#")
if !labelColor.MatchString(f.color) {
return opts, clierr.Wrap(clierr.Usage, errors.New("--color requires six hexadecimal digits, such as d73a4a"))
}
opts.Color = &f.color
}
if cmd.Flags().Changed("description") {
opts.Description = &f.description
}
if cmd.Flags().Changed("exclusive") {
opts.Exclusive = &f.exclusive
}
if cmd.Flags().Changed("archived") {
opts.Archived = &f.archived
}
return opts, nil
}
func (a *application) labelCreateCommand(scope labelScope) *cobra.Command {
var fields labelFields
var file string
var force bool
cmd := &cobra.Command{Use: "create <name>", Aliases: []string{"new"}, Short: "Create a label", Long: `Create a label. Without --color, the label gets a random color. A label with the same
name, ignoring case, is refused unless --force updates it. Without a name on a terminal,
prompts ask for the name, color and description.`, Example: `$ tea label create bug --color d73a4a --description "Something is broken"
$ tea label create "priority/high" --exclusive
# Update the label if it exists
$ tea label create bug -c ee0701 --force`, Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if file != "" {
return a.importLabels(cmd, scope, file, force)
}
if len(args) == 1 {
if cmd.Flags().Changed("name") {
return clierr.Wrap(clierr.Usage, errors.New("pass the label name as an argument or --name, not both"))
}
if err := cmd.Flags().Set("name", args[0]); err != nil {
return err
}
}
opts, err := fields.options(cmd)
if err != nil {
return err
}
prompted, err := a.requireInput(cmd, &fields.name, "name", "Label name", nil)
if err != nil {
return err
}
opts.Name = &fields.name
if prompted {
if err := a.askLabelDetails(cmd, &opts); err != nil {
return err
}
}
if opts.Color == nil {
color := labelColors[rand.IntN(len(labelColors))]
opts.Color = &color
}
s, err := scope(cmd)
if err != nil {
return err
}
existing, err := s.All(cmd.Context())
if err != nil {
return err
}
item, updated, err := s.CreateUnique(cmd.Context(), existing, opts, force)
if err != nil {
return err
}
return a.reportLabel(cmd, s, item, updated)
}}
fields.register(cmd, "Label `name`")
_ = cmd.Flags().MarkHidden("name")
cmd.Flags().BoolVarP(&force, "force", "f", false, "Update the label if one with the same name exists")
cmd.Flags().StringVar(&file, "file", "", "Import labels from a `file`, like tea label import")
_ = cmd.Flags().MarkHidden("file")
return cmd
}
// askLabelDetails asks for the optional color and description of a label whose name was prompted.
func (a *application) askLabelDetails(cmd *cobra.Command, opts *services.LabelOptions) error {
if opts.Color == nil {
color, err := a.deps.Prompter.Ask(cmd.Context(), "Color (hex, e.g. d73a4a; empty = random):", func(s string) error {
if s != "" && !labelColor.MatchString(strings.TrimPrefix(s, "#")) {
return errors.New("use six hexadecimal digits")
}
return nil
})
if err != nil {
return err
}
if color = strings.TrimPrefix(color, "#"); color != "" {
opts.Color = &color
}
}
if opts.Description == nil {
description, err := a.deps.Prompter.Ask(cmd.Context(), "Description (optional):", nil)
if err != nil {
return err
}
opts.Description = &description
}
return nil
}
// reportLabel reports a created or updated label as JSON or with a ✓ line.
func (a *application) reportLabel(cmd *cobra.Command, s services.Labels, item services.Label, updated bool) error {
verb := "Created"
if updated {
verb = "Updated"
}
a.done(cmd, "%s %s", verb, labelName(s, item))
return a.jsonResult(cmd, item)
}
func (a *application) labelEditCommand(scope labelScope, complete cobra.CompletionFunc) *cobra.Command {
var fields labelFields
var byName bool
cmd := &cobra.Command{Use: "edit <label>", Short: "Edit a label", Example: `$ tea label edit bug --name defect --color b60205
$ tea label edit "kind/question" --description "Further information is requested"
$ tea label edit old-stuff --archived`, Annotations: map[string]string{"tea.arguments": labelArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
opts, err := fields.options(cmd)
if err != nil {
return err
}
if opts == (services.LabelOptions{}) {
return errNothingToEdit
}
s, err := scope(cmd)
if err != nil {
return err
}
found, err := s.Find(cmd.Context(), args, byName)
if err != nil {
return err
}
item, err := s.Edit(cmd.Context(), found[0].ID, opts)
if err != nil {
return err
}
a.done(cmd, "Edited %s", labelName(s, item))
return a.jsonResult(cmd, item)
}}
fields.register(cmd, "Rename the label")
cmd.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a label name rather than an ID")
cmd.ValidArgsFunction = complete
return cmd
}
func (a *application) labelDeleteCommand(scope labelScope, complete cobra.CompletionFunc) *cobra.Command {
var byName bool
cmd := &cobra.Command{Use: "delete <label>...", Short: "Delete labels", Example: `$ tea label delete bug
$ tea label delete wontfix duplicate --yes`, Annotations: map[string]string{"tea.text": "label deletion does not return a resource", "tea.confirm": "true", "tea.arguments": labelArguments}, Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
s, err := scope(cmd)
if err != nil {
return err
}
found, err := s.Find(cmd.Context(), args, byName)
if err != nil {
return err
}
names := make([]string, len(found))
for i, item := range found {
names[i] = strconv.Quote(item.Name)
}
subject := "label " + names[0]
if len(found) > 1 {
subject = fmt.Sprintf("%d labels (%s)", len(found), strings.Join(names, ", "))
}
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + subject + " from " + s.Scope() + "?"}); err != nil {
return err
}
return batchDone(a, cmd, found, func(item services.Label) string { return "label " + strconv.Quote(item.Name) }, func(item services.Label) (services.Label, error) {
return item, s.Delete(cmd.Context(), item.ID)
}, func(item services.Label) string { return fmt.Sprintf("Deleted label %q from %s", item.Name, s.Scope()) })
}}
cmd.Flags().BoolVar(&byName, "by-name", false, "Match numeric arguments as label names rather than IDs")
cmd.ValidArgsFunction = complete
return cmd
}
func (a *application) labelImportCommand(scope labelScope) *cobra.Command {
var force bool
cmd := &cobra.Command{Use: "import <file>", Short: "Create labels from a file", Long: `Create the labels of a label file, one per line: "#color name", optionally followed by
" ;description" and " ;exclusive", " ;archived" or both. tea label export writes this
format, and Gitea's label templates use it. Existing labels are refused unless --force
updates them.`, Example: `$ tea label import labels.txt
$ tea label export --repo alice/old | tea label import - --repo alice/new`, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
return a.importLabels(cmd, scope, args[0], force)
}}
cmd.Flags().BoolVarP(&force, "force", "f", false, "Update labels that exist already")
return cmd
}
// importLabels creates the labels of a label file, or of stdin for "-".
func (a *application) importLabels(cmd *cobra.Command, scope labelScope, file string, force bool) error {
data, err := readFileOrStdin(cmd.InOrStdin(), file)
if err != nil {
return err
}
rows, err := parseLabelFile(strings.NewReader(string(data)))
if err != nil {
return err
}
s, err := scope(cmd)
if err != nil {
return err
}
return a.createLabels(cmd, s, rows, force)
}
// createLabels creates each label in rows, continuing past failures. A label that exists already
// is updated with force and fails otherwise.
func (a *application) createLabels(cmd *cobra.Command, s services.Labels, rows []services.LabelOptions, force bool) error {
existing, err := s.All(cmd.Context())
if err != nil {
return err
}
outcome := map[string]string{}
results, failures := applyBatch(cmd, rows, func(row services.LabelOptions) string { return "label " + strconv.Quote(*row.Name) }, func(row services.LabelOptions) (services.Label, error) {
item, updated, err := s.CreateUnique(cmd.Context(), existing, row, force)
switch {
case err != nil:
return item, err
case updated:
outcome[item.Name] = "Updated"
default:
outcome[item.Name] = "Created"
existing = append(existing, item)
}
return item, nil
}, func(item services.Label) {
a.done(cmd, "%s %s", outcome[item.Name], labelName(s, item))
})
return errors.Join(append(failures, writeBatchJSON(a, cmd, len(rows), results))...)
}
// completeScopedLabels completes the label names of the repository or, with --org, of the
// organization that *org names.
func (a *application) completeScopedLabels(org *string) cobra.CompletionFunc {
return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
if *org == "" && t.Repo == "" {
return nil, errRepositoryRequired
}
items, err := (services.Labels{Client: t.client, Repository: t.Repo, Organization: *org}).List(ctx, completionLimit, 1)
return describe(items, func(item services.Label) (string, string) { return item.Name, item.Description }), err
})
}