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) + }, + }, + } +}