diff --git a/.gitignore b/.gitignore index 4b9c885..bd0ccf8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ gitlink-cli.exe +/gitlink-cli diff --git a/README.md b/README.md index 6711900..67e5dc3 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans |----------|-------------| | 📦 Repo | List, create, fork, delete repositories, view repo info | | 🐛 Issue | Create, update, close, batch close, comment on issues | +| 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | | 🌿 Branch | Create, delete, list, protect, unprotect branches | @@ -232,6 +233,25 @@ gitlink-cli issue +assigners --owner Gitlink --repo forgeplus gitlink-cli issue +authors --owner Gitlink --repo forgeplus ``` +### Label Management + +```bash +# List issue labels +gitlink-cli label +list --owner Gitlink --repo forgeplus + +# Filter labels by keyword +gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug + +# Create a label (color defaults to #1E90FF) +gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "Something is broken" -c "#FF0000" + +# Update a label (unspecified fields are preserved) +gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00" + +# Delete a label +gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 +``` + ### Pull Requests ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 1a56fe4..d7e1a2b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -42,6 +42,7 @@ |------|------| | 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 | | 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | +| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | | 🌿 分支 | 创建、删除、保护分支 | @@ -243,6 +244,25 @@ gitlink-cli issue +assigners --owner Gitlink --repo forgeplus gitlink-cli issue +authors --owner Gitlink --repo forgeplus ``` +### 标签管理 + +```bash +# 列出 Issue 标签 +gitlink-cli label +list --owner Gitlink --repo forgeplus + +# 按关键词筛选标签 +gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug + +# 创建标签(颜色默认 #1E90FF) +gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "功能缺陷" -c "#FF0000" + +# 更新标签(未指定的字段会被保留) +gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00" + +# 删除标签 +gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 +``` + ### Pull Request ```bash diff --git a/doc/changes/label-shortcut.md b/doc/changes/label-shortcut.md new file mode 100644 index 0000000..942bb7d --- /dev/null +++ b/doc/changes/label-shortcut.md @@ -0,0 +1,18 @@ +# Label shortcut + +新增 `label` Shortcut 组,补齐 GitLink Issue 标签(项目标记 / `issue_tags`)OpenAPI 的常用操作封装: + +- `label +list` +- `label +create` +- `label +update` +- `label +delete` + +实现要点: + +- 列表支持 `--keyword` 关键词过滤、`--only-name` 精简返回、`--sort-by` / `--sort-direction` 排序,映射到 API 的 `order_by` / `order_direction`。 +- `+create` 的 `--color` 缺省为 `#1E90FF`;颜色统一做十六进制(`#RGB` / `#RRGGBB`)客户端校验,非法颜色在调用 API 前即报错。 +- `+update` 先从列表接口取标签当前值并与传入字段合并,避免漏传字段被清空(更新接口要求 `name`/`description`/`color` 同时提交);无任何变更字段时直接报错。 +- 路径使用 `/api/v1/{owner}/{repo}/issue_tags`,与 webhook/milestone 等组保持一致的 `/v1/` 前缀约定。 +- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数、payload,以及颜色校验和 id 归一化逻辑。 + +背景:在此之前,Issue 标签只能通过 Raw API(`issue_tags`)手工管理;`gitlink-code-review`、`gitlink-insight` 等 Skill 在做 Issue 分拣 / 打标签时都需要拼接原始请求。`label` 组将其提升为一等命令,并配套 `skills/gitlink-label/` Skill 文档,方便人类与 AI Agent 直接复用。 diff --git a/shortcuts/label/label.go b/shortcuts/label/label.go new file mode 100644 index 0000000..2b6a298 --- /dev/null +++ b/shortcuts/label/label.go @@ -0,0 +1,244 @@ +package label + +import ( + "encoding/json" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// defaultLabelColor is used when the caller does not provide a color. +const defaultLabelColor = "#1E90FF" + +// hexColorPattern matches #RGB and #RRGGBB hex color values. +var hexColorPattern = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) + +// Shortcuts returns issue label (项目标记) management shortcuts. +// +// Issue labels back the issue triage and PR gatekeeping workflows: until now +// they could only be managed through the raw API (issue_tags), so these +// shortcuts close that gap with first-class create/list/update/delete commands. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List issue labels", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Filter labels by keyword"}, + {Name: "only-name", Usage: "Return only label id and name: true or false"}, + {Name: "sort-by", Usage: "Sort field: updated_on, created_on, issues_count"}, + {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + setQueryIfPresent(q, "keyword", ctx.Arg("keyword")) + setQueryIfPresent(q, "only_name", ctx.Arg("only-name")) + setQueryIfPresent(q, "order_by", ctx.Arg("sort-by")) + setQueryIfPresent(q, "order_direction", ctx.Arg("sort-direction")) + env, err := ctx.CallAPIWithQuery("GET", labelPath(ctx), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create an issue label", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Label name", Required: true}, + {Name: "description", Short: "d", Usage: "Label description"}, + {Name: "color", Short: "c", Usage: "Label color in hex, for example: #1E90FF", Default: defaultLabelColor}, + }, + Run: runCreate, + }, + { + Name: "update", + Description: "Update an issue label while preserving unspecified fields", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Label ID", Required: true}, + {Name: "name", Short: "n", Usage: "Label name"}, + {Name: "description", Short: "d", Usage: "Label description"}, + {Name: "color", Short: "c", Usage: "Label color in hex, for example: #1E90FF"}, + }, + Run: runUpdate, + }, + { + Name: "delete", + Description: "Delete an issue label", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Label ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", labelItemPath(ctx, id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func runCreate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + color := firstNonEmpty(ctx.Arg("color"), defaultLabelColor) + if err := validateColor(color); err != nil { + return err + } + payload := map[string]interface{}{ + "name": name, + "description": ctx.Arg("description"), + "color": color, + } + env, err := ctx.CallAPI("POST", labelPath(ctx), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + if ctx.Arg("name") == "" && ctx.Arg("description") == "" && ctx.Arg("color") == "" { + return fmt.Errorf("at least one of --name, --description, or --color is required") + } + + // The update endpoint requires name, description and color together, so we + // merge the requested changes onto the label's current values to avoid + // clobbering fields the caller did not pass. + current, err := fetchLabel(ctx, id) + if err != nil { + return err + } + + name := firstNonEmpty(ctx.Arg("name"), stringFromMap(current, "name")) + if name == "" { + return fmt.Errorf("could not resolve label name for id %s; pass --name explicitly", id) + } + color := firstNonEmpty(ctx.Arg("color"), stringFromMap(current, "color"), defaultLabelColor) + if err := validateColor(color); err != nil { + return err + } + description := ctx.Arg("description") + if description == "" { + description = stringFromMap(current, "description") + } + + payload := map[string]interface{}{ + "name": name, + "description": description, + "color": color, + } + env, err := ctx.CallAPI("PATCH", labelItemPath(ctx, id), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +// fetchLabel looks up a single label by id from the list endpoint. GitLink does +// not expose a single-label GET, so we page through the list and match by id. +// A nil result (label not found) is not an error: the caller falls back to the +// flags it was given. +func fetchLabel(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) { + env, err := ctx.CallAPI("GET", labelPath(ctx), nil) + if err != nil { + return nil, err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil, nil + } + rawTags, ok := data["issue_tags"].([]interface{}) + if !ok { + return nil, nil + } + for _, raw := range rawTags { + tag, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if labelIDString(tag["id"]) == id { + return tag, nil + } + } + return nil, nil +} + +func labelPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) +} + +func labelItemPath(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("%s/%s", labelPath(ctx), url.PathEscape(id)) +} + +func validateColor(color string) error { + if !hexColorPattern.MatchString(color) { + return fmt.Errorf("invalid --color value %q: use a hex color like #1E90FF or #abc", color) + } + return nil +} + +func labelIDString(v interface{}) string { + switch id := v.(type) { + case string: + return id + case float64: + return strconv.FormatInt(int64(id), 10) + case json.Number: + return id.String() + default: + return "" + } +} + +func setQueryIfPresent(q url.Values, name, value string) { + if value != "" { + q.Set(name, value) + } +} + +func stringFromMap(values map[string]interface{}, key string) string { + if values == nil { + return "" + } + value, _ := values[key].(string) + return value +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/shortcuts/label/label_test.go b/shortcuts/label/label_test.go new file mode 100644 index 0000000..66bd5dc --- /dev/null +++ b/shortcuts/label/label_test.go @@ -0,0 +1,237 @@ +package label + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestLabelList(t *testing.T) { + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/issue_tags.json") + if got := r.URL.Query().Get("keyword"); got != "bug" { + t.Fatalf("got keyword %q, want %q", got, "bug") + } + if got := r.URL.Query().Get("order_by"); got != "issues_count" { + t.Fatalf("got order_by %q, want %q", got, "issues_count") + } + writeJSON(t, w, map[string]interface{}{"total_count": 0, "issue_tags": []interface{}{}}) + }) + defer server.Close() + + err := runLabelShortcut(t, server, "list", map[string]string{ + "keyword": "bug", + "sort-by": "issues_count", + }) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestLabelCreatePayload(t *testing.T) { + var payload map[string]interface{} + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/v1/owner/repo/issue_tags.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runLabelShortcut(t, server, "create", map[string]string{ + "name": "bug", + "description": "Something is broken", + "color": "#FF0000", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + + assertEqual(t, payload["name"], "bug") + assertEqual(t, payload["description"], "Something is broken") + assertEqual(t, payload["color"], "#FF0000") +} + +func TestLabelCreateUsesDefaultColor(t *testing.T) { + var payload map[string]interface{} + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/v1/owner/repo/issue_tags.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runLabelShortcut(t, server, "create", map[string]string{"name": "enhancement"}); err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + assertEqual(t, payload["color"], defaultLabelColor) +} + +func TestLabelCreateRejectsInvalidColor(t *testing.T) { + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid color should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runLabelShortcut(t, server, "create", map[string]string{ + "name": "bug", + "color": "red", + }) + if err == nil { + t.Fatal("expected invalid color to return an error") + } +} + +func TestLabelUpdatePreservesCurrentFields(t *testing.T) { + var payload map[string]interface{} + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issue_tags.json": + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "issue_tags": []interface{}{ + map[string]interface{}{ + "id": float64(7), + "name": "bug", + "description": "old description", + "color": "#FF0000", + }, + }, + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issue_tags/7.json": + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runLabelShortcut(t, server, "update", map[string]string{ + "id": "7", + "color": "#00FF00", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + // name and description preserved from current; only color changed. + assertEqual(t, payload["name"], "bug") + assertEqual(t, payload["description"], "old description") + assertEqual(t, payload["color"], "#00FF00") +} + +func TestLabelUpdateRequiresAtLeastOneField(t *testing.T) { + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("update with no fields should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runLabelShortcut(t, server, "update", map[string]string{"id": "7"}) + if err == nil { + t.Fatal("expected update with no fields to return an error") + } +} + +func TestLabelDelete(t *testing.T) { + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/v1/owner/repo/issue_tags/7.json") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runLabelShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } +} + +func TestValidateColor(t *testing.T) { + valid := []string{"#1E90FF", "#abc", "#ABCDEF", "#000"} + for _, c := range valid { + if err := validateColor(c); err != nil { + t.Fatalf("expected %q to be valid, got %v", c, err) + } + } + invalid := []string{"red", "1E90FF", "#12", "#GGGGGG", "#1234", ""} + for _, c := range invalid { + if err := validateColor(c); err == nil { + t.Fatalf("expected %q to be invalid", c) + } + } +} + +func TestLabelIDString(t *testing.T) { + assertEqual(t, labelIDString(float64(7)), "7") + assertEqual(t, labelIDString("9"), "9") + assertEqual(t, labelIDString(json.Number("11")), "11") + assertEqual(t, labelIDString(nil), "") +} + +func runLabelShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findLabelShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findLabelShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func newLabelTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func assertRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func assertEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if got != want { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index d42a5b6..282f872 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -8,6 +8,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" + "github.com/gitlink-org/gitlink-cli/shortcuts/label" "github.com/gitlink-org/gitlink-cli/shortcuts/member" "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" "github.com/gitlink-org/gitlink-cli/shortcuts/org" @@ -25,6 +26,7 @@ func RegisterAll(root *cobra.Command) { groups := map[string][]*common.Shortcut{ "repo": repo.Shortcuts(), "issue": issue.Shortcuts(), + "label": label.Shortcuts(), "member": member.Shortcuts(), "milestone": milestone.Shortcuts(), "pr": pr.Shortcuts(), @@ -42,6 +44,7 @@ func RegisterAll(root *cobra.Command) { descriptions := map[string]string{ "repo": "Repository operations", "issue": "Issue operations", + "label": "Issue label operations", "member": "Repository member operations", "milestone": "Milestone operations", "pr": "Pull request operations", diff --git a/skills/gitlink-label/SKILL.md b/skills/gitlink-label/SKILL.md new file mode 100644 index 0000000..45ee27b --- /dev/null +++ b/skills/gitlink-label/SKILL.md @@ -0,0 +1,72 @@ +--- +name: gitlink-label +version: 1.0.0 +description: "Issue label management: list, create, update, and delete GitLink issue labels (项目标记). Triggered when a user needs to manage labels, set up a triage taxonomy, or tag issues." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli label --help" +--- + +# gitlink-label + +**CRITICAL**: Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) before starting. It covers authentication, permissions, global flags, and GitLink API behavior. +**CRITICAL**: Confirm user intent before running write or destructive operations such as `+create`, `+update`, or `+delete`. +**CRITICAL**: Use `gitlink-cli` for GitLink resources. Do not use GitHub-only tools such as `gh`. + +## Shortcuts + +| Shortcut | Description | Operation | +|----------|-------------|-----------| +| `label +list` | List issue labels | Read | +| `label +create` | Create an issue label | Write | +| `label +update` | Update a label, preserving unspecified fields | Write | +| `label +delete` | Delete an issue label | Destructive | + +## Examples + +```bash +# List all labels +gitlink-cli label +list --owner Gitlink --repo forgeplus + +# Filter labels by keyword, sorted by issue count +gitlink-cli label +list --owner Gitlink --repo forgeplus -k bug --sort-by issues_count --sort-direction desc + +# Create a label (color defaults to #1E90FF when omitted) +gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "Something is broken" -c "#FF0000" + +# Update only the color; name and description are preserved +gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00" + +# Delete a label +gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 +``` + +## Parameters + +| Command | Key parameters | +|---------|----------------| +| `+list` | `--keyword`, `--only-name`, `--sort-by` (updated_on / created_on / issues_count), `--sort-direction` (asc / desc) | +| `+create` | `--name` (required), `--description`, `--color` (hex, default `#1E90FF`) | +| `+update` | `--id` (required) plus at least one of `--name`, `--description`, `--color` | +| `+delete` | `--id` (required) | + +## API Notes + +- Labels map to the GitLink "项目标记" / `issue_tags` API: `/api/v1/{owner}/{repo}/issue_tags`. +- `--color` must be a hex value (`#RGB` or `#RRGGBB`); it is validated client-side before the API call. +- `+update` first fetches the label's current values from the list endpoint and merges the requested changes, so fields you do not pass are preserved (the API requires `name`, `description`, and `color` together). +- To attach a label to an issue, pass its id via the issue update API field `issue_tag_ids` (see `gitlink-issue`); use `label +list --only-name true` to resolve label ids quickly. + +## Typical workflow: bootstrap a triage taxonomy + +```bash +# Create a consistent label set for issue triage +gitlink-cli label +create -n bug -c "#D73A4A" -d "Confirmed defect" +gitlink-cli label +create -n enhancement -c "#A2EEEF" -d "Feature request" +gitlink-cli label +create -n question -c "#D876E3" -d "Needs clarification" +gitlink-cli label +create -n security -c "#B60205" -d "Security-sensitive" + +# Verify the taxonomy +gitlink-cli label +list --only-name true --format json +```