feat(api): add --paginate and unwrap GitLink list responses in PaginateAll

Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
林晨 (Leo Cheng) 2026-07-08 12:43:33 +08:00
parent 9749a4c832
commit 5511d492cb
No known key found for this signature in database
GPG Key ID: 24FCF87A069356B9
7 changed files with 197 additions and 10 deletions

View File

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

View File

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

View File

@ -0,0 +1,7 @@
# 新增 `api --paginate` 自动翻页
`gitlink-cli api GET <PATH> --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 报错。

View File

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

View File

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

View File

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

View File

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