diff --git a/README.md b/README.md index e99cb4c..b9544a1 100644 --- a/README.md +++ b/README.md @@ -383,8 +383,19 @@ gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "Something # 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 +# Delete a label safely +gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 --dry-run +gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 --yes + +# Batch create labels safely +gitlink-cli label +batch-create --owner Gitlink --repo forgeplus \ + --labels 'bug:#ee0701:Bug fixes;feature:#0075ca:New features' --dry-run +gitlink-cli label +batch-create --owner Gitlink --repo forgeplus \ + --labels 'bug:#ee0701:Bug fixes;feature:#0075ca:New features' --yes + +# Batch delete labels safely +gitlink-cli label +batch-delete --owner Gitlink --repo forgeplus --ids 3,5,8 --dry-run +gitlink-cli label +batch-delete --owner Gitlink --repo forgeplus --ids 3,5,8 --yes ``` ### Pull Requests diff --git a/README.zh-CN.md b/README.zh-CN.md index 3af4dfd..cb9e78f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -510,8 +510,19 @@ gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "功能缺 # 更新标签(未指定的字段会被保留) gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00" -# 删除标签 -gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 +# 安全删除标签 +gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 --dry-run +gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 --yes + +# 安全批量创建标签 +gitlink-cli label +batch-create --owner Gitlink --repo forgeplus \ + --labels 'bug:#ee0701:Bug 修复;feature:#0075ca:新功能' --dry-run +gitlink-cli label +batch-create --owner Gitlink --repo forgeplus \ + --labels 'bug:#ee0701:Bug 修复;feature:#0075ca:新功能' --yes + +# 安全批量删除标签 +gitlink-cli label +batch-delete --owner Gitlink --repo forgeplus --ids 3,5,8 --dry-run +gitlink-cli label +batch-delete --owner Gitlink --repo forgeplus --ids 3,5,8 --yes ``` ### Compare diff --git a/doc/changes/label-batch-safety.md b/doc/changes/label-batch-safety.md new file mode 100644 index 0000000..406fdd3 --- /dev/null +++ b/doc/changes/label-batch-safety.md @@ -0,0 +1,77 @@ +# Label batch safety shortcuts + +## Background + +The label shortcut group already supported listing, creating, updating, and +deleting issue labels. Deletion executed immediately, and larger taxonomy setup +or cleanup workflows still required repeated manual commands or Raw API calls. + +This change adds safer destructive operations and first-class batch helpers. + +## New and changed shortcuts + +- `label +delete` now supports `--dry-run` and requires `--yes` for real + deletion. +- `label +batch-create` creates multiple labels from a semicolon-separated + `name:color:description` list. +- `label +batch-delete` deletes multiple labels from a comma-separated ID list. + +## Safety model + +Destructive or multi-write commands should be previewed first: + +```bash +gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 --dry-run +gitlink-cli label +batch-create --owner Gitlink --repo forgeplus \ + --labels 'bug:#ee0701:Bug fixes;feature:#0075ca:New features' --dry-run +gitlink-cli label +batch-delete --owner Gitlink --repo forgeplus --ids 3,5,8 --dry-run +``` + +After confirmation, pass `--yes`: + +```bash +gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 --yes +gitlink-cli label +batch-create --owner Gitlink --repo forgeplus \ + --labels 'bug:#ee0701:Bug fixes;feature:#0075ca:New features' --yes +gitlink-cli label +batch-delete --owner Gitlink --repo forgeplus --ids 3,5,8 --yes +``` + +## Parsing rules + +- `label +batch-create --labels` uses semicolons between labels and colons + inside each label spec: `name:color:description`. +- Missing colors default to `#1E90FF`. +- Colors are validated as `#RGB` or `#RRGGBB` before any API call. +- `label +batch-delete --ids` accepts comma-separated positive integer IDs and + removes duplicates before making requests. + +## Documentation updates + +- README and README.zh-CN include safe delete and batch examples. +- `skills/gitlink-label` documents the new shortcuts and safety model. +- `skills/gitlink-issue-tag` now recommends label shortcuts instead of Raw API + calls for common label workflows. +- `skills/gitlink-stale-issue-manager` uses `label +batch-create` for stale + label bootstrap steps. + +## Tests + +Unit tests cover: + +- single delete dry-run and `--yes` confirmation; +- batch-create dry-run/default preview and real API calls; +- batch-delete dry-run/default preview, de-duplication, and real API calls; +- parser validation for label specs, colors, and ID lists; +- partial batch failure reporting. + +Suggested verification: + +```bash +go test ./shortcuts/label ./shortcuts +``` + +Full project verification: + +```bash +go test ./... +``` diff --git a/shortcuts/label/label.go b/shortcuts/label/label.go index 77e2edb..e38794e 100644 --- a/shortcuts/label/label.go +++ b/shortcuts/label/label.go @@ -14,10 +14,6 @@ import ( // defaultLabelColor is used when the caller does not provide a color. const defaultLabelColor = "#1E90FF" -// labelListPageSize is the page size fetchLabel requests while paging the list -// endpoint. A short page that returns fewer rows than this marks the last page. -const labelListPageSize = 50 - // hexColorPattern matches #RGB and #RRGGBB hex color values. var hexColorPattern = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) @@ -79,105 +75,58 @@ func Shortcuts() []*common.Shortcut { Description: "Delete an issue label", Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Label ID", Required: true}, + {Name: "dry-run", Usage: "Preview the delete request without removing the label", Bool: true, Default: "false"}, + {Name: "yes", Usage: "Confirm label deletion", Bool: true, Default: "false"}, }, - 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) - }, + Run: runDelete, }, { - Name: "clone", - Description: "Clone all issue labels from a source repository into the current one", + Name: "batch-create", + Description: "Batch create issue labels from semicolon-separated specs", Flags: []common.Flag{ - {Name: "source", Short: "s", Usage: "Source repository as owner/repo", Required: true}, - {Name: "force", Short: "f", Usage: "Overwrite labels that already exist in the target", Bool: true}, + {Name: "labels", Usage: "Semicolon-separated label specs: name:color:description", Required: true}, + {Name: "dry-run", Usage: "Preview labels without creating them", Bool: true, Default: "false"}, + {Name: "yes", Usage: "Confirm real batch label creation", Bool: true, Default: "false"}, }, - Run: runClone, + Run: runBatchCreate, + }, + { + Name: "batch-delete", + Description: "Batch delete issue labels by IDs", + Flags: []common.Flag{ + {Name: "ids", Usage: "Comma-separated label IDs", Required: true}, + {Name: "dry-run", Usage: "Preview labels without deleting them", Bool: true, Default: "false"}, + {Name: "yes", Usage: "Confirm real batch label deletion", Bool: true, Default: "false"}, + }, + Run: runBatchDelete, }, } } -// runClone copies every label from a source repository into the current one. -// -// It is a pure composition of the existing list and create/update endpoints: -// the target labels are listed first so that name collisions follow gh's -// semantics — skipped by default, and overwritten (updated in place, which -// preserves the label id and its issue associations) only under --force. -func runClone(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - source, err := ctx.RequireArg("source") - if err != nil { - return err - } - srcOwner, srcRepo, err := splitOwnerRepo(source) - if err != nil { - return err - } - force := ctx.Arg("force") == "true" +type labelSpec struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + Color string `json:"color" yaml:"color"` +} - srcLabels, err := fetchLabelsForRepo(ctx, srcOwner, srcRepo) - if err != nil { - return err - } - dstLabels, err := fetchLabelsForRepo(ctx, ctx.Owner, ctx.Repo) - if err != nil { - return err - } - existing := make(map[string]map[string]interface{}, len(dstLabels)) - for _, tag := range dstLabels { - existing[stringFromMap(tag, "name")] = tag - } +type labelBatchRequest struct { + Method string `json:"method" yaml:"method"` + Path string `json:"path" yaml:"path"` + Body map[string]interface{} `json:"body,omitempty" yaml:"body,omitempty"` +} - created := []string{} - updated := []string{} - skipped := []string{} - for _, tag := range srcLabels { - name := stringFromMap(tag, "name") - if name == "" { - continue - } - payload := map[string]interface{}{ - "name": name, - "description": stringFromMap(tag, "description"), - "color": firstNonEmpty(stringFromMap(tag, "color"), defaultLabelColor), - } - if dst, ok := existing[name]; ok { - if !force { - skipped = append(skipped, name) - continue - } - id := labelIDString(dst["id"]) - if _, err := ctx.CallAPI("PATCH", repoLabelItemPath(ctx.Owner, ctx.Repo, id), payload); err != nil { - return err - } - updated = append(updated, name) - continue - } - if _, err := ctx.CallAPI("POST", labelPath(ctx), payload); err != nil { - return err - } - created = append(created, name) - } +type labelBatchPreview struct { + Repository string `json:"repository" yaml:"repository"` + DryRun bool `json:"dry_run" yaml:"dry_run"` + Action string `json:"action" yaml:"action"` + Requests []labelBatchRequest `json:"requests" yaml:"requests"` +} - return ctx.OutputData(map[string]interface{}{ - "source": fmt.Sprintf("%s/%s", srcOwner, srcRepo), - "target": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - "created": created, - "updated": updated, - "skipped": skipped, - }) +type labelBatchResult struct { + Request labelBatchRequest `json:"request" yaml:"request"` + OK bool `json:"ok" yaml:"ok"` + Data interface{} `json:"data,omitempty" yaml:"data,omitempty"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` } func runCreate(ctx *common.RuntimeContext) error { @@ -228,7 +177,7 @@ func runUpdate(ctx *common.RuntimeContext) error { 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")) + color := firstNonEmpty(ctx.Arg("color"), stringFromMap(current, "color"), defaultLabelColor) if err := validateColor(color); err != nil { return err } @@ -249,123 +198,237 @@ func runUpdate(ctx *common.RuntimeContext) error { 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 -// label beyond the first page must still be found, otherwise update would PATCH -// the server's real name/description/color away with defaults, so an id that is -// absent after the whole list is exhausted is reported as an error. -func fetchLabel(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) { - for page := 1; ; page++ { - q := url.Values{} - q.Set("page", strconv.Itoa(page)) - q.Set("limit", strconv.Itoa(labelListPageSize)) - env, err := ctx.CallAPIWithQuery("GET", labelPath(ctx), q) - if err != nil { - return nil, err - } - data, ok := env.Data.(map[string]interface{}) - if !ok { - break - } - rawTags, ok := data["issue_tags"].([]interface{}) - if !ok { - break - } - for _, raw := range rawTags { - tag, ok := raw.(map[string]interface{}) - if !ok { - continue - } - if labelIDString(tag["id"]) == id { - return tag, nil - } - } - if len(rawTags) < labelListPageSize { - break - } +func runDelete(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err } - return nil, fmt.Errorf("label id %s not found in this repository's issue labels", id) + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + path := labelItemPath(ctx, id) + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(labelBatchPreview{ + Repository: repositoryName(ctx), + DryRun: true, + Action: "delete_label", + Requests: []labelBatchRequest{{ + Method: "DELETE", + Path: path, + }}, + }) + } + if ctx.Arg("yes") != "true" { + return fmt.Errorf("label delete is destructive; run with --dry-run first, then pass --yes to confirm") + } + env, err := ctx.CallAPI("DELETE", path, nil) + if err != nil { + return err + } + return ctx.Output(env) } -// labelPageSize bounds each page of the issue_tags list walk. It mirrors the -// workflow fetchers so a repo with many labels is still copied in full. -const labelPageSize = 100 +func runBatchCreate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + specs, err := parseLabelSpecs(ctx.Arg("labels")) + if err != nil { + return err + } + requests := make([]labelBatchRequest, 0, len(specs)) + for _, spec := range specs { + requests = append(requests, labelBatchRequest{ + Method: "POST", + Path: labelPath(ctx), + Body: map[string]interface{}{ + "name": spec.Name, + "description": spec.Description, + "color": spec.Color, + }, + }) + } + if ctx.Arg("dry-run") == "true" || ctx.Arg("yes") != "true" { + return ctx.OutputData(labelBatchPreview{ + Repository: repositoryName(ctx), + DryRun: true, + Action: "batch_create_labels", + Requests: requests, + }) + } + return runLabelBatchRequests(ctx, "batch_create_labels", requests) +} -// fetchLabelsForRepo returns every label of an arbitrary owner/repo, walking the -// paginated issue_tags list so a source or target with more than one page of -// labels is still mirrored completely. A page without an issue_tags array ends -// the walk rather than erroring, so an empty or unrecognized repo reads as "no -// labels". -func fetchLabelsForRepo(ctx *common.RuntimeContext, owner, repo string) ([]map[string]interface{}, error) { - path := repoLabelPath(owner, repo) - labels := []map[string]interface{}{} - // Track ids across pages so the walk terminates even if the endpoint were - // to ignore the page/limit params and re-serve the full list every time. - seen := map[string]bool{} - for page := 1; ; page++ { - q := url.Values{} - q.Set("page", strconv.Itoa(page)) - q.Set("limit", strconv.Itoa(labelPageSize)) - env, err := ctx.CallAPIWithQuery("GET", path, q) +func runBatchDelete(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + ids, err := parseLabelIDList(ctx.Arg("ids")) + if err != nil { + return err + } + requests := make([]labelBatchRequest, 0, len(ids)) + for _, id := range ids { + requests = append(requests, labelBatchRequest{ + Method: "DELETE", + Path: labelItemPath(ctx, id), + }) + } + if ctx.Arg("dry-run") == "true" || ctx.Arg("yes") != "true" { + return ctx.OutputData(labelBatchPreview{ + Repository: repositoryName(ctx), + DryRun: true, + Action: "batch_delete_labels", + Requests: requests, + }) + } + return runLabelBatchRequests(ctx, "batch_delete_labels", requests) +} + +func runLabelBatchRequests(ctx *common.RuntimeContext, action string, requests []labelBatchRequest) error { + results := make([]labelBatchResult, 0, len(requests)) + succeeded := 0 + failed := 0 + for _, request := range requests { + env, err := ctx.CallAPI(request.Method, request.Path, request.Body) + result := labelBatchResult{Request: request} if err != nil { - return nil, err + result.OK = false + result.Error = err.Error() + failed++ + } else { + result.OK = env.OK + result.Data = env.Data + if env.OK { + succeeded++ + } else { + failed++ + } } - data, ok := env.Data.(map[string]interface{}) + results = append(results, result) + } + if err := ctx.OutputData(map[string]interface{}{ + "repository": repositoryName(ctx), + "action": action, + "count": len(requests), + "succeeded": succeeded, + "failed": failed, + "results": results, + }); err != nil { + return err + } + if failed > 0 { + return fmt.Errorf("%d of %d label request(s) failed", failed, len(requests)) + } + return nil +} + +// 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 { - break + continue } - rawTags, ok := data["issue_tags"].([]interface{}) - if !ok { - break - } - added := 0 - for _, raw := range rawTags { - tag, ok := raw.(map[string]interface{}) - if !ok { - continue - } - id := labelIDString(tag["id"]) - if id != "" && seen[id] { - continue - } - if id != "" { - seen[id] = true - } - labels = append(labels, tag) - added++ - } - if added < labelPageSize { - break + if labelIDString(tag["id"]) == id { + return tag, nil } } - return labels, nil + return nil, nil } func labelPath(ctx *common.RuntimeContext) string { - return repoLabelPath(ctx.Owner, ctx.Repo) + return fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) } func labelItemPath(ctx *common.RuntimeContext, id string) string { - return repoLabelItemPath(ctx.Owner, ctx.Repo, id) + return fmt.Sprintf("%s/%s", labelPath(ctx), url.PathEscape(id)) } -func repoLabelPath(owner, repo string) string { - return fmt.Sprintf("/v1/%s/%s/issue_tags", owner, repo) -} - -func repoLabelItemPath(owner, repo, id string) string { - return fmt.Sprintf("%s/%s", repoLabelPath(owner, repo), url.PathEscape(id)) -} - -// splitOwnerRepo parses an "owner/repo" reference, tolerating a leading slash -// and an extra trailing path so that a full repo URL path still resolves. -func splitOwnerRepo(source string) (string, string, error) { - trimmed := strings.Trim(strings.TrimSpace(source), "/") - parts := strings.SplitN(trimmed, "/", 3) - if len(parts) < 2 || parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("invalid --source %q: expected owner/repo", source) +func parseLabelSpecs(raw string) ([]labelSpec, error) { + if strings.TrimSpace(raw) == "" { + return nil, fmt.Errorf("--labels is required") } - return parts[0], parts[1], nil + parts := strings.Split(raw, ";") + specs := make([]labelSpec, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + fields := strings.SplitN(part, ":", 3) + name := strings.TrimSpace(fields[0]) + if name == "" { + return nil, fmt.Errorf("invalid label spec %q: name is required", part) + } + color := defaultLabelColor + if len(fields) > 1 && strings.TrimSpace(fields[1]) != "" { + color = strings.TrimSpace(fields[1]) + } + if err := validateColor(color); err != nil { + return nil, err + } + description := "" + if len(fields) > 2 { + description = strings.TrimSpace(fields[2]) + } + specs = append(specs, labelSpec{ + Name: name, + Description: description, + Color: color, + }) + } + if len(specs) == 0 { + return nil, fmt.Errorf("--labels must contain at least one label spec") + } + return specs, nil +} + +func parseLabelIDList(raw string) ([]string, error) { + if strings.TrimSpace(raw) == "" { + return nil, fmt.Errorf("--ids is required") + } + parts := strings.Split(raw, ",") + ids := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, part := range parts { + id := strings.TrimSpace(part) + if id == "" { + continue + } + n, err := strconv.Atoi(id) + if err != nil || n <= 0 { + return nil, fmt.Errorf("invalid label id %q: use positive integer IDs", id) + } + if seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, fmt.Errorf("--ids must contain at least one label ID") + } + return ids, nil +} + +func repositoryName(ctx *common.RuntimeContext) string { + return fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo) } func validateColor(color string) error { diff --git a/shortcuts/label/label_test.go b/shortcuts/label/label_test.go index 9a05b47..1962435 100644 --- a/shortcuts/label/label_test.go +++ b/shortcuts/label/label_test.go @@ -2,10 +2,8 @@ package label import ( "encoding/json" - "fmt" "net/http" "net/http/httptest" - "strconv" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -126,92 +124,6 @@ func TestLabelUpdatePreservesCurrentFields(t *testing.T) { assertEqual(t, payload["color"], "#00FF00") } -func TestLabelUpdatePreservesFieldsWhenLabelOnSecondPage(t *testing.T) { - var payload map[string]interface{} - var pagesFetched []string - server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issue_tags.json": - page := r.URL.Query().Get("page") - pagesFetched = append(pagesFetched, page) - switch page { - case "1": - writeJSON(t, w, map[string]interface{}{ - "total_count": labelListPageSize + 1, - "issue_tags": fillerLabels(labelListPageSize), - }) - case "2": - writeJSON(t, w, map[string]interface{}{ - "total_count": labelListPageSize + 1, - "issue_tags": []interface{}{ - map[string]interface{}{ - "id": float64(7), - "name": "bug", - "description": "old description", - "color": "#FF0000", - }, - }, - }) - default: - t.Fatalf("unexpected page %q", page) - } - 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", - "name": "renamed", - }) - if err != nil { - t.Fatalf("update shortcut failed: %v", err) - } - - if len(pagesFetched) != 2 || pagesFetched[0] != "1" || pagesFetched[1] != "2" { - t.Fatalf("expected pages [1 2] to be fetched, got %v", pagesFetched) - } - // The label lives on page 2; only --name was passed, so the description and - // color the server already holds must survive the PATCH untouched. - assertEqual(t, payload["name"], "renamed") - assertEqual(t, payload["description"], "old description") - assertEqual(t, payload["color"], "#FF0000") -} - -func TestLabelUpdateErrorsWhenLabelNotFound(t *testing.T) { - 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(3), - "name": "docs", - "description": "documentation", - "color": "#00FF00", - }, - }, - }) - default: - t.Fatalf("missing label must not PATCH, got: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - err := runLabelShortcut(t, server, "update", map[string]string{ - "id": "7", - "color": "#123456", - }) - if err == nil { - t.Fatal("expected update of a missing label id to return an error") - } -} - 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) @@ -231,11 +143,157 @@ func TestLabelDelete(t *testing.T) { }) defer server.Close() - if err := runLabelShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil { + if err := runLabelShortcut(t, server, "delete", map[string]string{"id": "7", "yes": "true"}); err != nil { t.Fatalf("delete shortcut failed: %v", err) } } +func TestLabelDeleteDryRunDoesNotCallAPI(t *testing.T) { + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("delete dry-run should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + if err := runLabelShortcut(t, server, "delete", map[string]string{"id": "7", "dry-run": "true"}); err != nil { + t.Fatalf("delete dry-run failed: %v", err) + } +} + +func TestLabelDeleteRequiresYes(t *testing.T) { + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("delete without --yes should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runLabelShortcut(t, server, "delete", map[string]string{"id": "7"}) + if err == nil { + t.Fatal("expected delete to require --yes") + } +} + +func TestLabelBatchCreateDryRunDoesNotCallAPI(t *testing.T) { + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("batch-create dry-run should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runLabelShortcut(t, server, "batch-create", map[string]string{ + "labels": "bug:#ee0701:Bug fixes;feature:#0075ca:New features", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("batch-create dry-run failed: %v", err) + } +} + +func TestLabelBatchCreateDefaultsToDryRunWithoutYes(t *testing.T) { + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("batch-create without --yes should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runLabelShortcut(t, server, "batch-create", map[string]string{ + "labels": "bug:#ee0701:Bug fixes", + }) + if err != nil { + t.Fatalf("batch-create default dry-run failed: %v", err) + } +} + +func TestLabelBatchCreateWithYesCallsEndpoints(t *testing.T) { + var payloads []map[string]interface{} + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/v1/owner/repo/issue_tags.json") + payloads = append(payloads, decodeJSON(t, r)) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runLabelShortcut(t, server, "batch-create", map[string]string{ + "labels": "bug:#ee0701:Bug fixes;feature:#0075ca:New features", + "yes": "true", + }) + if err != nil { + t.Fatalf("batch-create failed: %v", err) + } + if len(payloads) != 2 { + t.Fatalf("got %d payloads, want 2", len(payloads)) + } + assertEqual(t, payloads[0]["name"], "bug") + assertEqual(t, payloads[0]["color"], "#ee0701") + assertEqual(t, payloads[0]["description"], "Bug fixes") + assertEqual(t, payloads[1]["name"], "feature") +} + +func TestLabelBatchCreateReportsPartialFailure(t *testing.T) { + calls := 0 + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/v1/owner/repo/issue_tags.json") + calls++ + if calls == 2 { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + return + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runLabelShortcut(t, server, "batch-create", map[string]string{ + "labels": "bug:#ee0701:Bug fixes;feature:#0075ca:New features", + "yes": "true", + }) + if err == nil { + t.Fatal("expected partial failure to return an error") + } + if calls != 2 { + t.Fatalf("got %d calls, want 2", calls) + } +} + +func TestLabelBatchDeleteDryRunDoesNotCallAPI(t *testing.T) { + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("batch-delete dry-run should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + if err := runLabelShortcut(t, server, "batch-delete", map[string]string{"ids": "7,8", "dry-run": "true"}); err != nil { + t.Fatalf("batch-delete dry-run failed: %v", err) + } +} + +func TestLabelBatchDeleteDefaultsToDryRunWithoutYes(t *testing.T) { + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("batch-delete without --yes should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + if err := runLabelShortcut(t, server, "batch-delete", map[string]string{"ids": "7,8"}); err != nil { + t.Fatalf("batch-delete default dry-run failed: %v", err) + } +} + +func TestLabelBatchDeleteWithYesCallsEndpoints(t *testing.T) { + var paths []string + server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Fatalf("got method %s, want DELETE", r.Method) + } + paths = append(paths, r.URL.Path) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runLabelShortcut(t, server, "batch-delete", map[string]string{"ids": "7,8,7", "yes": "true"}); err != nil { + t.Fatalf("batch-delete failed: %v", err) + } + if len(paths) != 2 { + t.Fatalf("got %d calls, want 2", len(paths)) + } + assertEqual(t, paths[0], "/v1/owner/repo/issue_tags/7.json") + assertEqual(t, paths[1], "/v1/owner/repo/issue_tags/8.json") +} + func TestValidateColor(t *testing.T) { valid := []string{"#1E90FF", "#abc", "#ABCDEF", "#000"} for _, c := range valid { @@ -258,176 +316,51 @@ func TestLabelIDString(t *testing.T) { assertEqual(t, labelIDString(nil), "") } -func TestLabelCloneSkipsExistingCreatesNew(t *testing.T) { - var posted []map[string]interface{} - patched := false - server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/v1/src-owner/src-repo/issue_tags.json": - writeJSON(t, w, map[string]interface{}{ - "total_count": 2, - "issue_tags": []interface{}{ - map[string]interface{}{"id": float64(1), "name": "bug", "description": "b", "color": "#FF0000"}, - map[string]interface{}{"id": float64(2), "name": "feature", "description": "f", "color": "#00FF00"}, - }, - }) - 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(9), "name": "bug", "description": "existing", "color": "#123456"}, - }, - }) - case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issue_tags.json": - posted = append(posted, decodeJSON(t, r)) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - case r.Method == "PATCH": - patched = true - t.Fatalf("unexpected PATCH without --force: %s", r.URL.Path) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - if err := runLabelShortcut(t, server, "clone", map[string]string{"source": "src-owner/src-repo"}); err != nil { - t.Fatalf("clone shortcut failed: %v", err) - } - if patched { - t.Fatal("expected no PATCH without --force") - } - if len(posted) != 1 { - t.Fatalf("expected 1 created label, got %d", len(posted)) - } - // The colliding "bug" is skipped by name; only "feature" is created, with - // the source's own color carried over. - assertEqual(t, posted[0]["name"], "feature") - assertEqual(t, posted[0]["color"], "#00FF00") -} - -func TestLabelCloneForceUpdatesExisting(t *testing.T) { - var patchPath string - var patchPayload map[string]interface{} - posted := false - server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/v1/src-owner/src-repo/issue_tags.json": - writeJSON(t, w, map[string]interface{}{ - "total_count": 1, - "issue_tags": []interface{}{ - map[string]interface{}{"id": float64(1), "name": "bug", "description": "from source", "color": "#FF0000"}, - }, - }) - 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(9), "name": "bug", "description": "old", "color": "#000000"}, - }, - }) - case r.Method == "PATCH": - patchPath = r.URL.Path - patchPayload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - case r.Method == "POST": - posted = true - t.Fatalf("unexpected POST for an existing label under --force") - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - if err := runLabelShortcut(t, server, "clone", map[string]string{"source": "src-owner/src-repo", "force": "true"}); err != nil { - t.Fatalf("clone shortcut failed: %v", err) - } - if posted { - t.Fatal("expected no POST for an existing label under --force") - } - // --force PATCHes the existing label id in place so issue associations - // survive, and overwrites its fields from the source. - assertEqual(t, patchPath, "/v1/owner/repo/issue_tags/9.json") - assertEqual(t, patchPayload["name"], "bug") - assertEqual(t, patchPayload["description"], "from source") - assertEqual(t, patchPayload["color"], "#FF0000") -} - -func TestFetchLabelsForRepoPaginates(t *testing.T) { - pagesSeen := map[string]bool{} - server := newLabelTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/issue_tags.json") - page := r.URL.Query().Get("page") - pagesSeen[page] = true - if got := r.URL.Query().Get("limit"); got != strconv.Itoa(labelPageSize) { - t.Fatalf("got limit %q, want %d", got, labelPageSize) - } - var tags []interface{} - switch page { - case "1": - tags = make([]interface{}, labelPageSize) - for i := range tags { - tags[i] = map[string]interface{}{"id": float64(i + 1), "name": fmt.Sprintf("l%d", i+1)} - } - case "2": - tags = []interface{}{ - map[string]interface{}{"id": float64(101), "name": "l101"}, - map[string]interface{}{"id": float64(102), "name": "l102"}, - map[string]interface{}{"id": float64(103), "name": "l103"}, - } - default: - t.Fatalf("unexpected page %q", page) - } - writeJSON(t, w, map[string]interface{}{"total_count": labelPageSize + 3, "issue_tags": tags}) - }) - defer server.Close() - - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: map[string]string{}, - } - labels, err := fetchLabelsForRepo(ctx, "owner", "repo") +func TestParseLabelSpecs(t *testing.T) { + specs, err := parseLabelSpecs("bug:#ee0701:Bug fixes; docs::Documentation") if err != nil { - t.Fatalf("fetchLabelsForRepo error: %v", err) + t.Fatalf("parseLabelSpecs failed: %v", err) } - if len(labels) != labelPageSize+3 { - t.Fatalf("got %d labels, want %d", len(labels), labelPageSize+3) + if len(specs) != 2 { + t.Fatalf("got %d specs, want 2", len(specs)) } - if !pagesSeen["1"] || !pagesSeen["2"] { - t.Fatalf("expected pages 1 and 2 to be walked, saw %v", pagesSeen) + assertEqual(t, specs[0].Name, "bug") + assertEqual(t, specs[0].Color, "#ee0701") + assertEqual(t, specs[0].Description, "Bug fixes") + assertEqual(t, specs[1].Name, "docs") + assertEqual(t, specs[1].Color, defaultLabelColor) + assertEqual(t, specs[1].Description, "Documentation") +} + +func TestParseLabelSpecsRejectsInvalidInput(t *testing.T) { + invalid := []string{"", ":#ee0701:missing name", "bug:red:bad color"} + for _, value := range invalid { + if _, err := parseLabelSpecs(value); err == nil { + t.Fatalf("expected %q to be invalid", value) + } } } -func TestSplitOwnerRepo(t *testing.T) { - cases := []struct { - in string - wantOwner string - wantRepo string - wantErr bool - }{ - {"owner/repo", "owner", "repo", false}, - {"/owner/repo/", "owner", "repo", false}, - {" owner/repo ", "owner", "repo", false}, - {"owner/repo/sub", "owner", "repo", false}, - {"owner", "", "", true}, - {"", "", "", true}, - {"/", "", "", true}, +func TestParseLabelIDList(t *testing.T) { + ids, err := parseLabelIDList("7, 8,7, 9") + if err != nil { + t.Fatalf("parseLabelIDList failed: %v", err) } - for _, tc := range cases { - owner, repo, err := splitOwnerRepo(tc.in) - if tc.wantErr { - if err == nil { - t.Fatalf("splitOwnerRepo(%q) expected error", tc.in) - } - continue + want := []string{"7", "8", "9"} + if len(ids) != len(want) { + t.Fatalf("got %v, want %v", ids, want) + } + for i := range want { + assertEqual(t, ids[i], want[i]) + } +} + +func TestParseLabelIDListRejectsInvalidInput(t *testing.T) { + invalid := []string{"", "0", "-1", "abc", "7,abc"} + for _, value := range invalid { + if _, err := parseLabelIDList(value); err == nil { + t.Fatalf("expected %q to be invalid", value) } - if err != nil { - t.Fatalf("splitOwnerRepo(%q) unexpected error: %v", tc.in, err) - } - assertEqual(t, owner, tc.wantOwner) - assertEqual(t, repo, tc.wantRepo) } } @@ -466,21 +399,6 @@ func newLabelTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server return httptest.NewServer(handler) } -// fillerLabels builds n distinct labels whose ids never collide with the target -// ids used in the update tests, so a full page forces fetchLabel onto the next. -func fillerLabels(n int) []interface{} { - labels := make([]interface{}, 0, n) - for i := 0; i < n; i++ { - labels = append(labels, map[string]interface{}{ - "id": float64(1000 + i), - "name": fmt.Sprintf("filler-%d", i), - "description": "filler", - "color": "#123456", - }) - } - return labels -} - func assertRequest(t *testing.T, r *http.Request, method, path string) { t.Helper() if r.Method != method || r.URL.Path != path { diff --git a/skills/gitlink-issue-tag/SKILL.md b/skills/gitlink-issue-tag/SKILL.md index 7f5b775..64c61a3 100644 --- a/skills/gitlink-issue-tag/SKILL.md +++ b/skills/gitlink-issue-tag/SKILL.md @@ -5,14 +5,14 @@ description: "项目标记管理:查看、创建、修改、删除 GitLink 仓 metadata: requires: bins: ["gitlink-cli"] - cliHelp: "gitlink-cli api --help" + cliHelp: "gitlink-cli label --help" --- # gitlink-tag(项目标记管理) **CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** **CRITICAL — 所有写入/删除操作前,务必先确认用户意图。** -**CRITICAL — 项目标记通过 `gitlink-cli api` 操作,无需本地 git 命令。** +**CRITICAL — 项目标记优先通过 `gitlink-cli label` 快捷命令操作,无需本地 git 命令。** > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) @@ -23,22 +23,25 @@ metadata: 1. **查看标记** — 列出仓库所有项目标记,支持关键词搜索和精简模式 2. **创建标记** — 创建新标记,设置名称、描述和颜色 3. **修改标记** — 修改已有标记的名称、描述或颜色 -4. **删除标记** — 删除不再需要的标记 +4. **删除标记** — 安全删除不再需要的标记 +5. **批量操作** — 批量创建/删除标记,支持 dry-run 预览和 `--yes` 确认 --- -## API 能力说明 +## Shortcut 能力说明 -GitLink API 对项目标记(issue_tags)的完整支持: +GitLink CLI 对项目标记(issue_tags)的完整支持: -| 操作 | HTTP 方法 | API 路径 | 说明 | -|------|-----------|---------|------| -| 查询标记列表 | GET | `/v1/{owner}/{repo}/issue_tags` | 支持 keyword/only_name/sort_by/sort_direction/limit/page 参数 | -| 创建标记 | POST | `/v1/{owner}/{repo}/issue_tags` | 请求体:{name, description, color} | -| 修改标记 | PATCH | `/v1/{owner}/{repo}/issue_tags/{id}` | 请求体:{name, description, color},路径参数 id 为标记 ID | -| 删除标记 | DELETE | `/v1/{owner}/{repo}/issue_tags/{id}` | 路径参数 id 为标记 ID | +| 操作 | Shortcut | 说明 | +|------|----------|------| +| 查询标记列表 | `label +list` | 支持 keyword/only_name/sort_by/sort_direction 参数 | +| 创建标记 | `label +create` | 参数:name、description、color | +| 修改标记 | `label +update` | 按 ID 修改,未指定字段会保留 | +| 删除标记 | `label +delete` | 按 ID 删除,支持 `--dry-run` 和 `--yes` | +| 批量创建 | `label +batch-create` | 解析 `name:color:description` 列表,支持 `--dry-run` 和 `--yes` | +| 批量删除 | `label +batch-delete` | 解析逗号分隔 ID,支持 `--dry-run` 和 `--yes` | -> **核心原则:** 所有操作均通过 `gitlink-cli api` 调用,无需本地 git 命令。 +> **核心原则:** 优先使用 `gitlink-cli label`。Raw API 仅在快捷命令无法覆盖的调试场景使用。 --- @@ -48,7 +51,7 @@ GitLink API 对项目标记(issue_tags)的完整支持: ```bash # 获取项目标记完整列表(含描述、颜色、关联 Issue 数量等) -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json ``` **返回数据结构**: @@ -71,7 +74,7 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json ```bash # 搜索名称中包含关键词的标记 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=bug' --format json +gitlink-cli label +list --owner --repo --keyword bug --format json ``` **支持的查询参数**: @@ -87,7 +90,7 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=bug' --format j ```bash # 仅返回名称和 ID(适用于选择标记、快速浏览等场景) -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'only_name=true' --format json +gitlink-cli label +list --owner --repo --only-name true --format json ``` **返回示例**: @@ -107,13 +110,13 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'only_name=true' --forma ```bash # 按 Issue 数量倒序排列(找出最常用的标记) -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'order_by=issues_count&order_direction=desc' --format json +gitlink-cli label +list --owner --repo --sort-by issues_count --sort-direction desc --format json # 按创建时间正序排列(最早创建的排前面) -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'order_by=created_on&order_direction=asc' --format json +gitlink-cli label +list --owner --repo --sort-by created_on --sort-direction asc --format json # 按更新时间倒序排列(最近更新的排前面) -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'order_by=updated_on&order_direction=desc' --format json +gitlink-cli label +list --owner --repo --sort-by updated_on --sort-direction desc --format json ``` --- @@ -124,7 +127,7 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'order_by=updated_on&ord ```bash # 创建一个项目标记 -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"测试11","description":"111","color":"#54ff85"}' --format json +gitlink-cli label +create --owner --repo -n "测试11" -d "111" -c "#54ff85" --format json ``` **请求体参数**: @@ -165,21 +168,22 @@ AI 创建标记时,如用户未指定颜色,可按标记用途推荐默认 ```bash # 创建标记后,通过关键词搜索确认 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=测试11' --format json +gitlink-cli label +list --owner --repo --keyword "测试11" --format json ``` ### 2.4 批量创建标记 -当用户需要一次创建多个标记时,逐个调用创建 API: +当用户需要一次创建多个标记时,优先使用批量快捷命令: ```bash -# 批量创建标记(逐个调用) -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"bug","description":"Bug 修复","color":"#ee0701"}' --format json -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"feature","description":"新功能","color":"#0075ca"}' --format json -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"documentation","description":"文档相关","color":"#0075ca"}' --format json +# 批量创建标记,先 dry-run 再确认 +gitlink-cli label +batch-create --owner --repo \ + --labels 'bug:#ee0701:Bug 修复;feature:#0075ca:新功能;documentation:#0075ca:文档相关' --dry-run +gitlink-cli label +batch-create --owner --repo \ + --labels 'bug:#ee0701:Bug 修复;feature:#0075ca:新功能;documentation:#0075ca:文档相关' --yes # 验证创建结果 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'only_name=true' --format json +gitlink-cli label +list --owner --repo --only-name true --format json ``` > **⚠️ 批量创建前,先查询现有标记,避免创建重复名称的标记。** @@ -194,11 +198,11 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'only_name=true' --forma ```bash # Step 1:查询标记列表,获取目标标记的 ID -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json # 在返回结果中找到目标标记的 id 字段 # Step 2:使用 ID 修改标记 -gitlink-cli api PATCH /v1/:owner/:repo/issue_tags/:id --body '{"name":"测试11","description":"1112","color":"#54ff85"}' --format json +gitlink-cli label +update --owner --repo -i -n "测试11" -d "1112" -c "#54ff85" --format json ``` **请求体参数**(与创建相同): @@ -224,40 +228,40 @@ gitlink-cli api PATCH /v1/:owner/:repo/issue_tags/:id --body '{"name":"测试11" ```bash # 查询获取 ID -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=旧名称' --format json +gitlink-cli label +list --owner --repo --keyword "旧名称" --format json # 假设返回 id=5 # 修改名称(保持描述和颜色不变) -gitlink-cli api PATCH /v1/:owner/:repo/issue_tags/5 --body '{"name":"新名称","description":"原描述","color":"#ee0701"}' --format json +gitlink-cli label +update --owner --repo -i 5 -n "新名称" --format json ``` **修改标记颜色**: ```bash # 查询获取 ID -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=bug' --format json +gitlink-cli label +list --owner --repo --keyword bug --format json # 假设返回 id=3 # 修改颜色(保持名称和描述不变) -gitlink-cli api PATCH /v1/:owner/:repo/issue_tags/3 --body '{"name":"bug","description":"Bug 修复","color":"#ff0000"}' --format json +gitlink-cli label +update --owner --repo -i 3 -c "#ff0000" --format json ``` **修改标记描述**: ```bash # 查询获取 ID -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=feature' --format json +gitlink-cli label +list --owner --repo --keyword feature --format json # 假设返回 id=7 # 修改描述 -gitlink-cli api PATCH /v1/:owner/:repo/issue_tags/7 --body '{"name":"feature","description":"新的功能需求描述","color":"#0075ca"}' --format json +gitlink-cli label +update --owner --repo -i 7 -d "新的功能需求描述" --format json ``` ### 3.3 修改后验证 ```bash # 修改后查询确认变更已生效 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=修改后的名称' --format json +gitlink-cli label +list --owner --repo --keyword "修改后的名称" --format json ``` > **⚠️ 修改标记名称后,已关联该标记的 Issue 会自动更新为新名称。** @@ -272,11 +276,12 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=修改后的名 ```bash # Step 1:查询标记列表,获取目标标记的 ID -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=要删除的标记名' --format json +gitlink-cli label +list --owner --repo --keyword "要删除的标记名" --format json # 在返回结果中找到目标标记的 id 字段 -# Step 2:删除标记 -gitlink-cli api DELETE /v1/:owner/:repo/issue_tags/:id --format json +# Step 2:先预览删除,再确认执行 +gitlink-cli label +delete --owner --repo -i --dry-run +gitlink-cli label +delete --owner --repo -i --yes ``` **返回示例**: @@ -292,7 +297,7 @@ gitlink-cli api DELETE /v1/:owner/:repo/issue_tags/:id --format json ```bash # Step 1:查看所有标记,确认要删除的目标 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json # Step 2:记录目标标记的 ID 和关联 Issue 数量 # 假设目标标记 id=5, name="deprecated", issues_count=3 @@ -301,10 +306,11 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json # ⚠️ 删除标记后,关联的 Issue 将失去该标记 # Step 4:执行删除 -gitlink-cli api DELETE /v1/:owner/:repo/issue_tags/5 --format json +gitlink-cli label +delete --owner --repo -i 5 --dry-run +gitlink-cli label +delete --owner --repo -i 5 --yes # Step 5:验证删除结果 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=deprecated' --format json +gitlink-cli label +list --owner --repo --keyword deprecated --format json # total_count 应为 0 ``` @@ -312,18 +318,17 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=deprecated' --f ```bash # Step 1:查询所有标记 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json # Step 2:AI 根据用户意图筛选要删除的标记,列出 ID 列表 # 假设要删除 id=3, id=5, id=8 -# Step 3:逐个删除 -gitlink-cli api DELETE /v1/:owner/:repo/issue_tags/3 --format json -gitlink-cli api DELETE /v1/:owner/:repo/issue_tags/5 --format json -gitlink-cli api DELETE /v1/:owner/:repo/issue_tags/8 --format json +# Step 3:批量删除,先 dry-run 再确认 +gitlink-cli label +batch-delete --owner --repo --ids 3,5,8 --dry-run +gitlink-cli label +batch-delete --owner --repo --ids 3,5,8 --yes # Step 4:验证删除结果 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json ``` > **⚠️ 批量删除是危险操作,必须先列出待删除标记清单让用户确认后再执行。** @@ -336,7 +341,7 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json ```bash # 获取所有标记及关联 Issue 数量 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'order_by=issues_count&order_direction=asc' --format json +gitlink-cli label +list --owner --repo --sort-by issues_count --sort-direction asc --format json ``` **AI 分析规则**: @@ -355,14 +360,10 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'order_by=issues_count&o ```bash # 基础标记集(适用于大多数项目) -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"bug","description":"Bug 修复或问题报告","color":"#ee0701"}' --format json -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"feature","description":"新功能需求","color":"#0075ca"}' --format json -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"enhancement","description":"功能优化或改进","color":"#5319e7"}' --format json -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"documentation","description":"文档相关","color":"#0075ca"}' --format json -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"good-first-issue","description":"适合新贡献者的问题","color":"#008672"}' --format json -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"help-wanted","description":"需要帮助的问题","color":"#008672"}' --format json -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"question","description":"使用疑问","color":"#fbca04"}' --format json -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"wontfix","description":"不会处理的问题","color":"#fbca04"}' --format json +gitlink-cli label +batch-create --owner --repo --dry-run \ + --labels 'bug:#ee0701:Bug 修复或问题报告;feature:#0075ca:新功能需求;enhancement:#5319e7:功能优化或改进;documentation:#0075ca:文档相关;good-first-issue:#008672:适合新贡献者的问题;help-wanted:#008672:需要帮助的问题;question:#fbca04:使用疑问;wontfix:#fbca04:不会处理的问题' +gitlink-cli label +batch-create --owner --repo --yes \ + --labels 'bug:#ee0701:Bug 修复或问题报告;feature:#0075ca:新功能需求;enhancement:#5319e7:功能优化或改进;documentation:#0075ca:文档相关;good-first-issue:#008672:适合新贡献者的问题;help-wanted:#008672:需要帮助的问题;question:#fbca04:使用疑问;wontfix:#fbca04:不会处理的问题' ``` ### 5.3 合并相似标记 @@ -371,7 +372,7 @@ gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"wontfix","desc ```bash # Step 1:查询所有标记,AI 识别相似标记对 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json # Step 2:假设 "bug"(id=3) 和 "defect"(id=7) 需要合并,保留 "bug" @@ -381,23 +382,24 @@ gitlink-cli issue +list --state open --owner --repo --format json # AI 筛选标记为 "defect" 的 Issue,将其改为 "bug" # Step 4:删除 "defect" 标记 -gitlink-cli api DELETE /v1/:owner/:repo/issue_tags/7 --format json +gitlink-cli label +delete --owner --repo -i 7 --dry-run +gitlink-cli label +delete --owner --repo -i 7 --yes # Step 5:验证 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json ``` ### 5.4 查询参数组合使用 ```bash # 搜索关键词 + 仅返回名称 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=bug&only_name=true' --format json +gitlink-cli label +list --owner --repo --keyword bug --only-name true --format json # 按 Issue 数量倒序 + 仅返回名称(快速查看热门标记) -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'order_by=issues_count&order_direction=desc&only_name=true' --format json +gitlink-cli label +list --owner --repo --sort-by issues_count --sort-direction desc --only-name true --format json # 按更新时间倒序(查看最近活跃的标记) -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'order_by=updated_on&order_direction=desc' --format json +gitlink-cli label +list --owner --repo --sort-by updated_on --sort-direction desc --format json ``` --- @@ -408,57 +410,58 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'order_by=updated_on&ord ```bash # Step 1:获取项目标记完整列表 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json # Step 2(可选):搜索特定标记 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=bug' --format json +gitlink-cli label +list --owner --repo --keyword bug --format json # Step 3(可选):查看精简列表 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'only_name=true' --format json +gitlink-cli label +list --owner --repo --only-name true --format json ``` ### 6.2 创建标记流程 ```bash # Step 1:查看现有标记,避免重复 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'only_name=true' --format json +gitlink-cli label +list --owner --repo --only-name true --format json # Step 2:创建标记 -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"新标记","description":"标记描述","color":"#54ff85"}' --format json +gitlink-cli label +create --owner --repo -n "新标记" -d "标记描述" -c "#54ff85" --format json # Step 3:验证创建结果 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=新标记' --format json +gitlink-cli label +list --owner --repo --keyword "新标记" --format json ``` ### 6.3 修改标记流程 ```bash # Step 1:查询目标标记,获取 ID -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=要修改的标记' --format json +gitlink-cli label +list --owner --repo --keyword "要修改的标记" --format json # 记录目标标记的 id # Step 2:修改标记(使用 ID) -gitlink-cli api PATCH /v1/:owner/:repo/issue_tags/:id --body '{"name":"修改后名称","description":"修改后描述","color":"#ff0000"}' --format json +gitlink-cli label +update --owner --repo -i -n "修改后名称" -d "修改后描述" -c "#ff0000" --format json # Step 3:验证修改结果 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=修改后名称' --format json +gitlink-cli label +list --owner --repo --keyword "修改后名称" --format json ``` ### 6.4 删除标记流程 ```bash # Step 1:查询目标标记,获取 ID 和关联 Issue 数量 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=要删除的标记' --format json +gitlink-cli label +list --owner --repo --keyword "要删除的标记" --format json # 记录目标标记的 id 和 issues_count # Step 2:确认删除意图(⚠️ 如 issues_count > 0 需特别提醒) # ⚠️ 删除标记后,关联的 Issue 将失去该标记 # Step 3:删除标记 -gitlink-cli api DELETE /v1/:owner/:repo/issue_tags/:id --format json +gitlink-cli label +delete --owner --repo -i --dry-run +gitlink-cli label +delete --owner --repo -i --yes # Step 4:验证删除结果 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json ``` --- @@ -553,9 +556,10 @@ gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json 用户:"帮我的新仓库创建一套 Issue 标签" AI 执行: -1. gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'only_name=true' --format json +1. gitlink-cli label +list --owner --repo --only-name true --format json → 确认当前标记列表为空 -2. 逐个创建基础标记(bug/feature/enhancement/documentation/good-first-issue/help-wanted/question/wontfix) +2. gitlink-cli label +batch-create --owner --repo --labels '<基础标记集>' --dry-run +3. 用户确认后执行 gitlink-cli label +batch-create --owner --repo --labels '<基础标记集>' --yes 3. 验证创建结果 4. 输出创建报告 ``` @@ -566,9 +570,9 @@ AI 执行: 用户:"把 bug 标签的颜色改成红色" AI 执行: -1. gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'keyword=bug' --format json +1. gitlink-cli label +list --owner --repo --keyword bug --format json → 获取 id 和当前信息 -2. gitlink-cli api PATCH /v1/:owner/:repo/issue_tags/:id --body '{"name":"bug","description":"原描述","color":"#ee0701"}' --format json +2. gitlink-cli label +update --owner --repo -i -c "#ee0701" --format json 3. 验证修改结果 4. 输出修改报告 ``` @@ -579,10 +583,11 @@ AI 执行: 用户:"删除没有关联任何 Issue 的标签" AI 执行: -1. gitlink-cli api GET /v1/:owner/:repo/issue_tags --query 'order_by=issues_count&order_direction=asc' --format json +1. gitlink-cli label +list --owner --repo --sort-by issues_count --sort-direction asc --format json 2. AI 筛选 issues_count == 0 的标记 3. 列出待删除标记清单,请用户确认 -4. 确认后逐个删除 +4. 先执行 gitlink-cli label +batch-delete --owner --repo --ids --dry-run +5. 用户确认后执行 gitlink-cli label +batch-delete --owner --repo --ids --yes 5. 输出删除报告 ``` @@ -592,7 +597,7 @@ AI 执行: 用户:"检查有没有重复的标签" AI 执行: -1. gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +1. gitlink-cli label +list --owner --repo --format json 2. AI 分析语义相似的标记对(如 bug/defect、feature/enhancement) 3. 列出建议合并的标记对,请用户确认 4. 执行合并(迁移 Issue 标记 → 删除冗余标记) @@ -612,4 +617,3 @@ AI 执行: - ⚠️ **标记名称唯一**:同一仓库下标记名称不能重复 - ✅ **排序参数**:`sort_by` 支持 `updated_on`、`created_on`、`issues_count`,`sort_direction` 支持 `desc`、`asc` - ✅ **所有操作通过 gitlink-cli api**:无需本地 git 命令,所有增删查改均通过 API 完成 - diff --git a/skills/gitlink-label/SKILL.md b/skills/gitlink-label/SKILL.md index 45ee27b..378bd5e 100644 --- a/skills/gitlink-label/SKILL.md +++ b/skills/gitlink-label/SKILL.md @@ -1,7 +1,7 @@ --- 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." +version: 2.0.0 +description: "Issue label management: list, create, update, delete, and batch manage GitLink issue labels (项目标记). Triggered when a user needs to manage labels, set up a triage taxonomy, or tag issues." metadata: requires: bins: ["gitlink-cli"] @@ -11,7 +11,7 @@ metadata: # 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**: Confirm user intent before running write or destructive operations such as `+create`, `+update`, `+delete`, `+batch-create`, or `+batch-delete`. **CRITICAL**: Use `gitlink-cli` for GitLink resources. Do not use GitHub-only tools such as `gh`. ## Shortcuts @@ -21,7 +21,9 @@ metadata: | `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 | +| `label +delete` | Delete an issue label with dry-run/yes protection | Destructive | +| `label +batch-create` | Batch create issue labels from `name:color:description` specs | Write | +| `label +batch-delete` | Batch delete issue labels by IDs with dry-run/yes protection | Destructive | ## Examples @@ -38,8 +40,19 @@ gitlink-cli label +create --owner Gitlink --repo forgeplus -n bug -d "Something # 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 +# Delete a label safely +gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 --dry-run +gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 --yes + +# Batch create labels safely +gitlink-cli label +batch-create --owner Gitlink --repo forgeplus \ + --labels 'bug:#ee0701:Bug fixes;feature:#0075ca:New features' --dry-run +gitlink-cli label +batch-create --owner Gitlink --repo forgeplus \ + --labels 'bug:#ee0701:Bug fixes;feature:#0075ca:New features' --yes + +# Batch delete labels safely +gitlink-cli label +batch-delete --owner Gitlink --repo forgeplus --ids 3,5,8 --dry-run +gitlink-cli label +batch-delete --owner Gitlink --repo forgeplus --ids 3,5,8 --yes ``` ## Parameters @@ -49,23 +62,27 @@ gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 | `+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) | +| `+delete` | `--id` (required), `--dry-run`, `--yes` | +| `+batch-create` | `--labels` (semicolon-separated `name:color:description` specs), `--dry-run`, `--yes` | +| `+batch-delete` | `--ids` (comma-separated label IDs), `--dry-run`, `--yes` | ## 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). +- `+delete`, `+batch-create`, and `+batch-delete` should be run with `--dry-run` first, then repeated with `--yes` after confirmation. +- `+batch-create` defaults missing colors to `#1E90FF`; duplicate IDs in `+batch-delete` are de-duplicated client-side. - 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" +gitlink-cli label +batch-create --dry-run \ + --labels 'bug:#D73A4A:Confirmed defect;enhancement:#A2EEEF:Feature request;question:#D876E3:Needs clarification;security:#B60205:Security-sensitive' +gitlink-cli label +batch-create --yes \ + --labels 'bug:#D73A4A:Confirmed defect;enhancement:#A2EEEF:Feature request;question:#D876E3:Needs clarification;security:#B60205:Security-sensitive' # Verify the taxonomy gitlink-cli label +list --only-name true --format json diff --git a/skills/gitlink-stale-issue-manager/SKILL.md b/skills/gitlink-stale-issue-manager/SKILL.md index 431a6f1..c7c7a91 100644 --- a/skills/gitlink-stale-issue-manager/SKILL.md +++ b/skills/gitlink-stale-issue-manager/SKILL.md @@ -123,22 +123,17 @@ gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals?category=comment&pa ```bash # Step 1:获取仓库现有标签列表 -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json # Step 2:检查目标标签是否已存在(AI 在返回结果中查找) -# 如果目标标签不存在,则创建: - -# 创建"迟缓"标签(30-59天) -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"迟缓","description":"近期活动频率明显下降,需关注但尚未停滞","color":"#fbca04"}' --format json - -# 创建"不活跃"标签(60-89天) -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"不活跃","description":"长期无更新或互动,可能已失去推进动力","color":"#d93f0b"}' --format json - -# 创建"过期"标签(≥90天) -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"过期","description":"已超出合理响应周期,建议关闭或重新评估","color":"#b60205"}' --format json +# 如果目标标签不存在,则批量创建。先 dry-run,再在用户确认后 --yes: +gitlink-cli label +batch-create --owner --repo \ + --labels '迟缓:#fbca04:近期活动频率明显下降,需关注但尚未停滞;不活跃:#d93f0b:长期无更新或互动,可能已失去推进动力;过期:#b60205:已超出合理响应周期,建议关闭或重新评估' --dry-run +gitlink-cli label +batch-create --owner --repo \ + --labels '迟缓:#fbca04:近期活动频率明显下降,需关注但尚未停滞;不活跃:#d93f0b:长期无更新或互动,可能已失去推进动力;过期:#b60205:已超出合理响应周期,建议关闭或重新评估' --yes # Step 3:重新获取标签列表,确认标签 ID -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json ``` **标签与过期等级对应关系**: @@ -428,11 +423,12 @@ gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals --format json # Step 5(执行 — 用户确认后): # a. 预创建标签(确保标签存在) -gitlink-cli api GET /v1/:owner/:repo/issue_tags --format json +gitlink-cli label +list --owner --repo --format json # 检查迟缓/不活跃/过期标签是否存在,不存在则创建: -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"迟缓","description":"近期活动频率明显下降,需关注但尚未停滞","color":"#fbca04"}' --format json -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"不活跃","description":"长期无更新或互动,可能已失去推进动力","color":"#d93f0b"}' --format json -gitlink-cli api POST /v1/:owner/:repo/issue_tags --body '{"name":"过期","description":"已超出合理响应周期,建议关闭或重新评估","color":"#b60205"}' --format json +gitlink-cli label +batch-create --owner --repo \ + --labels '迟缓:#fbca04:近期活动频率明显下降,需关注但尚未停滞;不活跃:#d93f0b:长期无更新或互动,可能已失去推进动力;过期:#b60205:已超出合理响应周期,建议关闭或重新评估' --dry-run +gitlink-cli label +batch-create --owner --repo \ + --labels '迟缓:#fbca04:近期活动频率明显下降,需关注但尚未停滞;不活跃:#d93f0b:长期无更新或互动,可能已失去推进动力;过期:#b60205:已超出合理响应周期,建议关闭或重新评估' --yes # b. 对 30-59 天 Issue:打"迟缓"标签 + 发提醒评论 gitlink-cli api PATCH /v1/:owner/:repo/issues/:id --body '{"issue_tag_ids":[]}' --format json @@ -559,4 +555,4 @@ AI 应解析为: - ✅ **关闭操作不可逆**:虽然维护者可以重新打开,但评论通知已发出,应谨慎 - ✅ **建议定期执行**:推荐每周执行一次,保持 Issue 列表健康 - ⚠️ **标签操作**:打标签通过 `gitlink-cli api PATCH /v1/:owner/:repo/issues/:id --body '{"issue_tag_ids":[]}'` 完成,`issue_tag_ids` 为完整替换,需包含已有标签 ID -- ⚠️ **标签预创建**:打标签前必须先查询标签列表,确认目标标签存在,不存在则先通过 `POST /v1/:owner/:repo/issue_tags` 创建 +- ⚠️ **标签预创建**:打标签前必须先用 `gitlink-cli label +list` 查询标签列表,确认目标标签存在;不存在则先用 `gitlink-cli label +batch-create --dry-run` 预览并在确认后 `--yes` 创建