feat(label): add issue label shortcuts

Add a `label` shortcut group wrapping the GitLink issue_tags (项目标记) API
with +list / +create / +update / +delete, plus the gitlink-label Skill,
bilingual README usage, and a changelog entry.

- +list supports keyword filter, only-name, and sort options
- +create defaults color to #1E90FF and validates hex client-side
- +update fetches current values and merges, preserving unspecified fields
- unit tests cover HTTP method, path, query, payload, color validation, id coercion
- ignore the local `gitlink-cli` build artifact (.gitignore previously only
  ignored the Windows .exe)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
何开元 2026-05-30 06:37:54 -07:00
parent 4631b81e5c
commit cca3d019fe
8 changed files with 615 additions and 0 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
gitlink-cli.exe
/gitlink-cli

View File

@ -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

View File

@ -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

View File

@ -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 直接复用。

244
shortcuts/label/label.go Normal file
View File

@ -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 ""
}

View File

@ -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)
}
}

View File

@ -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",

View File

@ -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
```