From 044cd0e0d2733ca6d4703fe0007391241d6c2d79 Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Thu, 28 May 2026 15:51:13 +0800 Subject: [PATCH] feat(branch): complete OpenAPI shortcuts --- README.md | 24 +- README.zh-CN.md | 30 ++- doc/changes/branch-openapi-shortcuts.md | 40 ++++ shortcuts/branch/branch.go | 189 +++++++++++++++- shortcuts/branch/branch_test.go | 288 ++++++++++++++++-------- skills/README.md | 2 +- skills/gitlink-branch/SKILL.md | 89 ++++++-- 7 files changed, 535 insertions(+), 127 deletions(-) create mode 100644 doc/changes/branch-openapi-shortcuts.md diff --git a/README.md b/README.md index 289eb16..448e53b 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | -| 🌿 Branch | Create, delete, list, protect, unprotect branches | +| 🌿 Branch | List, create, delete, restore, set default, protect, unprotect branches | | 🏷️ Release | Create, edit, update, view, delete releases | | 🏢 Org | Manage organizations, members, teams | | 🔧 CI | View builds, logs, CI/CD operations | @@ -406,14 +406,24 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved ### Branch Management ```bash -# List branches -gitlink-cli branch +list --owner Gitlink --repo forgeplus +# List branches, including deleted branches when needed +gitlink-cli branch +list --owner Gitlink --repo forgeplus --keyword feature +gitlink-cli branch +list --owner Gitlink --repo forgeplus --state deleted -# Create a branch -gitlink-cli branch +create --name feature/new-feature +# List all branches without pagination +gitlink-cli branch +all --owner Gitlink --repo forgeplus -# Delete a branch -gitlink-cli branch +delete --name feature/old-feature +# Create a branch, with dry-run preview +gitlink-cli branch +create --owner Gitlink --repo forgeplus --name feature/new-feature --from master --dry-run + +# Delete a branch, with dry-run preview +gitlink-cli branch +delete --owner Gitlink --repo forgeplus --name feature/old-feature --dry-run + +# Set default branch +gitlink-cli branch +set-default --owner Gitlink --repo forgeplus --name develop --dry-run + +# Restore a deleted branch +gitlink-cli branch +restore --owner Gitlink --repo forgeplus --branch-id 7 --name feature/old-feature --dry-run # Protect a branch gitlink-cli branch +protect --name main diff --git a/README.zh-CN.md b/README.zh-CN.md index ce6f964..a9fe7a0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -108,7 +108,7 @@ | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | -| 🌿 分支 | 创建、删除、保护分支 | +| 🌿 分支 | 列出、创建、删除、恢复、设置默认分支、保护分支 | | 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release | | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | @@ -413,6 +413,34 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" ``` + +### 分支管理 + +```bash +# 列出分支,支持关键字和已删除分支过滤 +gitlink-cli branch +list --owner Gitlink --repo forgeplus --keyword feature +gitlink-cli branch +list --owner Gitlink --repo forgeplus --state deleted + +# 列出全部分支(无分页) +gitlink-cli branch +all --owner Gitlink --repo forgeplus + +# 创建分支,先 dry-run 预览 +gitlink-cli branch +create --owner Gitlink --repo forgeplus --name feature/new-feature --from master --dry-run + +# 删除分支,先 dry-run 预览 +gitlink-cli branch +delete --owner Gitlink --repo forgeplus --name feature/old-feature --dry-run + +# 设置默认分支 +gitlink-cli branch +set-default --owner Gitlink --repo forgeplus --name develop --dry-run + +# 恢复已删除分支 +gitlink-cli branch +restore --owner Gitlink --repo forgeplus --branch-id 7 --name feature/old-feature --dry-run + +# 保护/取消保护分支 +gitlink-cli branch +protect --owner Gitlink --repo forgeplus --name main +gitlink-cli branch +unprotect --owner Gitlink --repo forgeplus --name main +``` + ### 发布管理 ```bash diff --git a/doc/changes/branch-openapi-shortcuts.md b/doc/changes/branch-openapi-shortcuts.md new file mode 100644 index 0000000..982581a --- /dev/null +++ b/doc/changes/branch-openapi-shortcuts.md @@ -0,0 +1,40 @@ +# Branch OpenAPI Shortcuts + +补齐 GitLink 分支 OpenAPI 的生命周期操作,并增强现有 branch shortcut 的安全性和参数能力。 + +## 新增 / 增强命令 + +- `branch +list`:新增 `--keyword` 和 `--state all|deleted`,对齐 OpenAPI 查询参数。 +- `branch +all`:调用无分页分支列表接口。 +- `branch +create`:新增 `--dry-run`,预览创建分支请求。 +- `branch +delete`:切换到 OpenAPI 文档中的 `DELETE /api/v1/{owner}/{repo}/branches/{branch}.json`,并新增 `--dry-run`。 +- `branch +set-default`:设置仓库默认分支,支持 `--dry-run`。 +- `branch +restore`:恢复已删除分支,支持 `--dry-run`。 + +## OpenAPI 对齐 + +- `GET /api/v1/{owner}/{repo}/branches.json` +- `POST /api/v1/{owner}/{repo}/branches.json` +- `GET /api/v1/{owner}/{repo}/branches/all.json` +- `DELETE /api/v1/{owner}/{repo}/branches/{branch}.json` +- `PATCH /api/v1/{owner}/{repo}/branches/update_default_branch.json` +- `POST /api/v1/{owner}/{repo}/branches/restore.json` + +## 安全设计 + +- `branch +create`、`branch +delete`、`branch +set-default`、`branch +restore` 都支持 `--dry-run`。 +- `branch +delete` 会对包含 `/` 的分支名进行路径转义,避免把 `feature/foo` 误解析为多级路径。 +- `branch +restore` 校验 `--branch-id` 必须为正整数。 +- `branch +list --state` 仅允许 `all` 或 `deleted`,避免无效状态参数。 + +## 测试 + +新增单元测试覆盖: + +- list 查询参数。 +- all endpoint。 +- create payload 与 dry-run。 +- delete v1 endpoint 与路径转义。 +- set-default query 参数。 +- restore payload。 +- 无效 state / branch-id 不触发 API。 diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 0393ad5..41037e0 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -1,10 +1,14 @@ package branch import ( + "encoding/json" "fmt" "net/url" + "strconv" + "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -15,6 +19,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "list", Description: tr.T("cmd.branch.list.short"), Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + {Name: "state", Short: "s", Usage: "Branch state: all or deleted"}, {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, @@ -22,37 +28,65 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } + state, err := normalizeBranchState(ctx.Arg("state")) + if err != nil { + return err + } q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q) + q.Set("page", defaultBranchValue(ctx.Arg("page"), "1")) + q.Set("limit", defaultBranchValue(ctx.Arg("limit"), "20")) + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + if state != "" { + q.Set("state", state) + } + env, err := ctx.CallAPIWithQuery("GET", branchPath(ctx), q) if err != nil { return err } return ctx.Output(env) }, }, + { + Name: "all", + Description: "List all branches without pagination", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", branchPath(ctx)+"/all", nil) + if err != nil { + return err + } + return outputBranchEnvelope(ctx, env) + }, + }, { Name: "create", Description: tr.T("cmd.branch.create.short"), Flags: []common.Flag{ {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, {Name: "from", Short: "f", Usage: tr.T("flag.branch.from"), Default: "master"}, + {Name: "dry-run", Usage: "Preview the request body without creating the branch", Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, _ := ctx.RequireArg("name") - from := ctx.Arg("from") - if from == "" { - from = "master" + name, err := ctx.RequireArg("name") + if err != nil { + return err } + from := defaultBranchValue(ctx.Arg("from"), "master") payload := map[string]interface{}{ "new_branch_name": name, "old_branch_name": from, } - env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload) + if parseBranchBool(ctx.Arg("dry-run")) { + return ctx.OutputData(branchDryRun("create_branch", "POST", branchPath(ctx), payload, nil)) + } + env, err := ctx.CallAPI("POST", branchPath(ctx), payload) if err != nil { return err } @@ -64,16 +98,88 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.branch.delete.short"), Flags: []common.Flag{ {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, + {Name: "dry-run", Usage: "Preview the delete request without deleting the branch", Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, _ := ctx.RequireArg("name") + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + path := fmt.Sprintf("%s/%s", branchPath(ctx), url.PathEscape(name)) + if parseBranchBool(ctx.Arg("dry-run")) { + return ctx.OutputData(branchDryRun("delete_branch", "DELETE", path, nil, nil)) + } + env, err := ctx.CallAPI("DELETE", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "set-default", + Description: "Set the repository default branch", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Branch name to set as default", Required: true}, + {Name: "dry-run", Usage: "Preview the request without changing the default branch", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + path := branchPath(ctx) + "/update_default_branch" + q := url.Values{} + q.Set("name", name) + if parseBranchBool(ctx.Arg("dry-run")) { + return ctx.OutputData(branchDryRun("set_default_branch", "PATCH", path, nil, q)) + } + env, err := ctx.CallAPIWithQuery("PATCH", path, q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "restore", + Description: "Restore a deleted branch", + Flags: []common.Flag{ + {Name: "branch-id", Short: "i", Usage: "Deleted branch ID", Required: true}, + {Name: "name", Short: "n", Usage: "Deleted branch name", Required: true}, + {Name: "dry-run", Usage: "Preview the request body without restoring the branch", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + branchIDValue, err := ctx.RequireArg("branch-id") + if err != nil { + return err + } + branchID, err := parsePositiveBranchInt(branchIDValue, "branch-id") + if err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } payload := map[string]interface{}{ + "branch_id": branchID, "branch_name": name, } - env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload) + path := branchPath(ctx) + "/restore" + if parseBranchBool(ctx.Arg("dry-run")) { + return ctx.OutputData(branchDryRun("restore_branch", "POST", path, payload, nil)) + } + env, err := ctx.CallAPI("POST", path, payload) if err != nil { return err } @@ -128,3 +234,66 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { } return i18n.Default() } + +func branchPath(ctx *common.RuntimeContext) string { + return "/v1" + ctx.RepoPath() + "/branches" +} + +func outputBranchEnvelope(ctx *common.RuntimeContext, env *output.Envelope) error { + if raw, ok := env.Data.(string); ok { + var parsed interface{} + if err := json.Unmarshal([]byte(raw), &parsed); err == nil { + return ctx.OutputData(parsed) + } + } + return ctx.Output(env) +} + +func normalizeBranchState(value string) (string, error) { + state := strings.ToLower(strings.TrimSpace(value)) + if state == "" { + return "", nil + } + switch state { + case "all", "deleted": + return state, nil + default: + return "", fmt.Errorf("invalid --state %q: use all or deleted", value) + } +} + +func parsePositiveBranchInt(value, flagName string) (int, error) { + id, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || id <= 0 { + return 0, fmt.Errorf("invalid --%s %q: use a positive integer", flagName, value) + } + return id, nil +} + +func defaultBranchValue(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func parseBranchBool(value string) bool { + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + return err == nil && parsed +} + +func branchDryRun(action, method, path string, body map[string]interface{}, query url.Values) map[string]interface{} { + data := map[string]interface{}{ + "dry_run": true, + "action": action, + "method": method, + "path": path, + } + if body != nil { + data["body"] = body + } + if len(query) > 0 { + data["query"] = query.Encode() + } + return data +} diff --git a/shortcuts/branch/branch_test.go b/shortcuts/branch/branch_test.go index 1f1908f..3e03112 100644 --- a/shortcuts/branch/branch_test.go +++ b/shortcuts/branch/branch_test.go @@ -20,13 +20,15 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st Format: "json", Args: args, } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } return shortcut.Run(ctx) } func findShortcut(t *testing.T, name string) *common.Shortcut { t.Helper() - shortcuts := Shortcuts() - for _, s := range shortcuts { + for _, s := range Shortcuts() { if s.Name == name { return s } @@ -35,93 +37,205 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { return nil } -func writeJSON(w http.ResponseWriter, v interface{}) { +func newBranchTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func assertBranchRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func decodeBranchJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func writeBranchJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) -} - -// --- list --- - -func TestBranchList(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/owner/repo/branches.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, []interface{}{ - map[string]interface{}{"name": "master"}, - map[string]interface{}{"name": "develop"}, - }) - })) - defer server.Close() - - err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}) - if err != nil { - t.Fatalf("list failed: %v", err) + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) } } -// --- create --- +func writeBranchText(t *testing.T, w http.ResponseWriter, code int, text string) { + t.Helper() + w.WriteHeader(code) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} -func TestBranchCreate(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Fatalf("expected POST, got %s", r.Method) - } - if r.URL.Path != "/v1/owner/repo/branches.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"name": "feature-x"}) - })) +func assertBranchEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if got != want { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} + +func TestBranchListBuildsQuery(t *testing.T) { + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertBranchRequest(t, r, "GET", "/v1/owner/repo/branches.json") + query := r.URL.Query() + assertBranchEqual(t, query.Get("keyword"), "feature") + assertBranchEqual(t, query.Get("state"), "deleted") + assertBranchEqual(t, query.Get("page"), "2") + assertBranchEqual(t, query.Get("limit"), "5") + writeBranchJSON(t, w, map[string]interface{}{"total_count": 0, "branches": []interface{}{}}) + }) defer server.Close() - err := runShortcut(t, server, "create", map[string]string{"name": "feature-x", "from": "master"}) + err := runShortcut(t, server, "list", map[string]string{ + "keyword": "feature", + "state": "deleted", + "page": "2", + "limit": "5", + }) if err != nil { - t.Fatalf("create failed: %v", err) + t.Fatalf("list shortcut failed: %v", err) } } +func TestBranchAllUsesAllEndpoint(t *testing.T) { + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertBranchRequest(t, r, "GET", "/v1/owner/repo/branches/all.json") + writeBranchJSON(t, w, []map[string]interface{}{{"name": "master"}}) + }) + defer server.Close() + + if err := runShortcut(t, server, "all", nil); err != nil { + t.Fatalf("all shortcut failed: %v", err) + } +} + +func TestBranchCreatePayload(t *testing.T) { + var payload map[string]interface{} + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertBranchRequest(t, r, "POST", "/v1/owner/repo/branches.json") + payload = decodeBranchJSON(t, r) + writeBranchJSON(t, w, map[string]interface{}{"name": "feature/a"}) + }) + defer server.Close() + + err := runShortcut(t, server, "create", map[string]string{ + "name": "feature/a", + "from": "master", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + assertBranchEqual(t, payload["new_branch_name"], "feature/a") + assertBranchEqual(t, payload["old_branch_name"], "master") +} + func TestBranchCreateDefaultFrom(t *testing.T) { - // When 'from' is not set, it defaults to "master" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/owner/repo/branches.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"name": "feature-y"}) - })) + var payload map[string]interface{} + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertBranchRequest(t, r, "POST", "/v1/owner/repo/branches.json") + payload = decodeBranchJSON(t, r) + writeBranchJSON(t, w, map[string]interface{}{"name": "feature-y"}) + }) defer server.Close() err := runShortcut(t, server, "create", map[string]string{"name": "feature-y"}) if err != nil { t.Fatalf("create failed: %v", err) } + assertBranchEqual(t, payload["old_branch_name"], "master") } -// --- delete --- - -func TestBranchDelete(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/owner/repo/branches/delete.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"message": "deleted"}) - })) +func TestBranchCreateDryRunDoesNotCallAPI(t *testing.T) { + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path) + }) defer server.Close() - err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"}) + err := runShortcut(t, server, "create", map[string]string{ + "name": "feature/a", + "from": "master", + "dry-run": "true", + }) if err != nil { - t.Fatalf("delete failed: %v", err) + t.Fatalf("create dry-run failed: %v", err) } } -// --- protect --- +func TestBranchDeleteUsesV1EndpointAndEscapesName(t *testing.T) { + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" || r.URL.EscapedPath() != "/v1/owner/repo/branches/feature%2Fold.json" { + t.Fatalf("got request %s %s, want DELETE /v1/owner/repo/branches/feature%%2Fold.json", r.Method, r.URL.EscapedPath()) + } + writeBranchJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runShortcut(t, server, "delete", map[string]string{"name": "feature/old"}) + if err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } +} + +func TestBranchSetDefaultUsesQueryName(t *testing.T) { + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertBranchRequest(t, r, "PATCH", "/v1/owner/repo/branches/update_default_branch.json") + assertBranchEqual(t, r.URL.Query().Get("name"), "develop") + writeBranchJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runShortcut(t, server, "set-default", map[string]string{"name": "develop"}) + if err != nil { + t.Fatalf("set-default shortcut failed: %v", err) + } +} + +func TestBranchRestorePayload(t *testing.T) { + var payload map[string]interface{} + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertBranchRequest(t, r, "POST", "/v1/owner/repo/branches/restore.json") + payload = decodeBranchJSON(t, r) + writeBranchJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runShortcut(t, server, "restore", map[string]string{ + "branch-id": "7", + "name": "feature/deleted", + }) + if err != nil { + t.Fatalf("restore shortcut failed: %v", err) + } + assertBranchEqual(t, payload["branch_id"], float64(7)) + assertBranchEqual(t, payload["branch_name"], "feature/deleted") +} + +func TestBranchRejectsInvalidInputs(t *testing.T) { + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("server should not be called for invalid input: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + if err := runShortcut(t, server, "list", map[string]string{"state": "open"}); err == nil { + t.Fatal("expected invalid state to fail") + } + if err := runShortcut(t, server, "restore", map[string]string{"branch-id": "abc", "name": "deleted"}); err == nil { + t.Fatal("expected invalid branch-id to fail") + } +} func TestBranchProtect(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/owner/repo/protected_branches.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"message": "protected"}) - })) + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertBranchRequest(t, r, "POST", "/owner/repo/protected_branches.json") + writeBranchJSON(t, w, map[string]interface{}{"message": "protected"}) + }) defer server.Close() err := runShortcut(t, server, "protect", map[string]string{"name": "master"}) @@ -130,18 +244,11 @@ func TestBranchProtect(t *testing.T) { } } -// --- unprotect --- - func TestBranchUnprotect(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "DELETE" { - t.Fatalf("expected DELETE, got %s", r.Method) - } - if r.URL.Path != "/owner/repo/protected_branches/master.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{"message": "unprotected"}) - })) + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertBranchRequest(t, r, "DELETE", "/owner/repo/protected_branches/master.json") + writeBranchJSON(t, w, map[string]interface{}{"message": "unprotected"}) + }) defer server.Close() err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"}) @@ -150,13 +257,10 @@ func TestBranchUnprotect(t *testing.T) { } } -// --- HTTP error paths --- - func TestBranchListHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeBranchText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}) @@ -166,10 +270,9 @@ func TestBranchListHTTPError(t *testing.T) { } func TestBranchCreateHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeBranchText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "create", map[string]string{"name": "feature-x"}) @@ -179,10 +282,9 @@ func TestBranchCreateHTTPError(t *testing.T) { } func TestBranchDeleteHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeBranchText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"}) @@ -192,10 +294,9 @@ func TestBranchDeleteHTTPError(t *testing.T) { } func TestBranchProtectHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeBranchText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "protect", map[string]string{"name": "master"}) @@ -205,10 +306,9 @@ func TestBranchProtectHTTPError(t *testing.T) { } func TestBranchUnprotectHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeBranchText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"}) diff --git a/skills/README.md b/skills/README.md index af78bca..66a15dc 100644 --- a/skills/README.md +++ b/skills/README.md @@ -138,7 +138,7 @@ skills/ | **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` | | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | -| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | +| **gitlink-branch** | 分支管理 | `branch +list`, `branch +all`, `branch +create`, `branch +delete`, `branch +set-default`, `branch +restore` | | **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +edit`, `release +update`, `release +view` | ### 辅助 Skills diff --git a/skills/gitlink-branch/SKILL.md b/skills/gitlink-branch/SKILL.md index b4f1037..0c8b4fb 100644 --- a/skills/gitlink-branch/SKILL.md +++ b/skills/gitlink-branch/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-branch version: 1.0.0 -description: "分支管理:创建、查看、删除、保护分支。当用户需要操作 GitLink 分支时触发。" +description: "分支管理:列出、创建、删除、恢复、设置默认分支、保护分支。当用户需要操作 GitLink 分支时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -20,9 +20,12 @@ metadata: | Shortcut | 说明 | 操作类型 | |----------|------|----------| -| `branch +list` | 列出仓库的所有分支 | Read | -| `branch +create` | 创建新分支 | ⚠️ Write Operation | -| `branch +delete` | 删除分支 | 🔴 Destructive Operation | +| `branch +list` | 分页列出分支,支持 keyword/state 过滤 | Read | +| `branch +all` | 列出全部分支(无分页) | Read | +| `branch +create` | 创建新分支,支持 dry-run | ⚠️ Write Operation | +| `branch +delete` | 删除分支,支持 dry-run | 🔴 Destructive Operation | +| `branch +set-default` | 设置默认分支,支持 dry-run | ⚠️ Write Operation | +| `branch +restore` | 恢复已删除分支,支持 dry-run | ⚠️ Write Operation | | `branch +protect` | 设置分支保护规则 | ⚠️ Write Operation | | `branch +unprotect` | 移除分支保护规则 | ⚠️ Write Operation | @@ -34,6 +37,8 @@ metadata: |------|------|------| | `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | | `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | +| `--keyword, -k` | 否 | 搜索关键词 | +| `--state, -s` | 否 | 分支状态:`all` 或 `deleted` | | `--page, -p` | 否 | 页码(默认 `1`) | | `--limit, -l` | 否 | 每页条数(默认 `20`) | | `--format` | 否 | 输出格式:`json`/`table`/`yaml` | @@ -45,6 +50,7 @@ metadata: |------|------|------| | `--name, -n` | 是 | 新分支名称 | | `--from, -f` | 否 | 源分支或 commit(默认 `master`) | +| `--dry-run` | 否 | 只预览请求,不创建分支 | | `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | | `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | | `--format` | 否 | 输出格式:`json`/`table`/`yaml` | @@ -55,11 +61,32 @@ metadata: | 参数 | 必填 | 说明 | |------|------|------| | `--name, -n` | 是 | 要删除的分支名称 | +| `--dry-run` | 否 | 只预览请求,不删除分支 | | `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | | `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | | `--format` | 否 | 输出格式:`json`/`table`/`yaml` | | `--debug` | 否 | 启用调试输出 | + +### branch +set-default + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | 是 | 要设置为默认分支的名称 | +| `--dry-run` | 否 | 只预览请求,不修改默认分支 | +| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | +| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | + +### branch +restore + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--branch-id, -i` | 是 | 已删除分支的 branch_id,可从 `branch +list --state deleted` 获取 | +| `--name, -n` | 是 | 已删除分支名称 | +| `--dry-run` | 否 | 只预览请求,不恢复分支 | +| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | +| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | + ### branch +protect | 参数 | 必填 | 说明 | @@ -88,14 +115,18 @@ metadata: # 列出当前仓库的分支 gitlink-cli branch +list -# 指定仓库并分页 +# 指定仓库并分页 / 过滤 gitlink-cli branch +list --owner Gitlink --repo forgeplus --page 1 --limit 10 +gitlink-cli branch +list --owner Gitlink --repo forgeplus --keyword feature --state all + +# 列出全部分支(无分页) +gitlink-cli branch +all --owner Gitlink --repo forgeplus # 输出为 JSON gitlink-cli branch +list --format json -# 从 master 创建分支 -gitlink-cli branch +create --name feature/new-feature +# 从 master 创建分支(先 dry-run) +gitlink-cli branch +create --name feature/new-feature --dry-run # 从指定分支创建 gitlink-cli branch +create --name hotfix/bug-123 --from develop @@ -103,12 +134,19 @@ gitlink-cli branch +create --name hotfix/bug-123 --from develop # 指定仓库创建分支 gitlink-cli branch +create --name feature/x --owner someone --repo myrepo -# 删除分支 -gitlink-cli branch +delete --name feature/old-feature +# 删除分支(先 dry-run) +gitlink-cli branch +delete --name feature/old-feature --dry-run # 指定仓库删除分支 gitlink-cli branch +delete --name feature/old-feature --owner someone --repo myrepo +# 设置默认分支(先 dry-run) +gitlink-cli branch +set-default --name develop --dry-run + +# 查看已删除分支并恢复(先 dry-run) +gitlink-cli branch +list --state deleted +gitlink-cli branch +restore --branch-id 7 --name feature/old-feature --dry-run + # 保护分支 gitlink-cli branch +protect --name main @@ -127,8 +165,9 @@ gitlink-cli branch +unprotect --name main > This is a **Write Operation** — confirm user intent. 1. 确认用户希望创建的分支名称和源分支。 -2. 执行 `branch +create --name --from `。 -3. 输出创建结果。 +2. 先执行 `branch +create --name --from --dry-run`。 +3. 用户确认后去掉 `--dry-run` 执行。 +4. 输出创建结果。 ### branch +delete(Destructive Operation) @@ -136,8 +175,30 @@ gitlink-cli branch +unprotect --name main > This is a **Destructive Operation** — confirm user intent. 1. 确认用户确实希望删除该分支(此操作不可逆)。 -2. 执行 `branch +delete --name `。 -3. 输出删除结果。 +2. 先执行 `branch +delete --name --dry-run`。 +3. 用户确认后去掉 `--dry-run` 执行。 +4. 输出删除结果。 + + +### branch +set-default(Write Operation) + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent. + +1. 确认用户希望设置的默认分支。 +2. 先执行 `branch +set-default --name --dry-run`。 +3. 用户确认后去掉 `--dry-run` 执行。 +4. 输出设置结果。 + +### branch +restore(Write Operation) + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent. + +1. 先用 `branch +list --state deleted` 找到 `branch_id` 和分支名。 +2. 执行 `branch +restore --branch-id --name --dry-run`。 +3. 用户确认后去掉 `--dry-run` 执行。 +4. 输出恢复结果。 ### branch +protect(Write Operation) @@ -157,7 +218,7 @@ gitlink-cli branch +unprotect --name main 2. 执行 `branch +unprotect --name `。 3. 输出结果。 -> **注意:** 含 `/` 的分支名(如 `feature/my-branch`)可能无法通过 CLI 解除保护(受限于 API 路由),需通过 Web 页面操作。 +> **注意:** `branch +delete` 使用 v1 OpenAPI 路径并会对含 `/` 的分支名做路径转义;`branch +unprotect` 仍使用保护分支接口。 ## References