feat(label): add label +clone to copy labels from another repository
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
parent
9749a4c832
commit
44d2ba0db2
|
|
@ -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` 翻页遍历两页。
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue