diff --git a/doc/changes/label-clone.md b/doc/changes/label-clone.md new file mode 100644 index 0000000..6c89944 --- /dev/null +++ b/doc/changes/label-clone.md @@ -0,0 +1,15 @@ +# Label clone shortcut + +新增 `label +clone`,对齐 `gh label clone`:把源仓库的全部 Issue 标签复制到当前仓库。 + +- 用法:`label +clone --source owner/repo [--force]`。 +- 语义与 `gh` 一致:按**名称**判重,目标已存在的同名标签默认**跳过**;仅在 `--force` 下就地**覆盖**(`PATCH` 标签 id,保留标签 id 与其 Issue 关联)。 +- 纯组合已有端点:`GET issue_tags` 列举 + `POST` 新建 / `PATCH` 更新,不新增 API。 +- 返回 `created` / `updated` / `skipped` 三组名称,便于查看每个标签的去向。 + +实现要点: + +- 新增自包含的 `fetchLabelsForRepo(ctx, owner, repo)`,按 `page`/`limit` 翻页遍历 `issue_tags` 数组(与 `workflow` 的 `fetchAllListItems` 同一翻页范式),源仓库或目标仓库标签超过一页也能完整镜像。 +- **未改动既有 `fetchLabel`**:上游 PR #363(`fix/label-update-pagination`)正在为 `fetchLabel` 加翻页,clone 走独立的 `fetchLabelsForRepo` 以避免合并冲突、也不重新引入单页 bug。 +- 补充路径辅助 `repoLabelPath` / `repoLabelItemPath` 支持任意 owner/repo,原 `labelPath` / `labelItemPath` 改为其薄封装;`splitOwnerRepo` 解析 `owner/repo`(容忍首尾斜杠与多余尾部路径)。 +- 单测覆盖:默认跳过同名、新建缺失标签、`--force` 就地 `PATCH`,以及 `fetchLabelsForRepo` 翻页遍历两页。 diff --git a/shortcuts/label/label.go b/shortcuts/label/label.go index 2b6a298..003de79 100644 --- a/shortcuts/label/label.go +++ b/shortcuts/label/label.go @@ -91,9 +91,91 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "clone", + Description: "Clone all issue labels from a source repository into the current one", + 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}, + }, + Run: runClone, + }, } } +// 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" + + 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 + } + + 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) + } + + 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, + }) +} + func runCreate(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -192,12 +274,85 @@ func fetchLabel(ctx *common.RuntimeContext, id string) (map[string]interface{}, return nil, nil } +// 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 + +// 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) + 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 + } + 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 + } + } + return labels, nil +} + func labelPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) + return repoLabelPath(ctx.Owner, ctx.Repo) } func labelItemPath(ctx *common.RuntimeContext, id string) string { - return fmt.Sprintf("%s/%s", labelPath(ctx), url.PathEscape(id)) + return repoLabelItemPath(ctx.Owner, ctx.Repo, 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) + } + return parts[0], parts[1], nil } func validateColor(color string) error { diff --git a/shortcuts/label/label_test.go b/shortcuts/label/label_test.go index 66bd5dc..1e40116 100644 --- a/shortcuts/label/label_test.go +++ b/shortcuts/label/label_test.go @@ -2,8 +2,10 @@ package label import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strconv" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -170,6 +172,179 @@ 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") + if err != nil { + t.Fatalf("fetchLabelsForRepo error: %v", err) + } + if len(labels) != labelPageSize+3 { + t.Fatalf("got %d labels, want %d", len(labels), labelPageSize+3) + } + if !pagesSeen["1"] || !pagesSeen["2"] { + t.Fatalf("expected pages 1 and 2 to be walked, saw %v", pagesSeen) + } +} + +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}, + } + 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 + } + if err != nil { + t.Fatalf("splitOwnerRepo(%q) unexpected error: %v", tc.in, err) + } + assertEqual(t, owner, tc.wantOwner) + assertEqual(t, repo, tc.wantRepo) + } +} + func runLabelShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findLabelShortcut(t, name)