feat(api): api 命令新增 --paginate 自动翻页合并(对标 gh api --paginate)

This commit is contained in:
Taoyouce 2026-07-07 14:33:19 +00:00
parent dfa127c27a
commit 8c92076c52
4 changed files with 101 additions and 0 deletions

View File

@ -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")

View File

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

View File

@ -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",

View File

@ -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",