feat(ci): add repository CI control shortcuts

This commit is contained in:
NeeNe 2026-07-02 13:30:28 +08:00
parent 71ca2bb683
commit 0806f3096c
8 changed files with 334 additions and 16 deletions

View File

@ -477,13 +477,22 @@ gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --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 <build_id>
gitlink-cli ci +logs --owner Gitlink --repo forgeplus --build <build_id>
# Restart a build
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
gitlink-cli ci +restart --owner Gitlink --repo forgeplus --build <build_id>
# Stop a build
gitlink-cli ci +stop --owner Gitlink --repo forgeplus --build <build_id>
# 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

View File

@ -464,6 +464,26 @@ gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --dry-run
```
### CI/CD 操作
```bash
# 查看构建列表
gitlink-cli ci +builds --owner Gitlink --repo forgeplus
# 查看构建日志
gitlink-cli ci +logs --owner Gitlink --repo forgeplus --build <build_id>
# 重启或停止构建
gitlink-cli ci +restart --owner Gitlink --repo forgeplus --build <build_id>
gitlink-cli ci +stop --owner Gitlink --repo forgeplus --build <build_id>
# 查看 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

View File

@ -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 ./...
```

View File

@ -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",

View File

@ -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": "评论内容",

View File

@ -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]

View File

@ -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")
}
}

View File

@ -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`,便于解析构建状态和授权结果。