diff --git a/README.md b/README.md index e5e4318..0d29ddf 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,10 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved ```bash # List branches gitlink-cli branch +list --owner Gitlink --repo forgeplus +gitlink-cli branch +list --owner Gitlink --repo forgeplus --keyword fix --state deleted + +# List all branches without pagination +gitlink-cli branch +all --owner Gitlink --repo forgeplus # Create a branch gitlink-cli branch +create --name feature/new-feature @@ -451,6 +455,10 @@ gitlink-cli branch +protect --name main # Remove branch protection gitlink-cli branch +unprotect --name main + +# Set default branch or restore a deleted branch (preview first) +gitlink-cli branch +set-default --owner Gitlink --repo forgeplus --name main --dry-run +gitlink-cli branch +restore --owner Gitlink --repo forgeplus --id 7 --name feature/old --dry-run ``` ### Release Management diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..365eba9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -444,6 +444,25 @@ 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 +gitlink-cli branch +list --owner Gitlink --repo forgeplus --keyword fix --state deleted + +# 无分页列出全部分支 +gitlink-cli branch +all --owner Gitlink --repo forgeplus + +# 创建 / 删除分支 +gitlink-cli branch +create --owner Gitlink --repo forgeplus --name feature/new-feature +gitlink-cli branch +delete --owner Gitlink --repo forgeplus --name feature/old-feature + +# 设置默认分支或恢复已删除分支(先 dry-run 预览) +gitlink-cli branch +set-default --owner Gitlink --repo forgeplus --name main --dry-run +gitlink-cli branch +restore --owner Gitlink --repo forgeplus --id 7 --name feature/old --dry-run +``` + ### 发布管理 ```bash diff --git a/doc/changes/branch-lifecycle-shortcuts.md b/doc/changes/branch-lifecycle-shortcuts.md new file mode 100644 index 0000000..4070af4 --- /dev/null +++ b/doc/changes/branch-lifecycle-shortcuts.md @@ -0,0 +1,28 @@ +# Branch Lifecycle Shortcuts + +## Background + +GitLink OpenAPI exposes branch lifecycle capabilities that were not fully reachable from `gitlink-cli`: keyword/state branch listing, no-pagination listing, default branch switching, and deleted branch restoration. + +## What Changed + +Extended the `branch` shortcut group with documented OpenAPI coverage: + +- `branch +list --keyword --state` maps to `GET /api/v1/{owner}/{repo}/branches.json` query parameters. +- `branch +all` maps to `GET /api/v1/{owner}/{repo}/branches/all.json`. +- `branch +set-default --name` maps to `PATCH /api/v1/{owner}/{repo}/branches/update_default_branch.json?name=...`. +- `branch +restore --id --name` maps to `POST /api/v1/{owner}/{repo}/branches/restore.json` with `branch_id` and `branch_name`. + +Write operations support `--dry-run` so users and Agents can inspect the exact request before changing branch state. + +## Validation + +```bash +git diff --check +GOPROXY=https://goproxy.cn,direct go test ./shortcuts/branch ./shortcuts +go vet ./shortcuts/branch ./shortcuts +go run . branch +set-default --help +go run . branch +restore --help +GOPROXY=https://goproxy.cn,direct go test ./... +go vet ./... +``` diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 0393ad5..46455b1 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -3,6 +3,8 @@ package branch import ( "fmt" "net/url" + "strconv" + "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -17,6 +19,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Flags: []common.Flag{ {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + {Name: "keyword", Short: "k", Usage: "Filter branches by keyword"}, + {Name: "state", Short: "s", Usage: "Branch state: all, deleted, or empty for active branches"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -25,6 +29,15 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) + if keyword := strings.TrimSpace(ctx.Arg("keyword")); keyword != "" { + q.Set("keyword", keyword) + } + if state := strings.TrimSpace(ctx.Arg("state")); state != "" { + if err := validateBranchState(state); err != nil { + return err + } + q.Set("state", state) + } env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q) if err != nil { return err @@ -32,6 +45,20 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { 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", "/v1"+ctx.RepoPath()+"/branches/all", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "create", Description: tr.T("cmd.branch.create.short"), @@ -119,6 +146,25 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "set-default", + Description: "Set repository default branch", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, + {Name: "dry-run", Usage: "Preview the request without changing the default branch", Bool: true, Default: "false"}, + }, + Run: runSetDefault, + }, + { + Name: "restore", + Description: "Restore a deleted branch", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Deleted branch ID", Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, + {Name: "dry-run", Usage: "Preview the request without restoring the branch", Bool: true, Default: "false"}, + }, + Run: runRestore, + }, } } @@ -128,3 +174,81 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { } return i18n.Default() } + +func runSetDefault(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + path := "/v1" + ctx.RepoPath() + "/branches/update_default_branch" + query := url.Values{} + query.Set("name", name) + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "set_default_branch", + "method": "PATCH", + "path": path, + "query": query.Encode(), + }) + } + env, err := ctx.CallAPIWithQuery("PATCH", path, query) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runRestore(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := parsePositiveInt(ctx.Arg("id"), "id") + if err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + path := "/v1" + ctx.RepoPath() + "/branches/restore" + body := map[string]interface{}{ + "branch_id": id, + "branch_name": name, + } + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "restore_branch", + "method": "POST", + "path": path, + "body": body, + }) + } + env, err := ctx.CallAPI("POST", path, body) + if err != nil { + return err + } + return ctx.Output(env) +} + +func validateBranchState(state string) error { + switch state { + case "all", "deleted": + return nil + default: + return fmt.Errorf("invalid --state %q: use all or deleted", state) + } +} + +func parsePositiveInt(value, name string) (int64, error) { + value = strings.TrimSpace(value) + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("invalid --%s %q: use a positive integer", name, value) + } + return parsed, nil +} diff --git a/shortcuts/branch/branch_test.go b/shortcuts/branch/branch_test.go index 1f1908f..059c2cd 100644 --- a/shortcuts/branch/branch_test.go +++ b/shortcuts/branch/branch_test.go @@ -60,6 +60,56 @@ func TestBranchList(t *testing.T) { } } +func TestBranchListFilters(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) + } + if got := r.URL.Query().Get("keyword"); got != "feature" { + t.Fatalf("keyword = %q, want feature", got) + } + if got := r.URL.Query().Get("state"); got != "deleted" { + t.Fatalf("state = %q, want deleted", got) + } + writeJSON(w, map[string]interface{}{"total_count": 0, "branches": []interface{}{}}) + })) + defer server.Close() + + err := runShortcut(t, server, "list", map[string]string{ + "page": "1", "limit": "20", "keyword": "feature", "state": "deleted", + }) + if err != nil { + t.Fatalf("list with filters failed: %v", err) + } +} + +func TestBranchListRejectsInvalidState(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid state should not call API, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20", "state": "open"}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestBranchAll(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/branches/all.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(w, []interface{}{map[string]interface{}{"name": "master"}}) + })) + defer server.Close() + + err := runShortcut(t, server, "all", nil) + if err != nil { + t.Fatalf("all failed: %v", err) + } +} + // --- create --- func TestBranchCreate(t *testing.T) { @@ -150,6 +200,69 @@ func TestBranchUnprotect(t *testing.T) { } } +func TestBranchSetDefault(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/branches/update_default_branch.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("name"); got != "main" { + t.Fatalf("name query = %q, want main", got) + } + writeJSON(w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + if err := runShortcut(t, server, "set-default", map[string]string{"name": "main"}); err != nil { + t.Fatalf("set-default failed: %v", err) + } +} + +func TestBranchSetDefaultDryRunDoesNotCallAPI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(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() + + if err := runShortcut(t, server, "set-default", map[string]string{"name": "main", "dry-run": "true"}); err != nil { + t.Fatalf("set-default dry-run failed: %v", err) + } +} + +func TestBranchRestore(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/branches/restore.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode body: %v", err) + } + writeJSON(w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + if err := runShortcut(t, server, "restore", map[string]string{"id": "7", "name": "feature/deleted"}); err != nil { + t.Fatalf("restore failed: %v", err) + } + if payload["branch_id"] != float64(7) { + t.Fatalf("branch_id = %v, want 7", payload["branch_id"]) + } + if payload["branch_name"] != "feature/deleted" { + t.Fatalf("branch_name = %v, want feature/deleted", payload["branch_name"]) + } +} + +func TestBranchRestoreValidation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid restore should not call API, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + if err := runShortcut(t, server, "restore", map[string]string{"id": "0", "name": "feature/deleted"}); err == nil { + t.Fatal("expected invalid id error") + } +} + // --- HTTP error paths --- func TestBranchListHTTPError(t *testing.T) { diff --git a/skills/gitlink-branch/SKILL.md b/skills/gitlink-branch/SKILL.md index b4f1037..3d3c1f4 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 分支时触发。" +version: 1.1.0 +description: "分支管理:创建、查看、过滤、删除、保护、设置默认分支、恢复已删除分支。当用户需要操作 GitLink 分支时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -21,10 +21,13 @@ metadata: | Shortcut | 说明 | 操作类型 | |----------|------|----------| | `branch +list` | 列出仓库的所有分支 | Read | +| `branch +all` | 无分页列出仓库所有分支 | Read | | `branch +create` | 创建新分支 | ⚠️ Write Operation | | `branch +delete` | 删除分支 | 🔴 Destructive Operation | | `branch +protect` | 设置分支保护规则 | ⚠️ Write Operation | | `branch +unprotect` | 移除分支保护规则 | ⚠️ Write Operation | +| `branch +set-default` | 设置默认分支 | ⚠️ Write Operation | +| `branch +restore` | 恢复已删除分支 | ⚠️ Write Operation | ## 参数参考 @@ -36,6 +39,17 @@ metadata: | `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | | `--page, -p` | 否 | 页码(默认 `1`) | | `--limit, -l` | 否 | 每页条数(默认 `20`) | +| `--keyword, -k` | 否 | 分支关键词过滤 | +| `--state, -s` | 否 | 分支状态:`all` 或 `deleted` | +| `--format` | 否 | 输出格式:`json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +### branch +all + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | +| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | | `--format` | 否 | 输出格式:`json`/`table`/`yaml` | | `--debug` | 否 | 启用调试输出 | @@ -80,6 +94,29 @@ metadata: | `--format` | 否 | 输出格式:`json`/`table`/`yaml` | | `--debug` | 否 | 启用调试输出 | +### branch +set-default + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | 是 | 要设为默认分支的名称 | +| `--dry-run` | 否 | 预览请求,不修改默认分支 | +| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | +| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | +| `--format` | 否 | 输出格式:`json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +### branch +restore + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--id, -i` | 是 | 已删除分支的 branch_id | +| `--name, -n` | 是 | 要恢复的分支名称 | +| `--dry-run` | 否 | 预览请求,不恢复分支 | +| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | +| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | +| `--format` | 否 | 输出格式:`json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + > *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 ## 使用示例 @@ -91,6 +128,12 @@ 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 fix --state deleted + +# 无分页列出所有分支 +gitlink-cli branch +all --owner Gitlink --repo forgeplus + # 输出为 JSON gitlink-cli branch +list --format json @@ -117,6 +160,14 @@ gitlink-cli branch +protect --name main --owner someone --repo myrepo # 移除分支保护(仅简单分支名,含 / 的路径需通过 Web 操作) gitlink-cli branch +unprotect --name main + +# 设置默认分支(先 dry-run 预览) +gitlink-cli branch +set-default --name main --dry-run +gitlink-cli branch +set-default --name main + +# 恢复已删除分支(先 dry-run 预览) +gitlink-cli branch +restore --id 7 --name feature/old --dry-run +gitlink-cli branch +restore --id 7 --name feature/old ``` ## Workflow 注意事项 @@ -157,6 +208,24 @@ gitlink-cli branch +unprotect --name main 2. 执行 `branch +unprotect --name `。 3. 输出结果。 +### 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` 的命令。 + +### branch +restore(Write Operation) + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent. + +1. 通过 `branch +list --state deleted` 确认 `branch_id` 和分支名。 +2. 先执行 `branch +restore --id --name --dry-run` 预览。 +3. 用户确认后执行不带 `--dry-run` 的命令。 + > **注意:** 含 `/` 的分支名(如 `feature/my-branch`)可能无法通过 CLI 解除保护(受限于 API 路由),需通过 Web 页面操作。 ## References