From 8c92076c52403c34459992aba4d4bd56117b735e Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 14:33:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(api):=20api=20=E5=91=BD=E4=BB=A4=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20--paginate=20=E8=87=AA=E5=8A=A8=E7=BF=BB=E9=A1=B5?= =?UTF-8?q?=E5=90=88=E5=B9=B6=EF=BC=88=E5=AF=B9=E6=A0=87=20gh=20api=20--pa?= =?UTF-8?q?ginate=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/api/api.go | 42 +++++++++++++++++++++++ cmd/api/api_test.go | 57 ++++++++++++++++++++++++++++++++ internal/i18n/locales/en-US.json | 1 + internal/i18n/locales/zh-CN.json | 1 + 4 files changed, 101 insertions(+) diff --git a/cmd/api/api.go b/cmd/api/api.go index cae531a..f6f381a 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -28,6 +28,7 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command { 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 GET /:owner/:repo/issues --paginate gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}' gitlink-cli api POST /:owner/:repo/issues --body-file issue.json gitlink-cli api --batch-file plan.json --dry-run @@ -40,6 +41,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")) @@ -62,7 +64,11 @@ func validateAPIArgs(c *cobra.Command, args []string) error { func runAPI(c *cobra.Command, args []string) error { batchFile, _ := c.Flags().GetString("batch-file") + paginate, _ := c.Flags().GetBool("paginate") if batchFile != "" { + if paginate { + return fmt.Errorf("--paginate cannot be used with --batch-file") + } return runAPIBatch(c, batchFile) } @@ -94,6 +100,25 @@ func runAPI(c *cobra.Command, args []string) error { } } + if paginate { + if method != "GET" { + return fmt.Errorf("--paginate only supports GET requests, got %s", method) + } + if body != nil { + return fmt.Errorf("--paginate cannot be used with a request body") + } + 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(paginatedEnvelope(items), resolveFormat()) + } + env, err := cli.Do(method, path, body, query) if err != nil { var apiErr *client.APIError @@ -107,6 +132,23 @@ func runAPI(c *cobra.Command, args []string) error { return output.Print(env, resolveFormat()) } +// paginatedEnvelope wraps merged pages in the same shape as a single-page +// list response: {"total_count": N, "items": [...]}. +func paginatedEnvelope(items []json.RawMessage) *output.Envelope { + decoded := make([]interface{}, 0, len(items)) + for _, item := range items { + var v interface{} + if err := json.Unmarshal(item, &v); err == nil { + decoded = append(decoded, v) + } + } + data := map[string]interface{}{ + "total_count": len(decoded), + "items": decoded, + } + return output.SuccessEnvelope(data, &output.Meta{TotalCount: len(decoded)}) +} + 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..8a93ded 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -131,6 +131,63 @@ func TestRunAPIBadQuery(t *testing.T) { } } +func TestRunAPIPaginate(t *testing.T) { + setupAPITest(t, func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + w.Header().Set("Content-Type", "application/json") + switch page { + case "1": + json.NewEncoder(w).Encode(map[string]interface{}{ + "total_count": 3, + "issues": []interface{}{ + map[string]interface{}{"id": 1}, + map[string]interface{}{"id": 2}, + }, + }) + default: + json.NewEncoder(w).Encode(map[string]interface{}{ + "total_count": 3, + "issues": []interface{}{ + map[string]interface{}{"id": 3}, + }, + }) + } + }) + cmdutil.Format = "json" + + cmd := NewAPICmd() + cmd.SetArgs([]string{"GET", "/owner/repo/issues", "--paginate", "--query", "limit=2"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("runAPI paginate error: %v", err) + } +} + +func TestRunAPIPaginateRejectsNonGET(t *testing.T) { + setupAPITest(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("server should not be reached") + }) + cmdutil.Format = "json" + + cmd := NewAPICmd() + cmd.SetArgs([]string{"POST", "/owner/repo/issues", "--paginate"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for --paginate with POST") + } +} + +func TestRunAPIPaginateRejectsBatchFile(t *testing.T) { + setupAPITest(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("server should not be reached") + }) + cmdutil.Format = "json" + + cmd := NewAPICmd() + cmd.SetArgs([]string{"--batch-file", "plan.json", "--paginate"}) + if err := cmd.Execute(); err == nil { + t.Fatal("expected error for --paginate with --batch-file") + } +} + func TestRunAPIHTTPError(t *testing.T) { setupAPITest(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 49ae93c..83a373d 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -127,6 +127,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 all pages of a GET list endpoint and merge the results", "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 a257cf7..fa505e0 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -127,6 +127,7 @@ "flag.api.body_file": "从文件读取 JSON 请求体", "flag.api.body_stdin": "从标准输入读取 JSON 请求体", "flag.api.header": "附加请求头(key:value)", + "flag.api.paginate": "自动获取 GET 列表接口的全部分页并合并结果", "flag.api.query": "查询参数(key=val&key2=val2)", "flag.auth.token": "通过粘贴已有 Token 登录", "flag.branch.from": "源分支或 Commit",