mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
Use one canPrompt and pullNumberArguments for the comment commands, set display preferences instead of the retired environment variables in tests, and delete writeList, writeItem, batch, table, writeRecord(s), the legacy column map, confirm, require, validateMilestoneFlags and listWindow now that every command uses their replacements. Signed-off-by: bircni <bircni@icloud.com>
423 lines
16 KiB
Go
423 lines
16 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.labelCloneCommand(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 {
|
|
asker, ok := a.deps.Prompter.(terminal.Asker)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if opts.Color == nil {
|
|
color, err := asker.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 := asker.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 {
|
|
if a.options.json != "" {
|
|
return a.writeJSON(cmd, item)
|
|
}
|
|
verb := "Created"
|
|
if updated {
|
|
verb = "Updated"
|
|
}
|
|
a.done(cmd, "%s %s", verb, labelName(s, item))
|
|
return nil
|
|
}
|
|
|
|
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", Long: "Edit a label. Without flags on a terminal, a form starts from the current values.", 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
|
|
}
|
|
prompt := a.canPrompt()
|
|
form, hasForm := a.deps.Prompter.(terminal.LabelForm)
|
|
if opts == (services.LabelOptions{}) && (!prompt || !hasForm) {
|
|
return errNothingToEdit
|
|
}
|
|
s, err := scope(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
found, err := s.Find(cmd.Context(), args, byName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if opts == (services.LabelOptions{}) {
|
|
current := found[0]
|
|
values, err := form.EditLabel(cmd.Context(), "Editing "+labelName(s, current), terminal.LabelFormValues{
|
|
Name: current.Name, Color: current.Color, Description: current.Description, Exclusive: current.Exclusive, Archived: current.Archived,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts = services.LabelOptions{Name: &values.Name, Color: &values.Color, Description: &values.Description, Exclusive: &values.Exclusive, Archived: &values.Archived}
|
|
}
|
|
item, err := s.Edit(cmd.Context(), found[0].ID, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if a.options.json != "" {
|
|
return a.writeJSON(cmd, item)
|
|
}
|
|
a.done(cmd, "Edited %s", labelName(s, item))
|
|
return nil
|
|
}}
|
|
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() + "?", NonTTY: "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, false)
|
|
}
|
|
|
|
// createLabels creates each label in rows, continuing past failures. A label that exists already
|
|
// is updated with force, skipped with skip, and fails otherwise.
|
|
func (a *application) createLabels(cmd *cobra.Command, s services.Labels, rows []services.LabelOptions, force, skip 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)
|
|
var conflict *services.ConflictError
|
|
switch {
|
|
case skip && errors.As(err, &conflict) && conflict.Reason == services.ReasonExists:
|
|
outcome[*row.Name] = "Skipped"
|
|
return services.Label{Name: *row.Name}, nil
|
|
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) {
|
|
if outcome[item.Name] == "Skipped" {
|
|
a.warn(cmd, "Skipped %s: it exists already", labelName(s, item))
|
|
return
|
|
}
|
|
a.done(cmd, "%s %s", outcome[item.Name], labelName(s, item))
|
|
})
|
|
if a.options.json != "" {
|
|
failures = append(failures, writeBatchJSON(a, cmd, len(rows), results))
|
|
}
|
|
return errors.Join(failures...)
|
|
}
|
|
|
|
func (a *application) labelCloneCommand(scope labelScope) *cobra.Command {
|
|
var force bool
|
|
cmd := &cobra.Command{Use: "clone <source-repository>", Short: "Copy the labels of another repository", Long: `Create the labels of the source repository in the selected repository or organization.
|
|
Labels that exist already are skipped unless --force updates them.`, Example: `$ tea label clone alice/project
|
|
$ tea label clone alice/project --repo alice/new --force`, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
|
s, err := scope(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
source, err := (services.Labels{Client: s.Client, Repository: args[0]}).All(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows := make([]services.LabelOptions, len(source))
|
|
for i, item := range source {
|
|
rows[i] = services.LabelOptions{Name: &item.Name, Color: &item.Color, Description: &item.Description, Exclusive: &item.Exclusive}
|
|
}
|
|
return a.createLabels(cmd, s, rows, force, true)
|
|
}}
|
|
cmd.Flags().BoolVarP(&force, "force", "f", false, "Update labels that exist already")
|
|
cmd.ValidArgsFunction = a.completeRepos()
|
|
return cmd
|
|
}
|
|
|
|
// 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
|
|
})
|
|
}
|