From b82378bb4e55f84e718b32ea41815eea0342aa7b Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 22:05:58 +0000 Subject: [PATCH 01/19] =?UTF-8?q?feat(list):=20--all=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=BF=BB=E9=A1=B5=EF=BC=8C=E7=BF=BB=E9=A1=B5=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E7=94=9F=E4=BA=A7=20API=20=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E9=94=AE=E5=8C=85=E8=A3=B9=E5=BD=A2=E7=8A=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/list-all-pagination.md | 43 ++++++++ internal/client/pagination.go | 103 ++++++++++++++----- internal/client/pagination_test.go | 154 +++++++++++++++++++++++++++++ internal/i18n/locales/en-US.json | 1 + internal/i18n/locales/zh-CN.json | 1 + shortcuts/branch/branch.go | 8 ++ shortcuts/common/types.go | 23 +++++ shortcuts/issue/issue.go | 10 ++ shortcuts/issue/issue_test.go | 36 +++++++ shortcuts/pr/pr.go | 8 ++ shortcuts/release/release.go | 8 ++ 11 files changed, 368 insertions(+), 27 deletions(-) create mode 100644 doc/changes/list-all-pagination.md create mode 100644 internal/client/pagination_test.go diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md new file mode 100644 index 0000000..00ab336 --- /dev/null +++ b/doc/changes/list-all-pagination.md @@ -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` 全部通过。 diff --git a/internal/client/pagination.go b/internal/client/pagination.go index 6eafc99..02d3bd1 100644 --- a/internal/client/pagination.go +++ b/internal/client/pagination.go @@ -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 +} diff --git a/internal/client/pagination_test.go b/internal/client/pagination_test.go new file mode 100644 index 0000000..2948e5f --- /dev/null +++ b/internal/client/pagination_test.go @@ -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)) + } +} diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..59fdf8e 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -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", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..2f8c7f7 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -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 批处理计划", diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 0393ad5..a5c1e62 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -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 diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 87a052a..cb6ad3d 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -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, "": [...]}. +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) diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index b19027e..83c06fb 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -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 diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 48be057..ca57026 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -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) { diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 03f537f..334b8ca 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -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 diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 21d7ec4..10deb5a 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -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 From 343c5494fbe22ec2d741fbbe6f9e9426105f9618 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 22:22:41 +0000 Subject: [PATCH 02/19] =?UTF-8?q?feat(list):=20--all=20=E6=8E=A8=E5=B9=BF?= =?UTF-8?q?=E8=87=B3=20milestone/org/repo/search=EF=BC=8C=E5=85=B1=209=20?= =?UTF-8?q?=E4=B8=AA=E5=88=86=E9=A1=B5=20list=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=EF=BC=9B=E6=80=BB=E6=95=B0=E5=AD=97=E6=AE=B5=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/list-all-pagination.md | 15 +++++++++++++-- internal/client/pagination.go | 3 +++ shortcuts/milestone/milestone.go | 8 ++++++++ shortcuts/org/org.go | 8 ++++++++ shortcuts/repo/repo.go | 8 ++++++++ shortcuts/search/search.go | 16 ++++++++++++++++ 6 files changed, 56 insertions(+), 2 deletions(-) diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md index 00ab336..68bf550 100644 --- a/doc/changes/list-all-pagination.md +++ b/doc/changes/list-all-pagination.md @@ -16,10 +16,15 @@ - 遵循 `total_count`:达到总数即停止;另设最大页数护栏,防止 忽略 `page` 参数的端点造成死循环。 - `PaginateAll` 保持原签名,委托给 `PaginateAllKey`。 -- 四个 list 命令新增 `--all` 布尔参数(默认 false): +- 九个分页 list 命令新增 `--all` 布尔参数(默认 false): - `issue +list --all`(合并结果同样应用 number/database_id 规范化) - `pr +list --all`、`branch +list --all`、`release +list --all` + - `milestone +list --all`、`org +list --all`、`repo +list --all` + - `search +repos --all`、`search +users --all` + - 对应资源键:`issues`/`pulls`/`branches`/`releases`/`milestones`/ + `organizations`/`projects`/`users`(均生产实测确认) - 输出与单页响应同构:`{"total_count": N, "<资源名>": [...]}`。 +- 总数字段兼容 `total_count` 与 `count`(如 `/users/:login/projects`)。 - 中英文 i18n 新增 `flag.all` 文案。 ## 命令示例 @@ -28,10 +33,16 @@ # 拉取仓库全部 open issue(自动翻页合并) gitlink-cli issue +list --state open --all --format json -# 全部分支 / 全部 PR / 全部 release +# 全部分支 / 全部 PR / 全部 release / 全部里程碑 gitlink-cli branch +list --all gitlink-cli pr +list --state all --all gitlink-cli release +list --all +gitlink-cli milestone +list --all + +# 全部组织 / 某用户全部仓库 / 搜索结果全量 +gitlink-cli org +list --all +gitlink-cli repo +list --user Taoyouce --all +gitlink-cli search +repos --keyword gitlink --all ``` ## 测试 diff --git a/internal/client/pagination.go b/internal/client/pagination.go index 02d3bd1..fe72a30 100644 --- a/internal/client/pagination.go +++ b/internal/client/pagination.go @@ -84,6 +84,9 @@ func extractListItems(data interface{}, listKey string) ([]json.RawMessage, int, total := -1 if tc, ok := v["total_count"].(float64); ok { total = int(tc) + } else if tc, ok := v["count"].(float64); ok { + // Some endpoints (e.g. /users/:login/projects) report "count". + total = int(tc) } if listKey != "" { if slice, ok := v[listKey].([]interface{}); ok { diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go index 1ec2b30..1de3b86 100644 --- a/shortcuts/milestone/milestone.go +++ b/shortcuts/milestone/milestone.go @@ -21,6 +21,7 @@ func Shortcuts() []*common.Shortcut { {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -34,6 +35,13 @@ func Shortcuts() []*common.Shortcut { setQueryIfPresent(q, "only_name", ctx.Arg("only-name")) setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey(milestonePath(ctx), q, "milestones") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("milestones", items)) + } env, err := ctx.CallAPIWithQuery("GET", milestonePath(ctx), q) if err != nil { return err diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index f0b5e72..bdd244b 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -17,11 +17,19 @@ 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 { q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey("/organizations", q, "organizations") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("organizations", items)) + } env, err := ctx.CallAPIWithQuery("GET", "/organizations", q) if err != nil { return err diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 06774a6..85b7630 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -21,6 +21,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "category", Short: "c", Usage: tr.T("flag.repo.category"), Default: "manage"}, {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 { user := ctx.Arg("user") @@ -35,6 +36,13 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if user != "" { path = fmt.Sprintf("/users/%s/projects", user) } + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey(path, q, "projects") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("projects", items)) + } env, err := ctx.CallAPIWithQuery("GET", path, q) if err != nil { return err diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index a0ee4c2..a98f1c2 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -17,6 +17,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, {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 { keyword, _ := ctx.RequireArg("keyword") @@ -24,6 +25,13 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q.Set("search", keyword) q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey("/projects", q, "projects") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("projects", items)) + } env, err := ctx.CallAPIWithQuery("GET", "/projects", q) if err != nil { return err @@ -38,6 +46,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, {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 { keyword, _ := ctx.RequireArg("keyword") @@ -45,6 +54,13 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q.Set("search", keyword) q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey("/users/list", q, "users") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("users", items)) + } env, err := ctx.CallAPIWithQuery("GET", "/users/list", q) if err != nil { return err From 4435dd251d15b45804e8f2d368bab0fc506726ad Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 22:37:11 +0000 Subject: [PATCH 03/19] =?UTF-8?q?feat(list):=20label/member=20+list=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=88=86=E9=A1=B5=E4=B8=8E=20--all=EF=BC=9Bm?= =?UTF-8?q?ember=20list=20=E6=94=B9=E8=B5=B0=20v1=20=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=9D=9E=E7=AE=A1=E7=90=86=E5=91=98=20403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/list-all-pagination.md | 13 +++++++++++-- shortcuts/label/label.go | 12 ++++++++++++ shortcuts/member/member.go | 23 ++++++++++++++++++++++- shortcuts/member/member_test.go | 4 ++-- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md index 68bf550..88d1f24 100644 --- a/doc/changes/list-all-pagination.md +++ b/doc/changes/list-all-pagination.md @@ -16,15 +16,24 @@ - 遵循 `total_count`:达到总数即停止;另设最大页数护栏,防止 忽略 `page` 参数的端点造成死循环。 - `PaginateAll` 保持原签名,委托给 `PaginateAllKey`。 -- 九个分页 list 命令新增 `--all` 布尔参数(默认 false): +- 十一个分页 list 命令新增 `--all` 布尔参数(默认 false): - `issue +list --all`(合并结果同样应用 number/database_id 规范化) - `pr +list --all`、`branch +list --all`、`release +list --all` - `milestone +list --all`、`org +list --all`、`repo +list --all` - `search +repos --all`、`search +users --all` + - `label +list --all`、`member +list --all` - 对应资源键:`issues`/`pulls`/`branches`/`releases`/`milestones`/ - `organizations`/`projects`/`users`(均生产实测确认) + `organizations`/`projects`/`users`/`issue_tags`/`collaborators` + (均生产实测确认) - 输出与单页响应同构:`{"total_count": N, "<资源名>": [...]}`。 - 总数字段兼容 `total_count` 与 `count`(如 `/users/:login/projects`)。 +- 修复两个既有分页语义缺口(均生产实测确认端点本身分页): + - `label +list` 完全没有 `--page/--limit`(端点实际返回 `total_count`),现已补齐; + - `member +list` 既无分页又走遗留路径(非管理员直接 403),现改走 + `/v1/:owner/:repo/collaborators`(支持分页且普通成员可读)。 +- 未加 `--all` 的 list 端点均经生产验证为非分页或未部署: + `licenses`/`ignores` 返回全量数组;`pm/pipelines` 404 未部署; + `pipeline +runs` 用 `total_data` 非标准包裹;dataset 端点未部署(平台 issue #144255)。 - 中英文 i18n 新增 `flag.all` 文案。 ## 命令示例 diff --git a/shortcuts/label/label.go b/shortcuts/label/label.go index 2b6a298..a0c3320 100644 --- a/shortcuts/label/label.go +++ b/shortcuts/label/label.go @@ -32,16 +32,28 @@ func Shortcuts() []*common.Shortcut { {Name: "only-name", Usage: "Return only label id and name: true or false"}, {Name: "sort-by", Usage: "Sort field: updated_on, created_on, issues_count"}, {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) setQueryIfPresent(q, "keyword", ctx.Arg("keyword")) setQueryIfPresent(q, "only_name", ctx.Arg("only-name")) setQueryIfPresent(q, "order_by", ctx.Arg("sort-by")) setQueryIfPresent(q, "order_direction", ctx.Arg("sort-direction")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey(labelPath(ctx), q, "issue_tags") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("issue_tags", items)) + } env, err := ctx.CallAPIWithQuery("GET", labelPath(ctx), q) if err != nil { return err diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go index 3734da3..0b236d6 100644 --- a/shortcuts/member/member.go +++ b/shortcuts/member/member.go @@ -26,11 +26,26 @@ func Shortcuts() []*common.Shortcut { { Name: "list", Description: "List repository members", + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, + }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - env, err := ctx.CallAPI("GET", collaboratorsPath(ctx), nil) + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey(collaboratorsV1Path(ctx), q, "collaborators") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("collaborators", items)) + } + env, err := ctx.CallAPIWithQuery("GET", collaboratorsV1Path(ctx), q) if err != nil { return err } @@ -254,6 +269,12 @@ func collaboratorsPath(ctx *common.RuntimeContext) string { return fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo) } +// collaboratorsV1Path is the v1 read endpoint, which supports pagination and +// does not require admin permission (the legacy path rejects non-admins). +func collaboratorsV1Path(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s/collaborators", ctx.Owner, ctx.Repo) +} + func collaboratorsRemovePath(ctx *common.RuntimeContext) string { return fmt.Sprintf("%s/remove", collaboratorsPath(ctx)) } diff --git a/shortcuts/member/member_test.go b/shortcuts/member/member_test.go index d379e82..63872e9 100644 --- a/shortcuts/member/member_test.go +++ b/shortcuts/member/member_test.go @@ -15,8 +15,8 @@ import ( func TestMemberList(t *testing.T) { server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/collaborators.json") - writeJSON(t, w, map[string]interface{}{"total_count": 1, "members": []interface{}{}}) + assertRequest(t, r, "GET", "/v1/owner/repo/collaborators.json") + writeJSON(t, w, map[string]interface{}{"total_count": 1, "collaborators": []interface{}{}}) }) defer server.Close() From 2d9789b0607cdc55a16facf8903b9d44a5751871 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 22:41:20 +0000 Subject: [PATCH 04/19] =?UTF-8?q?docs(readme):=20--all=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=BF=BB=E9=A1=B5=E7=A4=BA=E4=BE=8B=EF=BC=88=E4=B8=AD?= =?UTF-8?q?=E8=8B=B1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 4 ++++ README.zh-CN.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index e5e4318..798573f 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,10 @@ gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role develope # List issues gitlink-cli issue +list --owner Gitlink --repo forgeplus +# Fetch all pages automatically (works on all paginated list commands: +# issue/pr/branch/release/milestone/org/repo/label/member +list, search +repos/+users) +gitlink-cli issue +list --owner Gitlink --repo forgeplus --all + # Create an issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..." diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..7fad292 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -335,6 +335,10 @@ gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role develope # 列出 Issue gitlink-cli issue +list --owner Gitlink --repo forgeplus +# 自动翻页拉取全部(适用于所有可分页 list 命令: +# issue/pr/branch/release/milestone/org/repo/label/member +list、search +repos/+users) +gitlink-cli issue +list --owner Gitlink --repo forgeplus --all + # 创建 Issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..." From a6679bf787a3bbdfc56c9d5e5ffaeda0a63f941d Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 22:48:38 +0000 Subject: [PATCH 05/19] =?UTF-8?q?feat(list):=20webhook=20+list=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E5=88=86=E9=A1=B5=E4=B8=8E=20--all=EF=BC=88=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E5=AE=9E=E6=B5=8B=E7=A1=AE=E8=AE=A4=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E5=88=86=E9=A1=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 2 +- README.zh-CN.md | 2 +- doc/changes/list-all-pagination.md | 12 +++++++----- shortcuts/webhook/webhook.go | 18 +++++++++++++++++- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 798573f..9c911be 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role develope gitlink-cli issue +list --owner Gitlink --repo forgeplus # Fetch all pages automatically (works on all paginated list commands: -# issue/pr/branch/release/milestone/org/repo/label/member +list, search +repos/+users) +# issue/pr/branch/release/milestone/org/repo/label/member/webhook +list, search +repos/+users) gitlink-cli issue +list --owner Gitlink --repo forgeplus --all # Create an issue diff --git a/README.zh-CN.md b/README.zh-CN.md index 7fad292..88d6229 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -336,7 +336,7 @@ gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role develope gitlink-cli issue +list --owner Gitlink --repo forgeplus # 自动翻页拉取全部(适用于所有可分页 list 命令: -# issue/pr/branch/release/milestone/org/repo/label/member +list、search +repos/+users) +# issue/pr/branch/release/milestone/org/repo/label/member/webhook +list、search +repos/+users) gitlink-cli issue +list --owner Gitlink --repo forgeplus --all # 创建 Issue diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md index 88d1f24..54b0145 100644 --- a/doc/changes/list-all-pagination.md +++ b/doc/changes/list-all-pagination.md @@ -16,21 +16,23 @@ - 遵循 `total_count`:达到总数即停止;另设最大页数护栏,防止 忽略 `page` 参数的端点造成死循环。 - `PaginateAll` 保持原签名,委托给 `PaginateAllKey`。 -- 十一个分页 list 命令新增 `--all` 布尔参数(默认 false): +- 十二个分页 list 命令新增 `--all` 布尔参数(默认 false): - `issue +list --all`(合并结果同样应用 number/database_id 规范化) - `pr +list --all`、`branch +list --all`、`release +list --all` - `milestone +list --all`、`org +list --all`、`repo +list --all` - `search +repos --all`、`search +users --all` - - `label +list --all`、`member +list --all` + - `label +list --all`、`member +list --all`、`webhook +list --all` - 对应资源键:`issues`/`pulls`/`branches`/`releases`/`milestones`/ - `organizations`/`projects`/`users`/`issue_tags`/`collaborators` + `organizations`/`projects`/`users`/`issue_tags`/`collaborators`/`webhooks` (均生产实测确认) - 输出与单页响应同构:`{"total_count": N, "<资源名>": [...]}`。 - 总数字段兼容 `total_count` 与 `count`(如 `/users/:login/projects`)。 -- 修复两个既有分页语义缺口(均生产实测确认端点本身分页): +- 修复三个既有分页语义缺口(均生产实测确认端点本身分页): - `label +list` 完全没有 `--page/--limit`(端点实际返回 `total_count`),现已补齐; - `member +list` 既无分页又走遗留路径(非管理员直接 403),现改走 - `/v1/:owner/:repo/collaborators`(支持分页且普通成员可读)。 + `/v1/:owner/:repo/collaborators`(支持分页且普通成员可读); + - `webhook +list` 完全没有 `--page/--limit`(探针实测:建 3 个 webhook 后 + `page=2&limit=1` 返回第二条,确认端点分页),现已补齐。 - 未加 `--all` 的 list 端点均经生产验证为非分页或未部署: `licenses`/`ignores` 返回全量数组;`pm/pipelines` 404 未部署; `pipeline +runs` 用 `total_data` 非标准包裹;dataset 端点未部署(平台 issue #144255)。 diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 7d0574a..2fef25c 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -2,6 +2,7 @@ package webhook import ( "fmt" + "net/url" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" @@ -29,11 +30,26 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { { Name: "list", Description: tr.T("cmd.webhook.list.short"), + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, + }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - env, err := ctx.CallAPI("GET", webhookPath(ctx), nil) + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey(webhookPath(ctx), q, "webhooks") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("webhooks", items)) + } + env, err := ctx.CallAPIWithQuery("GET", webhookPath(ctx), q) if err != nil { return err } From 6104ab53f983285c6876d773d60235384d602c85 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 22:54:26 +0000 Subject: [PATCH 06/19] =?UTF-8?q?chore(webhook):=20--all=20=E6=96=87?= =?UTF-8?q?=E6=A1=88=E6=8E=A5=E5=85=A5=20i18n=20flag.all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- shortcuts/webhook/webhook.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 2fef25c..3f313cd 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -33,7 +33,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Flags: []common.Flag{ {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, - {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, + {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { From d328a1d97b9cdd44596b526f6aab7969a43092d7 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 22:56:48 +0000 Subject: [PATCH 07/19] =?UTF-8?q?feat(issue):=20=E6=96=B0=E5=A2=9E=20issue?= =?UTF-8?q?=20+comments=20=E8=AF=BB=E8=AF=84=E8=AE=BA=E6=B5=81=EF=BC=88jou?= =?UTF-8?q?rnals=20=E5=88=86=E9=A1=B5=20+=20--all=EF=BC=8C=E5=AF=B9?= =?UTF-8?q?=E6=A0=87=20gh=20issue=20view=20--comments=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/list-all-pagination.md | 6 ++++++ internal/i18n/locales/en-US.json | 1 + internal/i18n/locales/zh-CN.json | 1 + shortcuts/issue/issue.go | 34 ++++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md index 54b0145..9bdb6fd 100644 --- a/doc/changes/list-all-pagination.md +++ b/doc/changes/list-all-pagination.md @@ -22,6 +22,7 @@ - `milestone +list --all`、`org +list --all`、`repo +list --all` - `search +repos --all`、`search +users --all` - `label +list --all`、`member +list --all`、`webhook +list --all` + - `issue +comments --all`(新增子命令,见下) - 对应资源键:`issues`/`pulls`/`branches`/`releases`/`milestones`/ `organizations`/`projects`/`users`/`issue_tags`/`collaborators`/`webhooks` (均生产实测确认) @@ -33,6 +34,11 @@ `/v1/:owner/:repo/collaborators`(支持分页且普通成员可读); - `webhook +list` 完全没有 `--page/--limit`(探针实测:建 3 个 webhook 后 `page=2&limit=1` 返回第二条,确认端点分页),现已补齐。 +- 新增 `issue +comments` 子命令(对标 `gh issue view --comments`): + 此前 CLI 只能发评论(`issue +comment`)无法读评论流,Agent 无法获取 + issue 讨论上下文;现接 `/v1/:owner/:repo/issues/:number/journals` + (资源键 `journals`,生产实测分页 + --all 合并通过), + 复用 --number/--id 语义。 - 未加 `--all` 的 list 端点均经生产验证为非分页或未部署: `licenses`/`ignores` 返回全量数组;`pm/pipelines` 404 未部署; `pipeline +runs` 用 `total_data` 非标准包裹;dataset 端点未部署(平台 issue #144255)。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 59fdf8e..9c2cc67 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -42,6 +42,7 @@ "cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data", "cmd.issue.close.short": "Close an issue", "cmd.issue.comment.short": "Add a comment to an issue", + "cmd.issue.comments.short": "List comments on an issue", "cmd.issue.create.short": "Create a new issue", "cmd.issue.list.short": "List issues", "cmd.issue.short": "Issue operations", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2f8c7f7..3e8d129 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -42,6 +42,7 @@ "cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据", "cmd.issue.close.short": "关闭议题", "cmd.issue.comment.short": "给议题添加评论", + "cmd.issue.comments.short": "列出 Issue 的评论", "cmd.issue.create.short": "创建新议题", "cmd.issue.list.short": "列出议题", "cmd.issue.short": "议题操作", diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 83c06fb..79d240e 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -307,6 +307,40 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "comments", + Description: tr.T("cmd.issue.comments.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + common.Flag{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + common.Flag{Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, + ), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + path := fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number) + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey(path, q, "journals") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("journals", items)) + } + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "assigners", Description: "List issue assigners", From 8958a686a6e57a21ef1e8aa37b3066bb5e3e4568 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 22:58:58 +0000 Subject: [PATCH 08/19] =?UTF-8?q?feat(pr):=20=E6=96=B0=E5=A2=9E=20pr=20+co?= =?UTF-8?q?mments=20=E8=AF=BB=20PR=20=E8=AF=84=E8=AE=BA=E6=B5=81=EF=BC=88?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=E5=AE=9E=E6=B5=8B=E5=BF=BD=E7=95=A5=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E5=8F=82=E6=95=B0=EF=BC=8C=E4=B8=8D=E6=9A=B4=E9=9C=B2?= =?UTF-8?q?=E5=81=87=E5=88=86=E9=A1=B5=20flag=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/list-all-pagination.md | 3 +++ internal/i18n/locales/en-US.json | 1 + internal/i18n/locales/zh-CN.json | 1 + shortcuts/pr/pr.go | 20 ++++++++++++++++++++ 4 files changed, 25 insertions(+) diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md index 9bdb6fd..d1eabeb 100644 --- a/doc/changes/list-all-pagination.md +++ b/doc/changes/list-all-pagination.md @@ -39,6 +39,9 @@ issue 讨论上下文;现接 `/v1/:owner/:repo/issues/:number/journals` (资源键 `journals`,生产实测分页 + --all 合并通过), 复用 --number/--id 语义。 +- 新增 `pr +comments` 子命令:读 PR 评论流 + (`/v1/:owner/:repo/pulls/:number/journals`)。生产实测该端点 + 忽略 page/limit 始终返回全量,故不暴露分页 flag(避免假分页语义)。 - 未加 `--all` 的 list 端点均经生产验证为非分页或未部署: `licenses`/`ignores` 返回全量数组;`pm/pipelines` 404 未部署; `pipeline +runs` 用 `total_data` 非标准包裹;dataset 端点未部署(平台 issue #144255)。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 9c2cc67..89b508e 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -55,6 +55,7 @@ "cmd.org.short": "Organization operations", "cmd.pr.close.short": "Close a pull request", "cmd.pr.comment.short": "Add a comment to a pull request", + "cmd.pr.comments.short": "List comments on a pull request", "cmd.pr.create.short": "Create a pull request", "cmd.pr.diff.short": "Show diff for a pull request", "cmd.pr.files.short": "List changed files in a pull request", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 3e8d129..2a30ef9 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -55,6 +55,7 @@ "cmd.org.short": "组织操作", "cmd.pr.close.short": "关闭拉取请求", "cmd.pr.comment.short": "给拉取请求添加评论", + "cmd.pr.comments.short": "列出 PR 的评论", "cmd.pr.create.short": "创建拉取请求", "cmd.pr.diff.short": "显示拉取请求 diff", "cmd.pr.files.short": "列出拉取请求中的变更文件", diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 334b8ca..5e9e01e 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -439,6 +439,26 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "comments", + Description: tr.T("cmd.pr.comments.short"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, _ := ctx.RequireArg("id") + // The pulls journals endpoint ignores page/limit and always + // returns the full list, so no pagination flags are exposed. + env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/journals", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } From 1ca60f9280d83383e61f569bc7bec3c8f72000e6 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 23:04:13 +0000 Subject: [PATCH 09/19] =?UTF-8?q?feat(list):=20=E6=96=B0=E5=A2=9E=20tag=20?= =?UTF-8?q?+list=20=E5=91=BD=E4=BB=A4=E7=BB=84=E3=80=81watchers/stargazers?= =?UTF-8?q?=20=E5=88=86=E9=A1=B5=EF=BC=9B=E4=BF=AE=E5=A4=8D=E7=BF=BB?= =?UTF-8?q?=E9=A1=B5=E5=8A=A9=E6=89=8B=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=B0=81?= =?UTF-8?q?=E9=A1=B6=20limit=20=E4=B8=A2=E6=95=B0=E6=8D=AE=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/list-all-pagination.md | 14 +++++++-- internal/client/pagination.go | 5 +++- internal/client/pagination_test.go | 28 +++++++++++++++++ shortcuts/register.go | 3 ++ shortcuts/register_test.go | 2 +- shortcuts/repo/repo.go | 12 ++++++++ shortcuts/tag/tag.go | 48 ++++++++++++++++++++++++++++++ 7 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 shortcuts/tag/tag.go diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md index d1eabeb..cf4cbb8 100644 --- a/doc/changes/list-all-pagination.md +++ b/doc/changes/list-all-pagination.md @@ -16,13 +16,14 @@ - 遵循 `total_count`:达到总数即停止;另设最大页数护栏,防止 忽略 `page` 参数的端点造成死循环。 - `PaginateAll` 保持原签名,委托给 `PaginateAllKey`。 -- 十二个分页 list 命令新增 `--all` 布尔参数(默认 false): +- 十五个分页 list 命令新增 `--all` 布尔参数(默认 false): - `issue +list --all`(合并结果同样应用 number/database_id 规范化) - `pr +list --all`、`branch +list --all`、`release +list --all` - `milestone +list --all`、`org +list --all`、`repo +list --all` - `search +repos --all`、`search +users --all` - `label +list --all`、`member +list --all`、`webhook +list --all` - `issue +comments --all`(新增子命令,见下) + - `tag +list --all`(新增命令组,见下)、`repo +watchers/+stargazers --all` - 对应资源键:`issues`/`pulls`/`branches`/`releases`/`milestones`/ `organizations`/`projects`/`users`/`issue_tags`/`collaborators`/`webhooks` (均生产实测确认) @@ -33,7 +34,16 @@ - `member +list` 既无分页又走遗留路径(非管理员直接 403),现改走 `/v1/:owner/:repo/collaborators`(支持分页且普通成员可读); - `webhook +list` 完全没有 `--page/--limit`(探针实测:建 3 个 webhook 后 - `page=2&limit=1` 返回第二条,确认端点分页),现已补齐。 + `page=2&limit=1` 返回第二条,确认端点分页),现已补齐; + - `repo +watchers/+stargazers` 完全没有分页 flag(端点实测分页, + 总数键 `count`,forgeplus watchers 264 / stargazers 577),现已补齐。 +- 新增 `tag +list` 命令组:平台暴露分页的 `/v1/:owner/:repo/tags` + 端点(轻量 tag 与 release 不同),但 CLI 此前完全没有 tag 命令; + 生产实测 forgeplus 16 个 tag 分页与 --all 合并均通过。 +- 修复翻页助手服务端封顶 limit 丢数据 bug:当端点把请求的 limit + 封顶(如请求 100 每页只返 20)时,旧逻辑因「页内条数 < limit」提前 + 终止只拿到首页;现已知 total 时以 total 为准(watchers 264 条全量 + 合并生产实测),新增回归单测。 - 新增 `issue +comments` 子命令(对标 `gh issue view --comments`): 此前 CLI 只能发评论(`issue +comment`)无法读评论流,Agent 无法获取 issue 讨论上下文;现接 `/v1/:owner/:repo/issues/:number/journals` diff --git a/internal/client/pagination.go b/internal/client/pagination.go index fe72a30..e0fa506 100644 --- a/internal/client/pagination.go +++ b/internal/client/pagination.go @@ -64,8 +64,11 @@ func (c *Client) PaginateAllKey(path string, params url.Values, listKey string) if totalCount >= 0 && len(all) >= totalCount { break } + // A short page only signals the end when the endpoint does not + // report a total: servers may cap the requested limit (e.g. ask + // for 100, get 20 per page), so with a known total we rely on it. limit, _ := strconv.Atoi(params.Get("limit")) - if len(items) < limit { + if totalCount < 0 && len(items) < limit { break } } diff --git a/internal/client/pagination_test.go b/internal/client/pagination_test.go index 2948e5f..88299c3 100644 --- a/internal/client/pagination_test.go +++ b/internal/client/pagination_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "net/url" "strconv" + "strings" "testing" ) @@ -152,3 +153,30 @@ func TestPaginateAllKeyMissingKeyNotList(t *testing.T) { t.Fatalf("len(items) = %d, want 1 (single-object fallback)", len(items)) } } + +func TestPaginateAllKeyServerCappedLimit(t *testing.T) { + // The server caps every page at 2 items regardless of the requested + // limit; with total_count reported, all 5 items must still be fetched. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + start := (page - 1) * 2 + var items []string + for i := start; i < start+2 && i < 5; i++ { + items = append(items, fmt.Sprintf(`{"id":%d}`, i)) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"count":5,"users":[%s]}`, strings.Join(items, ",")) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + params := url.Values{} + params.Set("limit", "100") + items, err := c.PaginateAllKey("/thing", params, "users") + if err != nil { + t.Fatalf("PaginateAllKey: %v", err) + } + if len(items) != 5 { + t.Fatalf("len(items) = %d, want 5", len(items)) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..e2045ec 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -23,6 +23,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/repo" "github.com/gitlink-org/gitlink-cli/shortcuts/search" + "github.com/gitlink-org/gitlink-cli/shortcuts/tag" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" @@ -50,6 +51,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "org": org.Shortcuts(tr), "user": user.Shortcuts(tr), "search": search.Shortcuts(tr), + "tag": tag.Shortcuts(), "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), @@ -75,6 +77,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "org": tr.T("cmd.org.short"), "user": tr.T("cmd.user.short"), "search": tr.T("cmd.search.short"), + "tag": "Git tag operations", "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"), diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 00f4c57..887e9f3 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -12,7 +12,7 @@ func TestRegisterAll(t *testing.T) { expectedGroups := []string{ "repo", "issue", "label", "license", "pr", "profile", "release", "branch", - "org", "user", "search", "ci", "workflow", + "org", "user", "search", "tag", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", "dataset", "health", "ignore", "wiki", } diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 85b7630..922c7c4 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -334,6 +334,15 @@ func runCommunityList(ctx *common.RuntimeContext, path string) error { if err != nil { return err } + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey(ctx.RepoPath()+"/"+path, q, "users") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("users", items)) + } env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/"+path, q) if err != nil { return err @@ -345,6 +354,9 @@ func communityListFlags() []common.Flag { return []common.Flag{ {Name: "start-at", Usage: "Start timestamp"}, {Name: "end-at", Usage: "End timestamp"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, } } diff --git a/shortcuts/tag/tag.go b/shortcuts/tag/tag.go new file mode 100644 index 0000000..a4adb56 --- /dev/null +++ b/shortcuts/tag/tag.go @@ -0,0 +1,48 @@ +package tag + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns git tag shortcuts. +// +// Tags previously had no first-class command even though the platform +// exposes a paginated v1 endpoint; releases only cover annotated releases, +// while lightweight tags were reachable through the raw API alone. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List repository git tags", + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path := fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo) + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey(path, q, "tags") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("tags", items)) + } + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} From 78fa470b9442e0e87c000bcd9bb97a859ed5403c Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 23:06:53 +0000 Subject: [PATCH 10/19] =?UTF-8?q?feat(list):=20=E6=96=B0=E5=A2=9E=20commit?= =?UTF-8?q?=20+list=20=E5=91=BD=E4=BB=A4=E7=BB=84=E4=B8=8E=20repo=20+forks?= =?UTF-8?q?=EF=BC=88=E5=88=86=E9=A1=B5=20+=20--all=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/list-all-pagination.md | 10 ++++-- shortcuts/commit/commit.go | 52 ++++++++++++++++++++++++++++++ shortcuts/register.go | 3 ++ shortcuts/register_test.go | 2 +- shortcuts/repo/repo.go | 29 +++++++++++++++++ 5 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 shortcuts/commit/commit.go diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md index cf4cbb8..6048934 100644 --- a/doc/changes/list-all-pagination.md +++ b/doc/changes/list-all-pagination.md @@ -16,14 +16,15 @@ - 遵循 `total_count`:达到总数即停止;另设最大页数护栏,防止 忽略 `page` 参数的端点造成死循环。 - `PaginateAll` 保持原签名,委托给 `PaginateAllKey`。 -- 十五个分页 list 命令新增 `--all` 布尔参数(默认 false): +- 十七个分页 list 命令新增 `--all` 布尔参数(默认 false): - `issue +list --all`(合并结果同样应用 number/database_id 规范化) - `pr +list --all`、`branch +list --all`、`release +list --all` - `milestone +list --all`、`org +list --all`、`repo +list --all` - `search +repos --all`、`search +users --all` - `label +list --all`、`member +list --all`、`webhook +list --all` - `issue +comments --all`(新增子命令,见下) - - `tag +list --all`(新增命令组,见下)、`repo +watchers/+stargazers --all` + - `tag +list --all`、`commit +list --all`(新增命令组,见下) + - `repo +watchers/+stargazers/+forks --all` - 对应资源键:`issues`/`pulls`/`branches`/`releases`/`milestones`/ `organizations`/`projects`/`users`/`issue_tags`/`collaborators`/`webhooks` (均生产实测确认) @@ -40,6 +41,11 @@ - 新增 `tag +list` 命令组:平台暴露分页的 `/v1/:owner/:repo/tags` 端点(轻量 tag 与 release 不同),但 CLI 此前完全没有 tag 命令; 生产实测 forgeplus 16 个 tag 分页与 --all 合并均通过。 +- 新增 `commit +list` 命令组:分页的 `/v1/:owner/:repo/commits` + 端点此前只能通过裸 api 命令访问;支持 `--ref` 指定分支/tag/SHA + (映射 sha 参数,生产实测 forgeplus 6762 commits、develop 5346)。 +- 新增 `repo +forks`:分页的 forks 列表(总数键 `count`, + 生产实测 forgeplus 77 个 fork 全量合并);此前只有 fork 创建命令。 - 修复翻页助手服务端封顶 limit 丢数据 bug:当端点把请求的 limit 封顶(如请求 100 每页只返 20)时,旧逻辑因「页内条数 < limit」提前 终止只拿到首页;现已知 total 时以 total 为准(watchers 264 条全量 diff --git a/shortcuts/commit/commit.go b/shortcuts/commit/commit.go new file mode 100644 index 0000000..4573db6 --- /dev/null +++ b/shortcuts/commit/commit.go @@ -0,0 +1,52 @@ +package commit + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns commit history shortcuts. +// +// Commit history previously had no first-class command even though the +// platform exposes a paginated v1 endpoint; agents had to fall back to the +// raw api command to read it. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List repository commits", + Flags: []common.Flag{ + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA to start from (default branch when omitted)"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + path := fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo) + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if ref := ctx.Arg("ref"); ref != "" { + q.Set("sha", ref) + } + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey(path, q, "commits") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("commits", items)) + } + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index e2045ec..8876c25 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -6,6 +6,7 @@ import ( "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" + "github.com/gitlink-org/gitlink-cli/shortcuts/commit" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" @@ -52,6 +53,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "user": user.Shortcuts(tr), "search": search.Shortcuts(tr), "tag": tag.Shortcuts(), + "commit": commit.Shortcuts(), "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), @@ -78,6 +80,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "user": tr.T("cmd.user.short"), "search": tr.T("cmd.search.short"), "tag": "Git tag operations", + "commit": "Commit history operations", "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"), diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 887e9f3..2957f18 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -12,7 +12,7 @@ func TestRegisterAll(t *testing.T) { expectedGroups := []string{ "repo", "issue", "label", "license", "pr", "profile", "release", "branch", - "org", "user", "search", "tag", "ci", "workflow", + "org", "user", "search", "tag", "commit", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", "dataset", "health", "ignore", "wiki", } diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 922c7c4..3414c36 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -159,6 +159,35 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return runCommunityList(ctx, "stargazers") }, }, + { + Name: "forks", + Description: "List repository forks", + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "all", Usage: "Fetch all pages automatically (ignores --page)", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + 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()+"/forks", q, "users") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("users", items)) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/forks", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "follow", Description: "Follow a repository", From ce8db5912e1025c2d5d9c8bd523dfe5cdbc76a77 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 23:08:23 +0000 Subject: [PATCH 11/19] =?UTF-8?q?feat(pr):=20=E6=96=B0=E5=A2=9E=20pr=20+co?= =?UTF-8?q?mmits=20=E8=AF=BB=20PR=20=E6=8F=90=E4=BA=A4=E5=88=97=E8=A1=A8?= =?UTF-8?q?=EF=BC=88=E7=AB=AF=E7=82=B9=E5=AE=9E=E6=B5=8B=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E5=8F=82=E6=95=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/list-all-pagination.md | 2 ++ internal/i18n/locales/en-US.json | 1 + internal/i18n/locales/zh-CN.json | 1 + shortcuts/pr/pr.go | 20 ++++++++++++++++++++ 4 files changed, 24 insertions(+) diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md index 6048934..36a8329 100644 --- a/doc/changes/list-all-pagination.md +++ b/doc/changes/list-all-pagination.md @@ -55,6 +55,8 @@ issue 讨论上下文;现接 `/v1/:owner/:repo/issues/:number/journals` (资源键 `journals`,生产实测分页 + --all 合并通过), 复用 --number/--id 语义。 +- 新增 `pr +commits` 子命令:读 PR 的提交列表(commits 端点); + 生产实测该端点同样忽略 page/limit 始终全量,故不暴露分页 flag。 - 新增 `pr +comments` 子命令:读 PR 评论流 (`/v1/:owner/:repo/pulls/:number/journals`)。生产实测该端点 忽略 page/limit 始终返回全量,故不暴露分页 flag(避免假分页语义)。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 89b508e..af61dc0 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -56,6 +56,7 @@ "cmd.pr.close.short": "Close a pull request", "cmd.pr.comment.short": "Add a comment to a pull request", "cmd.pr.comments.short": "List comments on a pull request", + "cmd.pr.commits.short": "List commits on a pull request", "cmd.pr.create.short": "Create a pull request", "cmd.pr.diff.short": "Show diff for a pull request", "cmd.pr.files.short": "List changed files in a pull request", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2a30ef9..af3aac9 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -56,6 +56,7 @@ "cmd.pr.close.short": "关闭拉取请求", "cmd.pr.comment.short": "给拉取请求添加评论", "cmd.pr.comments.short": "列出 PR 的评论", + "cmd.pr.commits.short": "列出 PR 的提交", "cmd.pr.create.short": "创建拉取请求", "cmd.pr.diff.short": "显示拉取请求 diff", "cmd.pr.files.short": "列出拉取请求中的变更文件", diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 5e9e01e..a4129e9 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -439,6 +439,26 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "commits", + Description: tr.T("cmd.pr.commits.short"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, _ := ctx.RequireArg("id") + // The pulls commits endpoint ignores page/limit and always + // returns the full list, so no pagination flags are exposed. + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/commits", ctx.RepoPath(), id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "comments", Description: tr.T("cmd.pr.comments.short"), From e225b86e807f9c0f89ceeeefd5f52f2b99c062a1 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 23:10:59 +0000 Subject: [PATCH 12/19] =?UTF-8?q?feat(commit):=20=E6=96=B0=E5=A2=9E=20comm?= =?UTF-8?q?it=20+view=20=E5=8D=95=E6=8F=90=E4=BA=A4=E8=AF=A6=E6=83=85?= =?UTF-8?q?=EF=BC=9BREADME=20=E4=B8=AD=E8=8B=B1=E8=A1=A5=E6=96=B0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 10 +++++++++- README.zh-CN.md | 10 +++++++++- doc/changes/list-all-pagination.md | 4 +++- shortcuts/commit/commit.go | 21 +++++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9c911be..988c68b 100644 --- a/README.md +++ b/README.md @@ -325,9 +325,17 @@ gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role develope gitlink-cli issue +list --owner Gitlink --repo forgeplus # Fetch all pages automatically (works on all paginated list commands: -# issue/pr/branch/release/milestone/org/repo/label/member/webhook +list, search +repos/+users) +# issue/pr/branch/release/milestone/org/repo/label/member/webhook/tag/commit +list, +# issue +comments, repo +watchers/+stargazers/+forks, search +repos/+users) gitlink-cli issue +list --owner Gitlink --repo forgeplus --all +# Commit history and single commit details +gitlink-cli commit +list --owner Gitlink --repo forgeplus --ref develop --all +gitlink-cli commit +view --owner Gitlink --repo forgeplus --sha + +# Git tags +gitlink-cli tag +list --owner Gitlink --repo forgeplus --all + # Create an issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..." diff --git a/README.zh-CN.md b/README.zh-CN.md index 88d6229..6aa9404 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -336,9 +336,17 @@ gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role develope gitlink-cli issue +list --owner Gitlink --repo forgeplus # 自动翻页拉取全部(适用于所有可分页 list 命令: -# issue/pr/branch/release/milestone/org/repo/label/member/webhook +list、search +repos/+users) +# issue/pr/branch/release/milestone/org/repo/label/member/webhook/tag/commit +list、 +# issue +comments、repo +watchers/+stargazers/+forks、search +repos/+users) gitlink-cli issue +list --owner Gitlink --repo forgeplus --all +# 提交历史与单个提交详情 +gitlink-cli commit +list --owner Gitlink --repo forgeplus --ref develop --all +gitlink-cli commit +view --owner Gitlink --repo forgeplus --sha + +# Git 标签 +gitlink-cli tag +list --owner Gitlink --repo forgeplus --all + # 创建 Issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..." diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md index 36a8329..7177f08 100644 --- a/doc/changes/list-all-pagination.md +++ b/doc/changes/list-all-pagination.md @@ -43,7 +43,9 @@ 生产实测 forgeplus 16 个 tag 分页与 --all 合并均通过。 - 新增 `commit +list` 命令组:分页的 `/v1/:owner/:repo/commits` 端点此前只能通过裸 api 命令访问;支持 `--ref` 指定分支/tag/SHA - (映射 sha 参数,生产实测 forgeplus 6762 commits、develop 5346)。 + (映射 sha 参数,生产实测 forgeplus 6762 commits、develop 5346); + 另新增 `commit +view --sha` 单提交详情(v1 无此端点,走遗留 + `/api/:owner/:repo/commits/:sha`,生产实测含 diff 统计与文件列表)。 - 新增 `repo +forks`:分页的 forks 列表(总数键 `count`, 生产实测 forgeplus 77 个 fork 全量合并);此前只有 fork 创建命令。 - 修复翻页助手服务端封顶 limit 丢数据 bug:当端点把请求的 limit diff --git a/shortcuts/commit/commit.go b/shortcuts/commit/commit.go index 4573db6..475dc89 100644 --- a/shortcuts/commit/commit.go +++ b/shortcuts/commit/commit.go @@ -48,5 +48,26 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "view", + Description: "View a single commit", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Commit SHA", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/commits/%s", ctx.RepoPath(), sha), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } From 2a92ac33dd1a40f868d59a237889f96016a3013c Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 23:13:51 +0000 Subject: [PATCH 13/19] =?UTF-8?q?feat(org):=20org=20+members=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20--all=20=E8=87=AA=E5=8A=A8=E7=BF=BB=E9=A1=B5?= =?UTF-8?q?=EF=BC=88=E8=B5=84=E6=BA=90=E9=94=AE=20organization=5Fusers?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/list-all-pagination.md | 5 +++-- shortcuts/org/org.go | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md index 7177f08..48d5a91 100644 --- a/doc/changes/list-all-pagination.md +++ b/doc/changes/list-all-pagination.md @@ -16,7 +16,7 @@ - 遵循 `total_count`:达到总数即停止;另设最大页数护栏,防止 忽略 `page` 参数的端点造成死循环。 - `PaginateAll` 保持原签名,委托给 `PaginateAllKey`。 -- 十七个分页 list 命令新增 `--all` 布尔参数(默认 false): +- 十八个分页 list 命令新增 `--all` 布尔参数(默认 false): - `issue +list --all`(合并结果同样应用 number/database_id 规范化) - `pr +list --all`、`branch +list --all`、`release +list --all` - `milestone +list --all`、`org +list --all`、`repo +list --all` @@ -24,7 +24,8 @@ - `label +list --all`、`member +list --all`、`webhook +list --all` - `issue +comments --all`(新增子命令,见下) - `tag +list --all`、`commit +list --all`(新增命令组,见下) - - `repo +watchers/+stargazers/+forks --all` + - `repo +watchers/+stargazers/+forks --all`、`org +members --all` + (资源键 `organization_users`,生产实测 gitlink 组织 71 名成员全量合并) - 对应资源键:`issues`/`pulls`/`branches`/`releases`/`milestones`/ `organizations`/`projects`/`users`/`issue_tags`/`collaborators`/`webhooks` (均生产实测确认) diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index bdd244b..cf27900 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -59,12 +59,20 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, {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 { id, _ := ctx.RequireArg("id") q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey(fmt.Sprintf("/organizations/%s/organization_users", id), q, "organization_users") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("organization_users", items)) + } env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q) if err != nil { return err From 524bf43accdee72fb835f13ad7bd4d7859af8bd9 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 23:15:44 +0000 Subject: [PATCH 14/19] =?UTF-8?q?feat(org):=20=E6=96=B0=E5=A2=9E=20org=20+?= =?UTF-8?q?repos=20=E7=BB=84=E7=BB=87=E4=BB=93=E5=BA=93=E5=88=97=E8=A1=A8?= =?UTF-8?q?=EF=BC=88=E5=88=86=E9=A1=B5=20+=20--all=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/list-all-pagination.md | 5 ++++- internal/i18n/locales/en-US.json | 1 + internal/i18n/locales/zh-CN.json | 1 + shortcuts/org/org.go | 29 +++++++++++++++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md index 48d5a91..36ecc06 100644 --- a/doc/changes/list-all-pagination.md +++ b/doc/changes/list-all-pagination.md @@ -16,7 +16,7 @@ - 遵循 `total_count`:达到总数即停止;另设最大页数护栏,防止 忽略 `page` 参数的端点造成死循环。 - `PaginateAll` 保持原签名,委托给 `PaginateAllKey`。 -- 十八个分页 list 命令新增 `--all` 布尔参数(默认 false): +- 十九个分页 list 命令新增 `--all` 布尔参数(默认 false): - `issue +list --all`(合并结果同样应用 number/database_id 规范化) - `pr +list --all`、`branch +list --all`、`release +list --all` - `milestone +list --all`、`org +list --all`、`repo +list --all` @@ -26,6 +26,9 @@ - `tag +list --all`、`commit +list --all`(新增命令组,见下) - `repo +watchers/+stargazers/+forks --all`、`org +members --all` (资源键 `organization_users`,生产实测 gitlink 组织 71 名成员全量合并) + - `org +repos --all`(新增子命令:组织仓库列表 + `/organizations/:name/projects`,此前只能裸 api 访问; + 生产实测 gitlink 组织 28 个仓库全量合并) - 对应资源键:`issues`/`pulls`/`branches`/`releases`/`milestones`/ `organizations`/`projects`/`users`/`issue_tags`/`collaborators`/`webhooks` (均生产实测确认) diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index af61dc0..49ae93c 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -52,6 +52,7 @@ "cmd.org.info.short": "Show organization details", "cmd.org.list.short": "List organizations", "cmd.org.members.short": "List organization members", + "cmd.org.repos.short": "List repositories of an organization", "cmd.org.short": "Organization operations", "cmd.pr.close.short": "Close a pull request", "cmd.pr.comment.short": "Add a comment to a pull request", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index af3aac9..a257cf7 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -52,6 +52,7 @@ "cmd.org.info.short": "显示组织详情", "cmd.org.list.short": "列出组织", "cmd.org.members.short": "列出组织成员", + "cmd.org.repos.short": "列出组织下的仓库", "cmd.org.short": "组织操作", "cmd.pr.close.short": "关闭拉取请求", "cmd.pr.comment.short": "给拉取请求添加评论", diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index cf27900..8f77ead 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -80,6 +80,35 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "repos", + Description: tr.T("cmd.org.repos.short"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, + {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 { + id, _ := ctx.RequireArg("id") + path := fmt.Sprintf("/organizations/%s/projects", id) + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("all") == "true" { + items, err := ctx.PaginateAllKey(path, q, "projects") + if err != nil { + return err + } + return ctx.Output(common.NewListEnvelope("projects", items)) + } + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "create", Description: tr.T("cmd.org.create.short"), From dfa127c27ab7bc742556b88ef58c66a9d49089f1 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 23:18:24 +0000 Subject: [PATCH 15/19] =?UTF-8?q?test:=20tag/commit=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E7=BB=84=E8=A1=A5=20httptest=20=E5=8D=95=E6=B5=8B=EF=BC=88?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E5=8F=82=E6=95=B0=E3=80=81--all=20=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E3=80=81--ref=20sha=E3=80=81view=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- shortcuts/commit/commit_test.go | 90 +++++++++++++++++++++++++++++++++ shortcuts/tag/tag_test.go | 67 ++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 shortcuts/commit/commit_test.go create mode 100644 shortcuts/tag/tag_test.go diff --git a/shortcuts/commit/commit_test.go b/shortcuts/commit/commit_test.go new file mode 100644 index 0000000..0fe593c --- /dev/null +++ b/shortcuts/commit/commit_test.go @@ -0,0 +1,90 @@ +package commit + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestCommitList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/commits.json") + if got := r.URL.Query().Get("sha"); got != "develop" { + t.Fatalf("sha query = %q, want develop", got) + } + writeJSON(t, w, map[string]interface{}{"total_count": 1, "commits": []interface{}{map[string]interface{}{"sha": "abc"}}}) + })) + defer server.Close() + + if err := runCommitShortcut(t, server, "list", map[string]string{"ref": "develop", "page": "1", "limit": "20"}); err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestCommitListAllMergesPages(t *testing.T) { + pages := map[string][]interface{}{ + "1": {map[string]interface{}{"sha": "a"}, map[string]interface{}{"sha": "b"}}, + "2": {map[string]interface{}{"sha": "c"}}, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/commits.json") + writeJSON(t, w, map[string]interface{}{"total_count": 3, "commits": pages[r.URL.Query().Get("page")]}) + })) + defer server.Close() + + if err := runCommitShortcut(t, server, "list", map[string]string{"all": "true", "page": "1", "limit": "2"}); err != nil { + t.Fatalf("list --all shortcut failed: %v", err) + } +} + +func TestCommitView(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/commits/abc123.json") + writeJSON(t, w, map[string]interface{}{"commit": map[string]interface{}{"sha": "abc123"}}) + })) + defer server.Close() + + if err := runCommitShortcut(t, server, "view", map[string]string{"sha": "abc123"}); err != nil { + t.Fatalf("view shortcut failed: %v", err) + } +} + +func runCommitShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func assertRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} diff --git a/shortcuts/tag/tag_test.go b/shortcuts/tag/tag_test.go new file mode 100644 index 0000000..03019cc --- /dev/null +++ b/shortcuts/tag/tag_test.go @@ -0,0 +1,67 @@ +package tag + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestTagList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/tags.json" { + t.Fatalf("got request %s %s, want GET /v1/owner/repo/tags.json", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]interface{}{"total_count": 1, "tags": []interface{}{map[string]interface{}{"name": "v1.0.0"}}}); err != nil { + t.Fatalf("failed to write response: %v", err) + } + })) + defer server.Close() + + if err := runTagShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}); err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestTagListAllMergesPages(t *testing.T) { + pages := map[string][]interface{}{ + "1": {map[string]interface{}{"name": "v1"}, map[string]interface{}{"name": "v2"}}, + "2": {map[string]interface{}{"name": "v3"}}, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]interface{}{"total_count": 3, "tags": pages[r.URL.Query().Get("page")]}); err != nil { + t.Fatalf("failed to write response: %v", err) + } + })) + defer server.Close() + + if err := runTagShortcut(t, server, "list", map[string]string{"all": "true", "page": "1", "limit": "2"}); err != nil { + t.Fatalf("list --all shortcut failed: %v", err) + } +} + +func runTagShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} From 8c92076c52403c34459992aba4d4bd56117b735e Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 14:33:19 +0000 Subject: [PATCH 16/19] =?UTF-8?q?feat(api):=20api=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20--paginate=20=E8=87=AA=E5=8A=A8=E7=BF=BB?= =?UTF-8?q?=E9=A1=B5=E5=90=88=E5=B9=B6=EF=BC=88=E5=AF=B9=E6=A0=87=20gh=20a?= =?UTF-8?q?pi=20--paginate=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", From 99e536226a36e0d015c13027010cf846eb421947 Mon Sep 17 00:00:00 2001 From: 1os21ka23r9navae6mrro <1os21ka23r9navae6mrro@gmail.com> Date: Wed, 8 Jul 2026 15:05:17 +0000 Subject: [PATCH 17/19] feat(tag): add +view with list-scan fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tag +view -n wraps GET /v1/:owner/:repo/tags/:name. In production the show endpoint's tag-existence precheck (gitea-hat tag_name_set) rejects tags that the paginated list returns ('标签不存在!' even for a tag visible in tags.json), so on error the command falls back to scanning the paginated list for the requested name and emits the matching entry. Production-reproduced the precheck bug and verified the fallback returns the correct tag. 2 unit tests cover the direct path and the fallback. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 4 ++++ shortcuts/tag/tag.go | 38 ++++++++++++++++++++++++++++++++++++++ shortcuts/tag/tag_test.go | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/README.md b/README.md index 988c68b..53933dc 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,10 @@ gitlink-cli commit +view --owner Gitlink --repo forgeplus --sha # Git tags gitlink-cli tag +list --owner Gitlink --repo forgeplus --all +# Show a single tag by name (falls back to a list scan when the +# show endpoint's existence precheck misfires) +gitlink-cli tag +view --owner Gitlink --repo forgeplus -n v4.0.0 + # Create an issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..." diff --git a/shortcuts/tag/tag.go b/shortcuts/tag/tag.go index a4adb56..885ac69 100644 --- a/shortcuts/tag/tag.go +++ b/shortcuts/tag/tag.go @@ -1,6 +1,7 @@ package tag import ( + "encoding/json" "fmt" "net/url" @@ -44,5 +45,42 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "view", + Description: "Show a git tag by name", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Tag name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/tags/%s", ctx.Owner, ctx.Repo, url.PathEscape(name)), nil) + if err == nil { + return ctx.Output(env) + } + // The show endpoint's tag-existence precheck is unreliable in + // production (rejects tags that the paginated list returns), + // so fall back to scanning the list for the requested name. + q := url.Values{} + q.Set("page", "1") + q.Set("limit", "20") + items, listErr := ctx.PaginateAllKey(fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo), q, "tags") + if listErr != nil { + return err + } + for _, item := range items { + var tag map[string]interface{} + if json.Unmarshal(item, &tag) == nil && tag["name"] == name { + return ctx.OutputData(tag) + } + } + return err + }, + }, } } diff --git a/shortcuts/tag/tag_test.go b/shortcuts/tag/tag_test.go index 03019cc..62940b1 100644 --- a/shortcuts/tag/tag_test.go +++ b/shortcuts/tag/tag_test.go @@ -65,3 +65,38 @@ func runTagShortcut(t *testing.T, server *httptest.Server, name string, args map t.Fatalf("shortcut %q not found", name) return nil } + +func TestTagViewDirectShow(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/owner/repo/tags/v1.0.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"name":"v1.0"}`)) + })) + defer server.Close() + + if err := runTagShortcut(t, server, "view", map[string]string{"name": "v1.0"}); err != nil { + t.Fatalf("view failed: %v", err) + } +} + +func TestTagViewFallsBackToListScan(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/v1/owner/repo/tags/mytag.json" { + w.Write([]byte(`{"status":-1,"message":"标签不存在!"}`)) + return + } + if r.URL.Path == "/v1/owner/repo/tags.json" { + w.Write([]byte(`{"total_count":1,"tags":[{"name":"mytag","id":"abc"}]}`)) + return + } + t.Fatalf("unexpected path: %s", r.URL.Path) + })) + defer server.Close() + + if err := runTagShortcut(t, server, "view", map[string]string{"name": "mytag"}); err != nil { + t.Fatalf("view fallback failed: %v", err) + } +} From 5893393473daecaa4d4165b4e070e0836313cb48 Mon Sep 17 00:00:00 2001 From: maidamaliziasimnw Date: Fri, 10 Jul 2026 16:41:08 +0000 Subject: [PATCH 18/19] =?UTF-8?q?perf(pagination):=20total=5Fcount=20?= =?UTF-8?q?=E5=B7=B2=E7=9F=A5=E6=97=B6=E5=B9=B6=E5=8F=91=E6=8A=93=E5=8F=96?= =?UTF-8?q?=E5=89=A9=E4=BD=99=E9=A1=B5=EF=BC=88=E6=9C=89=E7=95=8C=205=20wo?= =?UTF-8?q?rker=EF=BC=8C=E4=BF=9D=E6=8C=81=E9=A1=B5=E5=BA=8F=EF=BC=89?= =?UTF-8?q?=EF=BC=8C--all=20=E5=A4=A7=E5=88=97=E8=A1=A8=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=2020.2s=E2=86=928.4s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/client/pagination.go | 77 ++++++++++++++++++++++++++++++ internal/client/pagination_test.go | 65 +++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/internal/client/pagination.go b/internal/client/pagination.go index e0fa506..1bdabce 100644 --- a/internal/client/pagination.go +++ b/internal/client/pagination.go @@ -5,12 +5,18 @@ import ( "fmt" "net/url" "strconv" + "sync" ) // maxPaginationPages caps auto-pagination as a safety guard against // endpoints that ignore the page parameter and keep returning data. const maxPaginationPages = 1000 +// paginationWorkers bounds concurrent page fetches when the total page +// count is known after the first page, so remaining pages can be fetched +// in parallel without overwhelming the server. +const paginationWorkers = 5 + // 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) { @@ -35,6 +41,17 @@ func (c *Client) PaginateAllKey(path string, params url.Values, listKey string) totalCount := -1 for page := 1; page <= maxPaginationPages; page++ { + if page == 2 && totalCount >= 0 { + perPage := len(all) + if perPage > 0 && totalCount > perPage { + rest, err := c.fetchPagesConcurrent(path, params, listKey, perPage, totalCount) + if err != nil { + return nil, err + } + all = append(all, rest...) + } + break + } params.Set("page", strconv.Itoa(page)) env, err := c.Get(path, params) if err != nil { @@ -76,6 +93,66 @@ func (c *Client) PaginateAllKey(path string, params url.Values, listKey string) return all, nil } +// fetchPagesConcurrent fetches pages 2..N in parallel with a bounded worker +// pool, preserving page order in the returned slice. It is only used when +// the endpoint reported a total_count, so the page count is known upfront. +func (c *Client) fetchPagesConcurrent(path string, params url.Values, listKey string, perPage, totalCount int) ([]json.RawMessage, error) { + lastPage := (totalCount + perPage - 1) / perPage + if lastPage > maxPaginationPages { + lastPage = maxPaginationPages + } + + type pageResult struct { + items []json.RawMessage + err error + } + results := make([]pageResult, lastPage+1) + + var wg sync.WaitGroup + sem := make(chan struct{}, paginationWorkers) + for page := 2; page <= lastPage; page++ { + wg.Add(1) + go func(page int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + q := url.Values{} + for k, vs := range params { + q[k] = append([]string(nil), vs...) + } + q.Set("page", strconv.Itoa(page)) + env, err := c.Get(path, q) + if err != nil { + results[page] = pageResult{err: err} + return + } + if !env.OK { + results[page] = pageResult{err: fmt.Errorf("API error on page %d", page)} + return + } + items, _, isList := extractListItems(env.Data, listKey) + if !isList { + return + } + results[page] = pageResult{items: items} + }(page) + } + wg.Wait() + + var all []json.RawMessage + for page := 2; page <= lastPage; page++ { + if results[page].err != nil { + return nil, results[page].err + } + all = append(all, results[page].items...) + } + if remaining := totalCount - perPage; len(all) > remaining { + all = all[:remaining] + } + 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. diff --git a/internal/client/pagination_test.go b/internal/client/pagination_test.go index 88299c3..9a54349 100644 --- a/internal/client/pagination_test.go +++ b/internal/client/pagination_test.go @@ -154,6 +154,71 @@ func TestPaginateAllKeyMissingKeyNotList(t *testing.T) { } } +func TestPaginateAllKeyConcurrentPagesOrdered(t *testing.T) { + // With total_count known after page 1, pages 2..N are fetched + // concurrently; the combined result must stay in page order. + const total = 25 + 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")) + var items []map[string]interface{} + for i := (page-1)*limit + 1; i <= page*limit && i <= total; i++ { + items = append(items, map[string]interface{}{"id": i}) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "total_count": total, + "issues": items, + }) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + params := url.Values{} + params.Set("limit", "4") + items, err := c.PaginateAllKey("/repos/o/r/issues", params, "issues") + if err != nil { + t.Fatalf("PaginateAllKey: %v", err) + } + if len(items) != total { + t.Fatalf("len = %d, want %d", len(items), total) + } + for i, raw := range items { + var obj struct { + ID int `json:"id"` + } + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("unmarshal item %d: %v", i, err) + } + if obj.ID != i+1 { + t.Fatalf("item %d id = %d, want %d (page order broken)", i, obj.ID, i+1) + } + } +} + +func TestPaginateAllKeyConcurrentPageError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page == 3 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "total_count": 10, + "issues": []map[string]interface{}{{"id": page*2 - 1}, {"id": page * 2}}, + }) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + params := url.Values{} + params.Set("limit", "2") + if _, err := c.PaginateAllKey("/repos/o/r/issues", params, "issues"); err == nil { + t.Fatal("expected error from failing page") + } +} + func TestPaginateAllKeyServerCappedLimit(t *testing.T) { // The server caps every page at 2 items regardless of the requested // limit; with total_count reported, all 5 items must still be fetched. From 466a3d5fd93c2932fad849f50e657123eaf75ef4 Mon Sep 17 00:00:00 2001 From: maidamaliziasimnw Date: Fri, 10 Jul 2026 17:39:24 +0000 Subject: [PATCH 19/19] =?UTF-8?q?test(client):=20=E6=96=B0=E5=A2=9E=20Pagi?= =?UTF-8?q?nateAllKey=20=E5=9F=BA=E5=87=86=E6=B5=8B=E8=AF=95=EF=BC=88?= =?UTF-8?q?=E6=A8=A1=E6=8B=9F=E6=AF=8F=E9=A1=B5=E5=BB=B6=E8=BF=9F=EF=BC=8C?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=B9=B6=E5=8F=91=E7=BF=BB=E9=A1=B5=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- internal/client/pagination_test.go | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/client/pagination_test.go b/internal/client/pagination_test.go index 9a54349..d253cfe 100644 --- a/internal/client/pagination_test.go +++ b/internal/client/pagination_test.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "testing" + "time" ) func TestPaginateAllKeyResourceWrappedPages(t *testing.T) { @@ -245,3 +246,34 @@ func TestPaginateAllKeyServerCappedLimit(t *testing.T) { t.Fatalf("len(items) = %d, want 5", len(items)) } } + +// BenchmarkPaginateAllKey measures full-list pagination against a server with +// simulated per-page latency, exercising the concurrent page-fetch path. +func BenchmarkPaginateAllKey(b *testing.B) { + const total, perPage = 500, 50 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(5 * time.Millisecond) + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + var items []string + for i := (page-1)*perPage + 1; i <= page*perPage && i <= total; i++ { + items = append(items, fmt.Sprintf(`{"id":%d}`, i)) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"total_count":%d,"issues":[%s]}`, total, strings.Join(items, ",")) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + b.ResetTimer() + for i := 0; i < b.N; i++ { + params := url.Values{} + params.Set("limit", strconv.Itoa(perPage)) + items, err := c.PaginateAllKey("/repos/o/r/issues", params, "issues") + if err != nil { + b.Fatalf("PaginateAllKey: %v", err) + } + if len(items) != total { + b.Fatalf("len = %d, want %d", len(items), total) + } + } +}