diff --git a/cmd/api/api.go b/cmd/api/api.go index cae531a..4dcc772 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -40,6 +40,7 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command { apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file")) apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin")) apiCmd.Flags().String("query", "", tr.T("flag.api.query")) + apiCmd.Flags().Bool("paginate", false, tr.T("flag.api.paginate")) 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")) @@ -94,6 +95,10 @@ func runAPI(c *cobra.Command, args []string) error { } } + if paginate, _ := c.Flags().GetBool("paginate"); paginate { + return runAPIPaginate(cli, method, path, query) + } + env, err := cli.Do(method, path, body, query) if err != nil { var apiErr *client.APIError @@ -107,6 +112,27 @@ func runAPI(c *cobra.Command, args []string) error { return output.Print(env, resolveFormat()) } +// runAPIPaginate walks every page and prints the concatenated items as one array. +// PaginateAll drives GET only, so a non-GET method must fail loudly rather than +// silently degrade. +func runAPIPaginate(cli *client.Client, method, path string, query url.Values) error { + if method != "GET" { + return fmt.Errorf("--paginate only supports GET requests, got %s", method) + } + + items, err := cli.PaginateAll(path, query) + if err != nil { + var apiErr *client.APIError + if errors.As(err, &apiErr) { + errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "") + return output.Print(errEnv, resolveFormat()) + } + return err + } + + return output.Print(output.SuccessEnvelope(items, nil), resolveFormat()) +} + func readJSONBody(c *cobra.Command) (interface{}, error) { bodyStr, _ := c.Flags().GetString("body") bodyFile, _ := c.Flags().GetString("body-file") diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index dad41a8..cb47a70 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -1,7 +1,9 @@ package api import ( + "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "os" @@ -42,7 +44,7 @@ func TestNewAPICmd(t *testing.T) { } // Verify flags exist - flags := []string{"body", "query", "header", "batch-file", "dry-run", "continue-on-error", "var"} + flags := []string{"body", "query", "paginate", "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) @@ -390,6 +392,94 @@ func TestRunAPIBatchContinueOnError(t *testing.T) { } } +// captureStdout redirects os.Stdout while fn runs, since output.Print writes there directly. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + done := make(chan string, 1) + go func() { + var buf bytes.Buffer + io.Copy(&buf, r) + done <- buf.String() + }() + fn() + w.Close() + os.Stdout = old + return <-done +} + +type paginateEnvelope struct { + OK bool `json:"ok"` + Data []map[string]interface{} `json:"data"` +} + +func TestRunAPIPaginateCombinesPages(t *testing.T) { + // Real GitLink list shape wraps the array under a resource key, not "data". + var pages []string + setupAPITest(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/owner/repo/issues.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + page := r.URL.Query().Get("page") + pages = append(pages, page) + w.Header().Set("Content-Type", "application/json") + switch page { + case "1": + w.Write([]byte(`{"total_count":3,"issues":[{"id":1},{"id":2}]}`)) + case "2": + w.Write([]byte(`{"total_count":3,"issues":[{"id":3}]}`)) + default: + t.Fatalf("unexpected page: %s", page) + } + }) + cmdutil.Format = "json" + + out := captureStdout(t, func() { + cmd := NewAPICmd() + cmd.SetArgs([]string{"GET", "/repos/owner/repo/issues", "--paginate", "--query", "limit=2"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("paginate error: %v", err) + } + }) + + var env paginateEnvelope + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("unmarshal output %q: %v", out, err) + } + if !env.OK { + t.Fatalf("expected ok=true, got %s", out) + } + if len(env.Data) != 3 { + t.Fatalf("expected 3 combined items, got %d (%s)", len(env.Data), out) + } + for i, want := range []float64{1, 2, 3} { + if env.Data[i]["id"] != want { + t.Fatalf("item[%d].id = %v, want %v", i, env.Data[i]["id"], want) + } + } + if len(pages) != 2 || pages[0] != "1" || pages[1] != "2" { + t.Fatalf("expected pages [1 2], got %v", pages) + } +} + +func TestRunAPIPaginateRejectsNonGet(t *testing.T) { + setupAPITest(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("non-GET paginate should not reach server") + }) + cmdutil.Format = "json" + + cmd := NewAPICmd() + cmd.SetArgs([]string{"POST", "/items", "--paginate"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for --paginate with non-GET method") + } +} + func writeBatchPlan(t *testing.T, payload interface{}) string { t.Helper() data, err := json.Marshal(payload) diff --git a/doc/changes/api-paginate.md b/doc/changes/api-paginate.md new file mode 100644 index 0000000..5a28fa2 --- /dev/null +++ b/doc/changes/api-paginate.md @@ -0,0 +1,7 @@ +# 新增 `api --paginate` 自动翻页 + +`gitlink-cli api GET --paginate` 对齐 `gh api --paginate`:自动逐页抓取并把所有条目拼接成一个数组输出,免去手动传 `page`/`limit` 逐页拉取。仅支持 GET,其它方法会明确报错而不是静默退化。 + +配套修复了 `PaginateAll` 无法解包真实 GitLink 列表响应的问题。此前它只认顶层裸数组或 `data` 键下的数组,而 GitLink 列表接口把数组包在资源专属键下(`{"total_count":N,"pulls":[...]}`、`{"issues":[...]}`、`{"branches":[...]}` 等),这类响应会被当成单个对象直接返回、根本不翻页。现在解析顺序为:优先取 `data` 数组;否则取 map 中唯一的数组字段(覆盖 pulls/issues/branches/labels 等);无数组字段或存在多个数组字段(歧义)时,保留“单对象作为单元素返回”的旧行为。短页终止(本页条目数小于 limit 即停止)与既有的裸数组、`data` 包裹用例保持不变。 + +本次变更包含 `PaginateAll` 解包逻辑修复、`--paginate` 标志与 `runAPIPaginate` 路由、中英文帮助文案,以及单元测试:client 层验证 `{total_count, issues:[...]}` 两页拼接并正确解包 `issues`;cmd 层端到端验证 `--paginate` 合并多页输出与非 GET 报错。 diff --git a/internal/client/client_test.go b/internal/client/client_test.go index c46226b..ad2e575 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -547,6 +547,48 @@ func TestPaginateAllNotOK(t *testing.T) { } } +func TestPaginateAllGitLinkWrapperShape(t *testing.T) { + // GitLink list endpoints wrap the array under a resource-specific key + // ({"total_count":N,"issues":[...]}) rather than the generic "data" key. + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("page") { + case "1": + w.Write([]byte(`{"total_count":3,"issues":[{"id":1},{"id":2}]}`)) + case "2": + w.Write([]byte(`{"total_count":3,"issues":[{"id":3}]}`)) + default: + t.Fatalf("unexpected page: %s", r.URL.Query().Get("page")) + } + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + params := url.Values{} + params.Set("limit", "2") + items, err := c.PaginateAll("/repos/owner/repo/issues", params) + if err != nil { + t.Fatalf("PaginateAll error: %v", err) + } + if callCount != 2 { + t.Fatalf("expected 2 API calls, got %d", callCount) + } + if len(items) != 3 { + t.Fatalf("expected 3 combined items, got %d", len(items)) + } + for i, want := range []float64{1, 2, 3} { + var obj map[string]interface{} + if err := json.Unmarshal(items[i], &obj); err != nil { + t.Fatalf("unmarshal item %d: %v", i, err) + } + if obj["id"] != want { + t.Fatalf("item[%d].id = %v, want %v", i, obj["id"], want) + } + } +} + func TestShouldAppendJSONSuffixSkipsRawFilePath(t *testing.T) { if shouldAppendJSONSuffix("/Gitlink/forgeplus/raw/master/README.md") { t.Fatal("raw file path should not get .json suffix") diff --git a/internal/client/pagination.go b/internal/client/pagination.go index 6eafc99..139f563 100644 --- a/internal/client/pagination.go +++ b/internal/client/pagination.go @@ -39,19 +39,16 @@ func (c *Client) PaginateAll(path string, params url.Values) ([]json.RawMessage, items = append(items, raw) } case map[string]interface{}: - // Some endpoints wrap in {"data": [...], "total_count": N} - if arr, ok := data["data"]; ok { - if slice, ok := arr.([]interface{}); ok { - for _, item := range slice { - raw, _ := json.Marshal(item) - items = append(items, raw) - } - } - } else { + arr, ok := listArray(data) + if !ok { // Single object, not paginated raw, _ := json.Marshal(data) return []json.RawMessage{raw}, nil } + for _, item := range arr { + raw, _ := json.Marshal(item) + items = append(items, raw) + } } if len(items) == 0 { @@ -71,3 +68,26 @@ func (c *Client) PaginateAll(path string, params url.Values) ([]json.RawMessage, return all, nil } + +// listArray resolves the item array of one wrapped list page. GitLink wraps the +// array under the generic "data" key on some endpoints and under a +// resource-specific key on others ({"pulls":[...]}, {"issues":[...]}, +// {"branches":[...]}, ...), so prefer "data" and otherwise accept the sole +// array-valued field. A map with no array field — or several, which is +// ambiguous — is not a list page, so ok is false. +func listArray(data map[string]interface{}) (arr []interface{}, ok bool) { + if d, isArr := data["data"].([]interface{}); isArr { + return d, true + } + for _, v := range data { + slice, isArr := v.([]interface{}) + if !isArr { + continue + } + if ok { + return nil, false + } + arr, ok = slice, true + } + return arr, ok +} diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..1adae5b 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -122,6 +122,7 @@ "flag.api.body_file": "Read request body JSON from a file", "flag.api.body_stdin": "Read request body JSON from stdin", "flag.api.header": "Additional headers (key:value)", + "flag.api.paginate": "Fetch every page of results and output as one combined array", "flag.api.query": "Query parameters (key=val&key2=val2)", "flag.auth.token": "Login by pasting an existing token", "flag.branch.from": "Source branch or commit", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..7f8cff0 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -122,6 +122,7 @@ "flag.api.body_file": "从文件读取 JSON 请求体", "flag.api.body_stdin": "从标准输入读取 JSON 请求体", "flag.api.header": "附加请求头(key:value)", + "flag.api.paginate": "抓取所有分页结果并合并为一个数组输出", "flag.api.query": "查询参数(key=val&key2=val2)", "flag.auth.token": "通过粘贴已有 Token 登录", "flag.branch.from": "源分支或 Commit",