feat(list): 新增 tag +list 命令组、watchers/stargazers 分页;修复翻页助手服务端封顶 limit 丢数据 bug

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
laurencewannamaker 2026-07-05 23:04:13 +00:00
parent 8958a686a6
commit 1ca60f9280
7 changed files with 108 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

48
shortcuts/tag/tag.go Normal file
View File

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