feat(label): add label +clone to copy labels from another repository

Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
林晨 (Leo Cheng) 2026-07-08 12:43:33 +08:00
parent 9749a4c832
commit 44d2ba0db2
No known key found for this signature in database
GPG Key ID: 24FCF87A069356B9
3 changed files with 347 additions and 2 deletions

View File

@ -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` 翻页遍历两页。

View File

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

View File

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