diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 87cc0a3b..d4ed9d19 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -265,7 +265,7 @@ func TestCommandHelpShowsRelevantFlagsOnly(t *testing.T) { } out.Reset() require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "--help"})) - require.Contains(t, out.String(), "\nFLAGS\n -L, --limit int Maximum number of results (1..1000) (default 30)\n --org string ") + require.Contains(t, out.String(), "\nFLAGS\n -L, --limit int Maximum number of results (1..1000) (default 30)\n --org organization ") for _, args := range [][]string{{"version", "--help"}, {"alias", "list", "--help"}, {"auth", "status", "--help"}} { out.Reset() require.NoError(t, Execute(context.Background(), deps, args)) @@ -424,11 +424,6 @@ const ( issue attachment list issue attachment upload issue attachment view - label create - label delete - label edit - label export - label list milestone close milestone create milestone delete @@ -454,7 +449,6 @@ const ( ` longsToWrite = ` issue attachment - label milestone milestone issue pr attachment diff --git a/internal/app/human_output_test.go b/internal/app/human_output_test.go index a03dc8b3..1f48d486 100644 --- a/internal/app/human_output_test.go +++ b/internal/app/human_output_test.go @@ -66,8 +66,8 @@ func TestLabelListAppliesColorAndPagesWithNoInput(t *testing.T) { require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"})) require.Equal(t, 1, pager.calls) require.Equal(t, "fake-pager", pager.command) - require.Contains(t, pager.input, "\x1b[38;2;255;0;0mbug\x1b[0m") - require.Contains(t, pager.input, "\x1b[38;2;0;255;0mdocs\x1b[0m") + require.Contains(t, pager.input, "\x1b[38;2;255;0;0m● ff0000\x1b[0m") + require.Contains(t, pager.input, "\x1b[38;2;0;255;0m● 00ff00\x1b[0m") require.Equal(t, "PAGED:"+pager.input, out.String()) require.Empty(t, diagnostics.String()) } @@ -120,7 +120,7 @@ func TestTeaPagerAndColorEnvironmentOverrides(t *testing.T) { deps, out, _, pager := pagedDeps(t, server.URL, env, "pager", "configured-pager", "color", "never") require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"})) require.Equal(t, "tea-pager", pager.command) - require.Contains(t, pager.input, "\x1b[38;5;196mbug\x1b[0m") + require.Contains(t, pager.input, "\x1b[38;5;196m● ff0000\x1b[0m") out.Reset() require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "pager", "--show-source", "--json"})) require.JSONEq(t, `{"pager":{"value":"tea-pager","source":"TEA_PAGER"}}`, withoutANSI(out.String())) @@ -137,12 +137,12 @@ func TestColorAutoUsesStdoutTTY(t *testing.T) { deps, out, _ := serverDeps(t, server.URL) deps.Streams.OutTTY = false require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"})) - require.Equal(t, "1\tbug\tff0000\t\n", out.String()) + require.Equal(t, "1\tbug\tff0000\t\t\n", out.String()) out.Reset() deps.Streams.OutTTY = true require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"})) - require.Contains(t, out.String(), "\x1b[38;5;196mbug\x1b[0m") + require.Contains(t, out.String(), "\x1b[38;5;196m● ff0000\x1b[0m") } func TestMultiItemHumanOutputPagesOnce(t *testing.T) { diff --git a/internal/app/label_export.go b/internal/app/label_export.go index d43e146b..47c5e91c 100644 --- a/internal/app/label_export.go +++ b/internal/app/label_export.go @@ -7,18 +7,23 @@ import ( "io" "strings" - "gitea.dev/tea/internal/services" "gitea.dev/tea/internal/terminal" "github.com/spf13/cobra" ) -func (a *application) labelExportCommand(service func(*cobra.Command) (services.Labels, error)) *cobra.Command { +func (a *application) labelExportCommand(scope labelScope) *cobra.Command { var output string - command := &cobra.Command{Use: "export", Short: "Export all labels in the selected scope", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - if err := validateDownloadDestination(output); err != nil { - return err + command := &cobra.Command{Use: "export", Short: "Export labels to a label file", Long: `Write every label as a row of a label file, the format tea label import reads, to standard +output or to a new file.`, Example: `$ tea label export > labels.txt +$ tea label export --org acme --output labels.txt +# Copy the labels of one repository to another +$ tea label export -R alice/old | tea label import - -R alice/new`, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { + if output != "" { + if err := validateDownloadDestination(output); err != nil { + return err + } } - labels, err := service(cmd) + labels, err := scope(cmd) if err != nil { return err } @@ -42,6 +47,7 @@ func (a *application) labelExportCommand(service func(*cobra.Command) (services. return err }) }} - command.Flags().StringVar(&output, "output-file", "", "New export file, or - for stdout") + command.Flags().StringVarP(&output, "output", "O", "", "Write to a new `file` instead of standard output") + flagAliases(command, map[string]string{"output-file": "output"}) return command } diff --git a/internal/app/label_options_test.go b/internal/app/label_options_test.go index 69c87676..7b20070b 100644 --- a/internal/app/label_options_test.go +++ b/internal/app/label_options_test.go @@ -6,7 +6,9 @@ package app import ( "context" "encoding/json" + "fmt" "net/http" + "strings" "testing" "github.com/stretchr/testify/require" @@ -16,6 +18,10 @@ func TestLabelCreateAndEditExclusiveArchived(t *testing.T) { calls := 0 server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { + if calls > 0 && r.URL.Query().Get("page") == "1" { + _, _ = w.Write([]byte(`[{"id":3,"name":"scoped","color":"00aa00"}]`)) + return + } _, _ = w.Write([]byte(`[]`)) return } @@ -83,3 +89,107 @@ func TestLabelCreateRefusesDuplicateNames(t *testing.T) { require.JSONEq(t, `{"id":11,"color":"000000"}`, out.String()) require.Equal(t, []string{"PATCH /api/v1/repos/alice/project/labels/11"}, changed) } + +func TestLabelsByName(t *testing.T) { + var changed []string + labels := `[{"id":11,"name":"bug","color":"ee0701"},{"id":12,"name":"kind/feature","color":"84b6eb","exclusive":true},{"id":13,"name":"2026","color":"cccccc"}]` + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/repos/alice/project/labels", "GET /api/v1/repos/bob/source/labels": + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`[]`)) + return + } + if r.URL.Path == "/api/v1/repos/bob/source/labels" { + _, _ = w.Write([]byte(`[{"id":1,"name":"bug","color":"000000"},{"id":2,"name":"docs","color":"0000ff","description":"Docs"}]`)) + return + } + _, _ = w.Write([]byte(labels)) + default: + changed = append(changed, r.Method+" "+r.URL.Path+" "+requestFields(t, r, "name", "color", "description")) + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + _, _ = w.Write([]byte(`{"id":20,"name":"docs","color":"0000ff"}`)) + } + })) + defer server.Close() + deps, out, diagnostics := serverDeps(t, server.URL) + deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"}) + deps.Streams.OutTTY = true + run := func(args ...string) error { + out.Reset() + diagnostics.Reset() + return Execute(context.Background(), deps, append(args, "-R", "alice/project", "--no-input")) + } + require.NoError(t, run("label", "create", "docs", "-c", "#0000FF", "-d", "Docs")) + require.Equal(t, "✓ Created label \"docs\" in alice/project\n", diagnostics.String()) + require.NoError(t, run("label", "edit", "kind/feature", "-n", "kind/enhancement")) + require.Equal(t, "✓ Edited label \"docs\" in alice/project\n", diagnostics.String()) + require.NoError(t, run("label", "edit", "2026", "--by-name", "-d", "Year")) + require.NoError(t, run("label", "delete", "BUG", "12", "--yes")) + require.Equal(t, "✓ Deleted label \"bug\" from alice/project\n✓ Deleted label \"kind/feature\" from alice/project\n", diagnostics.String()) + require.NoError(t, run("label", "clone", "bob/source")) + require.Equal(t, "! Skipped label \"bug\" in alice/project: it exists already\n✓ Created label \"docs\" in alice/project\n", diagnostics.String()) + require.Equal(t, []string{ + "POST /api/v1/repos/alice/project/labels name=docs color=0000FF description=Docs", + "PATCH /api/v1/repos/alice/project/labels/12 name=kind/enhancement color= description=", + "PATCH /api/v1/repos/alice/project/labels/13 name= color= description=Year", + "DELETE /api/v1/repos/alice/project/labels/11 ", + "DELETE /api/v1/repos/alice/project/labels/12 ", + "POST /api/v1/repos/alice/project/labels name=docs color=0000ff description=Docs", + }, changed) + for _, test := range []struct { + args []string + want string + }{ + {[]string{"label", "edit", "nope", "-c", "000000"}, `label "nope" not found in alice/project`}, + {[]string{"label", "edit", "bug"}, "nothing to edit"}, + {[]string{"label", "delete", "bug"}, `confirmation required to delete label "bug" from alice/project; rerun with --yes`}, + {[]string{"label", "create", "x", "-c", "red"}, "--color requires six hexadecimal digits"}, + } { + require.ErrorContains(t, run(test.args...), test.want, test.args) + } +} + +// requestFields lists the JSON request body's named fields as name=value, empty without a body. +func requestFields(t *testing.T, r *http.Request, names ...string) string { + t.Helper() + if r.ContentLength == 0 { + return "" + } + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + fields := make([]string, len(names)) + for i, name := range names { + fields[i] = fmt.Sprintf("%s=%v", name, payload[name]) + } + return strings.Join(fields, " ") +} + +func TestLabelCreatePicksAColorAndPrompts(t *testing.T) { + var created []map[string]any + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`[]`)) + return + } + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + created = append(created, payload) + _, _ = w.Write([]byte(`{"id":1,"name":"x"}`)) + })) + defer server.Close() + deps, _, _ := serverDeps(t, server.URL) + require.NoError(t, Execute(context.Background(), deps, []string{"label", "create", "triage", "-R", "alice/project"})) + require.Contains(t, labelColors, created[0]["color"]) + prompt := &askingPrompter{scriptedPrompter: scriptedPrompter{inputs: map[string]string{"Label name": "prompted", "Color (hex, e.g. d73a4a; empty = random):": "#00AA00", "Description (optional):": "From a prompt"}}} + deps.Prompter = prompt + deps.Streams.InTTY, deps.Streams.ErrTTY = true, true + require.NoError(t, Execute(context.Background(), deps, []string{"label", "create", "-R", "alice/project"})) + require.Equal(t, "prompted", created[1]["name"]) + require.Equal(t, "00AA00", created[1]["color"]) + require.Equal(t, "From a prompt", created[1]["description"]) + require.Equal(t, []string{"Label name", "Color (hex, e.g. d73a4a; empty = random):", "Description (optional):"}, prompt.asked) +} diff --git a/internal/app/labelfile_test.go b/internal/app/labelfile_test.go index 4a279134..59a4c68e 100644 --- a/internal/app/labelfile_test.go +++ b/internal/app/labelfile_test.go @@ -86,5 +86,5 @@ func TestLabelImportAcceptsColorPrefixedRows(t *testing.T) { require.Len(t, created, 2) requirePayload(t, `{"name":"bug","color":"ee0701","description":"Something is not working"}`, strings.NewReader(created[0])) requirePayload(t, `{"name":"enhancement","color":"84b6eb"}`, strings.NewReader(created[1])) - require.Equal(t, "1\tbug\t\t\n2\tenhancement\t\t\n", out.String()) + require.Empty(t, out.String()) } diff --git a/internal/app/labels.go b/internal/app/labels.go new file mode 100644 index 00000000..93625e79 --- /dev/null +++ b/internal/app/labels.go @@ -0,0 +1,410 @@ +// 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 ", 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