diff --git a/README.md b/README.md index 682d6f7..4f2cf04 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 | Create, delete, restore, list, filter, protect, unprotect, and switch default branches | | 🏷️ Release | Create, edit, update, view, delete releases | | 🏢 Org | Manage organizations, members, teams | | 🔧 CI | View builds, logs, CI/CD operations | @@ -440,12 +440,24 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved # List branches gitlink-cli branch +list --owner Gitlink --repo forgeplus +# List deleted branches or search by keyword +gitlink-cli branch +list --owner Gitlink --repo forgeplus --state deleted --keyword release/ + +# List all branches with archive download URLs +gitlink-cli branch +all --owner Gitlink --repo forgeplus + # Create a branch gitlink-cli branch +create --name feature/new-feature # Delete a branch gitlink-cli branch +delete --name feature/old-feature +# Set the default branch +gitlink-cli branch +set-default --name main + +# Restore a deleted branch +gitlink-cli branch +restore --branch-id 7 --name feature/old-feature + # Protect a branch gitlink-cli branch +protect --name main diff --git a/README.zh-CN.md b/README.zh-CN.md index a1fc10a..af22816 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -108,7 +108,7 @@ | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | -| 🌿 分支 | 创建、删除、保护分支 | +| 🌿 分支 | 创建、删除、恢复、筛选、保护分支,并切换默认分支 | | 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release | | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | @@ -511,6 +511,37 @@ gitlink-cli pr +review-comment-update --owner Gitlink --repo forgeplus -i 42 --c gitlink-cli pr +review-comment-delete --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --dry-run ``` +### 分支管理 + +```bash +# 列出分支 +gitlink-cli branch +list --owner Gitlink --repo forgeplus + +# 列出已删除分支,或按关键字筛选 +gitlink-cli branch +list --owner Gitlink --repo forgeplus --state deleted --keyword release/ + +# 列出全部分支及归档下载链接 +gitlink-cli branch +all --owner Gitlink --repo forgeplus + +# 创建分支 +gitlink-cli branch +create --name feature/new-feature + +# 删除分支 +gitlink-cli branch +delete --name feature/old-feature + +# 切换默认分支 +gitlink-cli branch +set-default --name main + +# 恢复已删除分支 +gitlink-cli branch +restore --branch-id 7 --name feature/old-feature + +# 设置分支保护 +gitlink-cli branch +protect --name main + +# 移除分支保护 +gitlink-cli branch +unprotect --name main +``` + ### 发布管理 ```bash diff --git a/doc/changes/branch-lifecycle-shortcuts.md b/doc/changes/branch-lifecycle-shortcuts.md index 4070af4..6d2e3e7 100644 --- a/doc/changes/branch-lifecycle-shortcuts.md +++ b/doc/changes/branch-lifecycle-shortcuts.md @@ -1,28 +1,28 @@ # Branch Lifecycle Shortcuts -## Background +This change expands branch management coverage so repository maintainers can complete more of the branch lifecycle from `gitlink-cli` without falling back to manual API calls. -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. +## Commands -## What Changed +- `branch +all` +- `branch +set-default` +- `branch +restore` -Extended the `branch` shortcut group with documented OpenAPI coverage: +## Improvements -- `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`. +- `branch +list` now supports `--state` so users can inspect visible branches, deleted branches, or all branch records. +- `branch +list` now supports `--keyword` to filter branches by name on the server side. +- The README examples document the deleted-branch recovery flow so users can retrieve `branch_id` and restore the branch in one CLI workflow. -Write operations support `--dry-run` so users and Agents can inspect the exact request before changing branch state. +## API Mapping -## Validation +| Shortcut | Method | API path | +|----------|--------|----------| +| `branch +all` | GET | `/api/v1/{owner}/{repo}/branches/all.json` | +| `branch +set-default` | PATCH | `/api/v1/{owner}/{repo}/branches/update_default_branch.json?name=...` | +| `branch +restore` | POST | `/api/v1/{owner}/{repo}/branches/restore.json` | -```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 ./... -``` +## Verification + +- Unit tests cover request methods, paths, query parameters, restore payloads, invalid `branch-id` validation, and HTTP error handling. +- Documentation now includes branch filtering, default-branch switching, and deleted-branch restore examples. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index f86243e..cec6193 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -5,10 +5,13 @@ "cmd.auth.logout.short": "Logout from GitLink", "cmd.auth.short": "Authentication commands", "cmd.auth.status.short": "Show authentication status", + "cmd.branch.all.short": "List all branches with archive URLs", "cmd.branch.create.short": "Create a branch", "cmd.branch.delete.short": "Delete a branch", "cmd.branch.list.short": "List branches", "cmd.branch.protect.short": "Set branch protection", + "cmd.branch.restore.short": "Restore a deleted branch", + "cmd.branch.set_default.short": "Set the default branch", "cmd.branch.short": "Branch operations", "cmd.branch.unprotect.short": "Remove branch protection", "cmd.ci.builds.short": "List CI builds", @@ -123,6 +126,7 @@ "error.auth.login_failed": "login failed: {message}", "error.auth.store_token_failed": "failed to store token: {message}", "error.auth.token_empty": "token cannot be empty", + "error.branch.id_invalid": "invalid --branch-id: {value}; must be a positive integer", "error.config.save_failed": "failed to save config: {message}", "error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm", "error.missing_required_flag": "required flag --{name} is missing", @@ -139,7 +143,10 @@ "flag.api.query": "Query parameters (key=val&key2=val2)", "flag.auth.token": "Login by pasting an existing token", "flag.branch.from": "Source branch or commit", + "flag.branch.id": "Deleted branch ID", + "flag.branch.keyword": "Filter branches by keyword", "flag.branch.name": "Branch name", + "flag.branch.state": "Branch visibility: visible (default), deleted, or all", "flag.ci.build": "Build number", "flag.ci.stage": "Stage number", "flag.ci.step": "Step number", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index cc8b61b..64f3611 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -5,10 +5,13 @@ "cmd.auth.logout.short": "退出 GitLink 登录", "cmd.auth.short": "认证命令", "cmd.auth.status.short": "显示认证状态", + "cmd.branch.all.short": "列出全部分支及归档链接", "cmd.branch.create.short": "创建分支", "cmd.branch.delete.short": "删除分支", "cmd.branch.list.short": "列出分支", "cmd.branch.protect.short": "设置分支保护", + "cmd.branch.restore.short": "恢复已删除分支", + "cmd.branch.set_default.short": "设置默认分支", "cmd.branch.short": "分支操作", "cmd.branch.unprotect.short": "移除分支保护", "cmd.ci.builds.short": "列出 CI 构建", @@ -123,6 +126,7 @@ "error.auth.login_failed": "登录失败:{message}", "error.auth.store_token_failed": "保存 Token 失败:{message}", "error.auth.token_empty": "Token 不能为空", + "error.branch.id_invalid": "无效的 --branch-id:{value};必须是正整数", "error.config.save_failed": "保存配置失败:{message}", "error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认", "error.missing_required_flag": "缺少必需参数 --{name}", @@ -139,7 +143,10 @@ "flag.api.query": "查询参数(key=val&key2=val2)", "flag.auth.token": "通过粘贴已有 Token 登录", "flag.branch.from": "源分支或 Commit", + "flag.branch.id": "已删除分支 ID", + "flag.branch.keyword": "按关键字筛选分支", "flag.branch.name": "分支名称", + "flag.branch.state": "分支范围:默认可见分支,也可选 deleted 或 all", "flag.ci.build": "构建编号", "flag.ci.stage": "阶段编号", "flag.ci.step": "步骤编号", diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 41037e0..2c2a341 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -1,14 +1,13 @@ package branch import ( - "encoding/json" + "errors" "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" ) @@ -19,29 +18,25 @@ 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"}, + {Name: "state", Usage: tr.T("flag.branch.state")}, + {Name: "keyword", Short: "k", Usage: tr.T("flag.branch.keyword")}, }, Run: func(ctx *common.RuntimeContext) error { 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", 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("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if state := strings.TrimSpace(ctx.Arg("state")); state != "" { q.Set("state", state) } - env, err := ctx.CallAPIWithQuery("GET", branchPath(ctx), q) + if keyword := strings.TrimSpace(ctx.Arg("keyword")); keyword != "" { + q.Set("keyword", keyword) + } + env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q) if err != nil { return err } @@ -50,16 +45,16 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "all", - Description: "List all branches without pagination", + Description: tr.T("cmd.branch.all.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - env, err := ctx.CallAPI("GET", branchPath(ctx)+"/all", nil) + env, err := ctx.CallAPI("GET", "/v1"+ctx.RepoPath()+"/branches/all", nil) if err != nil { return err } - return outputBranchEnvelope(ctx, env) + return ctx.Output(env) }, }, { @@ -68,25 +63,21 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { 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, err := ctx.RequireArg("name") - if err != nil { - return err + name, _ := ctx.RequireArg("name") + from := ctx.Arg("from") + if from == "" { + from = "master" } - from := defaultBranchValue(ctx.Arg("from"), "master") payload := map[string]interface{}{ "new_branch_name": name, "old_branch_name": from, } - 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) + env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload) if err != nil { return err } @@ -98,21 +89,16 @@ 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, err := ctx.RequireArg("name") - if err != nil { - return err + name, _ := ctx.RequireArg("name") + payload := map[string]interface{}{ + "branch_name": name, } - 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) + env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload) if err != nil { return err } @@ -121,26 +107,18 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "set-default", - Description: "Set the repository default branch", + Description: tr.T("cmd.branch.set_default.short"), 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"}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, }, 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" + name, _ := ctx.RequireArg("name") 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) + env, err := ctx.CallAPIWithQuery("PATCH", "/v1"+ctx.RepoPath()+"/branches/update_default_branch", q) if err != nil { return err } @@ -149,37 +127,25 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "restore", - Description: "Restore a deleted branch", + Description: tr.T("cmd.branch.restore.short"), 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"}, + {Name: "branch-id", Usage: tr.T("flag.branch.id"), Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, }, 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") + branchID, err := parseBranchID(ctx) if err != nil { return err } + name, _ := ctx.RequireArg("name") payload := map[string]interface{}{ "branch_id": branchID, "branch_name": name, } - 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) + env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/restore", payload) if err != nil { return err } @@ -235,65 +201,11 @@ 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 +func parseBranchID(ctx *common.RuntimeContext) (int, error) { + value, _ := ctx.RequireArg("branch-id") + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return 0, errors.New(ctx.Tr.Tf("error.branch.id_invalid", i18n.Args{"value": value})) + } + return parsed, nil } diff --git a/shortcuts/branch/branch_test.go b/shortcuts/branch/branch_test.go index 3e03112..eb712ea 100644 --- a/shortcuts/branch/branch_test.go +++ b/shortcuts/branch/branch_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -19,16 +20,15 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st Repo: "repo", Format: "json", Args: args, - } - if ctx.Args == nil { - ctx.Args = map[string]string{} + Tr: i18n.Default(), } return shortcut.Run(ctx) } func findShortcut(t *testing.T, name string) *common.Shortcut { t.Helper() - for _, s := range Shortcuts() { + shortcuts := Shortcuts() + for _, s := range shortcuts { if s.Name == name { return s } @@ -37,205 +37,220 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { return nil } -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() +func writeJSON(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(payload); err != nil { - t.Fatalf("failed to write response: %v", err) + 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) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Fatalf("unexpected page: %s", got) + } + if got := r.URL.Query().Get("limit"); got != "20" { + t.Fatalf("unexpected limit: %s", got) + } + 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) } } -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 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{}{}}) - }) +func TestBranchListWithStateAndKeyword(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("state"); got != "deleted" { + t.Fatalf("unexpected state: %s", got) + } + if got := r.URL.Query().Get("keyword"); got != "release/" { + t.Fatalf("unexpected keyword: %s", got) + } + writeJSON(w, map[string]interface{}{ + "total_count": 1, + "branches": []interface{}{ + map[string]interface{}{"name": "release/1.0", "branch_id": 7, "is_deleted": true}, + }, + }) + })) defer server.Close() err := runShortcut(t, server, "list", map[string]string{ - "keyword": "feature", - "state": "deleted", "page": "2", - "limit": "5", + "limit": "50", + "state": "deleted", + "keyword": "release/", }) if err != nil { - t.Fatalf("list shortcut failed: %v", err) + t.Fatalf("list with filters 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"}}) - }) +func TestBranchAll(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/owner/repo/branches/all.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, []interface{}{ + map[string]interface{}{"name": "main"}, + map[string]interface{}{"name": "stable/1.0"}, + }) + })) defer server.Close() - if err := runShortcut(t, server, "all", nil); err != nil { - t.Fatalf("all shortcut failed: %v", err) + err := runShortcut(t, server, "all", nil) + if err != nil { + t.Fatalf("all 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"}) - }) +// --- create --- + +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"}) + })) defer server.Close() - err := runShortcut(t, server, "create", map[string]string{ - "name": "feature/a", - "from": "master", - }) + err := runShortcut(t, server, "create", map[string]string{"name": "feature-x", "from": "master"}) if err != nil { - t.Fatalf("create shortcut failed: %v", err) + t.Fatalf("create failed: %v", err) } - assertBranchEqual(t, payload["new_branch_name"], "feature/a") - assertBranchEqual(t, payload["old_branch_name"], "master") } func TestBranchCreateDefaultFrom(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-y"}) - }) + // 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"}) + })) 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") } -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() +// --- delete --- - err := runShortcut(t, server, "create", map[string]string{ - "name": "feature/a", - "from": "master", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("create dry-run failed: %v", err) - } -} - -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()) +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) } - writeBranchJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) + writeJSON(w, map[string]interface{}{"message": "deleted"}) + })) defer server.Close() - err := runShortcut(t, server, "delete", map[string]string{"name": "feature/old"}) + err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"}) if err != nil { - t.Fatalf("delete shortcut failed: %v", err) + t.Fatalf("delete 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"}) - }) +func TestBranchSetDefault(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" { + t.Fatalf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/branches/update_default_branch.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("name"); got != "release/1.0" { + t.Fatalf("unexpected default branch name: %s", got) + } + writeJSON(w, map[string]interface{}{"status": 0, "message": "success"}) + })) defer server.Close() - err := runShortcut(t, server, "set-default", map[string]string{"name": "develop"}) + err := runShortcut(t, server, "set-default", map[string]string{"name": "release/1.0"}) if err != nil { - t.Fatalf("set-default shortcut failed: %v", err) + t.Fatalf("set-default 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"}) - }) +func TestBranchRestore(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/restore.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode restore payload: %v", err) + } + if got := payload["branch_name"]; got != "feature/restore-me" { + t.Fatalf("unexpected branch_name: %v", got) + } + if got := payload["branch_id"]; got != float64(9) { + t.Fatalf("unexpected branch_id: %v", got) + } + writeJSON(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", + "branch-id": "9", + "name": "feature/restore-me", }) if err != nil { - t.Fatalf("restore shortcut failed: %v", err) + t.Fatalf("restore 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) - }) +func TestBranchRestoreRejectsInvalidBranchID(t *testing.T) { + var called bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusInternalServerError) + })) 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 { + err := runShortcut(t, server, "restore", map[string]string{ + "branch-id": "abc", + "name": "feature/restore-me", + }) + if err == nil { t.Fatal("expected invalid branch-id to fail") } + if called { + t.Fatal("restore should fail before making a request") + } } +// --- protect --- + func TestBranchProtect(t *testing.T) { - 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"}) - }) + 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"}) + })) defer server.Close() err := runShortcut(t, server, "protect", map[string]string{"name": "master"}) @@ -244,11 +259,18 @@ func TestBranchProtect(t *testing.T) { } } +// --- unprotect --- + func TestBranchUnprotect(t *testing.T) { - 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"}) - }) + 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"}) + })) defer server.Close() err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"}) @@ -257,10 +279,13 @@ func TestBranchUnprotect(t *testing.T) { } } +// --- HTTP error paths --- + func TestBranchListHTTPError(t *testing.T) { - server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeBranchText(t, w, http.StatusInternalServerError, "server error") - }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) defer server.Close() err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}) @@ -269,10 +294,24 @@ func TestBranchListHTTPError(t *testing.T) { } } +func TestBranchAllHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "all", nil) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + func TestBranchCreateHTTPError(t *testing.T) { - server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeBranchText(t, w, http.StatusInternalServerError, "server error") - }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) defer server.Close() err := runShortcut(t, server, "create", map[string]string{"name": "feature-x"}) @@ -281,10 +320,40 @@ func TestBranchCreateHTTPError(t *testing.T) { } } -func TestBranchDeleteHTTPError(t *testing.T) { - server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeBranchText(t, w, http.StatusInternalServerError, "server error") +func TestBranchSetDefaultHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "set-default", map[string]string{"name": "main"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +func TestBranchRestoreHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "restore", map[string]string{ + "branch-id": "3", + "name": "feature/restore-me", }) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +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")) + })) defer server.Close() err := runShortcut(t, server, "delete", map[string]string{"name": "old-branch"}) @@ -294,9 +363,10 @@ func TestBranchDeleteHTTPError(t *testing.T) { } func TestBranchProtectHTTPError(t *testing.T) { - server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeBranchText(t, w, http.StatusInternalServerError, "server error") - }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) defer server.Close() err := runShortcut(t, server, "protect", map[string]string{"name": "master"}) @@ -306,9 +376,10 @@ func TestBranchProtectHTTPError(t *testing.T) { } func TestBranchUnprotectHTTPError(t *testing.T) { - server := newBranchTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeBranchText(t, w, http.StatusInternalServerError, "server error") - }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) defer server.Close() err := runShortcut(t, server, "unprotect", map[string]string{"name": "master"})