diff --git a/README.md b/README.md index 797e6c7..32f8eb0 100644 --- a/README.md +++ b/README.md @@ -545,6 +545,10 @@ Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-s # With query parameters gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' + +# Batch plan with template variables and dry-run preview +gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=forgeplus --dry-run +gitlink-cli api --batch-file plan.json --continue-on-error ``` ## Global Parameters diff --git a/README.zh-CN.md b/README.zh-CN.md index 88c6119..8205d35 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -424,6 +424,10 @@ Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-s # 带查询参数 gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' + +# 使用模板变量预览批处理计划 +gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=forgeplus --dry-run +gitlink-cli api --batch-file plan.json --continue-on-error ``` ## 全局参数 diff --git a/cmd/api/api.go b/cmd/api/api.go index a9df7b1..cae531a 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -23,14 +23,16 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command { tr = translators[0] } apiCmd := &cobra.Command{ - Use: "api ", + Use: "api ( | --batch-file )", Short: tr.T("cmd.api.short"), Long: tr.T("cmd.api.long"), Example: ` gitlink-cli api GET /users/me gitlink-cli api GET /projects --query 'page=1&limit=10' gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}' - gitlink-cli api POST /:owner/:repo/issues --body-file issue.json`, - Args: cobra.ExactArgs(2), + gitlink-cli api POST /:owner/:repo/issues --body-file issue.json + gitlink-cli api --batch-file plan.json --dry-run + gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=gitlink-cli`, + Args: validateAPIArgs, RunE: runAPI, } @@ -39,11 +41,31 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command { apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin")) apiCmd.Flags().String("query", "", tr.T("flag.api.query")) apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header")) + apiCmd.Flags().String("batch-file", "", tr.T("flag.api.batch_file")) + apiCmd.Flags().Bool("dry-run", false, tr.T("flag.api.batch_dry_run")) + apiCmd.Flags().Bool("continue-on-error", false, tr.T("flag.api.batch_continue_on_error")) + apiCmd.Flags().StringArray("var", nil, tr.T("flag.api.batch_var")) return apiCmd } +func validateAPIArgs(c *cobra.Command, args []string) error { + batchFile, _ := c.Flags().GetString("batch-file") + if batchFile != "" { + if len(args) != 0 { + return fmt.Errorf("api batch mode does not accept METHOD or PATH arguments") + } + return nil + } + return cobra.ExactArgs(2)(c, args) +} + func runAPI(c *cobra.Command, args []string) error { + batchFile, _ := c.Flags().GetString("batch-file") + if batchFile != "" { + return runAPIBatch(c, batchFile) + } + method := strings.ToUpper(args[0]) path := args[1] diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index d9c32c7..dad41a8 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -34,7 +34,7 @@ func TestResolveFormat(t *testing.T) { func TestNewAPICmd(t *testing.T) { cmd := NewAPICmd() - if cmd.Use != "api " { + if cmd.Use != "api ( | --batch-file )" { t.Fatalf("Use = %q", cmd.Use) } if cmd.Short == "" { @@ -42,7 +42,7 @@ func TestNewAPICmd(t *testing.T) { } // Verify flags exist - flags := []string{"body", "query", "header"} + flags := []string{"body", "query", "header", "batch-file", "dry-run", "continue-on-error", "var"} for _, f := range flags { if cmd.Flags().Lookup(f) == nil { t.Fatalf("flag %q not found", f) @@ -201,3 +201,204 @@ func TestRunAPINoPrefix(t *testing.T) { t.Fatalf("runAPI no-prefix error: %v", err) } } + +func TestRenderBatchRequestsTemplateVars(t *testing.T) { + requests, err := renderBatchRequests([]batchRequest{ + { + Name: "comment-{{number}}", + Method: "post", + Path: "v1/{{owner}}/{{repo}}/issues/{{number}}/journals", + Query: map[string]interface{}{ + "label": []interface{}{"{{label}}", "triage"}, + "page": float64(1), + }, + Body: map[string]interface{}{ + "notes": "handled by {{actor}}", + "meta": map[string]interface{}{"repo": "{{repo}}"}, + }, + }, + }, map[string]string{ + "owner": "Gitlink", + "repo": "gitlink-cli", + "number": "42", + "label": "bug", + "actor": "bot", + }) + if err != nil { + t.Fatalf("renderBatchRequests error: %v", err) + } + if len(requests) != 1 { + t.Fatalf("len = %d, want 1", len(requests)) + } + req := requests[0] + if req.Name != "comment-42" { + t.Fatalf("Name = %q", req.Name) + } + if req.Method != "POST" { + t.Fatalf("Method = %q", req.Method) + } + if req.Path != "/v1/Gitlink/gitlink-cli/issues/42/journals" { + t.Fatalf("Path = %q", req.Path) + } + if got := req.Query["label"]; len(got) != 2 || got[0] != "bug" || got[1] != "triage" { + t.Fatalf("label query = %#v", got) + } + body := req.Body.(map[string]interface{}) + if body["notes"] != "handled by bot" { + t.Fatalf("notes = %#v", body["notes"]) + } +} + +func TestRenderBatchRequestsMissingVar(t *testing.T) { + _, err := renderBatchRequests([]batchRequest{{Method: "GET", Path: "/{{missing}}"}}, nil) + if err == nil { + t.Fatal("expected missing variable error") + } +} + +func TestRunAPIBatchDryRunDoesNotReachServer(t *testing.T) { + setupAPITest(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("dry-run should not reach server") + }) + cmdutil.Format = "json" + + plan := writeBatchPlan(t, map[string]interface{}{ + "vars": map[string]string{"owner": "Gitlink"}, + "requests": []map[string]interface{}{ + {"name": "me", "method": "GET", "path": "/users/me"}, + {"name": "repo", "method": "GET", "path": "/{{owner}}/gitlink-cli"}, + }, + }) + + cmd := NewAPICmd() + cmd.SetArgs([]string{"--batch-file", plan, "--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("dry-run batch error: %v", err) + } +} + +func TestRunAPIBatchExecutesRequestsWithOverrides(t *testing.T) { + var seen []string + var gotBody map[string]interface{} + setupAPITest(t, func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Method+" "+r.URL.String()) + switch r.URL.Path { + case "/v1/Mengz/gitlink-cli/issues.json": + if r.URL.Query().Get("state") != "open" { + t.Fatalf("state query = %q", r.URL.Query().Get("state")) + } + json.NewEncoder(w).Encode(map[string]interface{}{"issues": []interface{}{}}) + case "/v1/Mengz/gitlink-cli/issues/7/journals.json": + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + json.NewEncoder(w).Encode(map[string]interface{}{"id": 99}) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + }) + cmdutil.Format = "json" + + plan := writeBatchPlan(t, map[string]interface{}{ + "vars": map[string]string{"owner": "Gitlink", "repo": "gitlink-cli", "issue": "7"}, + "requests": []map[string]interface{}{ + { + "name": "list", + "method": "GET", + "path": "/v1/{{owner}}/{{repo}}/issues", + "query": map[string]interface{}{"state": "open"}, + }, + { + "name": "comment", + "method": "POST", + "path": "/v1/{{owner}}/{{repo}}/issues/{{issue}}/journals", + "body": map[string]interface{}{"notes": "hello {{repo}}"}, + }, + }, + }) + + cmd := NewAPICmd() + cmd.SetArgs([]string{"--batch-file", plan, "--var", "owner=Mengz"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("batch execute error: %v", err) + } + if len(seen) != 2 { + t.Fatalf("requests = %d, want 2 (%v)", len(seen), seen) + } + if gotBody["notes"] != "hello gitlink-cli" { + t.Fatalf("body notes = %#v", gotBody["notes"]) + } +} + +func TestRunAPIBatchStopsOnErrorByDefault(t *testing.T) { + var seen []string + setupAPITest(t, func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.URL.Path) + if r.URL.Path == "/fail.json" { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + }) + cmdutil.Format = "json" + + plan := writeBatchPlan(t, map[string]interface{}{ + "requests": []map[string]interface{}{ + {"method": "GET", "path": "/ok"}, + {"method": "GET", "path": "/fail"}, + {"method": "GET", "path": "/never"}, + }, + }) + + cmd := NewAPICmd() + cmd.SetArgs([]string{"--batch-file", plan}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected batch error") + } + if len(seen) != 2 { + t.Fatalf("requests = %d, want 2 (%v)", len(seen), seen) + } +} + +func TestRunAPIBatchContinueOnError(t *testing.T) { + var seen []string + setupAPITest(t, func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.URL.Path) + if r.URL.Path == "/fail.json" { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + }) + cmdutil.Format = "json" + + plan := writeBatchPlan(t, map[string]interface{}{ + "requests": []map[string]interface{}{ + {"method": "GET", "path": "/ok"}, + {"method": "GET", "path": "/fail"}, + {"method": "GET", "path": "/after"}, + }, + }) + + cmd := NewAPICmd() + cmd.SetArgs([]string{"--batch-file", plan, "--continue-on-error"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("batch should continue: %v", err) + } + if len(seen) != 3 { + t.Fatalf("requests = %d, want 3 (%v)", len(seen), seen) + } +} + +func writeBatchPlan(t *testing.T, payload interface{}) string { + t.Helper() + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal plan: %v", err) + } + path := filepath.Join(t.TempDir(), "plan.json") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatalf("write plan: %v", err) + } + return path +} diff --git a/cmd/api/batch.go b/cmd/api/batch.go new file mode 100644 index 0000000..99e47fc --- /dev/null +++ b/cmd/api/batch.go @@ -0,0 +1,351 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "regexp" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/output" +) + +type batchPlan struct { + Vars map[string]string `json:"vars"` + Requests []batchRequest `json:"requests"` +} + +type batchRequest struct { + Name string `json:"name"` + Method string `json:"method"` + Path string `json:"path"` + Query map[string]interface{} `json:"query"` + Body interface{} `json:"body"` +} + +type renderedBatchRequest struct { + Index int `json:"index" yaml:"index"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Method string `json:"method" yaml:"method"` + Path string `json:"path" yaml:"path"` + Query url.Values `json:"query,omitempty" yaml:"query,omitempty"` + Body interface{} `json:"body,omitempty" yaml:"body,omitempty"` +} + +type batchResult struct { + Index int `json:"index" yaml:"index"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Method string `json:"method" yaml:"method"` + Path string `json:"path" yaml:"path"` + OK bool `json:"ok" yaml:"ok"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` + Data interface{} `json:"data,omitempty" yaml:"data,omitempty"` +} + +type batchSummary struct { + DryRun bool `json:"dry_run" yaml:"dry_run"` + ContinueOnError bool `json:"continue_on_error" yaml:"continue_on_error"` + Total int `json:"total" yaml:"total"` + Succeeded int `json:"succeeded" yaml:"succeeded"` + Failed int `json:"failed" yaml:"failed"` + Variables map[string]string `json:"variables,omitempty" yaml:"variables,omitempty"` + Requests []renderedBatchRequest `json:"requests,omitempty" yaml:"requests,omitempty"` + Results []batchResult `json:"results,omitempty" yaml:"results,omitempty"` +} + +var templatePattern = regexp.MustCompile(`\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}`) + +func runAPIBatch(c *cobra.Command, batchFile string) error { + if hasSingleRequestInput(c) { + return fmt.Errorf("use batch flags separately from --body, --body-file, --body-stdin, --query, or --header") + } + + dryRun, _ := c.Flags().GetBool("dry-run") + continueOnError, _ := c.Flags().GetBool("continue-on-error") + overrides, err := parseBatchVars(c) + if err != nil { + return err + } + plan, err := readBatchPlan(batchFile) + if err != nil { + return err + } + + vars := mergeBatchVars(plan.Vars, overrides) + requests, err := renderBatchRequests(plan.Requests, vars) + if err != nil { + return err + } + if dryRun { + return output.Print(output.SuccessEnvelope(batchSummary{ + DryRun: true, + ContinueOnError: continueOnError, + Total: len(requests), + Variables: sortedVars(vars), + Requests: requests, + }, nil), resolveFormat()) + } + + cli, err := client.New() + if err != nil { + return err + } + cli.Debug = cmdutil.Debug + + summary := batchSummary{ + DryRun: false, + ContinueOnError: continueOnError, + Total: len(requests), + Variables: sortedVars(vars), + Results: make([]batchResult, 0, len(requests)), + } + for _, req := range requests { + result := batchResult{ + Index: req.Index, + Name: req.Name, + Method: req.Method, + Path: req.Path, + } + env, callErr := cli.Do(req.Method, req.Path, req.Body, req.Query) + if callErr != nil { + summary.Failed++ + result.OK = false + result.Error = apiBatchErrorMessage(callErr) + summary.Results = append(summary.Results, result) + if !continueOnError { + _ = output.Print(output.SuccessEnvelope(summary, nil), resolveFormat()) + return callErr + } + continue + } + summary.Succeeded++ + result.OK = true + if env != nil { + result.Data = env.Data + } + summary.Results = append(summary.Results, result) + } + + return output.Print(output.SuccessEnvelope(summary, nil), resolveFormat()) +} + +func hasSingleRequestInput(c *cobra.Command) bool { + body, _ := c.Flags().GetString("body") + bodyFile, _ := c.Flags().GetString("body-file") + bodyStdin, _ := c.Flags().GetBool("body-stdin") + query, _ := c.Flags().GetString("query") + headers, _ := c.Flags().GetStringSlice("header") + return body != "" || bodyFile != "" || bodyStdin || query != "" || len(headers) > 0 +} + +func parseBatchVars(c *cobra.Command) (map[string]string, error) { + raw, _ := c.Flags().GetStringArray("var") + vars := make(map[string]string, len(raw)) + for _, item := range raw { + key, value, ok := strings.Cut(item, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return nil, fmt.Errorf("invalid --var %q, want key=value", item) + } + vars[key] = value + } + return vars, nil +} + +func readBatchPlan(path string) (*batchPlan, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read batch file: %w", err) + } + var plan batchPlan + if err := json.Unmarshal(data, &plan); err != nil { + return nil, fmt.Errorf("invalid batch file JSON: %w", err) + } + if len(plan.Requests) == 0 { + return nil, fmt.Errorf("batch file must contain at least one request") + } + return &plan, nil +} + +func mergeBatchVars(planVars, overrides map[string]string) map[string]string { + vars := make(map[string]string, len(planVars)+len(overrides)) + for key, value := range planVars { + vars[key] = value + } + for key, value := range overrides { + vars[key] = value + } + return vars +} + +func renderBatchRequests(requests []batchRequest, vars map[string]string) ([]renderedBatchRequest, error) { + rendered := make([]renderedBatchRequest, 0, len(requests)) + for i, req := range requests { + method := strings.ToUpper(strings.TrimSpace(req.Method)) + if method == "" { + return nil, fmt.Errorf("request %d method is required", i+1) + } + path, err := renderTemplate(req.Path, vars) + if err != nil { + return nil, fmt.Errorf("request %d path: %w", i+1, err) + } + path = strings.TrimSpace(path) + if path == "" { + return nil, fmt.Errorf("request %d path is required", i+1) + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + query, err := renderBatchQuery(req.Query, vars) + if err != nil { + return nil, fmt.Errorf("request %d query: %w", i+1, err) + } + body, err := renderBatchValue(req.Body, vars) + if err != nil { + return nil, fmt.Errorf("request %d body: %w", i+1, err) + } + name, err := renderTemplate(req.Name, vars) + if err != nil { + return nil, fmt.Errorf("request %d name: %w", i+1, err) + } + rendered = append(rendered, renderedBatchRequest{ + Index: i + 1, + Name: name, + Method: method, + Path: path, + Query: query, + Body: body, + }) + } + return rendered, nil +} + +func renderBatchQuery(raw map[string]interface{}, vars map[string]string) (url.Values, error) { + if len(raw) == 0 { + return nil, nil + } + query := url.Values{} + keys := make([]string, 0, len(raw)) + for key := range raw { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + renderedKey, err := renderTemplate(key, vars) + if err != nil { + return nil, err + } + values, err := renderQueryValues(raw[key], vars) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + for _, value := range values { + query.Add(renderedKey, value) + } + } + return query, nil +} + +func renderQueryValues(raw interface{}, vars map[string]string) ([]string, error) { + switch value := raw.(type) { + case nil: + return []string{""}, nil + case string: + rendered, err := renderTemplate(value, vars) + return []string{rendered}, err + case []interface{}: + values := make([]string, 0, len(value)) + for _, item := range value { + itemValues, err := renderQueryValues(item, vars) + if err != nil { + return nil, err + } + values = append(values, itemValues...) + } + return values, nil + default: + return []string{fmt.Sprint(value)}, nil + } +} + +func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, error) { + switch value := raw.(type) { + case nil: + return nil, nil + case string: + return renderTemplate(value, vars) + case []interface{}: + items := make([]interface{}, 0, len(value)) + for _, item := range value { + rendered, err := renderBatchValue(item, vars) + if err != nil { + return nil, err + } + items = append(items, rendered) + } + return items, nil + case map[string]interface{}: + obj := make(map[string]interface{}, len(value)) + for key, item := range value { + renderedKey, err := renderTemplate(key, vars) + if err != nil { + return nil, err + } + rendered, err := renderBatchValue(item, vars) + if err != nil { + return nil, err + } + obj[renderedKey] = rendered + } + return obj, nil + default: + return raw, nil + } +} + +func renderTemplate(value string, vars map[string]string) (string, error) { + var missing []string + rendered := templatePattern.ReplaceAllStringFunc(value, func(match string) string { + parts := templatePattern.FindStringSubmatch(match) + if len(parts) != 2 { + return match + } + replacement, ok := vars[parts[1]] + if !ok { + missing = append(missing, parts[1]) + return match + } + return replacement + }) + if len(missing) > 0 { + sort.Strings(missing) + return "", fmt.Errorf("missing template variable(s): %s", strings.Join(missing, ", ")) + } + return rendered, nil +} + +func sortedVars(vars map[string]string) map[string]string { + if len(vars) == 0 { + return nil + } + copyVars := make(map[string]string, len(vars)) + for key, value := range vars { + copyVars[key] = value + } + return copyVars +} + +func apiBatchErrorMessage(err error) string { + var apiErr *client.APIError + if errors.As(err, &apiErr) { + return apiErr.Message + } + return err.Error() +} diff --git a/doc/changes/api-batch-runner.md b/doc/changes/api-batch-runner.md new file mode 100644 index 0000000..464b60b --- /dev/null +++ b/doc/changes/api-batch-runner.md @@ -0,0 +1,7 @@ +# 新增 Raw API 批处理执行器 + +`gitlink-cli api` 现在支持通过 `--batch-file` 读取 JSON 批处理计划,把多个尚未封装为 shortcut 的 GitLink API 请求组织成一次可审计的自动化执行。计划文件支持 `vars` 模板变量,`--var key=value` 可以在执行时覆盖变量,便于同一批处理流程复用到不同仓库、Issue 或分支。 + +批处理模式提供 `--dry-run` 预览渲染后的 method、path、query 和 body,不会访问远端;实际执行时会输出每一步的成功/失败、响应数据和汇总计数。默认遇到失败立即停止,传入 `--continue-on-error` 后会继续执行后续请求,适合批量巡检、批量评论、批量元数据修复等场景。 + +本次变更包含计划文件解析、模板渲染、query/body 递归替换、失败控制、结构化汇总输出、中英文帮助文案、README 示例、Skill reference 和单元测试。测试覆盖 dry-run 不发请求、变量覆盖、模板缺失报错、失败默认中断以及失败继续执行等关键行为。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index d9f3c34..7a71e48 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -90,6 +90,10 @@ "flag.api.body": "Request body (JSON string)", "flag.api.body_file": "Read request body JSON from a file", "flag.api.body_stdin": "Read request body JSON from stdin", + "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", + "flag.api.batch_dry_run": "Preview batch requests without sending remote requests", + "flag.api.batch_file": "Read an API batch plan from a JSON file", + "flag.api.batch_var": "Override a batch template variable (key=value, repeatable)", "flag.api.header": "Additional headers (key:value)", "flag.api.query": "Query parameters (key=val&key2=val2)", "flag.auth.token": "Login by pasting an existing token", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 848e605..376fcb6 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -90,6 +90,10 @@ "flag.api.body": "请求体(JSON 字符串)", "flag.api.body_file": "从文件读取 JSON 请求体", "flag.api.body_stdin": "从标准输入读取 JSON 请求体", + "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", + "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", + "flag.api.batch_file": "从 JSON 文件读取 API 批处理计划", + "flag.api.batch_var": "覆盖批处理模板变量(key=value,可重复)", "flag.api.header": "附加请求头(key:value)", "flag.api.query": "查询参数(key=val&key2=val2)", "flag.auth.token": "通过粘贴已有 Token 登录", diff --git a/skills/gitlink-shared/SKILL.md b/skills/gitlink-shared/SKILL.md index d1f9d33..9e5acdb 100644 --- a/skills/gitlink-shared/SKILL.md +++ b/skills/gitlink-shared/SKILL.md @@ -91,6 +91,7 @@ gitlink-cli auth login |------|------|------|----------| | Shortcuts | `gitlink-cli +` | `gitlink-cli repo +info` | 高频操作,推荐优先使用 | | Raw API | `gitlink-cli api ` | `gitlink-cli api GET /users/me` | Shortcuts 未覆盖的接口 | +| Raw API 批处理 | `gitlink-cli api --batch-file ` | `gitlink-cli api --batch-file plan.json --dry-run` | 对未封装接口做可审计的批量自动化 | ## GitLink API 注意事项 diff --git a/skills/gitlink-shared/references/raw-api-batch.md b/skills/gitlink-shared/references/raw-api-batch.md new file mode 100644 index 0000000..fedfaad --- /dev/null +++ b/skills/gitlink-shared/references/raw-api-batch.md @@ -0,0 +1,59 @@ +# Raw API 批处理 + +当某个 GitLink OpenAPI 尚未封装为 shortcut,但需要重复执行多步请求时,可以使用 `api --batch-file` 读取 JSON 计划文件。批处理会复用 gitlink-cli 的认证、base_url、输出格式和错误处理,并返回结构化汇总。 + +## 示例 + +```json +{ + "vars": { + "owner": "Gitlink", + "repo": "gitlink-cli", + "issue": "123" + }, + "requests": [ + { + "name": "list-open-issues", + "method": "GET", + "path": "/v1/{{owner}}/{{repo}}/issues", + "query": { + "state": "open", + "limit": 20 + } + }, + { + "name": "comment-issue", + "method": "POST", + "path": "/v1/{{owner}}/{{repo}}/issues/{{issue}}/journals", + "body": { + "notes": "批处理自动评论" + } + } + ] +} +``` + +```bash +gitlink-cli api --batch-file plan.json --dry-run +gitlink-cli api --batch-file plan.json --var issue=456 +gitlink-cli api --batch-file plan.json --continue-on-error --format json +``` + +## 字段 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `vars` | 否 | 模板变量,支持在 name/path/query/body 字符串中使用 `{{name}}` | +| `requests` | 是 | 请求数组,至少包含一个请求 | +| `requests[].name` | 否 | 步骤名称,会出现在结果汇总中 | +| `requests[].method` | 是 | HTTP 方法,如 GET、POST、PUT、PATCH、DELETE | +| `requests[].path` | 是 | API 路径,可省略开头的 `/` | +| `requests[].query` | 否 | 查询参数对象,值可为字符串、数字、布尔值或数组 | +| `requests[].body` | 否 | JSON 请求体,字符串字段会做模板替换 | + +## 注意事项 + +- 默认遇到失败会停止;需要继续执行后续步骤时传 `--continue-on-error`。 +- 写入类操作先用 `--dry-run` 检查渲染后的路径、query 和 body。 +- `--var key=value` 可重复传入,并覆盖计划文件里的同名变量,适合在不同仓库或 Issue 上复用同一计划。 +- 批处理模式不能和单次请求的 `--body`、`--body-file`、`--body-stdin`、`--query`、`--header` 混用。