From 41a2666c5b78d39892d764f2c345f5ba12ebd9cb Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Wed, 27 May 2026 22:20:18 +0800 Subject: [PATCH] feat(pipeline): add OpenAPI shortcuts --- README.md | 27 +- README.zh-CN.md | 27 +- doc/changes/pipeline-openapi-shortcuts.md | 39 +++ shortcuts/pipeline/pipeline.go | 389 ++++++++++++++++++++++ shortcuts/pipeline/pipeline_test.go | 389 ++++++++++++++++++++++ shortcuts/register.go | 43 +-- skills/README.md | 4 + skills/gitlink-pipeline/SKILL.md | 70 ++++ 8 files changed, 966 insertions(+), 22 deletions(-) create mode 100644 doc/changes/pipeline-openapi-shortcuts.md create mode 100644 shortcuts/pipeline/pipeline.go create mode 100644 shortcuts/pipeline/pipeline_test.go create mode 100644 skills/gitlink-pipeline/SKILL.md diff --git a/README.md b/README.md index 6711900..1180dac 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans ## Why gitlink-cli? - **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup -- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Org, Search, and User workflows are covered by high-level commands +- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands - **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output - **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded - **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install` @@ -48,6 +48,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🏷️ Release | Create, view, delete releases | | 🏢 Org | Manage organizations, members, teams | | 🔧 CI | View builds, logs, CI/CD operations | +| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs | | 🔔 Webhook | Manage repo webhooks and test deliveries | | 🔍 Search | Search repositories, users | | 👤 User | View user profiles and info | @@ -315,6 +316,28 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i ``` +### Pipeline Operations + +```bash +# List platform pipelines +gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20 + +# List repository pipeline runs +gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml + +# Start a pipeline workflow, previewing the request first +gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run + +# Inspect pipeline details and logs +gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7 +gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43 +gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99 + +# Toggle or delete pipeline workflows, previewing destructive writes first +gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run +gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run +``` + ### Search ```bash @@ -492,6 +515,7 @@ See [skills/README.md](skills/README.md) for details. | `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) | | `gitlink-release` | Release management (create, view, delete, etc.) | | `gitlink-ci` | CI/CD operations (builds, logs, etc.) | +| `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) | | `gitlink-search` | Search (repositories, users, etc.) | | `gitlink-org` | Organization management (members, teams, etc.) | | `gitlink-user` | User management (profile info, etc.) | @@ -524,6 +548,7 @@ gitlink-cli/ │ ├── release/ # Release shortcuts │ ├── org/ # Organization shortcuts │ ├── ci/ # CI shortcuts +│ ├── pipeline/ # Pipeline shortcuts │ ├── search/ # Search shortcuts │ ├── user/ # User shortcuts │ └── register.go # Registration entry point diff --git a/README.zh-CN.md b/README.zh-CN.md index 1a56fe4..6429d02 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,7 +28,7 @@ ## 为什么选择 gitlink-cli? - **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink -- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、组织、搜索、用户等常用工作流均提供高层命令 +- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令 - **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出 - **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装 - **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用 @@ -48,6 +48,7 @@ | 🏷️ 发布 | 创建、查看、删除 Release | | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | +| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | | 🔍 搜索 | 搜索仓库、用户 | | 👤 用户 | 查看用户资料和信息 | | 📋 项目管理 | Sprint 管理、看板、周报 | @@ -294,6 +295,28 @@ gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0. gitlink-cli release +view --owner Gitlink --repo forgeplus -i ``` +### 流水线管理 + +```bash +# 列出平台流水线 +gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20 + +# 列出仓库流水线运行记录 +gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml + +# 运行流水线工作流,先用 dry-run 预览请求 +gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run + +# 查看流水线详情、日志和运行结果 +gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7 +gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43 +gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99 + +# 启停或删除流水线工作流,写入/删除前先预览 +gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run +gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run +``` + ### 搜索 ```bash @@ -372,6 +395,7 @@ git push gitlink | `gitlink-release` | 发布管理(创建、查看、删除等) | | `gitlink-org` | 组织管理(成员、团队等) | | `gitlink-ci` | CI/CD 操作(构建、日志等) | +| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) | | `gitlink-search` | 搜索功能(仓库、用户等) | | `gitlink-user` | 用户管理(个人信息等) | | `gitlink-pm` | 项目管理(Sprint、看板、周报等) | @@ -403,6 +427,7 @@ gitlink-cli/ │ ├── release/ # Release shortcuts │ ├── org/ # 组织 shortcuts │ ├── ci/ # CI shortcuts +│ ├── pipeline/ # Pipeline shortcuts │ ├── search/ # 搜索 shortcuts │ ├── user/ # 用户 shortcuts │ └── register.go # 注册入口 diff --git a/doc/changes/pipeline-openapi-shortcuts.md b/doc/changes/pipeline-openapi-shortcuts.md new file mode 100644 index 0000000..1bb1e60 --- /dev/null +++ b/doc/changes/pipeline-openapi-shortcuts.md @@ -0,0 +1,39 @@ +# Pipeline OpenAPI Shortcuts + +Submitter: Wang Yue + +This change adds a dedicated `pipeline` shortcut group for GitLink Pipeline OpenAPI coverage. + +## Commands + +- `pipeline +list` +- `pipeline +runs` +- `pipeline +run` +- `pipeline +view` +- `pipeline +delete` +- `pipeline +save-yaml` +- `pipeline +enable` +- `pipeline +disable` +- `pipeline +logs` +- `pipeline +results` + +## API Mapping + +| Shortcut | Method | API path | +|----------|--------|----------| +| `pipeline +list` | GET | `/api/pm/pipelines.json` | +| `pipeline +runs` | GET | `/api/v1/{owner}/{repo}/actions/runs.json` | +| `pipeline +run` | POST | `/api/v1/{owner}/{repo}/actions/runs.json` | +| `pipeline +view` | GET | `/api/v1/{owner}/{repo}/pipelines/{id}.json` | +| `pipeline +delete` | DELETE | `/api/v1/{owner}/{repo}/pipelines/{id}.json` | +| `pipeline +save-yaml` | POST | `/api/v1/{owner}/{repo}/pipelines/save_yaml` | +| `pipeline +enable` | POST | `/api/v1/{owner}/{repo}/actions/enable.json` | +| `pipeline +disable` | POST | `/api/v1/{owner}/{repo}/actions/disable.json` | +| `pipeline +logs` | POST | `/api/v1/{owner}/{repo}/actions/runs/{run_id}/jobs/0` | +| `pipeline +results` | GET | `/api/v1/{owner}/{repo}/pipelines/run_results.json` | + +## Verification + +- Unit tests cover request methods, paths, query parameters, request bodies, dry-run behavior, and invalid ID validation. +- Help documentation is available through `gitlink-cli pipeline --help` and command-specific help. +- Write and delete commands support `--dry-run` to preview requests before changing pipeline state. diff --git a/shortcuts/pipeline/pipeline.go b/shortcuts/pipeline/pipeline.go new file mode 100644 index 0000000..846f57e --- /dev/null +++ b/shortcuts/pipeline/pipeline.go @@ -0,0 +1,389 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List platform pipelines", + Flags: []common.Flag{ + {Name: "owner-id", Usage: "Owner user or organization ID"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + q := pageLimitQuery(ctx) + setQueryIfPresent(q, ctx, "owner-id", "owner_id") + env, err := ctx.CallAPIWithQuery("GET", "/pm/pipelines", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "runs", + Description: "List pipeline run records", + Flags: runFilterFlags(), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPIWithQuery("GET", pipelineV1RepoPath(ctx)+"/actions/runs", runFilterQuery(ctx)) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "run", + Description: "Run a pipeline workflow", + Flags: append(runFilterFlags(), + common.Flag{Name: "dry-run", Usage: "Preview the run request without starting a pipeline", Bool: true, Default: "false"}, + ), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := runFilterQuery(ctx) + path := pipelineV1RepoPath(ctx) + "/actions/runs" + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "dry_run": true, + "action": "run_pipeline", + "method": "POST", + "path": path, + "query": q, + }) + } + env, err := ctx.CallAPIWithQuery("POST", path, q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "view", + Description: "Show pipeline details", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Pipeline ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := requiredPositiveInt(ctx, "id") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pipelines/%d", pipelineV1RepoPath(ctx), id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a pipeline", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Pipeline ID", Required: true}, + {Name: "dry-run", Usage: "Preview the delete request without changing pipeline state", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := requiredPositiveInt(ctx, "id") + if err != nil { + return err + } + path := fmt.Sprintf("%s/pipelines/%d", pipelineV1RepoPath(ctx), id) + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "dry_run": true, + "action": "delete_pipeline", + "method": "DELETE", + "path": path, + }) + } + env, err := ctx.CallAPI("DELETE", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "save-yaml", + Description: "Save a visual pipeline YAML graph", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Pipeline ID", Required: true}, + {Name: "pipeline-json", Usage: "Pipeline graph JSON object or string", Required: true}, + {Name: "dry-run", Usage: "Preview the save request without changing pipeline state", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := requiredPositiveInt(ctx, "id") + if err != nil { + return err + } + pipelineJSON, err := ctx.RequireArg("pipeline-json") + if err != nil { + return err + } + payload := map[string]interface{}{ + "id": id, + "pipeline_json": parseJSONValue(pipelineJSON), + } + path := pipelineV1RepoPath(ctx) + "/pipelines/save_yaml" + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "dry_run": true, + "action": "save_pipeline_yaml", + "method": "POST", + "path": path, + "payload": payload, + }) + } + env, err := ctx.CallAPI("POST", path, payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "enable", + Description: "Enable a pipeline workflow", + Flags: workflowStateFlags(true), + Run: func(ctx *common.RuntimeContext) error { + return runWorkflowState(ctx, "enable") + }, + }, + { + Name: "disable", + Description: "Disable a pipeline workflow", + Flags: workflowStateFlags(true), + Run: func(ctx *common.RuntimeContext) error { + return runWorkflowState(ctx, "disable") + }, + }, + { + Name: "logs", + Description: "Query pipeline run logs", + Flags: []common.Flag{ + {Name: "run-id", Short: "r", Usage: "Pipeline run ID", Required: true}, + {Name: "id", Short: "i", Usage: "Pipeline ID", Required: true}, + {Name: "index", Usage: "Run index", Required: true}, + {Name: "job", Short: "j", Usage: "Job index", Default: "0"}, + {Name: "cursor", Usage: "Log cursor"}, + {Name: "step", Usage: "Log step", Default: "1"}, + {Name: "expanded", Usage: "Whether the log cursor is expanded", Bool: true, Default: "true"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + runID, err := ctx.RequireArg("run-id") + if err != nil { + return err + } + id, err := requiredPositiveInt(ctx, "id") + if err != nil { + return err + } + index, err := ctx.RequireArg("index") + if err != nil { + return err + } + job, err := requiredNonNegativeIntWithDefault(ctx, "job", 0) + if err != nil { + return err + } + step, err := requiredPositiveIntWithDefault(ctx, "step", 1) + if err != nil { + return err + } + payload := map[string]interface{}{ + "id": id, + "index": index, + "job": job, + "owner": ctx.Owner, + "repo": ctx.Repo, + "log_cursors": []map[string]interface{}{ + { + "cursor": ctx.Arg("cursor"), + "expanded": ctx.Arg("expanded") == "true", + "step": step, + }, + }, + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/actions/runs/%s/jobs/0", pipelineV1RepoPath(ctx), url.PathEscape(runID)), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "results", + Description: "Show pipeline run report results", + Flags: []common.Flag{ + {Name: "run-id", Short: "r", Usage: "Pipeline run ID"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + setQueryIfPresent(q, ctx, "run-id", "run_id") + env, err := ctx.CallAPIWithQuery("GET", pipelineV1RepoPath(ctx)+"/pipelines/run_results", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func pipelineV1RepoPath(ctx *common.RuntimeContext) string { + return "/v1" + ctx.RepoPath() +} + +func runFilterFlags() []common.Flag { + return []common.Flag{ + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}, + {Name: "workflow", Short: "w", Usage: "Workflow file name"}, + } +} + +func runFilterQuery(ctx *common.RuntimeContext) url.Values { + q := url.Values{} + setQueryIfPresent(q, ctx, "ref", "ref") + setQueryIfPresent(q, ctx, "workflow", "workflow") + return q +} + +func workflowStateFlags(includeDryRun bool) []common.Flag { + flags := []common.Flag{ + {Name: "id", Short: "i", Usage: "Pipeline ID", Required: true}, + {Name: "workflow", Short: "w", Usage: "Workflow file name", Required: true}, + } + if includeDryRun { + flags = append(flags, common.Flag{Name: "dry-run", Usage: "Preview the request without changing pipeline state", Bool: true, Default: "false"}) + } + return flags +} + +func runWorkflowState(ctx *common.RuntimeContext, action string) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := requiredPositiveInt(ctx, "id") + if err != nil { + return err + } + workflow, err := ctx.RequireArg("workflow") + if err != nil { + return err + } + payload := map[string]interface{}{ + "id": id, + "workflow": workflow, + } + path := fmt.Sprintf("%s/actions/%s", pipelineV1RepoPath(ctx), action) + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "dry_run": true, + "action": action + "_pipeline", + "method": "POST", + "path": path, + "payload": payload, + }) + } + env, err := ctx.CallAPI("POST", path, payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func pageLimitQuery(ctx *common.RuntimeContext) url.Values { + q := url.Values{} + setQueryIfPresent(q, ctx, "page", "page") + setQueryIfPresent(q, ctx, "limit", "limit") + return q +} + +func setQueryIfPresent(q url.Values, ctx *common.RuntimeContext, flagName, queryName string) { + if value := ctx.Arg(flagName); value != "" { + q.Set(queryName, value) + } +} + +func parseJSONValue(value string) interface{} { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return value + } + var parsed interface{} + if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil { + return parsed + } + return value +} + +func requiredPositiveInt(ctx *common.RuntimeContext, flagName string) (int, error) { + value, err := ctx.RequireArg(flagName) + if err != nil { + return 0, err + } + id, err := strconv.Atoi(value) + if err != nil || id <= 0 { + return 0, fmt.Errorf("--%s must be a positive integer", flagName) + } + return id, nil +} + +func requiredPositiveIntWithDefault(ctx *common.RuntimeContext, flagName string, defaultValue int) (int, error) { + value := ctx.Arg(flagName) + if value == "" { + return defaultValue, nil + } + id, err := strconv.Atoi(value) + if err != nil || id <= 0 { + return 0, fmt.Errorf("--%s must be a positive integer", flagName) + } + return id, nil +} + +func requiredNonNegativeIntWithDefault(ctx *common.RuntimeContext, flagName string, defaultValue int) (int, error) { + value := ctx.Arg(flagName) + if value == "" { + return defaultValue, nil + } + id, err := strconv.Atoi(value) + if err != nil || id < 0 { + return 0, fmt.Errorf("--%s must be a non-negative integer", flagName) + } + return id, nil +} diff --git a/shortcuts/pipeline/pipeline_test.go b/shortcuts/pipeline/pipeline_test.go new file mode 100644 index 0000000..981964e --- /dev/null +++ b/shortcuts/pipeline/pipeline_test.go @@ -0,0 +1,389 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestPipelineList(t *testing.T) { + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertPipelineRequest(t, r, "GET", "/pm/pipelines.json") + assertPipelineQuery(t, r, "owner_id", "42") + assertPipelineQuery(t, r, "page", "2") + assertPipelineQuery(t, r, "limit", "50") + writePipelineJSON(t, w, map[string]interface{}{"status": 0, "message": "ok"}) + }) + defer server.Close() + + err := runPipelineShortcut(t, server, "list", map[string]string{ + "owner-id": "42", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestPipelineRuns(t *testing.T) { + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertPipelineRequest(t, r, "GET", "/v1/owner/repo/actions/runs.json") + assertPipelineQuery(t, r, "ref", "master") + assertPipelineQuery(t, r, "workflow", "build.yml") + writePipelineJSON(t, w, map[string]interface{}{"runs": []interface{}{}}) + }) + defer server.Close() + + err := runPipelineShortcut(t, server, "runs", map[string]string{ + "ref": "master", + "workflow": "build.yml", + }) + if err != nil { + t.Fatalf("runs shortcut failed: %v", err) + } +} + +func TestPipelineRun(t *testing.T) { + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertPipelineRequest(t, r, "POST", "/v1/owner/repo/actions/runs.json") + assertPipelineQuery(t, r, "ref", "master") + assertPipelineQuery(t, r, "workflow", "build.yml") + writePipelineJSON(t, w, map[string]interface{}{"status": 0}) + }) + defer server.Close() + + err := runPipelineShortcut(t, server, "run", map[string]string{ + "ref": "master", + "workflow": "build.yml", + }) + if err != nil { + t.Fatalf("run shortcut failed: %v", err) + } +} + +func TestPipelineView(t *testing.T) { + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertPipelineRequest(t, r, "GET", "/v1/owner/repo/pipelines/7.json") + writePipelineJSON(t, w, map[string]interface{}{"id": 7}) + }) + defer server.Close() + + if err := runPipelineShortcut(t, server, "view", map[string]string{"id": "7"}); err != nil { + t.Fatalf("view shortcut failed: %v", err) + } +} + +func TestPipelineDelete(t *testing.T) { + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertPipelineRequest(t, r, "DELETE", "/v1/owner/repo/pipelines/7.json") + writePipelineJSON(t, w, map[string]interface{}{"status": 0}) + }) + defer server.Close() + + if err := runPipelineShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } +} + +func TestPipelineSaveYamlPayload(t *testing.T) { + var payload map[string]interface{} + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertPipelineRequest(t, r, "POST", "/v1/owner/repo/pipelines/save_yaml.json") + payload = decodePipelineJSON(t, r) + writePipelineJSON(t, w, map[string]interface{}{"status": 0}) + }) + defer server.Close() + + err := runPipelineShortcut(t, server, "save-yaml", map[string]string{ + "id": "7", + "pipeline-json": `{"nodes":[]}`, + }) + if err != nil { + t.Fatalf("save-yaml shortcut failed: %v", err) + } + + assertPipelineEqual(t, payload["id"], float64(7)) + graph, ok := payload["pipeline_json"].(map[string]interface{}) + if !ok { + t.Fatalf("pipeline_json = %T, want object", payload["pipeline_json"]) + } + nodes, ok := graph["nodes"].([]interface{}) + if !ok { + t.Fatalf("nodes = %T, want array", graph["nodes"]) + } + if len(nodes) != 0 { + t.Fatalf("nodes length = %d, want 0", len(nodes)) + } +} + +func TestPipelineEnablePayload(t *testing.T) { + var payload map[string]interface{} + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertPipelineRequest(t, r, "POST", "/v1/owner/repo/actions/enable.json") + payload = decodePipelineJSON(t, r) + writePipelineJSON(t, w, map[string]interface{}{"status": 0}) + }) + defer server.Close() + + err := runPipelineShortcut(t, server, "enable", map[string]string{ + "id": "7", + "workflow": "build.yml", + }) + if err != nil { + t.Fatalf("enable shortcut failed: %v", err) + } + + assertPipelineEqual(t, payload["id"], float64(7)) + assertPipelineEqual(t, payload["workflow"], "build.yml") +} + +func TestPipelineDisablePayload(t *testing.T) { + var payload map[string]interface{} + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertPipelineRequest(t, r, "POST", "/v1/owner/repo/actions/disable.json") + payload = decodePipelineJSON(t, r) + writePipelineJSON(t, w, map[string]interface{}{"status": 0}) + }) + defer server.Close() + + err := runPipelineShortcut(t, server, "disable", map[string]string{ + "id": "7", + "workflow": "build.yml", + }) + if err != nil { + t.Fatalf("disable shortcut failed: %v", err) + } + + assertPipelineEqual(t, payload["id"], float64(7)) + assertPipelineEqual(t, payload["workflow"], "build.yml") +} + +func TestPipelineLogsPayload(t *testing.T) { + var payload map[string]interface{} + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertPipelineRequest(t, r, "POST", "/v1/owner/repo/actions/runs/99/jobs/0.json") + payload = decodePipelineJSON(t, r) + writePipelineJSON(t, w, map[string]interface{}{"status": 0}) + }) + defer server.Close() + + err := runPipelineShortcut(t, server, "logs", map[string]string{ + "run-id": "99", + "id": "7", + "index": "43", + "job": "0", + "cursor": "cursor-1", + "step": "1", + "expanded": "true", + }) + if err != nil { + t.Fatalf("logs shortcut failed: %v", err) + } + + assertPipelineEqual(t, payload["id"], float64(7)) + assertPipelineEqual(t, payload["index"], "43") + assertPipelineEqual(t, payload["job"], float64(0)) + assertPipelineEqual(t, payload["owner"], "owner") + assertPipelineEqual(t, payload["repo"], "repo") + cursors, ok := payload["log_cursors"].([]interface{}) + if !ok { + t.Fatalf("log_cursors = %T, want array", payload["log_cursors"]) + } + if len(cursors) != 1 { + t.Fatalf("log_cursors length = %d, want 1", len(cursors)) + } + cursor, ok := cursors[0].(map[string]interface{}) + if !ok { + t.Fatalf("cursor entry = %T, want object", cursors[0]) + } + assertPipelineEqual(t, cursor["cursor"], "cursor-1") + assertPipelineEqual(t, cursor["expanded"], true) + assertPipelineEqual(t, cursor["step"], float64(1)) +} + +func TestPipelineResults(t *testing.T) { + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertPipelineRequest(t, r, "GET", "/v1/owner/repo/pipelines/run_results.json") + assertPipelineQuery(t, r, "run_id", "99") + writePipelineJSON(t, w, map[string]interface{}{"reports": []interface{}{}}) + }) + defer server.Close() + + if err := runPipelineShortcut(t, server, "results", map[string]string{"run-id": "99"}); err != nil { + t.Fatalf("results shortcut failed: %v", err) + } +} + +func TestPipelineDryRunDoesNotCallAPI(t *testing.T) { + dryRunCases := []struct { + name string + args map[string]string + }{ + {name: "run", args: map[string]string{"dry-run": "true", "ref": "master"}}, + {name: "delete", args: map[string]string{"dry-run": "true", "id": "7"}}, + {name: "save-yaml", args: map[string]string{"dry-run": "true", "id": "7", "pipeline-json": `{"nodes":[]}`}}, + {name: "enable", args: map[string]string{"dry-run": "true", "id": "7", "workflow": "build.yml"}}, + {name: "disable", args: map[string]string{"dry-run": "true", "id": "7", "workflow": "build.yml"}}, + } + + for _, tc := range dryRunCases { + t.Run(tc.name, func(t *testing.T) { + server := newPipelineTestServer(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() + + if err := runPipelineShortcut(t, server, tc.name, tc.args); err != nil { + t.Fatalf("%s dry-run failed: %v", tc.name, err) + } + }) + } +} + +func TestPipelineRejectsInvalidID(t *testing.T) { + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid id should not call API, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runPipelineShortcut(t, server, "view", map[string]string{"id": "abc"}) + if err == nil { + t.Fatal("expected invalid id to return an error") + } +} + +func runPipelineShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findPipelineShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findPipelineShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func newPipelineTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func assertPipelineRequest(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 assertPipelineQuery(t *testing.T, r *http.Request, key, want string) { + t.Helper() + if got := r.URL.Query().Get(key); got != want { + t.Fatalf("query %s = %q, want %q", key, got, want) + } +} + +func decodePipelineJSON(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 writePipelineJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func assertPipelineEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} + +func TestPipelineShortcutNames(t *testing.T) { + got := map[string]bool{} + for _, shortcut := range Shortcuts() { + got[shortcut.Name] = true + } + want := []string{"list", "runs", "run", "view", "delete", "save-yaml", "enable", "disable", "logs", "results"} + for _, name := range want { + if !got[name] { + t.Fatalf("missing shortcut %q in %v", name, got) + } + } + if len(got) != len(want) { + t.Fatalf("shortcut count = %d, want %d: %v", len(got), len(want), got) + } +} + +func TestPipelineRunIDIsPathEscaped(t *testing.T) { + server := newPipelineTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Fatalf("got method %s, want POST", r.Method) + } + if got, want := r.URL.EscapedPath(), "/v1/owner/repo/actions/runs/run%2F99/jobs/0.json"; got != want { + t.Fatalf("escaped path = %q, want %q", got, want) + } + writePipelineJSON(t, w, map[string]interface{}{"status": 0}) + }) + defer server.Close() + + err := runPipelineShortcut(t, server, "logs", map[string]string{ + "run-id": "run/99", + "id": "7", + "index": "43", + "expanded": "true", + }) + if err != nil { + t.Fatalf("logs shortcut failed: %v", err) + } +} + +func ExampleShortcuts() { + for _, shortcut := range Shortcuts() { + fmt.Println(shortcut.Name) + } + // Output: + // list + // runs + // run + // view + // delete + // save-yaml + // enable + // disable + // logs + // results +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 49b20ed..86b948a 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -11,6 +11,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/member" "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" "github.com/gitlink-org/gitlink-cli/shortcuts/org" + "github.com/gitlink-org/gitlink-cli/shortcuts/pipeline" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/repo" @@ -23,37 +24,39 @@ import ( // RegisterAll mounts all shortcut groups onto the root command. func RegisterAll(root *cobra.Command) { groups := map[string][]*common.Shortcut{ - "repo": repo.Shortcuts(), + "repo": repo.Shortcuts(), "issue": issue.Shortcuts(), "member": member.Shortcuts(), "milestone": milestone.Shortcuts(), + "pipeline": pipeline.Shortcuts(), "pr": pr.Shortcuts(), - "release": release.Shortcuts(), - "branch": branch.Shortcuts(), - "org": org.Shortcuts(), - "user": user.Shortcuts(), - "search": search.Shortcuts(), - "ci": ci.Shortcuts(), - "compare": compare.Shortcuts(), - "webhook": webhook.Shortcuts(), - "workflow": workflow.Shortcuts(), + "release": release.Shortcuts(), + "branch": branch.Shortcuts(), + "org": org.Shortcuts(), + "user": user.Shortcuts(), + "search": search.Shortcuts(), + "ci": ci.Shortcuts(), + "compare": compare.Shortcuts(), + "webhook": webhook.Shortcuts(), + "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ - "repo": "Repository operations", + "repo": "Repository operations", "issue": "Issue operations", "member": "Repository member operations", "milestone": "Milestone operations", + "pipeline": "Pipeline operations", "pr": "Pull request operations", - "release": "Release operations", - "branch": "Branch operations", - "org": "Organization operations", - "user": "User operations", - "search": "Search operations", - "ci": "CI/CD operations", - "compare": "Compare branches, tags, or commits", - "webhook": "Webhook operations", - "workflow": "AI agent workflow analysis", + "release": "Release operations", + "branch": "Branch operations", + "org": "Organization operations", + "user": "User operations", + "search": "Search operations", + "ci": "CI/CD operations", + "compare": "Compare branches, tags, or commits", + "webhook": "Webhook operations", + "workflow": "AI agent workflow analysis", } for name, shortcuts := range groups { diff --git a/skills/README.md b/skills/README.md index b4c6998..c7ed276 100644 --- a/skills/README.md +++ b/skills/README.md @@ -108,6 +108,8 @@ skills/ │ ├── SKILL.md # CI 操作指南 │ └── examples/ │ └── ci-workflow.md # CI 工作流 +├── gitlink-pipeline/ # 流水线工作流 +│ └── SKILL.md # Pipeline 操作指南 ├── gitlink-pm/ # 项目管理 │ └── SKILL.md # PM 操作指南 └── gitlink-workflow/ # AI 自动化工作流 @@ -138,6 +140,7 @@ 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-pm** | 项目管理 | 通过 Raw API 访问 | | **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes | @@ -238,6 +241,7 @@ gitlink-cli org +info -i Gitlink **发布和搜索**: - [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) - 搜索命令 **组织和用户**: diff --git a/skills/gitlink-pipeline/SKILL.md b/skills/gitlink-pipeline/SKILL.md new file mode 100644 index 0000000..397ac10 --- /dev/null +++ b/skills/gitlink-pipeline/SKILL.md @@ -0,0 +1,70 @@ +--- +name: gitlink-pipeline +version: 1.0.0 +description: "Pipeline workflow operations: list pipelines, run workflows, inspect runs, fetch logs, save visual YAML, enable, disable, and delete pipelines." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli pipeline --help" +--- + +# gitlink-pipeline + +Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) first for authentication, global flags, and API behavior. + +**CRITICAL**: confirm user intent before running write or delete commands. Prefer `--dry-run` before `pipeline +run`, `pipeline +save-yaml`, `pipeline +enable`, `pipeline +disable`, or `pipeline +delete`. + +## Shortcuts + +| Shortcut | Description | +|----------|-------------| +| `pipeline +list` | List platform pipelines | +| `pipeline +runs` | List repository pipeline run records | +| `pipeline +run` | Start a pipeline workflow | +| `pipeline +view` | Show pipeline details | +| `pipeline +delete` | Delete a pipeline | +| `pipeline +save-yaml` | Save a visual pipeline YAML graph | +| `pipeline +enable` | Enable a pipeline workflow | +| `pipeline +disable` | Disable a pipeline workflow | +| `pipeline +logs` | Query pipeline run logs | +| `pipeline +results` | Show pipeline run report results | + +## Examples + +```bash +# List platform pipelines +gitlink-cli pipeline +list --owner-id 123 --page 1 --limit 20 + +# List and run workflow records for a repository +gitlink-cli pipeline +runs --owner Gitlink --repo forgeplus --ref master --workflow build.yml +gitlink-cli pipeline +run --owner Gitlink --repo forgeplus --ref master --workflow build.yml --dry-run + +# Inspect a pipeline and its logs +gitlink-cli pipeline +view --owner Gitlink --repo forgeplus --id 7 +gitlink-cli pipeline +logs --owner Gitlink --repo forgeplus --run-id 99 --id 7 --index 43 +gitlink-cli pipeline +results --owner Gitlink --repo forgeplus --run-id 99 + +# Save a visual pipeline graph +gitlink-cli pipeline +save-yaml --owner Gitlink --repo forgeplus \ + --id 7 --pipeline-json '{"nodes":[]}' --dry-run + +# Toggle or delete pipeline workflows +gitlink-cli pipeline +enable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run +gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow build.yml --dry-run +gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run +``` + +## API Mapping + +| Shortcut | Method | API path | +|----------|--------|----------| +| `pipeline +list` | GET | `/api/pm/pipelines.json` | +| `pipeline +runs` | GET | `/api/v1/{owner}/{repo}/actions/runs.json` | +| `pipeline +run` | POST | `/api/v1/{owner}/{repo}/actions/runs.json` | +| `pipeline +view` | GET | `/api/v1/{owner}/{repo}/pipelines/{id}.json` | +| `pipeline +delete` | DELETE | `/api/v1/{owner}/{repo}/pipelines/{id}.json` | +| `pipeline +save-yaml` | POST | `/api/v1/{owner}/{repo}/pipelines/save_yaml` | +| `pipeline +enable` | POST | `/api/v1/{owner}/{repo}/actions/enable.json` | +| `pipeline +disable` | POST | `/api/v1/{owner}/{repo}/actions/disable.json` | +| `pipeline +logs` | POST | `/api/v1/{owner}/{repo}/actions/runs/{run_id}/jobs/0` | +| `pipeline +results` | GET | `/api/v1/{owner}/{repo}/pipelines/run_results.json` |