From 0806f3096cd279358b9cce35031414b9af2af92c Mon Sep 17 00:00:00 2001 From: NeeNe <26158277@qq.com> Date: Thu, 2 Jul 2026 13:30:28 +0800 Subject: [PATCH] feat(ci): add repository CI control shortcuts --- README.md | 15 ++- README.zh-CN.md | 20 ++++ doc/changes/ci-control-shortcuts.md | 63 ++++++++++++ internal/i18n/locales/en-US.json | 7 ++ internal/i18n/locales/zh-CN.json | 7 ++ shortcuts/ci/ci.go | 62 ++++++++++++ shortcuts/ci/ci_test.go | 143 ++++++++++++++++++++++++++++ skills/gitlink-ci/SKILL.md | 33 ++++--- 8 files changed, 334 insertions(+), 16 deletions(-) create mode 100644 doc/changes/ci-control-shortcuts.md diff --git a/README.md b/README.md index e5e4318..975a9ad 100644 --- a/README.md +++ b/README.md @@ -477,13 +477,22 @@ gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --d ```bash # List builds -gitlink-cli ci +list --owner Gitlink --repo forgeplus +gitlink-cli ci +builds --owner Gitlink --repo forgeplus # View build log -gitlink-cli ci +log --owner Gitlink --repo forgeplus -i +gitlink-cli ci +logs --owner Gitlink --repo forgeplus --build # Restart a build -gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i +gitlink-cli ci +restart --owner Gitlink --repo forgeplus --build + +# Stop a build +gitlink-cli ci +stop --owner Gitlink --repo forgeplus --build + +# Check CI authorization and safely toggle repository CI +gitlink-cli ci +authorize --owner Gitlink --repo forgeplus +gitlink-cli ci +activate --owner Gitlink --repo forgeplus --dry-run +gitlink-cli ci +activate --owner Gitlink --repo forgeplus --yes +gitlink-cli ci +deactivate --owner Gitlink --repo forgeplus --dry-run ``` ### Pipeline Operations diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..d80e3dd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -464,6 +464,26 @@ gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --dry-run ``` +### CI/CD 操作 + +```bash +# 查看构建列表 +gitlink-cli ci +builds --owner Gitlink --repo forgeplus + +# 查看构建日志 +gitlink-cli ci +logs --owner Gitlink --repo forgeplus --build + +# 重启或停止构建 +gitlink-cli ci +restart --owner Gitlink --repo forgeplus --build +gitlink-cli ci +stop --owner Gitlink --repo forgeplus --build + +# 查看 CI 授权状态并安全启停仓库 CI +gitlink-cli ci +authorize --owner Gitlink --repo forgeplus +gitlink-cli ci +activate --owner Gitlink --repo forgeplus --dry-run +gitlink-cli ci +activate --owner Gitlink --repo forgeplus --yes +gitlink-cli ci +deactivate --owner Gitlink --repo forgeplus --dry-run +``` + ### 流水线管理 ```bash diff --git a/doc/changes/ci-control-shortcuts.md b/doc/changes/ci-control-shortcuts.md new file mode 100644 index 0000000..d81cce4 --- /dev/null +++ b/doc/changes/ci-control-shortcuts.md @@ -0,0 +1,63 @@ +# CI control shortcuts + +## Background + +The CI shortcut group already supported build listing, log inspection, restart, +and stop operations. Repository-level CI activation, deactivation, and +authorization checks were still documented as Raw API calls in `gitlink-ci`. + +This change adds first-class CI control shortcuts. + +## New shortcuts + +- `ci +activate` activates CI for a repository. +- `ci +deactivate` deactivates CI for a repository. +- `ci +authorize` shows CI authorization state for a repository. + +## Safety model + +`ci +authorize` is read-only and can run directly: + +```bash +gitlink-cli ci +authorize --owner Gitlink --repo forgeplus +``` + +`ci +activate` and `ci +deactivate` change repository CI state, so they require +an explicit confirmation flag and support dry-run previews: + +```bash +gitlink-cli ci +activate --owner Gitlink --repo forgeplus --dry-run +gitlink-cli ci +activate --owner Gitlink --repo forgeplus --yes +``` + +```bash +gitlink-cli ci +deactivate --owner Gitlink --repo forgeplus --dry-run +gitlink-cli ci +deactivate --owner Gitlink --repo forgeplus --yes +``` + +## Documentation updates + +- README and README.zh-CN include CI control examples. +- `skills/gitlink-ci` now prefers `ci +activate`, `ci +deactivate`, and + `ci +authorize` instead of Raw API calls. + +## Tests + +Unit tests cover: + +- endpoint method/path mapping for activate, deactivate, and authorize; +- dry-run behavior for state-changing commands; +- `--yes` confirmation guards; +- HTTP error propagation. + +Suggested verification: + +```bash +go test ./shortcuts/ci ./shortcuts +``` + +Full project verification: + +```bash +go test ./... +``` diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..1e7b79d 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -11,7 +11,10 @@ "cmd.branch.protect.short": "Set branch protection", "cmd.branch.short": "Branch operations", "cmd.branch.unprotect.short": "Remove branch protection", + "cmd.ci.activate.short": "Activate repository CI", + "cmd.ci.authorize.short": "Show repository CI authorization status", "cmd.ci.builds.short": "List CI builds", + "cmd.ci.deactivate.short": "Deactivate repository CI", "cmd.ci.logs.short": "View build logs", "cmd.ci.restart.short": "Restart a build", "cmd.ci.short": "CI/CD operations", @@ -126,7 +129,11 @@ "flag.auth.token": "Login by pasting an existing token", "flag.branch.from": "Source branch or commit", "flag.branch.name": "Branch name", + "flag.ci.activate_dry_run": "Preview the CI activation request without changing remote state", + "flag.ci.activate_yes": "Confirm repository CI activation", "flag.ci.build": "Build number", + "flag.ci.deactivate_dry_run": "Preview the CI deactivation request without changing remote state", + "flag.ci.deactivate_yes": "Confirm repository CI deactivation", "flag.ci.stage": "Stage number", "flag.ci.step": "Step number", "flag.comment.body": "Comment body", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..8676e05 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -11,7 +11,10 @@ "cmd.branch.protect.short": "设置分支保护", "cmd.branch.short": "分支操作", "cmd.branch.unprotect.short": "移除分支保护", + "cmd.ci.activate.short": "激活仓库 CI", + "cmd.ci.authorize.short": "显示仓库 CI 授权状态", "cmd.ci.builds.short": "列出 CI 构建", + "cmd.ci.deactivate.short": "停用仓库 CI", "cmd.ci.logs.short": "查看构建日志", "cmd.ci.restart.short": "重启构建", "cmd.ci.short": "CI/CD 操作", @@ -126,7 +129,11 @@ "flag.auth.token": "通过粘贴已有 Token 登录", "flag.branch.from": "源分支或 Commit", "flag.branch.name": "分支名称", + "flag.ci.activate_dry_run": "预览 CI 激活请求,不修改远端状态", + "flag.ci.activate_yes": "确认激活仓库 CI", "flag.ci.build": "构建编号", + "flag.ci.deactivate_dry_run": "预览 CI 停用请求,不修改远端状态", + "flag.ci.deactivate_yes": "确认停用仓库 CI", "flag.ci.stage": "阶段编号", "flag.ci.step": "步骤编号", "flag.comment.body": "评论内容", diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index 6794c47..45f11f6 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -1,6 +1,7 @@ package ci import ( + "errors" "fmt" "net/url" @@ -96,9 +97,70 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "activate", + Description: tr.T("cmd.ci.activate.short"), + Flags: ciControlFlags(tr, "flag.ci.activate_dry_run", "flag.ci.activate_yes"), + Run: func(ctx *common.RuntimeContext) error { + return runCIControl(ctx, "activate_ci", "POST", "activate", "activating CI changes repository CI state; run --dry-run first, then pass --yes to execute") + }, + }, + { + Name: "deactivate", + Description: tr.T("cmd.ci.deactivate.short"), + Flags: ciControlFlags(tr, "flag.ci.deactivate_dry_run", "flag.ci.deactivate_yes"), + Run: func(ctx *common.RuntimeContext) error { + return runCIControl(ctx, "deactivate_ci", "DELETE", "deactivate", "deactivating CI changes repository CI state; run --dry-run first, then pass --yes to execute") + }, + }, + { + Name: "authorize", + Description: tr.T("cmd.ci.authorize.short"), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/ci_authorize", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } +func ciControlFlags(tr *i18n.Translator, dryRunKey, yesKey string) []common.Flag { + return []common.Flag{ + {Name: "dry-run", Usage: tr.T(dryRunKey), Bool: true, Default: "false"}, + {Name: "yes", Usage: tr.T(yesKey), Bool: true, Default: "false"}, + } +} + +func runCIControl(ctx *common.RuntimeContext, action, method, suffix, confirmMessage string) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path := ctx.RepoPath() + "/" + suffix + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": action, + "method": method, + "path": path, + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + }) + } + if ctx.Arg("yes") != "true" { + return errors.New(confirmMessage) + } + env, err := ctx.CallAPI(method, path, nil) + if err != nil { + return err + } + return ctx.Output(env) +} + func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { if len(translators) > 0 && translators[0] != nil { return translators[0] diff --git a/shortcuts/ci/ci_test.go b/shortcuts/ci/ci_test.go index 98f257b..0422603 100644 --- a/shortcuts/ci/ci_test.go +++ b/shortcuts/ci/ci_test.go @@ -127,6 +127,110 @@ func TestCIStop(t *testing.T) { } } +// --- activate / deactivate / authorize --- + +func TestCIActivateDryRunDoesNotCallAPI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("activate dry-run should not call remote API: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runShortcut(t, server, "activate", map[string]string{"dry-run": "true"}) + if err != nil { + t.Fatalf("activate dry-run failed: %v", err) + } +} + +func TestCIActivateRequiresYes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("activate without --yes should not call remote API: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runShortcut(t, server, "activate", nil) + if err == nil { + t.Fatal("expected activate to require --yes") + } +} + +func TestCIActivateWithYesCallsEndpoint(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 != "/owner/repo/activate.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runShortcut(t, server, "activate", map[string]string{"yes": "true"}) + if err != nil { + t.Fatalf("activate failed: %v", err) + } +} + +func TestCIDeactivateDryRunDoesNotCallAPI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("deactivate dry-run should not call remote API: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runShortcut(t, server, "deactivate", map[string]string{"dry-run": "true"}) + if err != nil { + t.Fatalf("deactivate dry-run failed: %v", err) + } +} + +func TestCIDeactivateRequiresYes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("deactivate without --yes should not call remote API: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runShortcut(t, server, "deactivate", nil) + if err == nil { + t.Fatal("expected deactivate to require --yes") + } +} + +func TestCIDeactivateWithYesCallsEndpoint(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/deactivate.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runShortcut(t, server, "deactivate", map[string]string{"yes": "true"}) + if err != nil { + t.Fatalf("deactivate failed: %v", err) + } +} + +func TestCIAuthorizeCallsEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/owner/repo/ci_authorize.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, map[string]interface{}{"authorized": true}) + })) + defer server.Close() + + err := runShortcut(t, server, "authorize", nil) + if err != nil { + t.Fatalf("authorize failed: %v", err) + } +} + // --- HTTP error paths --- func TestCIBuildsHTTPError(t *testing.T) { @@ -180,3 +284,42 @@ func TestCIStopHTTPError(t *testing.T) { t.Fatal("expected error for HTTP 500") } } + +func TestCIActivateHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "activate", map[string]string{"yes": "true"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +func TestCIDeactivateHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "deactivate", map[string]string{"yes": "true"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +func TestCIAuthorizeHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "authorize", nil) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} diff --git a/skills/gitlink-ci/SKILL.md b/skills/gitlink-ci/SKILL.md index 016daaa..293ea57 100644 --- a/skills/gitlink-ci/SKILL.md +++ b/skills/gitlink-ci/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-ci -version: 1.0.0 -description: "CI/CD 操作:查看构建列表、构建日志、重启/停止构建。当用户需要操作 GitLink CI 时触发。" +version: 2.0.0 +description: "CI/CD 操作:查看构建列表、构建日志、重启/停止构建、启停仓库 CI、查看 CI 授权状态。当用户需要操作 GitLink CI 时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -24,6 +24,9 @@ metadata: | `ci +logs` | 构建日志 | 是 | | `ci +restart` | 重启构建 | 是 | | `ci +stop` | 停止构建 | 是 | +| `ci +activate` | 激活仓库 CI,先 dry-run | 是 | +| `ci +deactivate` | 停用仓库 CI,先 dry-run | 是 | +| `ci +authorize` | 查看 CI 授权状态 | 是 | ## 使用示例 @@ -39,17 +42,21 @@ gitlink-cli ci +restart --build 42 # 停止构建 gitlink-cli ci +stop --build 42 + +# 查看 CI 授权状态 +gitlink-cli ci +authorize --owner myuser --repo myrepo + +# 激活仓库 CI,先预览再执行 +gitlink-cli ci +activate --owner myuser --repo myrepo --dry-run +gitlink-cli ci +activate --owner myuser --repo myrepo --yes + +# 停用仓库 CI,先预览再执行 +gitlink-cli ci +deactivate --owner myuser --repo myrepo --dry-run +gitlink-cli ci +deactivate --owner myuser --repo myrepo --yes ``` -## Raw API 补充 +## 注意事项 -```bash -# 激活 CI -gitlink-cli api POST /:owner/:repo/activate - -# 停用 CI -gitlink-cli api DELETE /:owner/:repo/deactivate - -# CI 授权状态 -gitlink-cli api GET /:owner/:repo/ci_authorize -``` +- `ci +activate` 和 `ci +deactivate` 会修改仓库 CI 状态,Agent 必须先执行 `--dry-run` 并获得用户确认,再加 `--yes`。 +- `ci +authorize` 是只读查询,可直接执行。 +- 在 Agent 场景建议统一加 `--format json`,便于解析构建状态和授权结果。 -- 2.34.1