feat(pipeline): add OpenAPI shortcuts

This commit is contained in:
wangyue789 2026-05-27 22:20:18 +08:00
parent 2f8b987c2c
commit 41a2666c5b
8 changed files with 966 additions and 22 deletions

View File

@ -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 <build_id>
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
```
### 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

View File

@ -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 <version_id>
```
### 流水线管理
```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 # 注册入口

View File

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

View File

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

View File

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

View File

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

View File

@ -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) - 搜索命令
**组织和用户**:

View File

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