feat(label): address labels by name and add import and clone

label edit bug and label delete kind/feature failed with "invalid resource
number", and label create took its name as --name and required
--color. Like gh, label create takes the name as an argument and picks
a random color without -c; label edit and delete take names or IDs
(delete several at once, after a confirmation that names them), and
-c/-d/-n/-f are the gh short flags. Without a name on a terminal, label
create asks for the name, color and description.

label list shows ID NAME COLOR DESCRIPTION FLAGS with a colored swatch
and exclusive and archived labels flagged. Mutations report ✓ lines.
label import reads a label file or stdin (label create --file still
works), label clone copies another repository's labels, skipping those
that exist unless --force, and label export writes to stdout by default
or to -O/--output.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:16:09 +02:00
1 parent 10417049a1
commit 2ba02cf915
10 files changed
+581 -192

No files matched your search

+1 -7
View File
@@ -265,7 +265,7 @@ func TestCommandHelpShowsRelevantFlagsOnly(t *testing.T) {
} }
out.Reset() out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "--help"})) 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"}} { for _, args := range [][]string{{"version", "--help"}, {"alias", "list", "--help"}, {"auth", "status", "--help"}} {
out.Reset() out.Reset()
require.NoError(t, Execute(context.Background(), deps, args)) require.NoError(t, Execute(context.Background(), deps, args))
@@ -424,11 +424,6 @@ const (
issue attachment list issue attachment list
issue attachment upload issue attachment upload
issue attachment view issue attachment view
label create
label delete
label edit
label export
label list
milestone close milestone close
milestone create milestone create
milestone delete milestone delete
@@ -454,7 +449,6 @@ const (
` `
longsToWrite = ` longsToWrite = `
issue attachment issue attachment
label
milestone milestone
milestone issue milestone issue
pr attachment pr attachment
+5 -5
View File
@@ -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.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, 1, pager.calls) require.Equal(t, 1, pager.calls)
require.Equal(t, "fake-pager", pager.command) 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;255;0;0m● ff0000\x1b[0m")
require.Contains(t, pager.input, "\x1b[38;2;0;255;0mdocs\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.Equal(t, "PAGED:"+pager.input, out.String())
require.Empty(t, diagnostics.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") 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.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, "tea-pager", pager.command) 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() out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "pager", "--show-source", "--json"})) 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())) 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, out, _ := serverDeps(t, server.URL)
deps.Streams.OutTTY = false deps.Streams.OutTTY = false
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"})) 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() out.Reset()
deps.Streams.OutTTY = true deps.Streams.OutTTY = true
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"})) 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) { func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
+11 -5
View File
@@ -7,18 +7,23 @@ import (
"io" "io"
"strings" "strings"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra" "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 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 { 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 { if err := validateDownloadDestination(output); err != nil {
return err return err
} }
labels, err := service(cmd) }
labels, err := scope(cmd)
if err != nil { if err != nil {
return err return err
} }
@@ -42,6 +47,7 @@ func (a *application) labelExportCommand(service func(*cobra.Command) (services.
return err 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 return command
} }
+110
View File
@@ -6,7 +6,9 @@ package app
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -16,6 +18,10 @@ func TestLabelCreateAndEditExclusiveArchived(t *testing.T) {
calls := 0 calls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { 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(`[]`)) _, _ = w.Write([]byte(`[]`))
return return
} }
@@ -83,3 +89,107 @@ func TestLabelCreateRefusesDuplicateNames(t *testing.T) {
require.JSONEq(t, `{"id":11,"color":"000000"}`, out.String()) require.JSONEq(t, `{"id":11,"color":"000000"}`, out.String())
require.Equal(t, []string{"PATCH /api/v1/repos/alice/project/labels/11"}, changed) 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=<nil> description=<nil>",
"PATCH /api/v1/repos/alice/project/labels/13 name=<nil> color=<nil> 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)
}
+1 -1
View File
@@ -86,5 +86,5 @@ func TestLabelImportAcceptsColorPrefixedRows(t *testing.T) {
require.Len(t, created, 2) require.Len(t, created, 2)
requirePayload(t, `{"name":"bug","color":"ee0701","description":"Something is not working"}`, strings.NewReader(created[0])) 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])) 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())
} }
+410
View File
@@ -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 <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", 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
}
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
})
}
-162
View File
@@ -5,8 +5,6 @@ package app
import ( import (
"errors" "errors"
"fmt"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -17,162 +15,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func (a *application) labelCommand() *cobra.Command {
group := &cobra.Command{Use: "label", Short: "Manage repository or organization labels"}
var org string
group.PersistentFlags().StringVar(&org, "org", "", "Manage organization labels instead of repository labels")
service := 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
}
group.AddCommand(a.labelExportCommand(service))
var limit, page int
list := &cobra.Command{Use: "list", Short: "List labels in the selected scope", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
s, err := service(cmd)
if err != nil {
return err
}
items, err := s.List(cmd.Context(), limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, labelRow)
}}
pageFlags(list, &limit, &page)
group.AddCommand(list)
for _, verb := range []string{"create", "edit"} {
var name, color, description, file string
var exclusive, archived, force bool
command := &cobra.Command{Use: verb, Short: titleCase(verb) + " a label"}
if verb == "edit" {
command.Use += " <id>"
command.Args = argsExact(1)
} else {
command.Args = argsExact(0)
}
command.Flags().StringVar(&name, "name", "", "Label name")
command.Flags().StringVar(&color, "color", "", "Six-digit RGB color")
command.Flags().StringVar(&description, "description", "", "Label description")
command.Flags().BoolVar(&exclusive, "exclusive", false, "Only one label from this exclusive group may be applied")
command.Flags().BoolVar(&archived, "archived", false, "Archive the label so it cannot be newly applied")
if verb == "create" {
command.Flags().StringVar(&file, "file", "", "Import color/name rows with optional ;description")
command.Flags().BoolVar(&force, "force", false, "Update the label if one with the same name exists")
}
command.RunE = func(cmd *cobra.Command, args []string) error {
opts := services.LabelOptions{}
var imported []services.LabelOptions
var id int64
if verb == "edit" {
var err error
id, err = number(args[0])
if err != nil {
return err
}
}
if file != "" {
if cmd.Flags().Changed("name") || cmd.Flags().Changed("color") || cmd.Flags().Changed("description") || cmd.Flags().Changed("exclusive") || cmd.Flags().Changed("archived") {
return clierr.Wrap(clierr.Usage, errors.New("--file cannot be combined with label fields"))
}
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
if imported, err = parseLabelFile(f); err != nil {
return err
}
} else {
if verb == "create" {
if _, err := a.require(cmd, &name, "name"); err != nil {
return err
}
if _, err := a.require(cmd, &color, "color"); err != nil {
return err
}
}
if verb == "create" || cmd.Flags().Changed("name") {
if name == "" {
return clierr.Wrap(clierr.Usage, errors.New("label name cannot be empty"))
}
opts.Name = &name
}
if verb == "create" || cmd.Flags().Changed("color") {
color = strings.TrimPrefix(color, "#")
if !labelColor.MatchString(color) {
return clierr.Wrap(clierr.Usage, errors.New("--color requires six hexadecimal digits"))
}
opts.Color = &color
}
if cmd.Flags().Changed("description") {
opts.Description = &description
}
if cmd.Flags().Changed("exclusive") {
opts.Exclusive = &exclusive
}
if cmd.Flags().Changed("archived") {
opts.Archived = &archived
}
if opts.Name == nil && opts.Color == nil && opts.Description == nil && opts.Exclusive == nil && opts.Archived == nil {
return clierr.Wrap(clierr.Usage, errors.New("supply label fields to edit"))
}
}
s, err := service(cmd)
if err != nil {
return err
}
var existing []services.Label
if verb == "create" {
if existing, err = s.All(cmd.Context()); err != nil {
return err
}
}
if file != "" {
results := []services.Label{}
var failures []error
for _, row := range imported {
item, err := s.CreateUnique(cmd.Context(), existing, row, force)
if err != nil {
failures = append(failures, fmt.Errorf("label %s: %w", *row.Name, err))
continue
}
results, existing = append(results, item), append(existing, item)
}
return errors.Join(append(failures, writeList(a, cmd, results, labelRow))...)
}
var item services.Label
if verb == "create" {
item, err = s.CreateUnique(cmd.Context(), existing, opts, force)
} else {
item, err = s.Edit(cmd.Context(), id, opts)
}
if err != nil {
return err
}
return writeItem(a, cmd, item, labelRow)
}
group.AddCommand(command)
}
group.AddCommand(&cobra.Command{Use: "delete <id>", Short: "Delete a label", Annotations: textOutput("label deletion does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
id, err := number(args[0])
if err != nil {
return err
}
s, err := service(cmd)
if err != nil {
return err
}
if err := a.confirm(cmd, "Delete label "+args[0]); err != nil {
return err
}
return s.Delete(cmd.Context(), id)
}})
return group
}
func (a *application) milestoneCommand() *cobra.Command { func (a *application) milestoneCommand() *cobra.Command {
group := &cobra.Command{Use: "milestone", Short: "Manage repository milestones"} group := &cobra.Command{Use: "milestone", Short: "Manage repository milestones"}
service := func(cmd *cobra.Command) (services.Milestones, error) { service := func(cmd *cobra.Command) (services.Milestones, error) {
@@ -343,10 +185,6 @@ func (a *application) milestoneCommand() *cobra.Command {
return group return group
} }
func labelRow(item services.Label) []terminal.Cell {
return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10)}, {Text: item.Name, Color: item.Color}, {Text: item.Color, Color: item.Color}, {Text: item.Description}}
}
func milestoneRow(item services.Milestone) []terminal.Cell { func milestoneRow(item services.Milestone) []terminal.Cell {
return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10)}, {Text: item.Title}, {Text: item.State, Color: stateColors[item.State]}} return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10)}, {Text: item.Title}, {Text: item.State, Color: stateColors[item.State]}}
} }
+1 -1
View File
@@ -71,7 +71,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"issue", "list", "--page", "0"}, `"--page" flag: must be a positive integer`}, {[]string{"issue", "list", "--page", "0"}, `"--page" flag: must be a positive integer`},
{[]string{"issue", "list", "--label", "bug, "}, "label names cannot be empty"}, {[]string{"issue", "list", "--label", "bug, "}, "label names cannot be empty"},
{[]string{"label", "create", "--name", "bug", "--color", "wrong"}, "--color requires six hexadecimal digits"}, {[]string{"label", "create", "--name", "bug", "--color", "wrong"}, "--color requires six hexadecimal digits"},
{[]string{"label", "edit", "1"}, "supply label fields to edit"}, {[]string{"label", "edit", "1"}, "nothing to edit; pass a flag such as --title (see --help)"},
{[]string{"label", "list", "--org", "team", "-R", "a/b"}, "--org and --repo are mutually exclusive"}, {[]string{"label", "list", "--org", "team", "-R", "a/b"}, "--org and --repo are mutually exclusive"},
{[]string{"milestone", "close", " ", "--by-name"}, "milestone title or ID cannot be empty"}, {[]string{"milestone", "close", " ", "--by-name"}, "milestone title or ID cannot be empty"},
{[]string{"milestone", "create", "--title", "v1", "--deadline", "invalid"}, "invalid --deadline"}, {[]string{"milestone", "create", "--title", "v1", "--deadline", "invalid"}, "invalid --deadline"},
+24 -6
View File
@@ -25,23 +25,41 @@ func (s Labels) finder(byName bool) NameOrID[Label] {
return NameOrID[Label]{Kind: "label", Scope: s.Scope(), ID: func(l Label) int64 { return l.ID }, Name: func(l Label) string { return l.Name }, ByName: byName} return NameOrID[Label]{Kind: "label", Scope: s.Scope(), ID: func(l Label) int64 { return l.ID }, Name: func(l Label) string { return l.Name }, ByName: byName}
} }
// Find returns the labels of the scope that raws address, each by ID or name (D10); byName matches
// numeric values as names only.
func (s Labels) Find(ctx context.Context, raws []string, byName bool) ([]Label, error) {
items, err := s.All(ctx)
if err != nil {
return nil, err
}
result := make([]Label, len(raws))
for i, raw := range raws {
if result[i], err = s.finder(byName).Find(items, raw); err != nil {
return nil, err
}
}
return result, nil
}
// CreateUnique creates a label unless existing, the labels of the scope, has one with its name, // CreateUnique creates a label unless existing, the labels of the scope, has one with its name,
// ignoring case. Then update edits that label with the supplied fields, keeping its name, and // ignoring case. Then update edits that label with the supplied fields, keeping its name, and
// otherwise CreateUnique fails: Gitea would create a second label that name filters cannot tell // otherwise CreateUnique fails: Gitea would create a second label that name filters cannot tell
// apart. // apart. It reports whether it updated a label.
func (s Labels) CreateUnique(ctx context.Context, existing []Label, opts LabelOptions, update bool) (Label, error) { func (s Labels) CreateUnique(ctx context.Context, existing []Label, opts LabelOptions, update bool) (Label, bool, error) {
current, err := s.finder(true).Find(existing, deref(opts.Name)) current, err := s.finder(true).Find(existing, deref(opts.Name))
if errors.As(err, new(*NotFoundError)) { if errors.As(err, new(*NotFoundError)) {
return s.Create(ctx, opts) label, err := s.Create(ctx, opts)
return label, false, err
} }
if err != nil { if err != nil {
return Label{}, err return Label{}, false, err
} }
if !update { if !update {
return Label{}, &ConflictError{Reason: ReasonExists, Kind: "label", Key: strconv.Quote(current.Name), Scope: s.Scope(), Hint: "pass --force to update it"} return Label{}, false, &ConflictError{Reason: ReasonExists, Kind: "label", Key: strconv.Quote(current.Name), Scope: s.Scope(), Hint: "pass --force to update it"}
} }
opts.Name = nil opts.Name = nil
return s.Edit(ctx, current.ID, opts) label, err := s.Edit(ctx, current.ID, opts)
return label, true, err
} }
func selectLabelNames(scope string, names []string, matches map[string][]int64) ([]int64, error) { func selectLabelNames(scope string, names []string, matches map[string][]int64) ([]int64, error) {
+18 -5
View File
@@ -45,7 +45,24 @@ func (s Labels) orgEndpoint() string { return "orgs/" + url.PathEscape(s.Organiz
// List returns scoped labels without merging unrelated organization labels. // List returns scoped labels without merging unrelated organization labels.
func (s Labels) List(ctx context.Context, limit, page int) ([]Label, error) { func (s Labels) List(ctx context.Context, limit, page int) ([]Label, error) {
labels, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Label, *gitea.Response, error) { labels, _, err := s.list(ctx, limit, page)
if err != nil {
return nil, err
}
return convertAll(labels, labelFrom), nil
}
// ListPage is List with the number of labels in the scope, as the server counts them.
func (s Labels) ListPage(ctx context.Context, limit, page int) (Page[Label], error) {
labels, err := listPage(limit, page, func(limit, page int) ([]*gitea.Label, http.Header, error) {
labels, resp, err := s.list(ctx, limit, page)
return labels, headerOf(resp), err
})
return Page[Label]{Items: convertAll(labels.Items, labelFrom), More: labels.More, Total: labels.Total, Counted: labels.Counted}, err
}
func (s Labels) list(ctx context.Context, limit, page int) ([]*gitea.Label, *gitea.Response, error) {
return call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Label, *gitea.Response, error) {
if s.Organization != "" { if s.Organization != "" {
return c.Organizations.ListOrgLabels(ctx, s.Organization, gitea.ListOrgLabelsOptions{ListOptions: listOptions(limit, page)}) return c.Organizations.ListOrgLabels(ctx, s.Organization, gitea.ListOrgLabelsOptions{ListOptions: listOptions(limit, page)})
} }
@@ -55,10 +72,6 @@ func (s Labels) List(ctx context.Context, limit, page int) ([]Label, error) {
} }
return c.Repositories.ListRepoLabels(ctx, owner, repo, gitea.ListLabelsOptions{ListOptions: listOptions(limit, page)}) return c.Repositories.ListRepoLabels(ctx, owner, repo, gitea.ListLabelsOptions{ListOptions: listOptions(limit, page)})
}) })
if err != nil {
return nil, err
}
return convertAll(labels, labelFrom), nil
} }
// All returns every label in the scope. // All returns every label in the scope.