feat(list): --all 自动翻页,翻页助手对齐生产 API 资源键包裹形状

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
laurencewannamaker 2026-07-05 22:05:58 +00:00
parent 9749a4c832
commit b82378bb4e
11 changed files with 368 additions and 27 deletions

View File

@ -0,0 +1,43 @@
# list 命令 --all 自动翻页
## 背景
`issue +list`、`pr +list`、`branch +list`、`release +list` 此前一次只能取一页,
用户或 AI Agent 想拿到全量列表必须手动循环 `--page`。代码中虽有 `PaginateAll`
翻页助手,但它只识别 `data` 包裹键;而 GitLink 生产 API 的列表响应实际用
资源名包裹数组(如 `{"total_count":N,"issues":[...]}`、`"pulls"`、`"branches"`、
`"releases"`),导致该助手在真实端点上退化为「单对象」返回,从未被任何命令使用。
## 变更内容
- `internal/client`:翻页助手对齐生产响应形状
- 新增 `PaginateAllKey(path, params, listKey)`:按指定资源键提取数组;
`listKey` 为空时自动探测(顶层数组 / `data` 包裹 / 唯一数组字段)。
- 遵循 `total_count`:达到总数即停止;另设最大页数护栏,防止
忽略 `page` 参数的端点造成死循环。
- `PaginateAll` 保持原签名,委托给 `PaginateAllKey`
- 四个 list 命令新增 `--all` 布尔参数(默认 false
- `issue +list --all`(合并结果同样应用 number/database_id 规范化)
- `pr +list --all`、`branch +list --all`、`release +list --all`
- 输出与单页响应同构:`{"total_count": N, "<资源名>": [...]}`。
- 中英文 i18n 新增 `flag.all` 文案。
## 命令示例
```bash
# 拉取仓库全部 open issue自动翻页合并
gitlink-cli issue +list --state open --all --format json
# 全部分支 / 全部 PR / 全部 release
gitlink-cli branch +list --all
gitlink-cli pr +list --state all --all
gitlink-cli release +list --all
```
## 测试
- `internal/client/pagination_test.go`:资源键包裹多页合并、`total_count`
截断(模拟忽略 page 的异常端点)、`data` 包裹、唯一数组字段自动探测、
单对象回退、指定键缺失回退,共 6 个用例。
- `shortcuts/issue``--all` 端到端用例验证按页请求序列与合并。
- `go test ./...`、`go vet`、`gofmt` 全部通过。

View File

@ -7,8 +7,23 @@ import (
"strconv"
)
// maxPaginationPages caps auto-pagination as a safety guard against
// endpoints that ignore the page parameter and keep returning data.
const maxPaginationPages = 1000
// PaginateAll fetches all pages and returns combined results.
// The list array is auto-detected inside the response body.
func (c *Client) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) {
return c.PaginateAllKey(path, params, "")
}
// PaginateAllKey fetches all pages, extracting the list array from the
// response field named listKey (e.g. "issues", "pulls", "branches").
// When listKey is empty the array is auto-detected: top-level arrays,
// the conventional "data" wrapper, or a unique array-valued field.
// Pagination stops when a page returns fewer items than the limit, when
// total_count (if reported) is reached, or at the safety page cap.
func (c *Client) PaginateAllKey(path string, params url.Values, listKey string) ([]json.RawMessage, error) {
if params == nil {
params = url.Values{}
}
@ -17,57 +32,91 @@ func (c *Client) PaginateAll(path string, params url.Values) ([]json.RawMessage,
}
var all []json.RawMessage
page := 1
totalCount := -1
for {
for page := 1; page <= maxPaginationPages; page++ {
params.Set("page", strconv.Itoa(page))
env, err := c.Get(path, params)
if err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("API error on page %d", page)
}
// Try to extract array from data
var items []json.RawMessage
switch data := env.Data.(type) {
case []interface{}:
for _, item := range data {
raw, _ := json.Marshal(item)
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 {
// Single object, not paginated
raw, _ := json.Marshal(data)
items, pageTotal, isList := extractListItems(env.Data, listKey)
if !isList {
if page == 1 {
raw, _ := json.Marshal(env.Data)
return []json.RawMessage{raw}, nil
}
break
}
if pageTotal >= 0 {
totalCount = pageTotal
}
if len(items) == 0 {
break
}
all = append(all, items...)
// Check if we got fewer items than limit
if totalCount >= 0 && len(all) >= totalCount {
break
}
limit, _ := strconv.Atoi(params.Get("limit"))
if len(items) < limit {
break
}
page++
}
return all, nil
}
// extractListItems locates the list array inside a decoded response body.
// It returns the items, the reported total_count (-1 when absent) and
// whether a list array was found at all.
func extractListItems(data interface{}, listKey string) ([]json.RawMessage, int, bool) {
switch v := data.(type) {
case []interface{}:
return marshalItems(v), -1, true
case map[string]interface{}:
total := -1
if tc, ok := v["total_count"].(float64); ok {
total = int(tc)
}
if listKey != "" {
if slice, ok := v[listKey].([]interface{}); ok {
return marshalItems(slice), total, true
}
return nil, total, false
}
if slice, ok := v["data"].([]interface{}); ok {
return marshalItems(slice), total, true
}
// Auto-detect: GitLink v1 list endpoints wrap the array in a
// resource-named field ({"total_count":N,"issues":[...]}).
var found []interface{}
arrays := 0
for _, val := range v {
if slice, ok := val.([]interface{}); ok {
arrays++
found = slice
}
}
if arrays == 1 {
return marshalItems(found), total, true
}
return nil, total, false
}
return nil, -1, false
}
func marshalItems(items []interface{}) []json.RawMessage {
out := make([]json.RawMessage, 0, len(items))
for _, item := range items {
raw, _ := json.Marshal(item)
out = append(out, raw)
}
return out
}

View File

@ -0,0 +1,154 @@
package client
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
)
func TestPaginateAllKeyResourceWrappedPages(t *testing.T) {
// GitLink v1 list shape: {"total_count":N, "issues":[...]}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit != 2 {
t.Fatalf("limit = %d, want 2", limit)
}
var items []map[string]interface{}
switch page {
case 1:
items = []map[string]interface{}{{"id": 1}, {"id": 2}}
case 2:
items = []map[string]interface{}{{"id": 3}}
default:
t.Fatalf("unexpected page %d", page)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"total_count": 3,
"issues": items,
})
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
params := url.Values{}
params.Set("limit", "2")
items, err := c.PaginateAllKey("/owner/repo/issues", params, "issues")
if err != nil {
t.Fatalf("PaginateAllKey: %v", err)
}
if len(items) != 3 {
t.Fatalf("len(items) = %d, want 3", len(items))
}
}
func TestPaginateAllKeyStopsAtTotalCount(t *testing.T) {
// A broken endpoint that keeps returning full pages must stop at total_count.
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"total_count": 4,
"pulls": []map[string]interface{}{{"id": 1}, {"id": 2}},
})
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
params := url.Values{}
params.Set("limit", "2")
items, err := c.PaginateAllKey("/owner/repo/pulls", params, "pulls")
if err != nil {
t.Fatalf("PaginateAllKey: %v", err)
}
if len(items) != 4 {
t.Fatalf("len(items) = %d, want 4", len(items))
}
if calls != 2 {
t.Fatalf("calls = %d, want 2", calls)
}
}
func TestPaginateAllAutoDetectsUniqueArrayField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"total_count": 1,
"branches": []map[string]interface{}{{"name": "master"}},
})
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
items, err := c.PaginateAll("/owner/repo/branches", nil)
if err != nil {
t.Fatalf("PaginateAll: %v", err)
}
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
}
func TestPaginateAllDataWrapper(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":[{"id":1}],"total_count":1}`)
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
items, err := c.PaginateAll("/things", nil)
if err != nil {
t.Fatalf("PaginateAll: %v", err)
}
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
}
func TestPaginateAllSingleObjectFallback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":42,"name":"solo"}`)
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
items, err := c.PaginateAll("/thing", nil)
if err != nil {
t.Fatalf("PaginateAll: %v", err)
}
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
var obj map[string]interface{}
if err := json.Unmarshal(items[0], &obj); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if obj["name"] != "solo" {
t.Fatalf("name = %v, want solo", obj["name"])
}
}
func TestPaginateAllKeyMissingKeyNotList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":42}`)
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
items, err := c.PaginateAllKey("/thing", nil, "issues")
if err != nil {
t.Fatalf("PaginateAllKey: %v", err)
}
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1 (single-object fallback)", len(items))
}
}

View File

@ -114,6 +114,7 @@
"error.missing_required_flag": "required flag --{name} is missing",
"error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login",
"error.unsupported_language": "unsupported language: {lang}",
"flag.all": "Fetch all pages automatically (ignores --page)",
"flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure",
"flag.api.batch_dry_run": "Preview batch requests without sending remote requests",
"flag.api.batch_file": "Read an API batch plan from a JSON file",

View File

@ -114,6 +114,7 @@
"error.missing_required_flag": "缺少必需参数 --{name}",
"error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录",
"error.unsupported_language": "不支持的语言:{lang}",
"flag.all": "自动获取全部分页(忽略 --page",
"flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求",
"flag.api.batch_dry_run": "预览批处理请求,不发送远端请求",
"flag.api.batch_file": "从 JSON 文件读取 API 批处理计划",

View File

@ -17,6 +17,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
{Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -25,6 +26,13 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if ctx.Arg("all") == "true" {
items, err := ctx.PaginateAllKey("/v1"+ctx.RepoPath()+"/branches", q, "branches")
if err != nil {
return err
}
return ctx.Output(common.NewListEnvelope("branches", items))
}
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
if err != nil {
return err

View File

@ -95,6 +95,29 @@ func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.R
return ctx.Client.PaginateAll(path, params)
}
// PaginateAllKey fetches all pages of a list endpoint whose response wraps
// the array in the field named listKey (e.g. "issues", "pulls").
func (ctx *RuntimeContext) PaginateAllKey(path string, params url.Values, listKey string) ([]json.RawMessage, error) {
return ctx.Client.PaginateAllKey(path, params, listKey)
}
// NewListEnvelope wraps combined pages in the same shape as a single-page
// response: {"total_count": N, "<listKey>": [...]}.
func NewListEnvelope(listKey string, 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),
listKey: decoded,
}
return output.SuccessEnvelope(data, &output.Meta{TotalCount: len(decoded)})
}
// Output prints the envelope in the configured format.
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
return output.Print(env, ctx.Format)

View File

@ -63,6 +63,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
{Name: "sort-direction", Usage: tr.T("flag.sort_direction")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
{Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -101,6 +102,15 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" {
q.Set("sort_direction", sortDirection)
}
if ctx.Arg("all") == "true" {
items, err := ctx.PaginateAllKey(v1RepoPath(ctx)+"/issues", q, "issues")
if err != nil {
return err
}
env := common.NewListEnvelope("issues", items)
normalizeIssueListIDs(env)
return ctx.Output(env)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return err

View File

@ -171,6 +171,42 @@ func TestIssueListStateAll(t *testing.T) {
}
}
func TestIssueListAllPaginates(t *testing.T) {
var pages []string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/owner/repo/issues.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
page := r.URL.Query().Get("page")
pages = append(pages, page)
assertEqual(t, r.URL.Query().Get("limit"), "2")
var issues []interface{}
if page == "1" {
issues = []interface{}{
map[string]interface{}{"id": float64(1), "project_issues_index": float64(11)},
map[string]interface{}{"id": float64(2), "project_issues_index": float64(12)},
}
} else {
issues = []interface{}{
map[string]interface{}{"id": float64(3), "project_issues_index": float64(13)},
}
}
writeJSON(t, w, map[string]interface{}{
"total_count": float64(3),
"issues": issues,
})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{"all": "true", "limit": "2"})
if err != nil {
t.Fatalf("list --all failed: %v", err)
}
if len(pages) != 2 || pages[0] != "1" || pages[1] != "2" {
t.Fatalf("pages requested = %v, want [1 2]", pages)
}
}
// --- create ---
func TestIssueCreate(t *testing.T) {

View File

@ -47,6 +47,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
{Name: "sort-direction", Usage: tr.T("flag.sort_direction")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
{Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -82,6 +83,13 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" {
q.Set("sort_direction", sortDirection)
}
if ctx.Arg("all") == "true" {
items, err := ctx.PaginateAllKey(v1RepoPath(ctx)+"/pulls", q, "pulls")
if err != nil {
return err
}
return ctx.Output(common.NewListEnvelope("pulls", items))
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/pulls", q)
if err != nil {
return err

View File

@ -20,6 +20,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
{Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -28,6 +29,13 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if ctx.Arg("all") == "true" {
items, err := ctx.PaginateAllKey(ctx.RepoPath()+"/releases", q, "releases")
if err != nil {
return err
}
return ctx.Output(common.NewListEnvelope("releases", items))
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q)
if err != nil {
return err