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