diff --git a/README.md b/README.md index 1c44321..448e53b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/). +The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/README.md). **[中文文档](./README.zh-CN.md)** @@ -78,11 +78,19 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans jiangtx
jiangtx +
+ luwanzhou +
luwanzhou +
+
+ whale_hihihi +
whale_hihihi +
## Why gitlink-cli? -- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup +- **Agent-Native Design** — Structured [Skills](./skills/README.md) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup - **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands - **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output - **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded @@ -100,19 +108,19 @@ 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 | -| 🏷️ Release | Create, edit, update, view, delete releases, and manage release assets | +| 🌿 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 | | ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs | | 🔔 Webhook | Manage repo webhooks and test deliveries | | 🔍 Search | Search repositories, users | +| 📊 Dataset | Query research datasets by project | | 👤 User | View user profiles and info | +| 📊 Profile | User ability, role, major, activity, and contribution statistics | | 📋 PM | Sprint management, kanban boards, weekly reports | | 🤖 Workflow | AI-powered issue triage, PR review, release notes | -The `message` shortcut group adds inbox automation for listing messages, checking unread counters, marking messages as read, and deleting older notifications. - ## Installation & Quick Start ### Requirements @@ -287,22 +295,6 @@ gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role D gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true ``` -### Message Center Management - -```bash -# List the latest notifications with unread filtering -gitlink-cli message +list --login Mengz --type notification --status unread --limit 10 - -# Show unread counters for notifications and @mentions -gitlink-cli message +stats --login Mengz - -# Preview marking all notifications as read -gitlink-cli message +read --login Mengz --type notification --all --dry-run - -# Delete selected @mention messages -gitlink-cli message +delete --login Mengz --type atme --ids 101,102 -``` - ### Issue Management ```bash @@ -414,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 @@ -442,16 +444,6 @@ gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0. # View a release gitlink-cli release +view --owner Gitlink --repo forgeplus -i -# List assets attached to a release -gitlink-cli release +assets --owner Gitlink --repo forgeplus -i - -# Attach or detach existing asset IDs while preserving release metadata -gitlink-cli release +attach --owner Gitlink --repo forgeplus -i --attachment-ids 12,34 --dry-run -gitlink-cli release +detach --owner Gitlink --repo forgeplus -i --attachment-ids 34 --dry-run - -# Upload a local file and attach it to the release in one step -gitlink-cli release +upload --owner Gitlink --repo forgeplus -i --file dist/gitlink-cli_linux_amd64.tar.gz --asset-name gitlink-cli-linux-amd64.tar.gz --description "Linux binary" --dry-run - # Get edit data and update while preserving unspecified fields gitlink-cli release +edit --owner Gitlink --repo forgeplus -i gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b "Updated changelog" --dry-run @@ -495,6 +487,16 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run ``` +### Ignore File Templates + +```bash +# List all available .gitignore templates +gitlink-cli ignore +list + +# Filter templates by name +gitlink-cli ignore +list --name Go +``` + ### Search ```bash @@ -505,17 +507,27 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` -### Feedback +### User Profile + +`profile` surfaces GitLink's native user statistics (ability, role, major, activity, +contribution). When `--user` is omitted it defaults to the authenticated user. ```bash -# Preview a feedback submission without calling the API -gitlink-cli feedback +create --content "The CLI install guide needs a Windows note." --category docs --dry-run +# Development ability scores + language breakdown +gitlink-cli profile +ability --user zhangsan -# Submit longer feedback from a file and attach contact/repository context -gitlink-cli feedback +create --from feedback.md --category cli --contact mengz@example.com --repo-ref Gitlink/gitlink-cli +# Role positioning / major (discipline) categories +gitlink-cli profile +role --user zhangsan +gitlink-cli profile +major --user zhangsan -# Pipe feedback from another command -Get-Content feedback.md | gitlink-cli feedback +create --stdin --category feature +# Ability within a time window (Unix timestamps) +gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600 + +# Recent activity (issues / PRs / commits per day) for the current user +gitlink-cli profile +activity + +# Contribution heatmap for a given year +gitlink-cli profile +contribution --user zhangsan --year 2025 ``` ### Workflow Agent Commands @@ -617,6 +629,32 @@ Safety: - `workflow +pr-summary` does not comment, approve, reject, or merge pull requests. - `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes. +### Dataset + +`dataset` manages and queries GitLink research datasets (title, description, +paper content, license, owning project). + +```bash +# List datasets for one or more projects (by numeric project ID) +gitlink-cli dataset +list --ids 5988 + +# View a repository's dataset and attachments +gitlink-cli dataset +view --owner Gitlink --repo forgeplus + +# Create / update a repository's dataset (preview first with --dry-run) +gitlink-cli dataset +create --owner me --repo proj -t "My dataset" -d "..." --license-id 359 --dry-run +gitlink-cli dataset +update --owner me --repo proj -t "My dataset" -d "updated" + +# Delete a dataset attachment (destructive: preview, then confirm with --yes) +gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --dry-run +gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --yes +``` + +> Note: `dataset +list` (platform dataset query) is verified on production +> gitlink.org.cn. The per-repo `+view`/`+create`/`+update` routes follow the +> published OpenAPI contract but are not yet deployed on production (they return +> 404 there); they will work once the platform enables them. + ### Raw API For endpoints not covered by shortcuts, use the Raw API directly: @@ -673,7 +711,7 @@ git push gitlink The `skills/` directory contains Agent Skill files for AI-automated GitLink operations. -See [skills/README.md](skills/README.md) for details. +See [skills/README.md](./skills/README.md) for details. | Skill | Description | |-------|-------------| @@ -743,7 +781,7 @@ gitlink-cli/ ## Documentation -- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation +- [Skills Guide](./skills/README.md) — AI Agent Skills detailed documentation - [Design Document](doc/design.md) — Architecture design and development plan ## FAQ @@ -808,7 +846,7 @@ gitlink-cli uses Windows Credential Manager for secure token storage. If Credent ### Q: Where can I find the full API reference? -See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md). +See [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md). ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index a48b3c9..5dffc62 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -100,7 +100,7 @@ | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | -| 🌿 分支 | 创建、删除、保护分支 | +| 🌿 分支 | 列出、创建、删除、恢复、设置默认分支、保护分支 | | 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release | | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | @@ -403,6 +403,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 46455b1..41037e0 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -1,12 +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" ) @@ -17,28 +19,29 @@ 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: "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 { 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")) - if keyword := strings.TrimSpace(ctx.Arg("keyword")); keyword != "" { + 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 := strings.TrimSpace(ctx.Arg("state")); state != "" { - if err := validateBranchState(state); err != nil { - return err - } + if state != "" { q.Set("state", state) } - env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q) + env, err := ctx.CallAPIWithQuery("GET", branchPath(ctx), q) if err != nil { return err } @@ -52,11 +55,11 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - env, err := ctx.CallAPI("GET", "/v1"+ctx.RepoPath()+"/branches/all", nil) + env, err := ctx.CallAPI("GET", branchPath(ctx)+"/all", nil) if err != nil { return err } - return ctx.Output(env) + return outputBranchEnvelope(ctx, env) }, }, { @@ -65,21 +68,25 @@ 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, _ := 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 } @@ -91,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 } @@ -146,25 +225,6 @@ 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, - }, } } @@ -175,80 +235,65 @@ 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 +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 runRestore(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err +func normalizeBranchState(value string) (string, error) { + state := strings.ToLower(strings.TrimSpace(value)) + if state == "" { + return "", nil } - 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 + return state, nil default: - return fmt.Errorf("invalid --state %q: use all or deleted", state) + return "", fmt.Errorf("invalid --state %q: use all or deleted", value) } } -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) +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 parsed, nil + 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 1bb0a3e..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,154 +37,205 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { return nil } -func writeJSON(w http.ResponseWriter, v interface{}) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) +func newBranchTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) } -// --- 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) +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 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{}{}}) - })) +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") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %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{}{}}) + }) defer server.Close() err := runShortcut(t, server, "list", map[string]string{ - "page": "1", "limit": "20", "keyword": "feature", "state": "deleted", + "keyword": "feature", + "state": "deleted", + "page": "2", + "limit": "5", }) if err != nil { - t.Fatalf("list with filters failed: %v", err) + t.Fatalf("list shortcut 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) - })) +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() - err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20", "state": "open"}) - if err == nil { - t.Fatal("expected validation error") + if err := runShortcut(t, server, "all", nil); err != nil { + t.Fatalf("all shortcut failed: %v", err) } } -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"}}) - })) +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, "all", nil) + err := runShortcut(t, server, "create", map[string]string{ + "name": "feature/a", + "from": "master", + }) if err != nil { - t.Fatalf("all failed: %v", err) - } -} - -// --- 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-x", "from": "master"}) - if err != nil { - t.Fatalf("create failed: %v", err) + 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 falls back to the repository default branch. - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/owner/repo.json": - writeJSON(w, map[string]interface{}{"default_branch": "main"}) - case "/v1/owner/repo/branches.json": - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode payload: %v", err) - } - if payload["old_branch_name"] != "main" { - t.Fatalf("expected old_branch_name to be default branch main, got %v", payload["old_branch_name"]) - } - writeJSON(w, map[string]interface{}{"name": "feature-y"}) - default: - t.Fatalf("unexpected path: %s", r.URL.Path) - } - })) + 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"}) @@ -191,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"}) @@ -211,76 +257,10 @@ 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) { - 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"}) @@ -290,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"}) @@ -303,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"}) @@ -316,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"}) @@ -329,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"}) @@ -340,38 +316,3 @@ func TestBranchUnprotectHTTPError(t *testing.T) { t.Fatal("expected error for HTTP 500") } } - -func TestBranchAllUsesAllEndpoint(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() - - if err := runShortcut(t, server, "all", nil); err != nil { - t.Fatalf("all failed: %v", err) - } -} - -func TestBranchSetDefaultPatchesName(t *testing.T) { - var payload map[string]interface{} - 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 err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode payload: %v", err) - } - writeJSON(w, map[string]interface{}{"status": float64(0), "message": "success"}) - })) - defer server.Close() - - if err := runShortcut(t, server, "set-default", map[string]string{"name": "develop"}); err != nil { - t.Fatalf("set-default failed: %v", err) - } - if payload["name"] != "develop" { - t.Fatalf("expected name=develop, got %v", payload["name"]) - } -} diff --git a/skills/README.md b/skills/README.md index eaf1799..a9102f4 100644 --- a/skills/README.md +++ b/skills/README.md @@ -131,7 +131,7 @@ skills/ | **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete` | | **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 3d3c1f4..0c8b4fb 100644 --- a/skills/gitlink-branch/SKILL.md +++ b/skills/gitlink-branch/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-branch -version: 1.1.0 -description: "分支管理:创建、查看、过滤、删除、保护、设置默认分支、恢复已删除分支。当用户需要操作 GitLink 分支时触发。" +version: 1.0.0 +description: "分支管理:列出、创建、删除、恢复、设置默认分支、保护分支。当用户需要操作 GitLink 分支时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -20,14 +20,14 @@ metadata: | Shortcut | 说明 | 操作类型 | |----------|------|----------| -| `branch +list` | 列出仓库的所有分支 | Read | -| `branch +all` | 无分页列出仓库所有分支 | 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 | -| `branch +set-default` | 设置默认分支 | ⚠️ Write Operation | -| `branch +restore` | 恢复已删除分支 | ⚠️ Write Operation | ## 参数参考 @@ -37,19 +37,10 @@ metadata: |------|------|------| | `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | | `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | +| `--keyword, -k` | 否 | 搜索关键词 | +| `--state, -s` | 否 | 分支状态:`all` 或 `deleted` | | `--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` | 否 | 启用调试输出 | @@ -59,6 +50,7 @@ metadata: |------|------|------| | `--name, -n` | 是 | 新分支名称 | | `--from, -f` | 否 | 源分支或 commit(默认 `master`) | +| `--dry-run` | 否 | 只预览请求,不创建分支 | | `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | | `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | | `--format` | 否 | 输出格式:`json`/`table`/`yaml` | @@ -69,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 | 参数 | 必填 | 说明 | @@ -94,29 +107,6 @@ 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` 可自动推断。 ## 使用示例 @@ -125,20 +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 +list --owner Gitlink --repo forgeplus --keyword fix --state deleted - -# 无分页列出所有分支 +# 列出全部分支(无分页) 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 @@ -146,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 @@ -160,14 +155,6 @@ 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 注意事项 @@ -178,8 +165,9 @@ gitlink-cli branch +restore --id 7 --name feature/old > 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) @@ -187,8 +175,30 @@ gitlink-cli branch +restore --id 7 --name feature/old > 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) @@ -208,25 +218,7 @@ gitlink-cli branch +restore --id 7 --name feature/old 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 页面操作。 +> **注意:** `branch +delete` 使用 v1 OpenAPI 路径并会对含 `/` 的分支名做路径转义;`branch +unprotect` 仍使用保护分支接口。 ## References