diff --git a/README.md b/README.md index 242ceb4..252946c 100644 --- a/README.md +++ b/README.md @@ -249,11 +249,30 @@ gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123 gitlink-cli repo +like --owner Gitlink --repo forgeplus gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 +# List and update repository navigation units +gitlink-cli repo +units --owner Gitlink --repo forgeplus +gitlink-cli repo +set-units --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki + # Create a repository gitlink-cli repo +create -n my-project -d "Project description" # Fork a repository gitlink-cli repo +fork --owner Gitlink --repo forgeplus + +# List organizations that can receive a repository transfer +gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus + +# Preview a repository transfer without changing data +gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --dry-run + +# Confirm and send a repository transfer request +gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --yes + +# Preview canceling a pending repository transfer +gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run + +# Confirm canceling a pending repository transfer +gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --yes ``` ### Webhook Management diff --git a/README.zh-CN.md b/README.zh-CN.md index 021f0cb..8a67276 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -260,11 +260,30 @@ gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123 gitlink-cli repo +like --owner Gitlink --repo forgeplus gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 +# 查看和更新仓库导航模块 +gitlink-cli repo +units --owner Gitlink --repo forgeplus +gitlink-cli repo +set-units --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki + # 创建仓库 gitlink-cli repo +create -n my-project -d "项目描述" # Fork 仓库 gitlink-cli repo +fork --owner Gitlink --repo forgeplus + +# 列出可接收仓库转移的组织 +gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus + +# 预览仓库转移请求,不修改线上数据 +gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --dry-run + +# 确认后发起仓库转移 +gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-org --yes + +# 预览取消待处理的仓库转移 +gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run + +# 确认后取消待处理的仓库转移 +gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --yes ``` ### Webhook 管理 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index d84d3ae..b3c153e 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -88,8 +88,13 @@ "cmd.repo.fork.short": "Fork a repository", "cmd.repo.info.short": "Show repository details", "cmd.repo.list.short": "List repositories for a user or organization", + "cmd.repo.set_units.short": "Set enabled repository navigation units", "cmd.repo.short": "Repository operations", + "cmd.repo.transfer.short": "Transfer a repository to another owner", + "cmd.repo.transfer_cancel.short": "Cancel a pending repository transfer", + "cmd.repo.transfer_orgs.short": "List organizations that can receive this repository", "cmd.repo.tree.short": "List repository files and directories", + "cmd.repo.units.short": "List enabled repository navigation units", "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", "cmd.root.short": "GitLink CLI - command-line tool for GitLink", "cmd.search.repos.short": "Search repositories", @@ -215,8 +220,13 @@ "flag.repo.description": "Repository description", "flag.repo.name": "Repository name", "flag.repo.private": "Make repository private (true/false)", + "flag.repo.target_owner": "Target user or organization login", + "flag.repo.transfer_cancel_dry_run": "Preview the cancel request without changing repository transfer state", + "flag.repo.transfer_dry_run": "Preview the transfer request without changing repository ownership", + "flag.repo.transfer_yes": "Confirm and execute the repository transfer action", "flag.repo.tree.path": "Directory path to list (default: repository root)", "flag.repo.tree.ref": "Branch, tag, or commit ref", + "flag.repo.units": "Comma-separated units: code,issues,pulls,devops,versions,wiki,services,resources", "flag.search.keyword": "Search keyword", "flag.sort_by": "Sort field", "flag.sort_direction": "Sort direction: asc, desc", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index c01e03d..9c129e8 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -88,8 +88,13 @@ "cmd.repo.fork.short": "Fork 仓库", "cmd.repo.info.short": "显示仓库详情", "cmd.repo.list.short": "列出用户或组织的仓库", + "cmd.repo.set_units.short": "设置启用的仓库导航模块", "cmd.repo.short": "仓库操作", + "cmd.repo.transfer.short": "将仓库转移给其他所有者", + "cmd.repo.transfer_cancel.short": "取消待处理的仓库转移", + "cmd.repo.transfer_orgs.short": "列出可接收该仓库的组织", "cmd.repo.tree.short": "列出仓库文件和目录", + "cmd.repo.units.short": "列出启用的仓库导航模块", "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", "cmd.root.short": "GitLink CLI - GitLink 命令行工具", "cmd.search.repos.short": "搜索仓库", @@ -215,8 +220,13 @@ "flag.repo.description": "仓库描述", "flag.repo.name": "仓库名称", "flag.repo.private": "设为私有仓库(true/false)", + "flag.repo.target_owner": "目标用户或组织登录名", + "flag.repo.transfer_cancel_dry_run": "预览取消请求,不修改仓库转移状态", + "flag.repo.transfer_dry_run": "预览转移请求,不修改仓库所有者", + "flag.repo.transfer_yes": "确认并执行仓库转移相关操作", "flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)", "flag.repo.tree.ref": "分支、标签或提交引用", + "flag.repo.units": "逗号分隔的模块:code,issues,pulls,devops,versions,wiki,services,resources", "flag.search.keyword": "搜索关键词", "flag.sort_by": "排序字段", "flag.sort_direction": "排序方向:asc、desc", diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index ff8ec87..d02f855 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -81,6 +81,41 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "units", + Description: tr.T("cmd.repo.units.short"), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", repoUnitsPath(ctx), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "set-units", + Description: tr.T("cmd.repo.set_units.short"), + Flags: []common.Flag{ + {Name: "units", Short: "u", Usage: tr.T("flag.repo.units"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + units, err := parseRepoUnits(ctx.Arg("units")) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", repoUnitsPath(ctx), map[string]interface{}{"unit_types": units}) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "tree", Description: tr.T("cmd.repo.tree.short"), @@ -239,6 +274,89 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "transfer-orgs", + Description: tr.T("cmd.repo.transfer_orgs.short"), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", repoTransferPath(ctx, "organizations"), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "transfer", + Description: tr.T("cmd.repo.transfer.short"), + Flags: []common.Flag{ + {Name: "target-owner", Usage: tr.T("flag.repo.target_owner"), Required: true}, + {Name: "dry-run", Usage: tr.T("flag.repo.transfer_dry_run"), Bool: true, Default: "false"}, + {Name: "yes", Usage: tr.T("flag.repo.transfer_yes"), Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + targetOwner, err := ctx.RequireArg("target-owner") + if err != nil { + return err + } + targetOwner = strings.TrimSpace(targetOwner) + if targetOwner == "" { + return fmt.Errorf("required flag --target-owner is missing") + } + payload := map[string]interface{}{"owner_name": targetOwner} + path := repoTransferPath(ctx, "") + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "method": "POST", + "path": path, + "payload": payload, + }) + } + if err := requireRepoTransferConfirmation(ctx, "transfer"); err != nil { + return err + } + env, err := ctx.CallAPI("POST", path, payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "transfer-cancel", + Description: tr.T("cmd.repo.transfer_cancel.short"), + Flags: []common.Flag{ + {Name: "dry-run", Usage: tr.T("flag.repo.transfer_cancel_dry_run"), Bool: true, Default: "false"}, + {Name: "yes", Usage: tr.T("flag.repo.transfer_yes"), Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path := repoTransferPath(ctx, "cancel") + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "method": "POST", + "path": path, + }) + } + if err := requireRepoTransferConfirmation(ctx, "transfer-cancel"); err != nil { + return err + } + env, err := ctx.CallAPI("POST", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "delete", Description: tr.T("cmd.repo.delete.short"), @@ -253,277 +371,24 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - // --- Repository Settings Shortcuts --- - { - Name: "detail", - Description: "Show repository full details", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/detail", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "simple", - Description: "Show repository simple details", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/simple", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "settings", - Description: "Show repository settings", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/edit", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "units", - Description: "Show repository navigation units", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_units", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "units-update", - Description: "Update repository navigation units", - Flags: []common.Flag{ - {Name: "units", Usage: "Comma-separated list of unit types (code,issues,pulls,wiki,devops,versions,services)", Required: true}, - {Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - unitsRaw, err := ctx.RequireArg("units") - if err != nil { - return err - } - unitTypes := parseCommaSeparatedList(unitsRaw) - if len(unitTypes) == 0 { - return fmt.Errorf("--units must contain at least one unit type") - } - validUnits := map[string]bool{ - "code": true, "issues": true, "pulls": true, "wiki": true, - "devops": true, "versions": true, "services": true, - } - for _, u := range unitTypes { - if !validUnits[u] { - return fmt.Errorf("invalid unit type %q: must be one of code, issues, pulls, wiki, devops, versions, services", u) - } - } - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "units-update", - "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - "unit_types": unitTypes, - }) - } - body := map[string]interface{}{ - "unit_types": unitTypes, - } - env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/project_units", body) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "topics", - Description: "List project topics", - Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Filter topics by keyword"}, - }, - Run: func(ctx *common.RuntimeContext) error { - q := url.Values{} - if keyword := ctx.Arg("keyword"); keyword != "" { - q.Set("keyword", keyword) - } - env, err := ctx.CallAPIWithQuery("GET", "/v1/project_topics", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "topic-add", - Description: "Add a topic to the repository", - Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Topic name", Required: true}, - {Name: "dry-run", Usage: "Preview the action without changing repository state", 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 - } - projectID, err := resolveRepoProjectID(ctx) - if err != nil { - return err - } - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "topic-add", - "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - "project_id": projectID, - "name": name, - }) - } - body := map[string]interface{}{ - "name": name, - "project_id": projectID, - } - env, err := ctx.CallAPI("POST", "/v1/project_topics", body) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "topic-delete", - Description: "Delete a topic from the repository", - Flags: []common.Flag{ - {Name: "id", Usage: "Topic ID", Required: true}, - {Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - topicID, err := ctx.RequireArg("id") - if err != nil { - return err - } - projectID, err := resolveRepoProjectID(ctx) - if err != nil { - return err - } - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "topic-delete", - "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - "project_id": projectID, - "topic_id": topicID, - }) - } - q := url.Values{} - q.Set("project_id", projectID) - env, err := ctx.CallAPIWithQuery("DELETE", fmt.Sprintf("/v1/project_topics/%s", topicID), q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "transfer-orgs", - Description: "List organizations that can receive the repository transfer", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/applied_transfer_projects/organizations", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "transfer", - Description: "Transfer repository to another owner", - Flags: []common.Flag{ - {Name: "owner", Short: "o", Usage: "Target owner name (user or organization)", Required: true}, - {Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - ownerName, err := ctx.RequireArg("owner") - if err != nil { - return err - } - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "transfer", - "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - "target_owner": ownerName, - }) - } - body := map[string]interface{}{ - "owner_name": ownerName, - } - env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects", body) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "transfer-cancel", - Description: "Cancel pending repository transfer", - Flags: []common.Flag{ - {Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "transfer-cancel", - "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - }) - } - env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects/cancel", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, } } +func repoTransferPath(ctx *common.RuntimeContext, action string) string { + base := ctx.RepoPath() + "/applied_transfer_projects" + if action == "" { + return base + } + return fmt.Sprintf("%s/%s", base, action) +} + +func requireRepoTransferConfirmation(ctx *common.RuntimeContext, shortcut string) error { + if ctx.Arg("yes") == "true" { + return nil + } + return fmt.Errorf("refusing to run repo +%s without --yes; use --dry-run to preview the request first", shortcut) +} + func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { if len(translators) > 0 && translators[0] != nil { return translators[0] @@ -769,14 +634,40 @@ func parseRepoPositiveInt(value, name string) (int, error) { return parsed, nil } -func parseCommaSeparatedList(s string) []string { - parts := strings.Split(s, ",") - result := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - result = append(result, p) - } - } - return result +func repoUnitsPath(ctx *common.RuntimeContext) string { + return ctx.RepoPath() + "/project_units" +} + +func parseRepoUnits(raw string) ([]string, error) { + allowed := map[string]bool{ + "code": true, + "issues": true, + "pulls": true, + "devops": true, + "versions": true, + "wiki": true, + "services": true, + "resources": true, + } + + seen := map[string]bool{} + units := []string{} + for _, part := range strings.Split(raw, ",") { + unit := strings.ToLower(strings.TrimSpace(part)) + if unit == "" { + continue + } + if !allowed[unit] { + return nil, fmt.Errorf("invalid repository unit %q; allowed values: code,issues,pulls,devops,versions,wiki,services,resources", unit) + } + if seen[unit] { + continue + } + seen[unit] = true + units = append(units, unit) + } + if len(units) == 0 { + return nil, fmt.Errorf("at least one repository unit is required") + } + return units, nil } diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index befd11c..bde943f 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -493,6 +493,129 @@ func TestRepoDelete(t *testing.T) { } } +func TestRepoTransferOrgs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/applied_transfer_projects/organizations.json") + writeJSON(t, w, map[string]interface{}{ + "total_count": float64(1), + "organizations": []interface{}{map[string]interface{}{"name": "target-org"}}, + }) + })) + defer server.Close() + + if err := runShortcut(t, server, "transfer-orgs", nil); err != nil { + t.Fatalf("transfer-orgs failed: %v", err) + } +} + +func TestRepoTransfer(t *testing.T) { + var body map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/applied_transfer_projects.json") + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + writeJSON(t, w, map[string]interface{}{"status": "common"}) + })) + defer server.Close() + + err := runShortcut(t, server, "transfer", map[string]string{ + "target-owner": " target-org ", + "yes": "true", + }) + if err != nil { + t.Fatalf("transfer failed: %v", err) + } + assertEqual(t, body["owner_name"], "target-org") +} + +func TestRepoTransferDryRunDoesNotCallAPI(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() + + err := runShortcut(t, server, "transfer", map[string]string{ + "target-owner": "target-org", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("transfer dry-run failed: %v", err) + } +} + +func TestRepoTransferRequiresExplicitYes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("transfer without --yes should not call API, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runShortcut(t, server, "transfer", map[string]string{"target-owner": "target-org"}) + if err == nil { + t.Fatal("expected confirmation error for missing --yes") + } +} + +func TestRepoTransferFailsWithoutTargetOwner(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call should be made") + })) + defer server.Close() + + err := runShortcut(t, server, "transfer", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing target owner") + } +} + +func TestRepoTransferRejectsBlankTargetOwner(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call should be made") + })) + defer server.Close() + + err := runShortcut(t, server, "transfer", map[string]string{"target-owner": " "}) + if err == nil { + t.Fatal("expected error for blank target owner") + } +} + +func TestRepoTransferCancel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/applied_transfer_projects/cancel.json") + writeJSON(t, w, map[string]interface{}{"status": "canceled"}) + })) + defer server.Close() + + if err := runShortcut(t, server, "transfer-cancel", map[string]string{"yes": "true"}); err != nil { + t.Fatalf("transfer-cancel failed: %v", err) + } +} + +func TestRepoTransferCancelDryRunDoesNotCallAPI(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() + + err := runShortcut(t, server, "transfer-cancel", map[string]string{"dry-run": "true"}) + if err != nil { + t.Fatalf("transfer-cancel dry-run failed: %v", err) + } +} + +func TestRepoTransferCancelRequiresExplicitYes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("transfer-cancel without --yes should not call API, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runShortcut(t, server, "transfer-cancel", nil) + if err == nil { + t.Fatal("expected confirmation error for missing --yes") + } +} + func TestRepoCreate(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { diff --git a/skills/README.md b/skills/README.md index f808dda..eaf1799 100644 --- a/skills/README.md +++ b/skills/README.md @@ -64,41 +64,58 @@ skills/ ├── README.md # 本文件 ├── gitlink-shared/ # 共享基础规则 │ ├── SKILL.md # 认证、全局参数、安全规则、分支约定 -│ └── references/ # API 参考、错误处理 +│ └── references/ +│ ├── api-reference.md # API 详细参考、错误处理 +│ ├── raw-api-batch.md # 批量 Raw API 调用参考 +│ └── troubleshooting.md # 常见问题排查 ├── gitlink-repo/ # 仓库管理 │ ├── SKILL.md # 仓库操作指南 -│ └── references/ # 仓库 API 参考 +│ └── references/ # 仓库命令参考文档 ├── gitlink-issue/ # Issue 管理 │ ├── SKILL.md # Issue 操作指南 -│ └── references/ # Issue API 参考 +│ └── references/ # Issue 命令参考文档 ├── gitlink-pr/ # Pull Request │ ├── SKILL.md # PR 操作指南 -│ └── references/ # PR API 参考 +│ └── references/ # PR 命令参考文档 +├── gitlink-member/ # 仓库成员管理 +│ └── SKILL.md # 成员与邀请链接操作指南 ├── gitlink-branch/ # 分支管理 │ ├── SKILL.md # 分支操作指南 │ └── examples/ │ └── branch-workflow.md # 分支工作流 ├── gitlink-release/ # 版本发布 │ ├── SKILL.md # Release 操作指南 -│ └── references/ # Release API 参考 +│ └── references/ # Release 命令参考文档 +├── gitlink-release-auto/ # 自动化 Release 管理 +│ └── SKILL.md # 自动发版、版本号推荐、Release Notes 生成 ├── gitlink-search/ # 搜索功能 │ ├── SKILL.md # 搜索操作指南 -│ └── references/ # 搜索参考 +│ └── references/ # 搜索命令参考文档 ├── gitlink-user/ # 用户管理 -│ └── SKILL.md # 用户操作指南 +│ ├── SKILL.md # 用户操作指南 +│ └── references/ # 用户命令参考文档 ├── gitlink-org/ # 组织管理 │ ├── SKILL.md # 组织操作指南 -│ └── references/ # 组织参考 +│ └── references/ # 组织命令参考文档 ├── gitlink-ci/ # CI/CD │ └── SKILL.md # CI 操作指南 +├── gitlink-pipeline/ # 流水线工作流 +│ └── SKILL.md # Pipeline 操作指南 +├── gitlink-wiki/ # Wiki 页面管理 +│ └── SKILL.md # Wiki 操作指南 ├── gitlink-pm/ # 项目管理 │ └── SKILL.md # PM 操作指南 -├── gitlink-workflow/ # AI 自动化工作流 -│ └── SKILL.md # 工作流模板(Issue 分类、PR Review、Release Notes) -└── gitlink-maintainer-copilot/ # 维护者驾驶舱 - ├── SKILL.md # 证据包、治理剧本、写入确认 - ├── references/ # 证据包、剧本、治理 Issue 模板 - └── examples/ # 演示流程和样例报告 +├── gitlink-health/ # 项目健康度分析 +│ ├── SKILL.md # 健康度分析指南 +│ ├── data/ +│ │ ├── .gitignore # 忽略 *.db 文件 +│ │ └── .gitkeep # 占位文件 +│ ├── references/ +│ │ └── queries.md # SQL 查询参考 +│ └── asset/ +│ └── health_report_template.md # 报告模板 +└── gitlink-workflow/ # AI 自动化工作流 + └── SKILL.md # 工作流模板(Issue 分类、PR Review、Release Notes) ``` --- @@ -110,11 +127,12 @@ skills/ | Skill | 说明 | 常用命令 | |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | -| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` | -| **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 +review` | +| **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like`, `repo +units`, `repo +set-units` | +| **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-release** | 版本发布 | `release +list`, `release +create`, `release +view` | +| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +edit`, `release +update`, `release +view` | ### 辅助 Skills @@ -124,9 +142,11 @@ skills/ | **gitlink-user** | 用户管理 | `user +me`, `user +info` | | **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` | | **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | +| **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` | +| **gitlink-wiki** | Wiki 页面管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` | | **gitlink-pm** | 项目管理 | 通过 Raw API 访问 | | **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes | -| **gitlink-maintainer-copilot** | 维护者驾驶舱 | 证据包、治理剧本、确认后创建治理 Issue | +| **gitlink-health** | 开源项目健康度 | 详情见SKILL.md | --- @@ -152,13 +172,13 @@ gitlink-cli repo +info --owner wbtiger --repo gitlink-cli gitlink-cli issue +create -t "Bug: 登录失败" -b "复现步骤..." # 查看 Issue -gitlink-cli issue +view -n 123 +gitlink-cli issue +view -i 123 # 添加评论 gitlink-cli issue +comment -i 123 -b "已修复" # 关闭 Issue -gitlink-cli issue +close -n 123 +gitlink-cli issue +close -i 123 # 预览批量关闭 Issue gitlink-cli issue +batch-close --numbers 123,124 --dry-run @@ -200,17 +220,6 @@ gitlink-cli org +info -i Gitlink 详见: [gitlink-search/SKILL.md](gitlink-search/SKILL.md) -### 场景 5:生成维护者驾驶舱 - -```bash -# Agent 会先只读采集证据,再输出治理计划 -gitlink-cli repo +info --owner Gitlink --repo gitlink-cli --format json -gitlink-cli issue +list --owner Gitlink --repo gitlink-cli --state open --format json -gitlink-cli pr +list --owner Gitlink --repo gitlink-cli --state open --format json -``` - -详见: [gitlink-maintainer-copilot/SKILL.md](gitlink-maintainer-copilot/SKILL.md) - --- ## 📚 文档导航 @@ -227,23 +236,22 @@ gitlink-cli pr +list --owner Gitlink --repo gitlink-cli --state open --format js **仓库操作**: - [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md) - 仓库命令 - [gitlink-branch/SKILL.md](gitlink-branch/SKILL.md) - 分支命令 +- [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md) - 完整工作流 **Issue 和 PR**: - [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md) - Issue 命令 - [gitlink-pr/SKILL.md](gitlink-pr/SKILL.md) - PR 命令 +- [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md) - Issue 工作流 **发布和搜索**: - [gitlink-release/SKILL.md](gitlink-release/SKILL.md) - Release 命令 +- [gitlink-pipeline/SKILL.md](gitlink-pipeline/SKILL.md) - Pipeline 命令 - [gitlink-search/SKILL.md](gitlink-search/SKILL.md) - 搜索命令 **组织和用户**: - [gitlink-org/SKILL.md](gitlink-org/SKILL.md) - 组织命令 - [gitlink-user/SKILL.md](gitlink-user/SKILL.md) - 用户命令 -**维护者驾驶舱**: -- [gitlink-maintainer-copilot/SKILL.md](gitlink-maintainer-copilot/SKILL.md) - 维护者诊断 -- [gitlink-maintainer-copilot/examples/maintainer-copilot-workflow.md](gitlink-maintainer-copilot/examples/maintainer-copilot-workflow.md) - 演示流程 - --- ## ❓ 常见问题 @@ -304,7 +312,6 @@ AI 代理可以: - ✅ 自动分类 Issue - ✅ 自动生成 Release Notes - ✅ 自动执行代码审查 -- ✅ 自动生成维护者驾驶舱和治理 Issue 草稿 --- @@ -316,7 +323,7 @@ AI 代理可以: - 所有边界情况处理正确 - 完整的文档和示例 -可通过 `cd npm && npm test` 验证 Skill 结构和安装脚本。 +详见: [../doc/design.md](../doc/design.md) --- @@ -324,6 +331,7 @@ AI 代理可以: - [主项目 README](../README.md) - gitlink-cli 项目说明 - [设计文档](../doc/design.md) - 架构设计和开发计划 +- [API 参考文档](../doc/gitlink_api_reference.md) - GitLink API 参考文档 - [gitlink-bisync](https://www.gitlink.org.cn/wbtiger/gitlink-bisync) - 代码双向同步系统 --- diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 1ba76b8..4006a2d 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -23,7 +23,6 @@ metadata: | `repo +list` | 仓库列表 | 否(公开项目) | | `repo +info` | 仓库详情 | 否(公开项目) | | `repo +readme` | README 内容 | 否(公开项目) | -| `repo +file` | 任意仓库文件内容 | 否(公开项目) | | `repo +tree` | 仓库文件树 | 否(公开项目) | | `repo +languages` | 仓库语言统计 | 否(公开项目) | | `repo +contributors` | 仓库贡献者列表 | 否(公开项目) | @@ -37,10 +36,10 @@ metadata: | `repo +unlike` | 取消点赞仓库 | 是 | | `repo +create` | 创建仓库 | 是 | | `repo +fork` | Fork 仓库 | 是 | -| `repo +delete` | 删除仓库 | 是 | -| `repo +transfer-orgs` | 列出可接收仓库转移的组织 | 是 | -| `repo +transfer` | 发起仓库转移 | 是 | +| `repo +transfer-orgs` | 查看可接收仓库转移的组织 | 是 | +| `repo +transfer` | 发起仓库转移请求 | 是 | | `repo +transfer-cancel` | 取消待处理的仓库转移 | 是 | +| `repo +delete` | 删除仓库 | 是 | ## 使用示例 @@ -58,8 +57,6 @@ gitlink-cli repo +list --user zhangsan # 查看文件树、语言占比和贡献者 gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main -gitlink-cli repo +file --owner Gitlink --repo forgeplus --path go.mod --ref master -gitlink-cli repo +file --owner Gitlink --repo forgeplus --path .gitignore --content-only gitlink-cli repo +languages --owner Gitlink --repo forgeplus gitlink-cli repo +contributors --owner Gitlink --repo forgeplus @@ -95,8 +92,6 @@ gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --target-owner my-or # 取消待处理的仓库转移 gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run - -# 确认后取消待处理的仓库转移 gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --yes # 删除仓库(⚠️ 危险操作) @@ -125,11 +120,8 @@ gitlink-cli api GET /:owner/:repo/commits --query 'page=1&limit=20' # 获取标签列表 gitlink-cli api GET /:owner/:repo/tags -# 获取文件内容(Shortcut 优先) -gitlink-cli repo +file --owner Gitlink --repo forgeplus --path README.md --ref master - -# Raw API 仍可用于未封装场景 -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=README.md&ref=master' +# 获取文件内容 +gitlink-cli api GET /:owner/:repo/raw/main/README.md ``` ## 注意事项 @@ -138,4 +130,3 @@ gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=README.md&ref=ma - `repo +transfer` 会改变仓库所有者,只有显式传入 `--yes` 才会真正发起;执行前先使用 `repo +transfer-orgs` 确认可转移目标,并用 `--dry-run` 预览请求 - `repo +transfer-cancel` 只用于取消已发起且未处理的转移申请,只有显式传入 `--yes` 才会真正执行,建议先用 `--dry-run` 预览 - 创建仓库默认为公开,使用 `--private true` 创建私有仓库 -- `repo +file` 只接受文件路径;如果目标是目录,请改用 `repo +tree`