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

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 --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
}
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"
"regexp"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
@ -28,25 +29,28 @@ type batchRequest struct {
Path string `json:"path"`
Query map[string]interface{} `json:"query"`
Body interface{} `json:"body"`
Save map[string]string `json:"save"`
}
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"`
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"`
Save map[string]string `json:"save,omitempty" yaml:"save,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"`
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"`
Saved map[string]string `json:"saved,omitempty" yaml:"saved,omitempty"`
}
type batchSummary struct {
@ -79,7 +83,7 @@ func runAPIBatch(c *cobra.Command, batchFile string) error {
}
vars := mergeBatchVars(plan.Vars, overrides)
requests, err := renderBatchRequests(plan.Requests, vars)
requests, err := renderBatchRequestsForDryRun(plan.Requests, vars)
if err != nil {
return err
}
@ -102,17 +106,16 @@ func runAPIBatch(c *cobra.Command, batchFile string) error {
summary := batchSummary{
DryRun: false,
ContinueOnError: continueOnError,
Total: len(requests),
Total: len(plan.Requests),
Variables: sortedVars(vars),
Results: make([]batchResult, 0, len(requests)),
Results: make([]batchResult, 0, len(plan.Requests)),
}
for _, req := range requests {
result := batchResult{
Index: req.Index,
Name: req.Name,
Method: req.Method,
Path: req.Path,
for i, rawReq := range plan.Requests {
req, err := renderBatchRequest(i, rawReq, vars, false)
if err != nil {
return err
}
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++
@ -129,6 +132,25 @@ func runAPIBatch(c *cobra.Command, batchFile string) error {
result.OK = true
if env != nil {
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)
}
@ -188,46 +210,67 @@ func mergeBatchVars(planVars, overrides map[string]string) map[string]string {
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)
renderedReq, err := renderBatchRequest(i, req, vars, false)
if err != nil {
return nil, fmt.Errorf("request %d path: %w", i+1, err)
return nil, 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,
})
rendered = append(rendered, renderedReq)
}
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) {
return renderBatchQueryWithOptions(raw, vars, false)
}
func renderBatchQueryWithOptions(raw map[string]interface{}, vars map[string]string, allowMissing bool) (url.Values, error) {
if len(raw) == 0 {
return nil, nil
}
@ -238,11 +281,11 @@ func renderBatchQuery(raw map[string]interface{}, vars map[string]string) (url.V
}
sort.Strings(keys)
for _, key := range keys {
renderedKey, err := renderTemplate(key, vars)
renderedKey, err := renderTemplateWithOptions(key, vars, allowMissing)
if err != nil {
return nil, err
}
values, err := renderQueryValues(raw[key], vars)
values, err := renderQueryValuesWithOptions(raw[key], vars, allowMissing)
if err != nil {
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) {
return renderQueryValuesWithOptions(raw, vars, false)
}
func renderQueryValuesWithOptions(raw interface{}, vars map[string]string, allowMissing bool) ([]string, error) {
switch value := raw.(type) {
case nil:
return []string{""}, nil
case string:
rendered, err := renderTemplate(value, vars)
rendered, err := renderTemplateWithOptions(value, vars, allowMissing)
return []string{rendered}, err
case []interface{}:
values := make([]string, 0, len(value))
for _, item := range value {
itemValues, err := renderQueryValues(item, vars)
itemValues, err := renderQueryValuesWithOptions(item, vars, allowMissing)
if err != nil {
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) {
return renderBatchValueWithOptions(raw, vars, false)
}
func renderBatchValueWithOptions(raw interface{}, vars map[string]string, allowMissing bool) (interface{}, error) {
switch value := raw.(type) {
case nil:
return nil, nil
case string:
return renderTemplate(value, vars)
return renderTemplateWithOptions(value, vars, allowMissing)
case []interface{}:
items := make([]interface{}, 0, len(value))
for _, item := range value {
rendered, err := renderBatchValue(item, vars)
rendered, err := renderBatchValueWithOptions(item, vars, allowMissing)
if err != nil {
return nil, err
}
@ -294,11 +345,11 @@ func renderBatchValue(raw interface{}, vars map[string]string) (interface{}, err
case map[string]interface{}:
obj := make(map[string]interface{}, len(value))
for key, item := range value {
renderedKey, err := renderTemplate(key, vars)
renderedKey, err := renderTemplateWithOptions(key, vars, allowMissing)
if err != nil {
return nil, err
}
rendered, err := renderBatchValue(item, vars)
rendered, err := renderBatchValueWithOptions(item, vars, allowMissing)
if err != nil {
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) {
return renderTemplateWithOptions(value, vars, false)
}
func renderTemplateWithOptions(value string, vars map[string]string, allowMissing bool) (string, error) {
var missing []string
rendered := templatePattern.ReplaceAllStringFunc(value, func(match string) string {
parts := templatePattern.FindStringSubmatch(match)
@ -319,6 +406,9 @@ func renderTemplate(value string, vars map[string]string) (string, error) {
}
replacement, ok := vars[parts[1]]
if !ok {
if allowMissing {
return match
}
missing = append(missing, parts[1])
return match
}
@ -331,6 +421,62 @@ func renderTemplate(value string, vars map[string]string) (string, error) {
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 {
if len(vars) == 0 {
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": {
"state": "open",
"limit": 20
},
"save": {
"issue": "issues.0.id",
"subject": "data.issues.0.subject"
}
},
{
"name": "comment-issue",
"name": "comment-issue-{{issue}}",
"method": "POST",
"path": "/v1/{{owner}}/{{repo}}/issues/{{issue}}/journals",
"body": {
"notes": "批处理自动评论"
"notes": "批处理自动评论{{subject}}"
}
}
]
@ -50,10 +54,21 @@ gitlink-cli api --batch-file plan.json --continue-on-error --format json
| `requests[].path` | 是 | API 路径,可省略开头的 `/` |
| `requests[].query` | 否 | 查询参数对象,值可为字符串、数字、布尔值或数组 |
| `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`
- 写入类操作先用 `--dry-run` 检查渲染后的路径、query 和 body。
- 写入类操作先用 `--dry-run` 检查渲染后的路径、query 和 bodydry-run 会保留尚需由前序 `save` 产生的 `{{变量}}` 占位符
- `--var key=value` 可重复传入,并覆盖计划文件里的同名变量,适合在不同仓库或 Issue 上复用同一计划。
- 批处理模式不能和单次请求的 `--body`、`--body-file`、`--body-stdin`、`--query`、`--header` 混用。