forked from Gitlink/gitlink-cli
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:
parent
4631b81e5c
commit
cca3d019fe
|
|
@ -1,2 +1,3 @@
|
|||
|
||||
gitlink-cli.exe
|
||||
/gitlink-cli
|
||||
|
|
|
|||
20
README.md
20
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 直接复用。
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
Loading…
Reference in New Issue