// 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"} 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()) } 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 ", 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 forceHint(err) } verb := "Created" if updated { verb = "Updated" } a.done(cmd, "%s %s", verb, labelName(s, item)) return a.jsonResult(cmd, item) }} 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 } // forceHint points at --force when a label exists already. func forceHint(err error) error { if conflict, ok := errors.AsType[*services.ConflictError](err); ok && conflict.Reason == services.ReasonExists { conflict.Hint = "pass --force to update it" } return err } func (a *application) labelEditCommand(scope labelScope, complete cobra.CompletionFunc) *cobra.Command { var fields labelFields var byName bool cmd := &cobra.Command{Use: "edit