Compare commits

...

1 Commits

Author SHA1 Message Date
wangyue789 2d0972fea0 feat(api): support saved variables in batch plans 2026-06-22 12:32:48 +08:00
6 changed files with 362 additions and 66 deletions

View File

@ -695,6 +695,10 @@ Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-s
# With query parameters # With query parameters
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
# Multi-step batch plan, including saved variables from earlier responses
gitlink-cli api --batch-file plan.json --dry-run
gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=forgeplus
``` ```
## Global Parameters ## Global Parameters

View File

@ -569,6 +569,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 GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
# 多步骤批处理,可保存前序响应字段作为后续变量
gitlink-cli api --batch-file plan.json --dry-run
gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=forgeplus
``` ```
## 全局参数 ## 全局参数

View File

@ -402,3 +402,87 @@ func writeBatchPlan(t *testing.T, payload interface{}) string {
} }
return path return path
} }
func TestRunAPIBatchSavesVarsForLaterRequests(t *testing.T) {
var gotBody map[string]interface{}
var seen []string
setupAPITest(t, func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Method+" "+r.URL.Path)
switch r.URL.Path {
case "/v1/owner/repo/issues.json":
json.NewEncoder(w).Encode(map[string]interface{}{
"issues": []interface{}{
map[string]interface{}{"id": float64(42), "subject": "Saved subject"},
},
})
case "/v1/owner/repo/issues/42/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": "owner", "repo": "repo"},
"requests": []map[string]interface{}{
{
"name": "find-issue",
"method": "GET",
"path": "/v1/{{owner}}/{{repo}}/issues",
"save": map[string]string{
"issue": "issues.0.id",
"subject": "data.issues.0.subject",
},
},
{
"name": "comment-{{issue}}",
"method": "POST",
"path": "/v1/{{owner}}/{{repo}}/issues/{{issue}}/journals",
"body": map[string]interface{}{"notes": "Follow up: {{subject}}"},
},
},
})
cmd := NewAPICmd()
cmd.SetArgs([]string{"--batch-file", plan})
if err := cmd.Execute(); err != nil {
t.Fatalf("batch execute error: %v", err)
}
if len(seen) != 2 {
t.Fatalf("seen = %v, want 2 requests", seen)
}
if gotBody["notes"] != "Follow up: Saved subject" {
t.Fatalf("notes = %#v", gotBody["notes"])
}
}
func TestRunAPIBatchDryRunAllowsSavedVars(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{}{
"requests": []map[string]interface{}{
{"method": "GET", "path": "/issues", "save": map[string]string{"issue": "issues.0.id"}},
{"method": "GET", "path": "/issues/{{issue}}"},
},
})
cmd := NewAPICmd()
cmd.SetArgs([]string{"--batch-file", plan, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run with future save var failed: %v", err)
}
}
func TestExtractBatchSavedVarsErrorsOnMissingPath(t *testing.T) {
_, err := extractBatchSavedVars(map[string]interface{}{"issues": []interface{}{}}, map[string]string{"issue": "issues.0.id"})
if err == nil {
t.Fatal("expected missing path error")
}
}

View File

@ -8,6 +8,7 @@ import (
"os" "os"
"regexp" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -28,25 +29,28 @@ type batchRequest struct {
Path string `json:"path"` Path string `json:"path"`
Query map[string]interface{} `json:"query"` Query map[string]interface{} `json:"query"`
Body interface{} `json:"body"` Body interface{} `json:"body"`
Save map[string]string `json:"save"`
} }
type renderedBatchRequest struct { type renderedBatchRequest struct {
Index int `json:"index" yaml:"index"` Index int `json:"index" yaml:"index"`
Name string `json:"name,omitempty" yaml:"name,omitempty"` Name string `json:"name,omitempty" yaml:"name,omitempty"`
Method string `json:"method" yaml:"method"` Method string `json:"method" yaml:"method"`
Path string `json:"path" yaml:"path"` Path string `json:"path" yaml:"path"`
Query url.Values `json:"query,omitempty" yaml:"query,omitempty"` Query url.Values `json:"query,omitempty" yaml:"query,omitempty"`
Body interface{} `json:"body,omitempty" yaml:"body,omitempty"` Body interface{} `json:"body,omitempty" yaml:"body,omitempty"`
Save map[string]string `json:"save,omitempty" yaml:"save,omitempty"`
} }
type batchResult struct { type batchResult struct {
Index int `json:"index" yaml:"index"` Index int `json:"index" yaml:"index"`
Name string `json:"name,omitempty" yaml:"name,omitempty"` Name string `json:"name,omitempty" yaml:"name,omitempty"`
Method string `json:"method" yaml:"method"` Method string `json:"method" yaml:"method"`
Path string `json:"path" yaml:"path"` Path string `json:"path" yaml:"path"`
OK bool `json:"ok" yaml:"ok"` OK bool `json:"ok" yaml:"ok"`
Error string `json:"error,omitempty" yaml:"error,omitempty"` Error string `json:"error,omitempty" yaml:"error,omitempty"`
Data interface{} `json:"data,omitempty" yaml:"data,omitempty"` Data interface{} `json:"data,omitempty" yaml:"data,omitempty"`
Saved map[string]string `json:"saved,omitempty" yaml:"saved,omitempty"`
} }
type batchSummary struct { type batchSummary struct {
@ -79,7 +83,7 @@ func runAPIBatch(c *cobra.Command, batchFile string) error {
} }
vars := mergeBatchVars(plan.Vars, overrides) vars := mergeBatchVars(plan.Vars, overrides)
requests, err := renderBatchRequests(plan.Requests, vars) requests, err := renderBatchRequestsForDryRun(plan.Requests, vars)
if err != nil { if err != nil {
return err return err
} }
@ -102,17 +106,16 @@ func runAPIBatch(c *cobra.Command, batchFile string) error {
summary := batchSummary{ summary := batchSummary{
DryRun: false, DryRun: false,
ContinueOnError: continueOnError, ContinueOnError: continueOnError,
Total: len(requests), Total: len(plan.Requests),
Variables: sortedVars(vars), Variables: sortedVars(vars),
Results: make([]batchResult, 0, len(requests)), Results: make([]batchResult, 0, len(plan.Requests)),
} }
for _, req := range requests { for i, rawReq := range plan.Requests {
result := batchResult{ req, err := renderBatchRequest(i, rawReq, vars, false)
Index: req.Index, if err != nil {
Name: req.Name, return err
Method: req.Method,
Path: req.Path,
} }
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) env, callErr := cli.Do(req.Method, req.Path, req.Body, req.Query)
if callErr != nil { if callErr != nil {
summary.Failed++ summary.Failed++
@ -129,6 +132,25 @@ func runAPIBatch(c *cobra.Command, batchFile string) error {
result.OK = true result.OK = true
if env != nil { if env != nil {
result.Data = env.Data result.Data = env.Data
if len(req.Save) > 0 {
saved, err := extractBatchSavedVars(env.Data, req.Save)
if err != nil {
summary.Succeeded--
summary.Failed++
result.OK = false
result.Error = err.Error()
summary.Results = append(summary.Results, result)
if !continueOnError {
_ = output.Print(output.SuccessEnvelope(summary, nil), resolveFormat())
return err
}
continue
}
for key, value := range saved {
vars[key] = value
}
result.Saved = saved
}
} }
summary.Results = append(summary.Results, result) summary.Results = append(summary.Results, result)
} }
@ -188,46 +210,67 @@ func mergeBatchVars(planVars, overrides map[string]string) map[string]string {
func renderBatchRequests(requests []batchRequest, vars map[string]string) ([]renderedBatchRequest, error) { func renderBatchRequests(requests []batchRequest, vars map[string]string) ([]renderedBatchRequest, error) {
rendered := make([]renderedBatchRequest, 0, len(requests)) rendered := make([]renderedBatchRequest, 0, len(requests))
for i, req := range requests { for i, req := range requests {
method := strings.ToUpper(strings.TrimSpace(req.Method)) renderedReq, err := renderBatchRequest(i, req, vars, false)
if method == "" {
return nil, fmt.Errorf("request %d method is required", i+1)
}
path, err := renderTemplate(req.Path, vars)
if err != nil { if err != nil {
return nil, fmt.Errorf("request %d path: %w", i+1, err) return nil, err
} }
path = strings.TrimSpace(path) rendered = append(rendered, renderedReq)
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 return rendered, nil
} }
func renderBatchRequestsForDryRun(requests []batchRequest, vars map[string]string) ([]renderedBatchRequest, error) {
rendered := make([]renderedBatchRequest, 0, len(requests))
for i, req := range requests {
renderedReq, err := renderBatchRequest(i, req, vars, true)
if err != nil {
return nil, err
}
rendered = append(rendered, renderedReq)
}
return rendered, nil
}
func renderBatchRequest(i int, req batchRequest, vars map[string]string, allowMissing bool) (renderedBatchRequest, error) {
method := strings.ToUpper(strings.TrimSpace(req.Method))
if method == "" {
return renderedBatchRequest{}, fmt.Errorf("request %d method is required", i+1)
}
path, err := renderTemplateWithOptions(req.Path, vars, allowMissing)
if err != nil {
return renderedBatchRequest{}, fmt.Errorf("request %d path: %w", i+1, err)
}
path = strings.TrimSpace(path)
if path == "" {
return renderedBatchRequest{}, fmt.Errorf("request %d path is required", i+1)
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
query, err := renderBatchQueryWithOptions(req.Query, vars, allowMissing)
if err != nil {
return renderedBatchRequest{}, fmt.Errorf("request %d query: %w", i+1, err)
}
body, err := renderBatchValueWithOptions(req.Body, vars, allowMissing)
if err != nil {
return renderedBatchRequest{}, fmt.Errorf("request %d body: %w", i+1, err)
}
name, err := renderTemplateWithOptions(req.Name, vars, allowMissing)
if err != nil {
return renderedBatchRequest{}, fmt.Errorf("request %d name: %w", i+1, err)
}
save, err := renderBatchSave(req.Save, vars, allowMissing)
if err != nil {
return renderedBatchRequest{}, fmt.Errorf("request %d save: %w", i+1, err)
}
return renderedBatchRequest{Index: i + 1, Name: name, Method: method, Path: path, Query: query, Body: body, Save: save}, nil
}
func renderBatchQuery(raw map[string]interface{}, vars map[string]string) (url.Values, error) { func renderBatchQuery(raw map[string]interface{}, vars map[string]string) (url.Values, error) {
return renderBatchQueryWithOptions(raw, vars, false)
}
func renderBatchQueryWithOptions(raw map[string]interface{}, vars map[string]string, allowMissing bool) (url.Values, error) {
if len(raw) == 0 { if len(raw) == 0 {
return nil, nil return nil, nil
} }
@ -238,11 +281,11 @@ func renderBatchQuery(raw map[string]interface{}, vars map[string]string) (url.V
} }
sort.Strings(keys) sort.Strings(keys)
for _, key := range keys { for _, key := range keys {
renderedKey, err := renderTemplate(key, vars) renderedKey, err := renderTemplateWithOptions(key, vars, allowMissing)
if err != nil { if err != nil {
return nil, err return nil, err
} }
values, err := renderQueryValues(raw[key], vars) values, err := renderQueryValuesWithOptions(raw[key], vars, allowMissing)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", key, err) return nil, fmt.Errorf("%s: %w", key, err)
} }
@ -254,16 +297,20 @@ func renderBatchQuery(raw map[string]interface{}, vars map[string]string) (url.V
} }
func renderQueryValues(raw interface{}, vars map[string]string) ([]string, error) { func renderQueryValues(raw interface{}, vars map[string]string) ([]string, error) {
return renderQueryValuesWithOptions(raw, vars, false)
}
func renderQueryValuesWithOptions(raw interface{}, vars map[string]string, allowMissing bool) ([]string, error) {
switch value := raw.(type) { switch value := raw.(type) {
case nil: case nil:
return []string{""}, nil return []string{""}, nil
case string: case string:
rendered, err := renderTemplate(value, vars) rendered, err := renderTemplateWithOptions(value, vars, allowMissing)
return []string{rendered}, err return []string{rendered}, err
case []interface{}: case []interface{}:
values := make([]string, 0, len(value)) values := make([]string, 0, len(value))
for _, item := range value { for _, item := range value {
itemValues, err := renderQueryValues(item, vars) itemValues, err := renderQueryValuesWithOptions(item, vars, allowMissing)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -276,15 +323,19 @@ func renderQueryValues(raw interface{}, vars map[string]string) ([]string, error
} }
func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, error) { func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, error) {
return renderBatchValueWithOptions(raw, vars, false)
}
func renderBatchValueWithOptions(raw interface{}, vars map[string]string, allowMissing bool) (interface{}, error) {
switch value := raw.(type) { switch value := raw.(type) {
case nil: case nil:
return nil, nil return nil, nil
case string: case string:
return renderTemplate(value, vars) return renderTemplateWithOptions(value, vars, allowMissing)
case []interface{}: case []interface{}:
items := make([]interface{}, 0, len(value)) items := make([]interface{}, 0, len(value))
for _, item := range value { for _, item := range value {
rendered, err := renderBatchValue(item, vars) rendered, err := renderBatchValueWithOptions(item, vars, allowMissing)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -294,11 +345,11 @@ func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, err
case map[string]interface{}: case map[string]interface{}:
obj := make(map[string]interface{}, len(value)) obj := make(map[string]interface{}, len(value))
for key, item := range value { for key, item := range value {
renderedKey, err := renderTemplate(key, vars) renderedKey, err := renderTemplateWithOptions(key, vars, allowMissing)
if err != nil { if err != nil {
return nil, err return nil, err
} }
rendered, err := renderBatchValue(item, vars) rendered, err := renderBatchValueWithOptions(item, vars, allowMissing)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -310,7 +361,43 @@ func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, err
} }
} }
func renderBatchSave(raw map[string]string, vars map[string]string, allowMissing bool) (map[string]string, error) {
if len(raw) == 0 {
return nil, nil
}
save := make(map[string]string, len(raw))
keys := make([]string, 0, len(raw))
for key := range raw {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
renderedKey, err := renderTemplateWithOptions(key, vars, allowMissing)
if err != nil {
return nil, err
}
renderedKey = strings.TrimSpace(renderedKey)
if renderedKey == "" {
return nil, fmt.Errorf("save variable name cannot be empty")
}
renderedPath, err := renderTemplateWithOptions(raw[key], vars, allowMissing)
if err != nil {
return nil, err
}
renderedPath = strings.TrimSpace(renderedPath)
if renderedPath == "" {
return nil, fmt.Errorf("save path for %q cannot be empty", renderedKey)
}
save[renderedKey] = renderedPath
}
return save, nil
}
func renderTemplate(value string, vars map[string]string) (string, error) { func renderTemplate(value string, vars map[string]string) (string, error) {
return renderTemplateWithOptions(value, vars, false)
}
func renderTemplateWithOptions(value string, vars map[string]string, allowMissing bool) (string, error) {
var missing []string var missing []string
rendered := templatePattern.ReplaceAllStringFunc(value, func(match string) string { rendered := templatePattern.ReplaceAllStringFunc(value, func(match string) string {
parts := templatePattern.FindStringSubmatch(match) parts := templatePattern.FindStringSubmatch(match)
@ -319,6 +406,9 @@ func renderTemplate(value string, vars map[string]string) (string, error) {
} }
replacement, ok := vars[parts[1]] replacement, ok := vars[parts[1]]
if !ok { if !ok {
if allowMissing {
return match
}
missing = append(missing, parts[1]) missing = append(missing, parts[1])
return match return match
} }
@ -331,6 +421,62 @@ func renderTemplate(value string, vars map[string]string) (string, error) {
return rendered, nil return rendered, nil
} }
func extractBatchSavedVars(data interface{}, save map[string]string) (map[string]string, error) {
if len(save) == 0 {
return nil, nil
}
saved := make(map[string]string, len(save))
keys := make([]string, 0, len(save))
for key := range save {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
value, err := extractBatchPath(data, save[key])
if err != nil {
return nil, fmt.Errorf("save %s from %s: %w", key, save[key], err)
}
saved[key] = fmt.Sprint(value)
}
return saved, nil
}
func extractBatchPath(data interface{}, path string) (interface{}, error) {
current := data
parts := strings.Split(path, ".")
if len(parts) > 0 && parts[0] == "data" {
parts = parts[1:]
}
for _, part := range parts {
if part == "" {
return nil, fmt.Errorf("empty path segment")
}
switch value := current.(type) {
case map[string]interface{}:
next, ok := value[part]
if !ok {
return nil, fmt.Errorf("field %q not found", part)
}
current = next
case []interface{}:
index, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("segment %q is not an array index", part)
}
if index < 0 || index >= len(value) {
return nil, fmt.Errorf("array index %d out of range", index)
}
current = value[index]
default:
return nil, fmt.Errorf("cannot descend into %T", current)
}
}
if current == nil {
return nil, fmt.Errorf("value is null")
}
return current, nil
}
func sortedVars(vars map[string]string) map[string]string { func sortedVars(vars map[string]string) map[string]string {
if len(vars) == 0 { if len(vars) == 0 {
return nil return nil

View File

@ -0,0 +1,43 @@
# API Batch Saved Variables
## Summary
Enhances `gitlink-cli api --batch-file` so a request can save fields from its response and reuse them in later requests.
A batch request can now include:
```json
{
"name": "find-issue",
"method": "GET",
"path": "/v1/{{owner}}/{{repo}}/issues",
"save": {
"issue": "issues.0.id",
"subject": "data.issues.0.subject"
}
}
```
Later requests can reference `{{issue}}` and `{{subject}}` in `name`, `path`, `query`, `body`, and `save` fields.
## JSON path syntax
- Dot-separated object fields: `issues.0.id`
- Array indexes: `issues.0.id`
- Optional leading `data.` is accepted for readability: `data.issues.0.id`
## Safety and behavior
- `--dry-run` now tolerates variables that will be produced by earlier `save` entries, preserving unresolved `{{var}}` placeholders in the preview.
- If a save path is missing during execution, the batch result records the error and obeys `--continue-on-error`.
- Each result includes a `saved` map when variables were extracted successfully.
## Validation
```bash
git diff --check
GOPROXY=https://goproxy.cn,direct go test ./cmd/api
go vet ./cmd/api
GOPROXY=https://goproxy.cn,direct go test ./...
go vet ./...
```

View File

@ -19,14 +19,18 @@
"query": { "query": {
"state": "open", "state": "open",
"limit": 20 "limit": 20
},
"save": {
"issue": "issues.0.id",
"subject": "data.issues.0.subject"
} }
}, },
{ {
"name": "comment-issue", "name": "comment-issue-{{issue}}",
"method": "POST", "method": "POST",
"path": "/v1/{{owner}}/{{repo}}/issues/{{issue}}/journals", "path": "/v1/{{owner}}/{{repo}}/issues/{{issue}}/journals",
"body": { "body": {
"notes": "批处理自动评论" "notes": "批处理自动评论{{subject}}"
} }
} }
] ]
@ -50,10 +54,21 @@ gitlink-cli api --batch-file plan.json --continue-on-error --format json
| `requests[].path` | 是 | API 路径,可省略开头的 `/` | | `requests[].path` | 是 | API 路径,可省略开头的 `/` |
| `requests[].query` | 否 | 查询参数对象,值可为字符串、数字、布尔值或数组 | | `requests[].query` | 否 | 查询参数对象,值可为字符串、数字、布尔值或数组 |
| `requests[].body` | 否 | JSON 请求体,字符串字段会做模板替换 | | `requests[].body` | 否 | JSON 请求体,字符串字段会做模板替换 |
| `requests[].save` | 否 | 将响应字段保存为后续模板变量,格式为 `{ "变量名": "字段路径" }` |
## save 字段路径
`save` 支持简单点号路径:
- 对象字段:`issues.0.id`
- 数组索引:`issues.0.id`
- 可选 `data.` 前缀:`data.issues.0.id`
保存成功后,后续请求可以在 `name`、`path`、`query`、`body` 和 `save` 中使用 `{{变量名}}`
## 注意事项 ## 注意事项
- 默认遇到失败会停止;需要继续执行后续步骤时传 `--continue-on-error` - 默认遇到失败会停止;需要继续执行后续步骤时传 `--continue-on-error`
- 写入类操作先用 `--dry-run` 检查渲染后的路径、query 和 body。 - 写入类操作先用 `--dry-run` 检查渲染后的路径、query 和 bodydry-run 会保留尚需由前序 `save` 产生的 `{{变量}}` 占位符
- `--var key=value` 可重复传入,并覆盖计划文件里的同名变量,适合在不同仓库或 Issue 上复用同一计划。 - `--var key=value` 可重复传入,并覆盖计划文件里的同名变量,适合在不同仓库或 Issue 上复用同一计划。
- 批处理模式不能和单次请求的 `--body`、`--body-file`、`--body-stdin`、`--query`、`--header` 混用。 - 批处理模式不能和单次请求的 `--body`、`--body-file`、`--body-stdin`、`--query`、`--header` 混用。