rickub/clipublic Fork
⑂ 1b60774
Commits
⬇ Clone ▾
git clone https://git.rickub.com/rickub/cli.git
git clone ssh://git@rickub.com/rickub/cli.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

label: add list/create/edit/delete/palette, hint from issue labelUnverified

Labels are addressed by name (ids are UUIDs). The server only accepts
colors from a fixed palette, so the CLI prints it on invalid_color and
exposes it as `label palette`. issue label points at label create when
a name does not exist.

Fixes #3

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgkcT3UPxwxukBKojB5eg4
Olivier Girardot committed 2026-09-03T07:26:56+02:00 Browse files
1b60774 parent: 72b5c57
modified cmd/issue.go +9 -0
@@ -1,10 +1,13 @@
11 package cmd
22
33 import (
4+ "errors"
45 "fmt"
56 "strconv"
67 "strings"
78
9+ "rickub.com/rickub/cli/internal/api"
10+
811 "github.com/spf13/cobra"
912 )
1013
@@ -346,6 +349,12 @@ func runIssueLabel(cmd *cobra.Command, args []string) error {
346349 names = labelNames(issueLabels)
347350 }
348351 if err := client.SetIssueLabels(cmd.Context(), owner, repo, n, names); err != nil {
352+ // Not %w: main prints a bare APIError it can unwrap, which would drop
353+ // the hint.
354+ var apiErr *api.APIError
355+ if errors.As(err, &apiErr) && apiErr.Code == "no_such_label" {
356+ return fmt.Errorf("%s — create it first with `rickub label create <name> -R %s/%s`", err, owner, repo)
357+ }
349358 return err
350359 }
351360 if len(names) == 0 {
@@ -1,10 +1,13 @@
1 package cmd1 package cmd
2 2
3 import (3 import (
4+ "errors"
4 "fmt"5 "fmt"
5 "strconv"6 "strconv"
6 "strings"7 "strings"
7 8
9+ "rickub.com/rickub/cli/internal/api"
10+
8 "github.com/spf13/cobra"11 "github.com/spf13/cobra"
9 )12 )
10 13
@@ -346,6 +349,12 @@ func runIssueLabel(cmd *cobra.Command, args []string) error {
346 names = labelNames(issueLabels)349 names = labelNames(issueLabels)
347 }350 }
348 if err := client.SetIssueLabels(cmd.Context(), owner, repo, n, names); err != nil {351 if err := client.SetIssueLabels(cmd.Context(), owner, repo, n, names); err != nil {
352+ // Not %w: main prints a bare APIError it can unwrap, which would drop
353+ // the hint.
354+ var apiErr *api.APIError
355+ if errors.As(err, &apiErr) && apiErr.Code == "no_such_label" {
356+ return fmt.Errorf("%s — create it first with `rickub label create <name> -R %s/%s`", err, owner, repo)
357+ }
349 return err358 return err
350 }359 }
351 if len(names) == 0 {360 if len(names) == 0 {
added cmd/label.go +224 -0
new file mode 100644
@@ -0,0 +1,224 @@
1+package cmd
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+
8+ "rickub.com/rickub/cli/internal/api"
9+
10+ "github.com/spf13/cobra"
11+)
12+
13+var (
14+ labelRepo string
15+ labelColor string
16+ labelNewName string
17+)
18+
19+func init() {
20+ labelCmd := &cobra.Command{
21+ Use: "label",
22+ Short: "Manage a repository's labels",
23+ }
24+ labelCmd.PersistentFlags().StringVarP(&labelRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)")
25+
26+ listCmd := &cobra.Command{
27+ Use: "list",
28+ Short: "List the repo's labels",
29+ Args: cobra.NoArgs,
30+ RunE: runLabelList,
31+ }
32+
33+ createCmd := &cobra.Command{
34+ Use: "create <name>",
35+ Short: "Create a label",
36+ Args: cobra.ExactArgs(1),
37+ RunE: runLabelCreate,
38+ }
39+ createCmd.Flags().StringVar(&labelColor, "color", "", "one of the palette colors (see `rickub label palette`; server default when omitted)")
40+
41+ editCmd := &cobra.Command{
42+ Use: "edit <name>",
43+ Short: "Rename or recolor a label",
44+ Args: cobra.ExactArgs(1),
45+ RunE: runLabelEdit,
46+ }
47+ editCmd.Flags().StringVar(&labelNewName, "name", "", "new name")
48+ editCmd.Flags().StringVar(&labelColor, "color", "", "new color, from the palette")
49+
50+ deleteCmd := &cobra.Command{
51+ Use: "delete <name>",
52+ Short: "Delete a label",
53+ Args: cobra.ExactArgs(1),
54+ RunE: runLabelDelete,
55+ }
56+
57+ paletteCmd := &cobra.Command{
58+ Use: "palette",
59+ Short: "Print the label colors the server accepts",
60+ Args: cobra.NoArgs,
61+ Run: func(cmd *cobra.Command, _ []string) {
62+ for _, c := range labelPalette {
63+ fmt.Fprintln(cmd.OutOrStdout(), c)
64+ }
65+ },
66+ }
67+
68+ labelCmd.AddCommand(listCmd, createCmd, editCmd, deleteCmd, paletteCmd)
69+ rootCmd.AddCommand(labelCmd)
70+}
71+
72+// labelPalette mirrors the server's fixed label palette (6-hex, no '#'): any
73+// other color is refused with invalid_color, so the CLI can say so up front.
74+var labelPalette = []string{"b60205", "d73a4a", "e99695", "fbca04", "fef2c0", "0e8a16", "c2e0c6", "006b75", "bfdadc", "1d76db", "0052cc", "5319e7", "d4c5f9", "e4e669", "ededed", "f9d0c4"}
75+
76+// colorError turns the server's invalid_color refusal into one that lists
77+// what would have been accepted.
78+func colorError(err error) error {
79+ var apiErr *api.APIError
80+ if errors.As(err, &apiErr) && apiErr.Code == "invalid_color" {
81+ return fmt.Errorf("%s — accepted colors: %s", err, strings.Join(labelPalette, ", "))
82+ }
83+ return err
84+}
85+
86+func runLabelList(cmd *cobra.Command, _ []string) error {
87+ client, err := newClient()
88+ if err != nil {
89+ return err
90+ }
91+ owner, repo, err := resolveRepo(labelRepo)
92+ if err != nil {
93+ return err
94+ }
95+ labels, err := client.ListLabels(cmd.Context(), owner, repo)
96+ if err != nil {
97+ return err
98+ }
99+ return printLabels(cmd, labels)
100+}
101+
102+func printLabels(cmd *cobra.Command, labels []api.Label) error {
103+ if flagJSON {
104+ return printJSON(cmd.OutOrStdout(), labels)
105+ }
106+ if len(labels) == 0 {
107+ fmt.Fprintln(cmd.OutOrStdout(), "No labels.")
108+ return nil
109+ }
110+ tw := newTabw(cmd.OutOrStdout())
111+ fmt.Fprintln(tw, "NAME\tCOLOR")
112+ for _, l := range labels {
113+ fmt.Fprintf(tw, "%s\t%s\n", l.Name, dash(l.Color))
114+ }
115+ return tw.Flush()
116+}
117+
118+func runLabelCreate(cmd *cobra.Command, args []string) error {
119+ client, err := newClient()
120+ if err != nil {
121+ return err
122+ }
123+ owner, repo, err := resolveRepo(labelRepo)
124+ if err != nil {
125+ return err
126+ }
127+ name := strings.TrimSpace(args[0])
128+ if name == "" {
129+ return fmt.Errorf("label name must not be empty")
130+ }
131+ l, err := client.CreateLabel(cmd.Context(), owner, repo, name, normalizeColor(labelColor))
132+ if err != nil {
133+ return colorError(err)
134+ }
135+ if flagJSON {
136+ return printJSON(cmd.OutOrStdout(), l)
137+ }
138+ fmt.Fprintf(cmd.OutOrStdout(), "Created label %s (%s)\n", l.Name, dash(l.Color))
139+ return nil
140+}
141+
142+func runLabelEdit(cmd *cobra.Command, args []string) error {
143+ if labelNewName == "" && labelColor == "" {
144+ return fmt.Errorf("nothing to change: pass --name and/or --color")
145+ }
146+ client, err := newClient()
147+ if err != nil {
148+ return err
149+ }
150+ owner, repo, err := resolveRepo(labelRepo)
151+ if err != nil {
152+ return err
153+ }
154+ l, err := findLabel(cmd, client, owner, repo, args[0])
155+ if err != nil {
156+ return err
157+ }
158+ patch := map[string]any{}
159+ if labelNewName != "" {
160+ patch["name"] = labelNewName
161+ }
162+ if labelColor != "" {
163+ patch["color"] = normalizeColor(labelColor)
164+ }
165+ updated, err := client.UpdateLabel(cmd.Context(), owner, repo, l.ID, patch)
166+ if err != nil {
167+ return colorError(err)
168+ }
169+ if flagJSON {
170+ return printJSON(cmd.OutOrStdout(), updated)
171+ }
172+ fmt.Fprintf(cmd.OutOrStdout(), "Updated label %s (%s)\n", updated.Name, dash(updated.Color))
173+ return nil
174+}
175+
176+func runLabelDelete(cmd *cobra.Command, args []string) error {
177+ client, err := newClient()
178+ if err != nil {
179+ return err
180+ }
181+ owner, repo, err := resolveRepo(labelRepo)
182+ if err != nil {
183+ return err
184+ }
185+ l, err := findLabel(cmd, client, owner, repo, args[0])
186+ if err != nil {
187+ return err
188+ }
189+ if err := client.DeleteLabel(cmd.Context(), owner, repo, l.ID); err != nil {
190+ return err
191+ }
192+ fmt.Fprintf(cmd.OutOrStdout(), "Deleted label %s\n", l.Name)
193+ return nil
194+}
195+
196+// findLabel resolves a label by exact name (falling back to a case-insensitive
197+// match when unambiguous); labels are addressed by name on the command line
198+// because ids are opaque UUIDs.
199+func findLabel(cmd *cobra.Command, client *api.Client, owner, repo, name string) (*api.Label, error) {
200+ labels, err := client.ListLabels(cmd.Context(), owner, repo)
201+ if err != nil {
202+ return nil, err
203+ }
204+ var loose *api.Label
205+ looseCount := 0
206+ for i := range labels {
207+ if labels[i].Name == name {
208+ return &labels[i], nil
209+ }
210+ if strings.EqualFold(labels[i].Name, name) {
211+ loose = &labels[i]
212+ looseCount++
213+ }
214+ }
215+ if looseCount == 1 {
216+ return loose, nil
217+ }
218+ return nil, fmt.Errorf("no label named %q in %s/%s (see `rickub label list -R %s/%s`)", name, owner, repo, owner, repo)
219+}
220+
221+// normalizeColor strips a leading '#' so both "d73a4a" and "#d73a4a" work.
222+func normalizeColor(c string) string {
223+ return strings.TrimPrefix(strings.TrimSpace(c), "#")
224+}
new file mode 100644
@@ -0,0 +1,224 @@
1+package cmd
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+
8+ "rickub.com/rickub/cli/internal/api"
9+
10+ "github.com/spf13/cobra"
11+)
12+
13+var (
14+ labelRepo string
15+ labelColor string
16+ labelNewName string
17+)
18+
19+func init() {
20+ labelCmd := &cobra.Command{
21+ Use: "label",
22+ Short: "Manage a repository's labels",
23+ }
24+ labelCmd.PersistentFlags().StringVarP(&labelRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)")
25+
26+ listCmd := &cobra.Command{
27+ Use: "list",
28+ Short: "List the repo's labels",
29+ Args: cobra.NoArgs,
30+ RunE: runLabelList,
31+ }
32+
33+ createCmd := &cobra.Command{
34+ Use: "create <name>",
35+ Short: "Create a label",
36+ Args: cobra.ExactArgs(1),
37+ RunE: runLabelCreate,
38+ }
39+ createCmd.Flags().StringVar(&labelColor, "color", "", "one of the palette colors (see `rickub label palette`; server default when omitted)")
40+
41+ editCmd := &cobra.Command{
42+ Use: "edit <name>",
43+ Short: "Rename or recolor a label",
44+ Args: cobra.ExactArgs(1),
45+ RunE: runLabelEdit,
46+ }
47+ editCmd.Flags().StringVar(&labelNewName, "name", "", "new name")
48+ editCmd.Flags().StringVar(&labelColor, "color", "", "new color, from the palette")
49+
50+ deleteCmd := &cobra.Command{
51+ Use: "delete <name>",
52+ Short: "Delete a label",
53+ Args: cobra.ExactArgs(1),
54+ RunE: runLabelDelete,
55+ }
56+
57+ paletteCmd := &cobra.Command{
58+ Use: "palette",
59+ Short: "Print the label colors the server accepts",
60+ Args: cobra.NoArgs,
61+ Run: func(cmd *cobra.Command, _ []string) {
62+ for _, c := range labelPalette {
63+ fmt.Fprintln(cmd.OutOrStdout(), c)
64+ }
65+ },
66+ }
67+
68+ labelCmd.AddCommand(listCmd, createCmd, editCmd, deleteCmd, paletteCmd)
69+ rootCmd.AddCommand(labelCmd)
70+}
71+
72+// labelPalette mirrors the server's fixed label palette (6-hex, no '#'): any
73+// other color is refused with invalid_color, so the CLI can say so up front.
74+var labelPalette = []string{"b60205", "d73a4a", "e99695", "fbca04", "fef2c0", "0e8a16", "c2e0c6", "006b75", "bfdadc", "1d76db", "0052cc", "5319e7", "d4c5f9", "e4e669", "ededed", "f9d0c4"}
75+
76+// colorError turns the server's invalid_color refusal into one that lists
77+// what would have been accepted.
78+func colorError(err error) error {
79+ var apiErr *api.APIError
80+ if errors.As(err, &apiErr) && apiErr.Code == "invalid_color" {
81+ return fmt.Errorf("%s — accepted colors: %s", err, strings.Join(labelPalette, ", "))
82+ }
83+ return err
84+}
85+
86+func runLabelList(cmd *cobra.Command, _ []string) error {
87+ client, err := newClient()
88+ if err != nil {
89+ return err
90+ }
91+ owner, repo, err := resolveRepo(labelRepo)
92+ if err != nil {
93+ return err
94+ }
95+ labels, err := client.ListLabels(cmd.Context(), owner, repo)
96+ if err != nil {
97+ return err
98+ }
99+ return printLabels(cmd, labels)
100+}
101+
102+func printLabels(cmd *cobra.Command, labels []api.Label) error {
103+ if flagJSON {
104+ return printJSON(cmd.OutOrStdout(), labels)
105+ }
106+ if len(labels) == 0 {
107+ fmt.Fprintln(cmd.OutOrStdout(), "No labels.")
108+ return nil
109+ }
110+ tw := newTabw(cmd.OutOrStdout())
111+ fmt.Fprintln(tw, "NAME\tCOLOR")
112+ for _, l := range labels {
113+ fmt.Fprintf(tw, "%s\t%s\n", l.Name, dash(l.Color))
114+ }
115+ return tw.Flush()
116+}
117+
118+func runLabelCreate(cmd *cobra.Command, args []string) error {
119+ client, err := newClient()
120+ if err != nil {
121+ return err
122+ }
123+ owner, repo, err := resolveRepo(labelRepo)
124+ if err != nil {
125+ return err
126+ }
127+ name := strings.TrimSpace(args[0])
128+ if name == "" {
129+ return fmt.Errorf("label name must not be empty")
130+ }
131+ l, err := client.CreateLabel(cmd.Context(), owner, repo, name, normalizeColor(labelColor))
132+ if err != nil {
133+ return colorError(err)
134+ }
135+ if flagJSON {
136+ return printJSON(cmd.OutOrStdout(), l)
137+ }
138+ fmt.Fprintf(cmd.OutOrStdout(), "Created label %s (%s)\n", l.Name, dash(l.Color))
139+ return nil
140+}
141+
142+func runLabelEdit(cmd *cobra.Command, args []string) error {
143+ if labelNewName == "" && labelColor == "" {
144+ return fmt.Errorf("nothing to change: pass --name and/or --color")
145+ }
146+ client, err := newClient()
147+ if err != nil {
148+ return err
149+ }
150+ owner, repo, err := resolveRepo(labelRepo)
151+ if err != nil {
152+ return err
153+ }
154+ l, err := findLabel(cmd, client, owner, repo, args[0])
155+ if err != nil {
156+ return err
157+ }
158+ patch := map[string]any{}
159+ if labelNewName != "" {
160+ patch["name"] = labelNewName
161+ }
162+ if labelColor != "" {
163+ patch["color"] = normalizeColor(labelColor)
164+ }
165+ updated, err := client.UpdateLabel(cmd.Context(), owner, repo, l.ID, patch)
166+ if err != nil {
167+ return colorError(err)
168+ }
169+ if flagJSON {
170+ return printJSON(cmd.OutOrStdout(), updated)
171+ }
172+ fmt.Fprintf(cmd.OutOrStdout(), "Updated label %s (%s)\n", updated.Name, dash(updated.Color))
173+ return nil
174+}
175+
176+func runLabelDelete(cmd *cobra.Command, args []string) error {
177+ client, err := newClient()
178+ if err != nil {
179+ return err
180+ }
181+ owner, repo, err := resolveRepo(labelRepo)
182+ if err != nil {
183+ return err
184+ }
185+ l, err := findLabel(cmd, client, owner, repo, args[0])
186+ if err != nil {
187+ return err
188+ }
189+ if err := client.DeleteLabel(cmd.Context(), owner, repo, l.ID); err != nil {
190+ return err
191+ }
192+ fmt.Fprintf(cmd.OutOrStdout(), "Deleted label %s\n", l.Name)
193+ return nil
194+}
195+
196+// findLabel resolves a label by exact name (falling back to a case-insensitive
197+// match when unambiguous); labels are addressed by name on the command line
198+// because ids are opaque UUIDs.
199+func findLabel(cmd *cobra.Command, client *api.Client, owner, repo, name string) (*api.Label, error) {
200+ labels, err := client.ListLabels(cmd.Context(), owner, repo)
201+ if err != nil {
202+ return nil, err
203+ }
204+ var loose *api.Label
205+ looseCount := 0
206+ for i := range labels {
207+ if labels[i].Name == name {
208+ return &labels[i], nil
209+ }
210+ if strings.EqualFold(labels[i].Name, name) {
211+ loose = &labels[i]
212+ looseCount++
213+ }
214+ }
215+ if looseCount == 1 {
216+ return loose, nil
217+ }
218+ return nil, fmt.Errorf("no label named %q in %s/%s (see `rickub label list -R %s/%s`)", name, owner, repo, owner, repo)
219+}
220+
221+// normalizeColor strips a leading '#' so both "d73a4a" and "#d73a4a" work.
222+func normalizeColor(c string) string {
223+ return strings.TrimPrefix(strings.TrimSpace(c), "#")
224+}