diff --git a/README.md b/README.md index 5577b2e..3fba5d0 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,22 @@ 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/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 + +# 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/README.zh-CN.md b/README.zh-CN.md index f770289..de688bb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -335,6 +335,18 @@ 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/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/cmd/api/api.go b/cmd/api/api.go index ca9460c..f6f381a 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -7,14 +7,12 @@ import ( "io" "net/url" "os" - "regexp" "strings" "github.com/spf13/cobra" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" - repoContext "github.com/gitlink-org/gitlink-cli/internal/context" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" ) @@ -30,10 +28,9 @@ 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 POST /v1/{{owner}}/{{repo}}/issues/{{number}}/journals --body '{"notes":"handled by {{actor}}"}' --var owner=Gitlink --var repo=gitlink-cli --var number=42 --var actor=bot - gitlink-cli api POST /v1/{{owner}}/{{repo}}/issues --body-file issue.json --var owner=Gitlink --var repo=gitlink-cli --dry-run gitlink-cli api --batch-file plan.json --dry-run gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=gitlink-cli`, Args: validateAPIArgs, @@ -44,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")) @@ -66,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) } @@ -77,23 +79,13 @@ func runAPI(c *cobra.Command, args []string) error { path = "/" + path } - vars, err := parseBatchVars(c) - if err != nil { - return err - } - addRepoContextVars(vars) - path, err = renderSinglePath(path, vars) - if err != nil { - return err - } - cli, err := client.New() if err != nil { return err } cli.Debug = cmdutil.Debug - body, err := readJSONBody(c, vars) + body, err := readJSONBody(c) if err != nil { return err } @@ -101,22 +93,30 @@ func runAPI(c *cobra.Command, args []string) error { var query url.Values queryStr, _ := c.Flags().GetString("query") if queryStr != "" { - query, err = renderSingleQuery(queryStr, vars) + var err error + query, err = url.ParseQuery(queryStr) if err != nil { - return err + return fmt.Errorf("invalid query string: %w", err) } } - dryRun, _ := c.Flags().GetBool("dry-run") - if dryRun { - return output.Print(output.SuccessEnvelope(map[string]interface{}{ - "dry_run": true, - "method": method, - "path": path, - "query": query, - "body": body, - "variables": sortedVars(vars), - }, nil), resolveFormat()) + 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) @@ -132,7 +132,24 @@ func runAPI(c *cobra.Command, args []string) error { return output.Print(env, resolveFormat()) } -func readJSONBody(c *cobra.Command, vars map[string]string) (interface{}, error) { +// 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") bodyStdin, _ := c.Flags().GetBool("body-stdin") @@ -172,64 +189,7 @@ func readJSONBody(c *cobra.Command, vars map[string]string) (interface{}, error) if err := json.Unmarshal(data, &body); err != nil { return nil, fmt.Errorf("invalid JSON body: %w", err) } - rendered, err := renderBatchValue(body, vars) - if err != nil { - return nil, fmt.Errorf("render JSON body: %w", err) - } - return rendered, nil -} - -func renderSinglePath(path string, vars map[string]string) (string, error) { - rendered, err := renderTemplate(rewriteColonPlaceholders(path), vars) - if err != nil { - return "", fmt.Errorf("render path: %w", err) - } - return rendered, nil -} - -func renderSingleQuery(raw string, vars map[string]string) (url.Values, error) { - rendered, err := renderTemplate(raw, vars) - if err != nil { - return nil, fmt.Errorf("render query: %w", err) - } - query, err := url.ParseQuery(rendered) - if err != nil { - return nil, fmt.Errorf("invalid query string: %w", err) - } - return query, nil -} - -func addRepoContextVars(vars map[string]string) { - if vars == nil { - return - } - if vars["owner"] != "" && vars["repo"] != "" { - return - } - owner, repo, err := repoContext.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo) - if err != nil { - return - } - if vars["owner"] == "" { - vars["owner"] = owner - } - if vars["repo"] == "" { - vars["repo"] = repo - } -} - -func rewriteColonPlaceholders(path string) string { - return colonPathVarPattern.ReplaceAllString(path, `$1{{$2}}`) -} - -var colonPathVarPattern = templatePattern - -func init() { - colonPathVarPattern = mustCompileColonPattern() -} - -func mustCompileColonPattern() *regexp.Regexp { - return regexp.MustCompile(`(^|/):([A-Za-z0-9_.-]+)`) + return body, nil } func resolveFormat() string { diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 3dca0b7..fb324ca 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/doc/changes/list-all-pagination.md b/doc/changes/list-all-pagination.md new file mode 100644 index 0000000..36ecc06 --- /dev/null +++ b/doc/changes/list-all-pagination.md @@ -0,0 +1,98 @@ +# 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` + - `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`、`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` + (均生产实测确认) + - 输出与单页响应同构:`{"total_count": N, "<资源名>": [...]}`。 +- 总数字段兼容 `total_count` 与 `count`(如 `/users/:login/projects`)。 +- 修复三个既有分页语义缺口(均生产实测确认端点本身分页): + - `label +list` 完全没有 `--page/--limit`(端点实际返回 `total_count`),现已补齐; + - `member +list` 既无分页又走遗留路径(非管理员直接 403),现改走 + `/v1/:owner/:repo/collaborators`(支持分页且普通成员可读); + - `webhook +list` 完全没有 `--page/--limit`(探针实测:建 3 个 webhook 后 + `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 合并均通过。 +- 新增 `commit +list` 命令组:分页的 `/v1/:owner/:repo/commits` + 端点此前只能通过裸 api 命令访问;支持 `--ref` 指定分支/tag/SHA + (映射 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 + 封顶(如请求 100 每页只返 20)时,旧逻辑因「页内条数 < limit」提前 + 终止只拿到首页;现已知 total 时以 total 为准(watchers 264 条全量 + 合并生产实测),新增回归单测。 +- 新增 `issue +comments` 子命令(对标 `gh issue view --comments`): + 此前 CLI 只能发评论(`issue +comment`)无法读评论流,Agent 无法获取 + 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(避免假分页语义)。 +- 未加 `--all` 的 list 端点均经生产验证为非分页或未部署: + `licenses`/`ignores` 返回全量数组;`pm/pipelines` 404 未部署; + `pipeline +runs` 用 `total_data` 非标准包裹;dataset 端点未部署(平台 issue #144255)。 +- 中英文 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 +gitlink-cli milestone +list --all + +# 全部组织 / 某用户全部仓库 / 搜索结果全量 +gitlink-cli org +list --all +gitlink-cli repo +list --user Taoyouce --all +gitlink-cli search +repos --keyword gitlink --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 139f563..1bdabce 100644 --- a/internal/client/pagination.go +++ b/internal/client/pagination.go @@ -5,10 +5,31 @@ 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) { + 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,77 +38,168 @@ 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++ { + 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 { 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{}: - arr, ok := listArray(data) - if !ok { - // 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 } - for _, item := range arr { - raw, _ := json.Marshal(item) - items = append(items, raw) - } + break + } + if pageTotal >= 0 { + totalCount = pageTotal } if len(items) == 0 { break } - all = append(all, items...) - // Check if we got fewer items than limit - limit, _ := strconv.Atoi(params.Get("limit")) - if len(items) < limit { + 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 totalCount < 0 && len(items) < limit { break } - - page++ } return all, nil } -// listArray resolves the item array of one wrapped list page. GitLink wraps the -// array under the generic "data" key on some endpoints and under a -// resource-specific key on others ({"pulls":[...]}, {"issues":[...]}, -// {"branches":[...]}, ...), so prefer "data" and otherwise accept the sole -// array-valued field. A map with no array field — or several, which is -// ambiguous — is not a list page, so ok is false. -func listArray(data map[string]interface{}) (arr []interface{}, ok bool) { - if d, isArr := data["data"].([]interface{}); isArr { - return d, true +// 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 } - for _, v := range data { - slice, isArr := v.([]interface{}) - if !isArr { - continue - } - if ok { - return nil, false - } - arr, ok = slice, true + + type pageResult struct { + items []json.RawMessage + err error } - return arr, ok + 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. +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) + } 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 { + 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..d253cfe --- /dev/null +++ b/internal/client/pagination_test.go @@ -0,0 +1,279 @@ +package client + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" +) + +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)) + } +} + +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. + 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)) + } +} + +// 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) + } + } +} diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index d4dda92..83a373d 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -21,19 +21,28 @@ "cmd.config.list.short": "List all configuration values", "cmd.config.set.short": "Set a configuration value", "cmd.config.short": "Manage gitlink-cli configuration", + "cmd.dataset.create.long": "Create the dataset of a repository with a title, description, optional license and research paper content.", + "cmd.dataset.create.short": "Create a repository's dataset", + "cmd.dataset.delete_attachment.long": "Delete a dataset attachment by its UUID. This is destructive: preview with --dry-run, then pass --yes to confirm.", + "cmd.dataset.delete_attachment.short": "Delete a dataset attachment by UUID", + "cmd.dataset.list.long": "List datasets for one or more GitLink projects by their numeric project IDs.", + "cmd.dataset.list.short": "List datasets by project IDs", + "cmd.dataset.short": "Dataset operations", + "cmd.dataset.update.long": "Update the dataset of a repository (title, description, optional license and research paper content).", + "cmd.dataset.update.short": "Update a repository's dataset", + "cmd.dataset.view.long": "View a repository's dataset and its attachments. Use --page/--limit to paginate attachments.", + "cmd.dataset.view.short": "View a repository's dataset", "cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.", "cmd.doctor.short": "Diagnose gitlink-cli environment problems", - "cmd.issue.batch_assign.short": "Assign issues to a user or via CSV", - "cmd.issue.batch_close.long": "Close multiple issues. Pass issue numbers via --numbers or read from a CSV via --from.\n\nDefaults to dry-run; pass --confirm to execute.", - "cmd.issue.batch_close.short": "Close multiple issues by issue numbers or a CSV file", - "cmd.issue.batch_create.short": "Create multiple issues from CSV", - "cmd.issue.batch_delete.short": "Batch delete multiple issues (use with caution)", - "cmd.issue.batch_label.long": "Batch add, remove, or set labels on multiple issues.\n\nDefaults to dry-run; pass --confirm to execute.", - "cmd.issue.batch_label.short": "Add, remove, or set labels on multiple issues", - "cmd.issue.batch_open.short": "Reopen multiple issues", - "cmd.issue.batch_update.short": "Update multiple issues via CSV or --ids", + "cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", + "cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + "cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", + "cmd.issue.batch_label.short": "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + "cmd.issue.batch_list.long": "List issue batch maintenance candidates without changing remote data.\n\nExamples:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", + "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", @@ -43,9 +52,12 @@ "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", + "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", @@ -57,6 +69,17 @@ "cmd.pr.version_diff.short": "Show diff for a pull request patchset version", "cmd.pr.versions.short": "List pull request patchset versions", "cmd.pr.view.short": "View pull request details", + "cmd.profile.ability.long": "Show a user's development ability scores (influence, contribution, activity, experience, language) and language breakdown, sourced from the GitLink platform statistics API.", + "cmd.profile.ability.short": "Show a user's development ability scores", + "cmd.profile.activity.long": "Show a user's recent activity statistics: daily issues, pull requests, and commits over a time window.", + "cmd.profile.activity.short": "Show a user's recent activity statistics", + "cmd.profile.contribution.long": "Show a user's contribution heatmap (daily contribution counts) for a given year.", + "cmd.profile.contribution.short": "Show a user's contribution heatmap", + "cmd.profile.major.long": "Show a user's major/discipline categories (e.g. deep learning, quantum computing) inferred from their projects.", + "cmd.profile.major.short": "Show a user's major/discipline categories", + "cmd.profile.role.long": "Show a user's role positioning derived from the GitLink platform statistics API.", + "cmd.profile.role.short": "Show a user's role positioning", + "cmd.profile.short": "User profile and statistics operations", "cmd.release.create.short": "Create a release", "cmd.release.delete.short": "Delete a release", "cmd.release.list.short": "List releases", @@ -71,19 +94,12 @@ "cmd.repo.tree.short": "List repository files and directories", "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", "cmd.root.short": "GitLink CLI - command-line tool for GitLink", - "cmd.search.issues.short": "Search issues in a repository", "cmd.search.repos.short": "Search repositories", "cmd.search.short": "Search operations", "cmd.search.users.short": "Search users", - "cmd.user.headmaps.short": "Show user contribution heatmap", "cmd.user.info.short": "Show user profile", "cmd.user.me.short": "Show current authenticated user", "cmd.user.short": "User operations", - "cmd.user.stats_activity.short": "Show user activity statistics", - "cmd.user.stats_develop.short": "Show user development capability", - "cmd.user.stats_major.short": "Show user major positioning", - "cmd.user.stats_role.short": "Show user role positioning", - "cmd.user.trends.short": "Show user project activity trends", "cmd.version.short": "Print version information", "cmd.webhook.create.short": "Create a repository webhook", "cmd.webhook.delete.short": "Delete a repository webhook", @@ -93,28 +109,16 @@ "cmd.webhook.test.short": "Trigger a test delivery for a webhook", "cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available", "cmd.webhook.view.short": "View webhook details", - "cmd.wiki.create.long": "Create a wiki page. Content is provided via --content, --content-file, or --content-base64 and is base64-encoded automatically.", - "cmd.wiki.create.short": "Create a wiki page", - "cmd.wiki.delete.long": "Delete a wiki page. Supports --dry-run to preview the request.", - "cmd.wiki.delete.short": "Delete a wiki page", - "cmd.wiki.export.long": "Export the repository wiki as markdown, pdf, or html.", - "cmd.wiki.export.short": "Export the wiki", - "cmd.wiki.list.long": "List all wiki pages for a repository.", - "cmd.wiki.list.short": "List wiki pages", - "cmd.wiki.short": "Repository wiki operations", - "cmd.wiki.update.long": "Update an existing wiki page. Content is optional; when omitted only the title/message change.", - "cmd.wiki.update.short": "Update a wiki page", - "cmd.wiki.view.long": "View a single wiki page by its page name.", - "cmd.wiki.view.short": "View a wiki page", "error.auth.delete_token_failed": "failed to delete token: {message}", "error.auth.login_failed": "login failed: {message}", "error.auth.store_token_failed": "failed to store token: {message}", "error.auth.token_empty": "token cannot be empty", "error.config.save_failed": "failed to save config: {message}", + "error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm", "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}", - "error.wiki.content_required": "wiki content is required; pass --content, --content-file, or --content-base64", - "error.wiki.export_type": "invalid --type; use markdown, pdf, or html", + "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", @@ -123,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", @@ -131,6 +136,17 @@ "flag.ci.stage": "Stage number", "flag.ci.step": "Step number", "flag.comment.body": "Comment body", + "flag.dataset.description": "Dataset description", + "flag.dataset.dry_run": "Preview the request without writing the dataset", + "flag.dataset.dry_run_delete": "Preview the request without deleting the attachment", + "flag.dataset.ids": "Comma-separated project IDs to query datasets for", + "flag.dataset.license_id": "License ID", + "flag.dataset.limit": "Attachment page size", + "flag.dataset.page": "Attachment page number", + "flag.dataset.paper_content": "Research paper content", + "flag.dataset.title": "Dataset title", + "flag.dataset.uuid": "Attachment UUID", + "flag.dataset.yes": "Confirm the destructive deletion", "flag.debug": "Enable debug output", "flag.description": "Description", "flag.doctor.skip_network": "Skip authenticated API connectivity checks", @@ -140,36 +156,13 @@ "flag.issue.assignee": "Assignee login", "flag.issue.assignee_id": "Assignee user ID", "flag.issue.author_id": "Author user ID", - "flag.issue.batch.confirm": "Confirm batch operation", - "flag.issue.batch.delay": "Delay in milliseconds between requests", - "flag.issue.batch.dry_run": "Preview the issues that would be changed without making any changes", - "flag.issue.batch.from": "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header", - "flag.issue.batch.label": "Filter by label name", - "flag.issue.batch.max": "Maximum number of issues to process", - "flag.issue.batch.numbers": "Comma-separated issue numbers from the web URL, for example: 1,2,3", - "flag.issue.batch.search": "Search issues by keyword", - "flag.issue.batch.state": "Filter by state: open, closed, all", - "flag.issue.batch_assign.assignee": "Assignee username or ID (uniform mode)", - "flag.issue.batch_assign.csv": "CSV file with number,assignee columns", - "flag.issue.batch_assign.numbers": "Comma-separated issue numbers", - "flag.issue.batch_close.state": "Filter by state before closing", - "flag.issue.batch_create.csv": "CSV file with issue data", - "flag.issue.batch_create.print_schema": "Print CSV header and exit", - "flag.issue.batch_delete.confirm": "Confirm deletion (required for safety)", - "flag.issue.batch_delete.dry_run": "Preview deletion without executing", - "flag.issue.batch_delete.ids": "Comma-separated issue IDs", - "flag.issue.batch_label.action": "Action: add, remove, or set", - "flag.issue.batch_label.csv": "CSV file path with issue numbers", - "flag.issue.batch_label.label_ids": "Comma-separated label IDs (mutually exclusive with --labels)", - "flag.issue.batch_label.labels": "Comma-separated label names", - "flag.issue.batch_label.numbers": "Comma-separated issue numbers", - "flag.issue.batch_update.assignees": "Comma-separated assignee user IDs", - "flag.issue.batch_update.csv": "CSV file path with updates", - "flag.issue.batch_update.ids": "Comma-separated issue IDs", - "flag.issue.batch_update.milestone": "Milestone ID", - "flag.issue.batch_update.priority": "Priority ID", - "flag.issue.batch_update.status": "New status: open or closed", - "flag.issue.batch_update.tags": "Comma-separated label/tag IDs", + "flag.issue.batch.reason": "Optional reason shown in the batch result", + "flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.", + "flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7", + "flag.issue.batch_close.state": "Filter by issue state before closing", + "flag.issue.batch_label.state": "Filter by issue state", + "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", + "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", "flag.issue.body": "Issue description", "flag.issue.label": "Label ID", "flag.issue.label_filter": "Filter by existing label", @@ -209,6 +202,10 @@ "flag.pr.tag_id": "Issue tag ID", "flag.pr.title": "PR title", "flag.pr.version_id": "Patchset version ID", + "flag.profile.end_time": "End time (Unix timestamp)", + "flag.profile.start_time": "Start time (Unix timestamp)", + "flag.profile.user": "Target user login (defaults to the authenticated user)", + "flag.profile.year": "Year for the contribution heatmap (e.g. 2025)", "flag.release.body": "Release notes", "flag.release.id": "Release ID", "flag.release.id_or_tag": "Release ID or tag", @@ -223,11 +220,6 @@ "flag.repo.private": "Make repository private (true/false)", "flag.repo.tree.path": "Directory path to list (default: repository root)", "flag.repo.tree.ref": "Branch, tag, or commit ref", - "flag.search.issues.assignee": "Filter by assignee user ID", - "flag.search.issues.author": "Filter by author user ID", - "flag.search.issues.category": "Issue category: all, opened, closed", - "flag.search.issues.milestone": "Filter by milestone ID", - "flag.search.issues.tag": "Filter by tag IDs (comma-separated)", "flag.search.keyword": "Search keyword", "flag.sort_by": "Sort field", "flag.sort_direction": "Sort direction: asc, desc", @@ -243,16 +235,6 @@ "flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.", "flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", "flag.webhook.url": "Webhook target URL", - "flag.wiki.content": "Wiki page content (plain text, base64-encoded automatically)", - "flag.wiki.content_base64": "Wiki page content already base64-encoded", - "flag.wiki.content_file": "Read wiki page content from a file (base64-encoded automatically)", - "flag.wiki.dry_run": "Preview the request without changing the wiki", - "flag.wiki.export_type": "Export format: markdown, pdf, or html", - "flag.wiki.message": "Commit message for the change", - "flag.wiki.page": "Wiki page name", - "flag.wiki.project_id": "GitLink project ID. If omitted, it is resolved from --owner/--repo.", - "flag.wiki.project_name": "Project name for export (defaults to the repository name)", - "flag.wiki.title": "Wiki page title (defaults to the page name)", "output.auth.env_hint": " Or set {env} environment variable", "output.auth.login_hint": " Run: gitlink-cli auth login", "output.config.file": "Config file: {path}", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index c85f4c9..fa505e0 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -21,19 +21,28 @@ "cmd.config.list.short": "列出所有配置项", "cmd.config.set.short": "设置配置项", "cmd.config.short": "管理 gitlink-cli 配置", + "cmd.dataset.create.long": "为仓库创建数据集,包含标题、描述、可选许可证和研究论文内容。", + "cmd.dataset.create.short": "创建仓库数据集", + "cmd.dataset.delete_attachment.long": "按 UUID 删除数据集附件。该操作具有破坏性:先用 --dry-run 预览,再传 --yes 确认。", + "cmd.dataset.delete_attachment.short": "按 UUID 删除数据集附件", + "cmd.dataset.list.long": "按数字项目 ID 列出一个或多个 GitLink 项目的数据集。", + "cmd.dataset.list.short": "按项目 ID 列出数据集", + "cmd.dataset.short": "数据集操作", + "cmd.dataset.update.long": "更新仓库数据集(标题、描述、可选许可证和研究论文内容)。", + "cmd.dataset.update.short": "更新仓库数据集", + "cmd.dataset.view.long": "查看仓库的数据集及其附件。用 --page/--limit 对附件分页。", + "cmd.dataset.view.short": "查看仓库数据集", "cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。", "cmd.doctor.short": "诊断 gitlink-cli 环境问题", - "cmd.issue.batch_assign.short": "批量指派议题负责人", - "cmd.issue.batch_close.long": "批量关闭多个议题。支持 --numbers 直接传入编号,或 --from 从 CSV 读取。\n\n默认 dry-run,传入 --confirm 后执行。", - "cmd.issue.batch_close.short": "批量关闭多个议题(按编号或 CSV)", - "cmd.issue.batch_create.short": "从 CSV 批量创建议题", - "cmd.issue.batch_delete.short": "批量删除多个议题(谨慎使用)", - "cmd.issue.batch_label.long": "批量给多个议题添加、移除或设置标签。\n\n默认 dry-run,传入 --confirm 后执行。", - "cmd.issue.batch_label.short": "批量管理议题标签(添加/移除/设置)", - "cmd.issue.batch_open.short": "批量重新打开多个议题", - "cmd.issue.batch_update.short": "批量更新议题(按 CSV 或 ID)", + "cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", + "cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。", + "cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", + "cmd.issue.batch_label.short": "给筛选后的议题批量添加标签。默认 dry-run;传入 --yes 后执行。", + "cmd.issue.batch_list.long": "列出议题批量维护候选项,不修改远端数据。\n\n示例:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", + "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": "议题操作", @@ -43,9 +52,12 @@ "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": "给拉取请求添加评论", + "cmd.pr.comments.short": "列出 PR 的评论", + "cmd.pr.commits.short": "列出 PR 的提交", "cmd.pr.create.short": "创建拉取请求", "cmd.pr.diff.short": "显示拉取请求 diff", "cmd.pr.files.short": "列出拉取请求中的变更文件", @@ -57,6 +69,17 @@ "cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff", "cmd.pr.versions.short": "列出拉取请求补丁集版本", "cmd.pr.view.short": "查看拉取请求详情", + "cmd.profile.ability.long": "显示用户的开发能力评分(影响力、贡献度、活跃度、项目经验、语言能力)及语言分布,数据来自 GitLink 平台统计接口。", + "cmd.profile.ability.short": "显示用户开发能力评分", + "cmd.profile.activity.long": "显示用户近期活动统计:时间范围内每日的疑修、合并请求和提交数量。", + "cmd.profile.activity.short": "显示用户近期活动统计", + "cmd.profile.contribution.long": "显示用户在指定年份的贡献热力图(每日贡献次数)。", + "cmd.profile.contribution.short": "显示用户贡献热力图", + "cmd.profile.major.long": "显示根据用户项目推断的专业/学科领域(如深度学习、量子计算)。", + "cmd.profile.major.short": "显示用户专业/学科定位", + "cmd.profile.role.long": "显示来自 GitLink 平台统计接口的用户角色定位。", + "cmd.profile.role.short": "显示用户角色定位", + "cmd.profile.short": "用户画像与统计操作", "cmd.release.create.short": "创建发布", "cmd.release.delete.short": "删除发布", "cmd.release.list.short": "列出发布", @@ -71,19 +94,12 @@ "cmd.repo.tree.short": "列出仓库文件和目录", "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", "cmd.root.short": "GitLink CLI - GitLink 命令行工具", - "cmd.search.issues.short": "搜索仓库议题", "cmd.search.repos.short": "搜索仓库", "cmd.search.short": "搜索操作", "cmd.search.users.short": "搜索用户", - "cmd.user.headmaps.short": "显示用户贡献热力图", "cmd.user.info.short": "显示用户资料", "cmd.user.me.short": "显示当前认证用户", "cmd.user.short": "用户操作", - "cmd.user.stats_activity.short": "显示用户活动统计", - "cmd.user.stats_develop.short": "显示用户开发能力", - "cmd.user.stats_major.short": "显示用户专业/学科定位", - "cmd.user.stats_role.short": "显示用户角色定位", - "cmd.user.trends.short": "显示用户项目活动趋势", "cmd.version.short": "打印版本信息", "cmd.webhook.create.short": "创建仓库 Webhook", "cmd.webhook.delete.short": "删除仓库 Webhook", @@ -93,28 +109,16 @@ "cmd.webhook.test.short": "触发 Webhook 测试投递", "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", "cmd.webhook.view.short": "查看 Webhook 详情", - "cmd.wiki.create.long": "创建 Wiki 页面。内容通过 --content、--content-file 或 --content-base64 提供,会自动进行 base64 编码。", - "cmd.wiki.create.short": "创建 Wiki 页面", - "cmd.wiki.delete.long": "删除 Wiki 页面。支持 --dry-run 预览请求。", - "cmd.wiki.delete.short": "删除 Wiki 页面", - "cmd.wiki.export.long": "将仓库 Wiki 导出为 markdown、pdf 或 html。", - "cmd.wiki.export.short": "导出 Wiki", - "cmd.wiki.list.long": "列出仓库的所有 Wiki 页面。", - "cmd.wiki.list.short": "列出 Wiki 页面", - "cmd.wiki.short": "仓库 Wiki 操作", - "cmd.wiki.update.long": "更新已有的 Wiki 页面。内容可选;省略时仅修改标题/提交信息。", - "cmd.wiki.update.short": "更新 Wiki 页面", - "cmd.wiki.view.long": "按页面名称查看单个 Wiki 页面。", - "cmd.wiki.view.short": "查看 Wiki 页面", "error.auth.delete_token_failed": "删除 Token 失败:{message}", "error.auth.login_failed": "登录失败:{message}", "error.auth.store_token_failed": "保存 Token 失败:{message}", "error.auth.token_empty": "Token 不能为空", "error.config.save_failed": "保存配置失败:{message}", + "error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认", "error.missing_required_flag": "缺少必需参数 --{name}", + "error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", "error.unsupported_language": "不支持的语言:{lang}", - "error.wiki.content_required": "需要提供 Wiki 内容;请使用 --content、--content-file 或 --content-base64", - "error.wiki.export_type": "无效的 --type;请使用 markdown、pdf 或 html", + "flag.all": "自动获取全部分页(忽略 --page)", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", "flag.api.batch_file": "从 JSON 文件读取 API 批处理计划", @@ -123,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", @@ -131,6 +136,17 @@ "flag.ci.stage": "阶段编号", "flag.ci.step": "步骤编号", "flag.comment.body": "评论内容", + "flag.dataset.description": "数据集描述", + "flag.dataset.dry_run": "预览请求,不写入数据集", + "flag.dataset.dry_run_delete": "预览请求,不删除附件", + "flag.dataset.ids": "用于查询数据集的项目 ID,逗号分隔", + "flag.dataset.license_id": "许可证 ID", + "flag.dataset.limit": "附件每页数量", + "flag.dataset.page": "附件页码", + "flag.dataset.paper_content": "研究论文内容", + "flag.dataset.title": "数据集标题", + "flag.dataset.uuid": "附件 UUID", + "flag.dataset.yes": "确认执行破坏性删除", "flag.debug": "启用调试输出", "flag.description": "描述", "flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查", @@ -140,36 +156,13 @@ "flag.issue.assignee": "负责人登录名", "flag.issue.assignee_id": "负责人用户 ID", "flag.issue.author_id": "作者用户 ID", - "flag.issue.batch.confirm": "确认执行批量操作", - "flag.issue.batch.delay": "请求间隔(毫秒)", - "flag.issue.batch.dry_run": "预览将被修改的议题,不实际执行", - "flag.issue.batch.from": "从 CSV 文件读取议题编号(支持 number/issue_number/project_issues_index 列)", - "flag.issue.batch.label": "按标签名筛选", - "flag.issue.batch.max": "最多处理的议题数", - "flag.issue.batch.numbers": "议题编号列表(逗号分隔),例如 1,2,3", - "flag.issue.batch.search": "按关键词搜索议题", - "flag.issue.batch.state": "按状态筛选:open、closed、all", - "flag.issue.batch_assign.assignee": "负责人用户名或 ID(统一模式)", - "flag.issue.batch_assign.csv": "CSV 文件(含 number,assignee 列)", - "flag.issue.batch_assign.numbers": "议题编号列表(逗号分隔)", + "flag.issue.batch.reason": "批量结果中显示的可选原因", + "flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。", + "flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7", "flag.issue.batch_close.state": "关闭前按议题状态筛选", - "flag.issue.batch_create.csv": "含议题数据的 CSV 文件", - "flag.issue.batch_create.print_schema": "打印 CSV 表头并退出", - "flag.issue.batch_delete.confirm": "确认删除(安全必需)", - "flag.issue.batch_delete.dry_run": "预览删除,不实际执行", - "flag.issue.batch_delete.ids": "议题 ID 列表(逗号分隔)", - "flag.issue.batch_label.action": "操作类型:add、remove 或 set", - "flag.issue.batch_label.csv": "含议题编号的 CSV 文件路径", - "flag.issue.batch_label.label_ids": "标签 ID 列表(逗号分隔,与 --labels 互斥)", - "flag.issue.batch_label.labels": "标签名列表(逗号分隔)", - "flag.issue.batch_label.numbers": "议题编号列表(逗号分隔)", - "flag.issue.batch_update.assignees": "负责人用户 ID 列表(逗号分隔)", - "flag.issue.batch_update.csv": "含更新数据的 CSV 文件路径", - "flag.issue.batch_update.ids": "议题 ID 列表(逗号分隔)", - "flag.issue.batch_update.milestone": "里程碑 ID", - "flag.issue.batch_update.priority": "优先级 ID", - "flag.issue.batch_update.status": "新状态:open 或 closed", - "flag.issue.batch_update.tags": "标签/标记 ID 列表(逗号分隔)", + "flag.issue.batch_label.state": "按议题状态筛选", + "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", + "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", "flag.issue.body": "议题描述", "flag.issue.label": "标签 ID", "flag.issue.label_filter": "按已有标签筛选", @@ -209,6 +202,10 @@ "flag.pr.tag_id": "议题标签 ID", "flag.pr.title": "PR 标题", "flag.pr.version_id": "补丁集版本 ID", + "flag.profile.end_time": "结束时间(Unix 时间戳)", + "flag.profile.start_time": "开始时间(Unix 时间戳)", + "flag.profile.user": "目标用户登录名(默认为当前认证用户)", + "flag.profile.year": "贡献热力图的年份(如 2025)", "flag.release.body": "发布说明", "flag.release.id": "发布 ID", "flag.release.id_or_tag": "发布 ID 或标签", @@ -223,11 +220,6 @@ "flag.repo.private": "设为私有仓库(true/false)", "flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)", "flag.repo.tree.ref": "分支、标签或提交引用", - "flag.search.issues.assignee": "按负责人用户 ID 筛选", - "flag.search.issues.author": "按作者用户 ID 筛选", - "flag.search.issues.category": "议题分类:all、opened、closed", - "flag.search.issues.milestone": "按里程碑 ID 筛选", - "flag.search.issues.tag": "按标签 ID 筛选(逗号分隔)", "flag.search.keyword": "搜索关键词", "flag.sort_by": "排序字段", "flag.sort_direction": "排序方向:asc、desc", @@ -243,16 +235,6 @@ "flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。", "flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", "flag.webhook.url": "Webhook 目标 URL", - "flag.wiki.content": "Wiki 页面内容(纯文本,自动 base64 编码)", - "flag.wiki.content_base64": "已经过 base64 编码的 Wiki 页面内容", - "flag.wiki.content_file": "从文件读取 Wiki 页面内容(自动 base64 编码)", - "flag.wiki.dry_run": "预览请求,不修改 Wiki", - "flag.wiki.export_type": "导出格式:markdown、pdf 或 html", - "flag.wiki.message": "本次变更的提交信息", - "flag.wiki.page": "Wiki 页面名称", - "flag.wiki.project_id": "GitLink 项目 ID。省略时从 --owner/--repo 解析。", - "flag.wiki.project_name": "导出用的项目名称(默认使用仓库名称)", - "flag.wiki.title": "Wiki 页面标题(默认使用页面名称)", "output.auth.env_hint": " 或设置 {env} 环境变量", "output.auth.login_hint": " 运行:gitlink-cli auth login", "output.config.file": "配置文件:{path}", diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 2c2a341..a5c1e62 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -1,11 +1,8 @@ package branch import ( - "errors" "fmt" "net/url" - "strconv" - "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -20,8 +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: "state", Usage: tr.T("flag.branch.state")}, - {Name: "keyword", Short: "k", Usage: tr.T("flag.branch.keyword")}, + {Name: "all", Usage: tr.T("flag.all"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -30,11 +26,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if state := strings.TrimSpace(ctx.Arg("state")); state != "" { - q.Set("state", state) - } - if keyword := strings.TrimSpace(ctx.Arg("keyword")); keyword != "" { - q.Set("keyword", keyword) + 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 { @@ -43,20 +40,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - { - Name: "all", - Description: tr.T("cmd.branch.all.short"), - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", "/v1"+ctx.RepoPath()+"/branches/all", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, { Name: "create", Description: tr.T("cmd.branch.create.short"), @@ -105,53 +88,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - { - Name: "set-default", - Description: tr.T("cmd.branch.set_default.short"), - Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - name, _ := ctx.RequireArg("name") - q := url.Values{} - q.Set("name", name) - env, err := ctx.CallAPIWithQuery("PATCH", "/v1"+ctx.RepoPath()+"/branches/update_default_branch", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "restore", - Description: tr.T("cmd.branch.restore.short"), - Flags: []common.Flag{ - {Name: "branch-id", Usage: tr.T("flag.branch.id"), Required: true}, - {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - branchID, err := parseBranchID(ctx) - if err != nil { - return err - } - name, _ := ctx.RequireArg("name") - payload := map[string]interface{}{ - "branch_id": branchID, - "branch_name": name, - } - env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/restore", payload) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, { Name: "protect", Description: tr.T("cmd.branch.protect.short"), @@ -200,12 +136,3 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { } return i18n.Default() } - -func parseBranchID(ctx *common.RuntimeContext) (int, error) { - value, _ := ctx.RequireArg("branch-id") - parsed, err := strconv.Atoi(strings.TrimSpace(value)) - if err != nil || parsed <= 0 { - return 0, errors.New(ctx.Tr.Tf("error.branch.id_invalid", i18n.Args{"value": value})) - } - return parsed, nil -} diff --git a/shortcuts/commit/commit.go b/shortcuts/commit/commit.go index 0155cdb..475dc89 100644 --- a/shortcuts/commit/commit.go +++ b/shortcuts/commit/commit.go @@ -4,36 +4,44 @@ import ( "fmt" "net/url" - "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func v1RepoPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) -} - -func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { - tr := shortcutTranslator(translators...) +// 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: tr.T("cmd.commit.list.short"), + Description: "List repository commits", Flags: []common.Flag{ - {Name: "sha", Short: "s", Usage: tr.T("flag.commit.sha_start")}, - {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + {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 sha := ctx.Arg("sha"); sha != "" { - q.Set("sha", sha) + if ref := ctx.Arg("ref"); ref != "" { + q.Set("sha", ref) } - env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/commits", q) + 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 } @@ -41,35 +49,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "recent", - Description: tr.T("cmd.commit.recent.short"), + Name: "view", + Description: "View a single commit", Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: tr.T("flag.commit.keyword")}, - {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - }, - 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 keyword := ctx.Arg("keyword"); keyword != "" { - q.Set("keyword", keyword) - } - env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/commits/recent", q) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "diff", - Description: tr.T("cmd.commit.diff.short"), - Flags: []common.Flag{ - {Name: "sha", Short: "s", Usage: tr.T("flag.commit.sha"), Required: true}, + {Name: "sha", Short: "s", Usage: "Commit SHA", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -79,37 +62,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/commits/%s/diff", v1RepoPath(ctx), url.PathEscape(sha)), nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "files", - Description: tr.T("cmd.commit.files.short"), - Flags: []common.Flag{ - {Name: "sha", Short: "s", Usage: tr.T("flag.commit.sha"), Required: true}, - {Name: "filepath", Short: "f", Usage: tr.T("flag.commit.filepath")}, - {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - sha, err := ctx.RequireArg("sha") - if err != nil { - return err - } - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if fp := ctx.Arg("filepath"); fp != "" { - q.Set("filepath", fp) - } - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/commits/%s/files", v1RepoPath(ctx), url.PathEscape(sha)), q) + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/commits/%s", ctx.RepoPath(), sha), nil) if err != nil { return err } @@ -118,10 +71,3 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, } } - -func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { - if len(translators) > 0 && translators[0] != nil { - return translators[0] - } - return i18n.Default() -} diff --git a/shortcuts/commit/commit_test.go b/shortcuts/commit/commit_test.go index 539af86..0fe593c 100644 --- a/shortcuts/commit/commit_test.go +++ b/shortcuts/commit/commit_test.go @@ -1,97 +1,90 @@ package commit import ( + "encoding/json" "net/http" "net/http/httptest" - "strings" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { +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 _, s := range Shortcuts() { - if s.Name == name { + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, Owner: "owner", Repo: "repo", Format: "json", Args: args, } - if ctx.Args == nil { - ctx.Args = map[string]string{} - } - return s.Run(ctx) + return shortcut.Run(ctx) } } t.Fatalf("shortcut %q not found", name) return nil } -func TestCommitListForwardsShaAndPaging(t *testing.T) { - var query string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - query = r.URL.RawQuery - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"total_count":1,"commits":[]}`)) - })) - defer server.Close() - - if err := runShortcut(t, server, "list", map[string]string{"sha": "develop", "page": "2", "limit": "5"}); err != nil { - t.Fatalf("list failed: %v", err) - } - for _, want := range []string{"sha=develop", "page=2", "limit=5"} { - if !strings.Contains(query, want) { - t.Fatalf("expected %q in query, got %q", want, query) - } +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 TestCommitRecentForwardsKeyword(t *testing.T) { - var query string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits/recent.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - query = r.URL.RawQuery - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"commits":[]}`)) - })) - defer server.Close() - - if err := runShortcut(t, server, "recent", map[string]string{"keyword": "fix", "page": "1", "limit": "20"}); err != nil { - t.Fatalf("recent failed: %v", err) - } - if !strings.Contains(query, "keyword=fix") { - t.Fatalf("expected keyword=fix in query, got %q", query) - } -} - -func TestCommitDiffAndFilesBuildShaPaths(t *testing.T) { - var paths []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - paths = append(paths, r.URL.Path) - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"ok":true}`)) - })) - defer server.Close() - - if err := runShortcut(t, server, "diff", map[string]string{"sha": "abc123"}); err != nil { - t.Fatalf("diff failed: %v", err) - } - if err := runShortcut(t, server, "files", map[string]string{"sha": "abc123", "filepath": "app/x.rb", "page": "1", "limit": "20"}); err != nil { - t.Fatalf("files failed: %v", err) - } - if paths[0] != "/v1/owner/repo/commits/abc123/diff.json" { - t.Fatalf("diff path = %s", paths[0]) - } - if paths[1] != "/v1/owner/repo/commits/abc123/files.json" { - t.Fatalf("files path = %s", paths[1]) +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/common/types.go b/shortcuts/common/types.go index 11762d1..4b7d5c6 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -96,6 +96,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 4b79aa4..79d240e 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -15,21 +16,54 @@ func v1RepoPath(ctx *common.RuntimeContext) string { return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) } +func normalizeIssueListState(state string) string { + switch strings.ToLower(strings.TrimSpace(state)) { + case "open", "opened": + return "opened" + case "closed": + return "closed" + case "all", "": + return "all" + default: + return state + } +} + type existingIssue struct { Subject string Description string + StatusID interface{} + PriorityID interface{} + TagIDs []interface{} + AssignerIDs []interface{} + BranchName string + StartDate string + DueDate string } -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ newBatchCloseShortcut(), + newBatchUpdateShortcut(), + newBatchDeleteShortcut(), { Name: "list", - Description: "List issues", + Description: tr.T("cmd.issue.list.short"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"}, + {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword")}, + {Name: "participant", Usage: tr.T("flag.issue.participant")}, + {Name: "author-id", Usage: tr.T("flag.issue.author_id")}, + {Name: "assignee-id", Usage: tr.T("flag.issue.assignee_id")}, + {Name: "milestone-id", Usage: tr.T("flag.issue.milestone")}, + {Name: "status-id", Usage: tr.T("flag.issue.status_id")}, + {Name: "tag-ids", Usage: tr.T("flag.issue.tag_ids")}, + {Name: "sort-by", Usage: tr.T("flag.sort_by")}, + {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 { @@ -39,7 +73,43 @@ func Shortcuts() []*common.Shortcut { q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) if s := ctx.Arg("state"); s != "" { - q.Set("state", s) + q.Set("category", normalizeIssueListState(s)) + } + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + if participant := ctx.Arg("participant"); participant != "" { + q.Set("participant_category", participant) + } + if authorID := ctx.Arg("author-id"); authorID != "" { + q.Set("author_id", authorID) + } + if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" { + q.Set("assigner_id", assigneeID) + } + if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" { + q.Set("milestone_id", milestoneID) + } + if statusID := ctx.Arg("status-id"); statusID != "" { + q.Set("status_id", statusID) + } + if tagIDs := ctx.Arg("tag-ids"); tagIDs != "" { + q.Set("issue_tag_ids", tagIDs) + } + if sortBy := ctx.Arg("sort-by"); sortBy != "" { + q.Set("sort_by", sortBy) + } + 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 { @@ -51,13 +121,19 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a new issue", + Description: tr.T("cmd.issue.create.short"), Flags: []common.Flag{ - {Name: "title", Short: "t", Usage: "Issue title", Required: true}, - {Name: "body", Short: "b", Usage: "Issue description"}, - {Name: "assignee", Short: "a", Usage: "Assignee login"}, - {Name: "milestone", Short: "m", Usage: "Milestone ID"}, - {Name: "label", Usage: "Label ID"}, + {Name: "title", Short: "t", Usage: tr.T("flag.issue.title"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.issue.body")}, + {Name: "assignee", Short: "a", Usage: tr.T("flag.issue.assignee")}, + {Name: "milestone", Short: "m", Usage: tr.T("flag.issue.milestone")}, + {Name: "label", Usage: tr.T("flag.issue.label")}, + {Name: "priority-id", Usage: "Priority ID", Default: "2"}, + {Name: "tag-ids", Usage: "Comma-separated issue tag IDs"}, + {Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"}, + {Name: "branch", Usage: "Linked branch name"}, + {Name: "start-date", Usage: "Start date (YYYY-MM-DD)"}, + {Name: "due-date", Usage: "Due date (YYYY-MM-DD)"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -82,18 +158,9 @@ func Shortcuts() []*common.Shortcut { if m := ctx.Arg("milestone"); m != "" { body["fixed_version_id"] = m } - if l := ctx.Arg("label"); l != "" { - var tagIDs []int - for _, s := range strings.Split(l, ",") { - s = strings.TrimSpace(s) - if id, err := strconv.Atoi(s); err == nil { - tagIDs = append(tagIDs, id) - } - } - if len(tagIDs) > 0 { - body["issue_tag_ids"] = tagIDs - } - } + if err := applyIssueMetadataArgs(ctx, body); err != nil { + return err + } env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) if err != nil { return err @@ -103,15 +170,13 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View issue details", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - }, + Description: tr.T("cmd.issue.view.short"), + Flags: issueNumberFlags(), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := issueNumberArg(ctx) if err != nil { return err } @@ -124,15 +189,13 @@ func Shortcuts() []*common.Shortcut { }, { Name: "close", - Description: "Close an issue", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - }, + Description: tr.T("cmd.issue.close.short"), + Flags: issueNumberFlags(), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := issueNumberArg(ctx) if err != nil { return err } @@ -144,8 +207,9 @@ func Shortcuts() []*common.Shortcut { body := map[string]interface{}{ "subject": current.Subject, "description": current.Description, - "status_id": 5, // 5 = closed } + preserveIssueMetadata(body, current) + body["status_id"] = 5 // 5 = closed env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { return err @@ -155,28 +219,31 @@ func Shortcuts() []*common.Shortcut { }, { Name: "update", - Description: "Update an issue", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - {Name: "title", Short: "t", Usage: "New title"}, - {Name: "body", Short: "b", Usage: "New description"}, - {Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"}, - {Name: "label", Short: "l", Usage: "Label IDs (comma-separated, empty to clear)"}, - }, + Description: tr.T("cmd.issue.update.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")}, + common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")}, + common.Flag{Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")}, + common.Flag{Name: "priority-id", Usage: "New priority ID"}, + common.Flag{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"}, + common.Flag{Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"}, + common.Flag{Name: "branch", Usage: "Linked branch name"}, + common.Flag{Name: "start-date", Usage: "Start date (YYYY-MM-DD)"}, + common.Flag{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"}, + ), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := issueNumberArg(ctx) if err != nil { return err } title := ctx.Arg("title") description := ctx.Arg("body") state := ctx.Arg("state") - label := ctx.Arg("label") - if title == "" && description == "" && state == "" && label == "" { - return fmt.Errorf("at least one of --title, --body, --state, or --label is required") + if title == "" && description == "" && state == "" && !hasIssueMetadataArgs(ctx) { + return fmt.Errorf("at least one update field is required") } current, err := fetchExistingIssue(ctx, number) @@ -188,6 +255,7 @@ func Shortcuts() []*common.Shortcut { "subject": current.Subject, "description": current.Description, } + preserveIssueMetadata(body, current) if t := ctx.Arg("title"); t != "" { body["subject"] = t } @@ -201,21 +269,8 @@ func Shortcuts() []*common.Shortcut { } body["status_id"] = statusID } - if label != "" { - if label == "clear" { - body["issue_tag_ids"] = []int{} - } else { - var tagIDs []int - for _, s := range strings.Split(label, ",") { - s = strings.TrimSpace(s) - if id, err := strconv.Atoi(s); err == nil { - tagIDs = append(tagIDs, id) - } - } - if len(tagIDs) > 0 { - body["issue_tag_ids"] = tagIDs - } - } + if err := applyIssueMetadataArgs(ctx, body); err != nil { + return err } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { @@ -226,16 +281,15 @@ func Shortcuts() []*common.Shortcut { }, { Name: "comment", - Description: "Add a comment to an issue", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - {Name: "body", Short: "b", Usage: "Comment body", Required: true}, - }, + Description: tr.T("cmd.issue.comment.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, + ), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := issueNumberArg(ctx) if err != nil { return err } @@ -253,6 +307,40 @@ func Shortcuts() []*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", @@ -295,9 +383,112 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "priorities", + Description: "List issue priorities", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_priorities", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "tags", + Description: "List issue tags", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + {Name: "only-name", Usage: "Only return tag names and IDs", Bool: true, Default: "false"}, + {Name: "order-by", Usage: "Order by: updated_on, created_on, issues_count"}, + {Name: "order-direction", Usage: "Order direction: asc or desc"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + if parseBool(ctx.Arg("only-name")) { + q.Set("only_name", "true") + } + if orderBy := ctx.Arg("order-by"); orderBy != "" { + q.Set("order_by", orderBy) + } + if orderDirection := ctx.Arg("order-direction"); orderDirection != "" { + q.Set("order_direction", orderDirection) + } + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_tags", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "statuses", + Description: "List issue statuses", + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + 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")) + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_statues", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + +func issueNumberFlags() []common.Flag { + return []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue number from the web URL (preferred)"}, + {Name: "id", Short: "i", Usage: "Compatibility alias for --number; this is not the database ID"}, + } +} + +func appendIssueNumberFlags(flags ...common.Flag) []common.Flag { + return append(issueNumberFlags(), flags...) +} + +func issueNumberArg(ctx *common.RuntimeContext) (string, error) { + if number := strings.TrimSpace(ctx.Arg("number")); number != "" { + return number, nil + } + if id := strings.TrimSpace(ctx.Arg("id")); id != "" { + return id, nil + } + return "", fmt.Errorf("required flag --number is missing (or use --id as a compatibility alias)") +} + // normalizeIssueListIDs adds "number" (project_issues_index) and renames // "id" to "database_id" so the user-facing output uses the project-level // issue number, not the global database primary key. @@ -345,9 +536,76 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss return &existingIssue{ Subject: subject, Description: description, + StatusID: nestedIssueID(issueData, "status"), + PriorityID: nestedIssueID(issueData, "priority"), + TagIDs: issueObjectIDs(issueData, "tags", "issue_tags"), + AssignerIDs: issueObjectIDs(issueData, "assigners"), + BranchName: stringField(issueData, "branch_name"), + StartDate: stringField(issueData, "start_date"), + DueDate: stringField(issueData, "due_date"), }, nil } +func preserveIssueMetadata(body map[string]interface{}, issue *existingIssue) { + if issue.StatusID != nil { + body["status_id"] = issue.StatusID + } + if issue.PriorityID != nil { + body["priority_id"] = issue.PriorityID + } + if len(issue.TagIDs) > 0 { + body["issue_tag_ids"] = issue.TagIDs + } + if len(issue.AssignerIDs) > 0 { + body["assigner_ids"] = issue.AssignerIDs + } + if issue.BranchName != "" { + body["branch_name"] = issue.BranchName + } + if issue.StartDate != "" { + body["start_date"] = issue.StartDate + } + if issue.DueDate != "" { + body["due_date"] = issue.DueDate + } +} + +func nestedIssueID(data map[string]interface{}, key string) interface{} { + item, ok := data[key].(map[string]interface{}) + if !ok { + return nil + } + return item["id"] +} + +func issueObjectIDs(data map[string]interface{}, keys ...string) []interface{} { + for _, key := range keys { + items, ok := data[key].([]interface{}) + if !ok { + continue + } + ids := make([]interface{}, 0, len(items)) + for _, item := range items { + obj, ok := item.(map[string]interface{}) + if !ok { + continue + } + if id, ok := obj["id"]; ok { + ids = append(ids, id) + } + } + if len(ids) > 0 { + return ids + } + } + return nil +} + +func stringField(data map[string]interface{}, key string) string { + value, _ := data[key].(string) + return value +} + func normalizeIssueStatus(state string) (interface{}, error) { switch strings.ToLower(strings.TrimSpace(state)) { case "open": @@ -361,3 +619,78 @@ func normalizeIssueStatus(state string) (interface{}, error) { return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state) } } + +func hasIssueMetadataArgs(ctx *common.RuntimeContext) bool { + for _, name := range []string{"priority-id", "tag-ids", "label", "assigner-ids", "branch", "start-date", "due-date"} { + if ctx.Arg(name) != "" { + return true + } + } + return false +} + +func applyIssueMetadataArgs(ctx *common.RuntimeContext, body map[string]interface{}) error { + if priority := ctx.Arg("priority-id"); priority != "" { + priorityID, err := parseIssueID(priority, "priority-id") + if err != nil { + return err + } + body["priority_id"] = priorityID + } + tagIDs := ctx.Arg("tag-ids") + if label := ctx.Arg("label"); label != "" { + if tagIDs != "" { + return fmt.Errorf("--label cannot be used with --tag-ids") + } + tagIDs = label + } + if tagIDs != "" { + ids, err := parseIssueIDList(tagIDs, "tag-ids") + if err != nil { + return err + } + body["issue_tag_ids"] = ids + } + if assignerIDs := ctx.Arg("assigner-ids"); assignerIDs != "" { + ids, err := parseIssueIDList(assignerIDs, "assigner-ids") + if err != nil { + return err + } + body["assigner_ids"] = ids + } + if branch := ctx.Arg("branch"); branch != "" { + body["branch_name"] = branch + } + if startDate := ctx.Arg("start-date"); startDate != "" { + body["start_date"] = startDate + } + if dueDate := ctx.Arg("due-date"); dueDate != "" { + body["due_date"] = dueDate + } + return nil +} + +func parseIssueIDList(value, flagName string) ([]int, error) { + parts := strings.Split(value, ",") + ids := make([]int, 0, len(parts)) + for _, part := range parts { + id, err := parseIssueID(part, flagName) + if err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil +} + +func parseIssueID(value, flagName string) (int, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, fmt.Errorf("--%s contains an empty ID", flagName) + } + id, err := strconv.Atoi(trimmed) + if err != nil || id <= 0 { + return 0, fmt.Errorf("--%s must contain positive numeric IDs", flagName) + } + return id, nil +} diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 48ebdec..3e72a1b 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/label/label.go b/shortcuts/label/label.go index ed72314..a0c3320 100644 --- a/shortcuts/label/label.go +++ b/shortcuts/label/label.go @@ -1,22 +1,60 @@ package label import ( + "encoding/json" "fmt" + "net/url" + "regexp" + "strconv" "strings" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// defaultLabelColor is used when the caller does not provide a color. +const defaultLabelColor = "#1E90FF" + +// hexColorPattern matches #RGB and #RRGGBB hex color values. +var hexColorPattern = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`) + +// Shortcuts returns issue label (项目标记) management shortcuts. +// +// Issue labels back the issue triage and PR gatekeeping workflows: until now +// they could only be managed through the raw API (issue_tags), so these +// shortcuts close that gap with first-class create/list/update/delete commands. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "list", - Description: "List repository labels", + Description: "List issue labels", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Filter labels by keyword"}, + {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 } - env, err := ctx.CallAPI("GET", labelPath(ctx), nil) + 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 } @@ -25,66 +63,28 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a repository label", + Description: "Create an issue label", Flags: []common.Flag{ {Name: "name", Short: "n", Usage: "Label name", Required: true}, - {Name: "color", Short: "c", Usage: "Label color (hex, e.g. #ff0000)", 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 - } - color, err := ctx.RequireArg("color") - if err != nil { - return err - } - env, err := ctx.CallAPI("POST", labelPath(ctx), map[string]interface{}{ - "name": name, - "color": color, - }) - if err != nil { - return err - } - return ctx.Output(env) + {Name: "description", Short: "d", Usage: "Label description"}, + {Name: "color", Short: "c", Usage: "Label color in hex, for example: #1E90FF", Default: defaultLabelColor}, }, + Run: runCreate, }, { Name: "update", - Description: "Update a repository label", + Description: "Update an issue label while preserving unspecified fields", Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Label ID", Required: true}, - {Name: "name", Short: "n", Usage: "New label name"}, - {Name: "color", Short: "c", Usage: "New label color (hex)"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - payload := map[string]interface{}{} - if v := ctx.Arg("name"); v != "" { - payload["name"] = v - } - if v := ctx.Arg("color"); v != "" { - payload["color"] = v - } - env, err := ctx.CallAPI("PATCH", labelItemPath(ctx, id), payload) - if err != nil { - return err - } - return ctx.Output(env) + {Name: "name", Short: "n", Usage: "Label name"}, + {Name: "description", Short: "d", Usage: "Label description"}, + {Name: "color", Short: "c", Usage: "Label color in hex, for example: #1E90FF"}, }, + Run: runUpdate, }, { Name: "delete", - Description: "Delete a repository label", + Description: "Delete an issue label", Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Label ID", Required: true}, }, @@ -103,56 +103,154 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, - { - Name: "batch-create", - Description: "Create multiple labels at once (names/colors comma-separated)", - Flags: []common.Flag{ - {Name: "names", Short: "n", Usage: "Label names (comma-separated, e.g. bug,feature,docs)", Required: true}, - {Name: "colors", Short: "c", Usage: "Colors (comma-separated, e.g. #ee0701,#84b6eb,#0075ca). If fewer than names, repeats last.", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - namesRaw, _ := ctx.RequireArg("names") - colorsRaw, _ := ctx.RequireArg("colors") - names := strings.Split(namesRaw, ",") - colors := strings.Split(colorsRaw, ",") - results := []map[string]interface{}{} - for i, name := range names { - name = strings.TrimSpace(name) - if name == "" { - continue - } - color := "#cccccc" - if i < len(colors) { - color = strings.TrimSpace(colors[i]) - } else if len(colors) > 0 { - color = strings.TrimSpace(colors[len(colors)-1]) - } - env, err := ctx.CallAPI("POST", labelPath(ctx), map[string]interface{}{ - "name": name, - "color": color, - }) - if err != nil { - results = append(results, map[string]interface{}{"name": name, "ok": false, "error": err.Error()}) - } else { - results = append(results, map[string]interface{}{"name": name, "ok": env.OK, "color": color}) - } - } - return ctx.OutputData(map[string]interface{}{ - "created": len(results), - "results": results, - }) - }, - }, } } +func runCreate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + color := firstNonEmpty(ctx.Arg("color"), defaultLabelColor) + if err := validateColor(color); err != nil { + return err + } + payload := map[string]interface{}{ + "name": name, + "description": ctx.Arg("description"), + "color": color, + } + env, err := ctx.CallAPI("POST", labelPath(ctx), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + if ctx.Arg("name") == "" && ctx.Arg("description") == "" && ctx.Arg("color") == "" { + return fmt.Errorf("at least one of --name, --description, or --color is required") + } + + // The update endpoint requires name, description and color together, so we + // merge the requested changes onto the label's current values to avoid + // clobbering fields the caller did not pass. + current, err := fetchLabel(ctx, id) + if err != nil { + return err + } + + name := firstNonEmpty(ctx.Arg("name"), stringFromMap(current, "name")) + if name == "" { + return fmt.Errorf("could not resolve label name for id %s; pass --name explicitly", id) + } + color := firstNonEmpty(ctx.Arg("color"), stringFromMap(current, "color"), defaultLabelColor) + if err := validateColor(color); err != nil { + return err + } + description := ctx.Arg("description") + if description == "" { + description = stringFromMap(current, "description") + } + + payload := map[string]interface{}{ + "name": name, + "description": description, + "color": color, + } + env, err := ctx.CallAPI("PATCH", labelItemPath(ctx, id), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +// fetchLabel looks up a single label by id from the list endpoint. GitLink does +// not expose a single-label GET, so we page through the list and match by id. +// A nil result (label not found) is not an error: the caller falls back to the +// flags it was given. +func fetchLabel(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) { + env, err := ctx.CallAPI("GET", labelPath(ctx), nil) + if err != nil { + return nil, err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil, nil + } + rawTags, ok := data["issue_tags"].([]interface{}) + if !ok { + return nil, nil + } + for _, raw := range rawTags { + tag, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if labelIDString(tag["id"]) == id { + return tag, nil + } + } + return nil, nil +} + func labelPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/%s/%s/labels", ctx.Owner, ctx.Repo) + return fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) } func labelItemPath(ctx *common.RuntimeContext, id string) string { - return fmt.Sprintf("%s/%s", labelPath(ctx), id) + return fmt.Sprintf("%s/%s", labelPath(ctx), url.PathEscape(id)) +} + +func validateColor(color string) error { + if !hexColorPattern.MatchString(color) { + return fmt.Errorf("invalid --color value %q: use a hex color like #1E90FF or #abc", color) + } + return nil +} + +func labelIDString(v interface{}) string { + switch id := v.(type) { + case string: + return id + case float64: + return strconv.FormatInt(int64(id), 10) + case json.Number: + return id.String() + default: + return "" + } +} + +func setQueryIfPresent(q url.Values, name, value string) { + if value != "" { + q.Set(name, value) + } +} + +func stringFromMap(values map[string]interface{}, key string) string { + if values == nil { + return "" + } + value, _ := values[key].(string) + return value +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" } diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go index 6b54d29..0b236d6 100644 --- a/shortcuts/member/member.go +++ b/shortcuts/member/member.go @@ -1,20 +1,51 @@ package member import ( + "encoding/csv" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// Shortcuts returns all shortcuts for project member management. +var roleAliases = map[string]string{ + "manager": "Manager", + "developer": "Developer", + "reporter": "Reporter", + "Manager": "Manager", + "Developer": "Developer", + "Reporter": "Reporter", +} + +// Shortcuts returns repository member management shortcuts. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "list", - Description: "List project members", + 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", ctx.RepoPath()+"/collaborators", 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 } @@ -23,22 +54,19 @@ func Shortcuts() []*common.Shortcut { }, { Name: "add", - Description: "Add a project member", + Description: "Add a repository member by user ID", Flags: []common.Flag{ - {Name: "user-id", Short: "u", Usage: "User ID to add", Required: true}, + {Name: "user-id", Short: "u", Usage: "GitLink user ID to add", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - userID, err := ctx.RequireArg("user-id") + userID, err := parseUserID(ctx.Arg("user-id")) if err != nil { return err } - body := map[string]interface{}{ - "user_id": userID, - } - env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body) + env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) if err != nil { return err } @@ -46,23 +74,134 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "remove", - Description: "Remove a project member", + Name: "batch-add", + Description: "Add multiple repository members by user IDs or a CSV file", Flags: []common.Flag{ - {Name: "user-id", Short: "u", Usage: "User ID to remove", Required: true}, + {Name: "user-ids", Short: "u", Usage: "Comma-separated GitLink user IDs, for example: 101,102"}, + {Name: "from", Usage: "Read user IDs from a CSV file. Supports a user_id/id column or first column without header"}, + {Name: "dry-run", Usage: "Preview members that would be added without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchAdd, + }, + { + Name: "remove", + Description: "Remove a repository member by user ID", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "GitLink user ID to remove", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - userID, err := ctx.RequireArg("user-id") + userID, err := parseUserID(ctx.Arg("user-id")) if err != nil { return err } - body := map[string]interface{}{ - "user_id": userID, + env, err := ctx.CallAPI("DELETE", collaboratorsRemovePath(ctx), map[string]interface{}{"user_id": userID}) + if err != nil { + return err } - env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body) + return ctx.Output(env) + }, + }, + { + Name: "role", + Description: "Change a repository member role", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "GitLink user ID to update", Required: true}, + {Name: "role", Short: "r", Usage: "Member role: Manager, Developer, or Reporter", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := parseUserID(ctx.Arg("user-id")) + if err != nil { + return err + } + role, err := normalizeRole(ctx.Arg("role")) + if err != nil { + return err + } + env, err := ctx.CallAPI("PUT", collaboratorsRolePath(ctx), map[string]interface{}{ + "user_id": userID, + "role": role, + }) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "invite-link", + Description: "Get or create a repository invite link", + Flags: []common.Flag{ + {Name: "role", Short: "r", Usage: "Invite role: manager, developer, or reporter", Default: "developer"}, + {Name: "apply", Usage: "Whether joining by invite requires approval: true or false", Default: "true"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + role, err := normalizeInviteRole(ctx.Arg("role")) + if err != nil { + return err + } + apply, err := parseBoolArg("apply", ctx.Arg("apply")) + if err != nil { + return err + } + query := url.Values{} + query.Set("role", role) + query.Set("is_apply", strconv.FormatBool(apply)) + env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "current_link"), query) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "invite-info", + Description: "Show repository invite link information", + Flags: []common.Flag{ + {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sign, err := ctx.RequireArg("sign") + if err != nil { + return err + } + query := url.Values{} + query.Set("invite_sign", sign) + env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "show_link"), query) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "accept-invite", + Description: "Accept a repository invite link", + Flags: []common.Flag{ + {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sign, err := ctx.RequireArg("sign") + if err != nil { + return err + } + query := url.Values{} + query.Set("invite_sign", sign) + env, err := ctx.CallAPIWithQuery("POST", inviteLinkPath(ctx, "redirect_link"), query) if err != nil { return err } @@ -71,3 +210,204 @@ func Shortcuts() []*common.Shortcut { }, } } + +func runBatchAdd(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userIDs, err := collectUserIDs(ctx.Arg("user-ids"), ctx.Arg("from")) + if err != nil { + return err + } + if len(userIDs) == 0 { + return fmt.Errorf("provide --user-ids or --from") + } + if parseDryRun(ctx.Arg("dry-run")) { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "user_ids": userIDs, + "count": len(userIDs), + }) + } + + results := make([]map[string]interface{}, 0, len(userIDs)) + succeeded := 0 + failed := 0 + for _, userID := range userIDs { + env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) + result := map[string]interface{}{"user_id": userID} + if err != nil { + result["ok"] = false + result["error"] = err.Error() + failed++ + } else { + result["ok"] = env.OK + result["data"] = env.Data + if env.OK { + succeeded++ + } else { + failed++ + } + } + results = append(results, result) + } + if err := ctx.OutputData(map[string]interface{}{ + "count": len(userIDs), + "succeeded": succeeded, + "failed": failed, + "results": results, + }); err != nil { + return err + } + if failed > 0 { + return fmt.Errorf("%d of %d member(s) failed to add", failed, len(userIDs)) + } + return nil +} + +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)) +} + +func collaboratorsRolePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("%s/change_role", collaboratorsPath(ctx)) +} + +func inviteLinkPath(ctx *common.RuntimeContext, action string) string { + return fmt.Sprintf("/%s/%s/project_invite_links/%s", ctx.Owner, ctx.Repo, action) +} + +func parseUserID(value string) (int, error) { + value = strings.TrimSpace(value) + userID, err := strconv.Atoi(value) + if err != nil || userID <= 0 { + return 0, fmt.Errorf("invalid user ID %q", value) + } + return userID, nil +} + +func normalizeRole(value string) (string, error) { + role, ok := roleAliases[strings.TrimSpace(value)] + if !ok { + return "", fmt.Errorf("invalid --role value %q: use Manager, Developer, or Reporter", value) + } + return role, nil +} + +func normalizeInviteRole(value string) (string, error) { + role, err := normalizeRole(value) + if err != nil { + return "", fmt.Errorf("invalid --role value %q: use manager, developer, or reporter", value) + } + return strings.ToLower(role), nil +} + +func parseBoolArg(name, value string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("invalid --%s value %q: use true or false", name, value) + } +} + +func parseDryRun(value string) bool { + ok, _ := parseBoolArg("dry-run", value) + return ok && strings.TrimSpace(value) != "" +} + +func collectUserIDs(inline, csvPath string) ([]int, error) { + seen := map[int]bool{} + var ids []int + add := func(raw string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + userID, err := parseUserID(raw) + if err != nil { + return err + } + if !seen[userID] { + seen[userID] = true + ids = append(ids, userID) + } + return nil + } + + for _, part := range strings.Split(inline, ",") { + if err := add(part); err != nil { + return nil, err + } + } + if csvPath != "" { + csvIDs, err := readUserIDsFromCSV(csvPath) + if err != nil { + return nil, err + } + for _, userID := range csvIDs { + if !seen[userID] { + seen[userID] = true + ids = append(ids, userID) + } + } + } + return ids, nil +} + +func readUserIDsFromCSV(path string) ([]int, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + rows, err := csv.NewReader(file).ReadAll() + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + + column := 0 + start := 0 + if idx := userIDColumn(rows[0]); idx >= 0 { + column = idx + start = 1 + } + + var ids []int + for _, row := range rows[start:] { + if column >= len(row) { + continue + } + userID, err := parseUserID(row[column]) + if err != nil { + return nil, err + } + ids = append(ids, userID) + } + return ids, nil +} + +func userIDColumn(header []string) int { + for i, name := range header { + switch strings.ToLower(strings.TrimSpace(name)) { + case "user_id", "userid", "id": + return i + } + } + return -1 +} diff --git a/shortcuts/member/member_test.go b/shortcuts/member/member_test.go index a06f5dc..63872e9 100644 --- a/shortcuts/member/member_test.go +++ b/shortcuts/member/member_test.go @@ -1,85 +1,286 @@ package member import ( + "encoding/json" "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" "testing" + "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) func TestMemberList(t *testing.T) { - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == "GET" && r.URL.Path == "/owner/repo/collaborators.json" { - common.WriteJSON(t, w, map[string]interface{}{ - "members": []interface{}{ - map[string]interface{}{ - "id": float64(1), - "login": "developer", - "role": "Manager", - }, - }, - }) - } else { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/collaborators.json") + writeJSON(t, w, map[string]interface{}{"total_count": 1, "collaborators": []interface{}{}}) }) defer server.Close() - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{}) - err := common.RunShortcut(t, Shortcuts(), "list", ctx) - if err != nil { - t.Fatalf("list failed: %v", err) + if err := runMemberShortcut(t, server, "list", nil); err != nil { + t.Fatalf("list shortcut failed: %v", err) } } func TestMemberAdd(t *testing.T) { - var addPayload map[string]interface{} - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" && r.URL.Path == "/owner/repo/collaborators.json" { - addPayload = common.DecodeJSON(t, r) - common.WriteJSON(t, w, map[string]interface{}{ - "status": 0, - "message": "添加成功", - }) - } else { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) }) defer server.Close() - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ - "user-id": "42", - }) - err := common.RunShortcut(t, Shortcuts(), "add", ctx) - if err != nil { - t.Fatalf("add failed: %v", err) + if err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "101"}); err != nil { + t.Fatalf("add shortcut failed: %v", err) } + assertNumber(t, payload["user_id"], 101) +} - common.AssertEqual(t, addPayload["user_id"], "42") +func TestMemberBatchAdd(t *testing.T) { + var seen []int + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload := decodeJSON(t, r) + seen = append(seen, int(payload["user_id"].(float64))) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + csvPath := writeTempCSV(t, "user_id\n102\n103\n") + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + "from": csvPath, + }) + if err != nil { + t.Fatalf("batch-add shortcut failed: %v", err) + } + want := []int{101, 102, 103} + if !reflect.DeepEqual(seen, want) { + t.Fatalf("batch-add user IDs = %v, want %v", seen, want) + } +} + +func TestMemberBatchAddDryRunDoesNotCallAPI(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("batch-add dry-run failed: %v", err) + } +} + +func TestMemberBatchAddReturnsErrorWhenAnyRequestFails(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload := decodeJSON(t, r) + if int(payload["user_id"].(float64)) == 102 { + http.Error(w, "member add failed", http.StatusBadRequest) + return + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + }) + if err == nil { + t.Fatal("expected batch-add to return an error when one request fails") + } } func TestMemberRemove(t *testing.T) { - var removePayload map[string]interface{} - server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == "DELETE" && r.URL.Path == "/owner/repo/collaborators/remove.json" { - removePayload = common.DecodeJSON(t, r) - common.WriteJSON(t, w, map[string]interface{}{ - "status": 0, - "message": "删除成功", - }) - } else { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/owner/repo/collaborators/remove.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) }) defer server.Close() - ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ - "user-id": "42", - }) - err := common.RunShortcut(t, Shortcuts(), "remove", ctx) - if err != nil { - t.Fatalf("remove failed: %v", err) + if err := runMemberShortcut(t, server, "remove", map[string]string{"user-id": "101"}); err != nil { + t.Fatalf("remove shortcut failed: %v", err) } - - common.AssertEqual(t, removePayload["user_id"], "42") + assertNumber(t, payload["user_id"], 101) +} + +func TestMemberRole(t *testing.T) { + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "PUT", "/owner/repo/collaborators/change_role.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "role", map[string]string{ + "user-id": "101", + "role": "developer", + }) + if err != nil { + t.Fatalf("role shortcut failed: %v", err) + } + assertNumber(t, payload["user_id"], 101) + if payload["role"] != "Developer" { + t.Fatalf("role = %v, want Developer", payload["role"]) + } +} + +func TestMemberInviteLink(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/project_invite_links/current_link.json") + if r.URL.Query().Get("role") != "developer" { + t.Fatalf("role query = %q, want developer", r.URL.Query().Get("role")) + } + if r.URL.Query().Get("is_apply") != "false" { + t.Fatalf("is_apply query = %q, want false", r.URL.Query().Get("is_apply")) + } + writeJSON(t, w, map[string]interface{}{"sign": "abc"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "invite-link", map[string]string{ + "role": "developer", + "apply": "false", + }) + if err != nil { + t.Fatalf("invite-link shortcut failed: %v", err) + } +} + +func TestMemberInviteInfo(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/project_invite_links/show_link.json") + if r.URL.Query().Get("invite_sign") != "abc" { + t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign")) + } + writeJSON(t, w, map[string]interface{}{"sign": "abc"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "invite-info", map[string]string{"sign": "abc"}); err != nil { + t.Fatalf("invite-info shortcut failed: %v", err) + } +} + +func TestMemberAcceptInvite(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/project_invite_links/redirect_link.json") + if r.URL.Query().Get("invite_sign") != "abc" { + t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign")) + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "accept-invite", map[string]string{"sign": "abc"}); err != nil { + t.Fatalf("accept-invite shortcut failed: %v", err) + } +} + +func TestCollectUserIDs(t *testing.T) { + csvPath := writeTempCSV(t, "name,id\nfirst,102\nsecond,103\n") + got, err := collectUserIDs("101,102", csvPath) + if err != nil { + t.Fatalf("collectUserIDs returned error: %v", err) + } + want := []int{101, 102, 103} + if !reflect.DeepEqual(got, want) { + t.Fatalf("collectUserIDs() = %v, want %v", got, want) + } +} + +func TestNormalizeRoleRejectsInvalidRole(t *testing.T) { + if _, err := normalizeRole("owner"); err == nil { + t.Fatal("expected invalid role to return an error") + } +} + +func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findMemberShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findMemberShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func newMemberTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +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 decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +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) + } +} + +func assertNumber(t *testing.T, got interface{}, want int) { + t.Helper() + value, ok := got.(float64) + if !ok { + t.Fatalf("got %v (%T), want JSON number", got, got) + } + if int(value) != want { + t.Fatalf("got %v, want %d", got, want) + } +} + +func writeTempCSV(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "members.csv") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp csv: %v", err) + } + return path } diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go index f3b1d45..1de3b86 100644 --- a/shortcuts/milestone/milestone.go +++ b/shortcuts/milestone/milestone.go @@ -3,11 +3,11 @@ package milestone import ( "fmt" "net/url" + "strings" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// Shortcuts returns all shortcuts for milestone management. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { @@ -15,9 +15,13 @@ func Shortcuts() []*common.Shortcut { Description: "List milestones", Flags: []common.Flag{ {Name: "keyword", Short: "k", Usage: "Search keyword"}, - {Name: "status", Short: "s", Usage: "Filter by status: open, closed, all", Default: "all"}, + {Name: "category", Short: "c", Usage: "Filter by category: opening, closed"}, + {Name: "only-name", Usage: "Return only milestone id and name: true or false"}, + {Name: "sort-by", Usage: "Sort field: created_on, updated_on, effective_date, issues_count, percent"}, + {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 { @@ -26,13 +30,19 @@ func Shortcuts() []*common.Shortcut { q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if k := ctx.Arg("keyword"); k != "" { - q.Set("keyword", k) + setQueryIfPresent(q, "keyword", ctx.Arg("keyword")) + setQueryIfPresent(q, "category", ctx.Arg("category")) + 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)) } - if s := ctx.Arg("status"); s != "all" { - q.Set("status", s) - } - env, err := ctx.CallAPIWithQuery("GET", v1Path(ctx)+"/milestones", q) + env, err := ctx.CallAPIWithQuery("GET", milestonePath(ctx), q) if err != nil { return err } @@ -44,27 +54,18 @@ func Shortcuts() []*common.Shortcut { Description: "Create a milestone", Flags: []common.Flag{ {Name: "name", Short: "n", Usage: "Milestone name", Required: true}, - {Name: "description", Short: "d", Usage: "Milestone description"}, - {Name: "due-date", Usage: "Due date (YYYY-MM-DD)"}, + {Name: "description", Short: "d", Usage: "Milestone description", Required: true}, + {Name: "due-date", Usage: "Due date in YYYY-MM-DD format", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, err := ctx.RequireArg("name") + payload, err := milestonePayload(ctx, true) if err != nil { return err } - body := map[string]interface{}{ - "name": name, - } - if desc := ctx.Arg("description"); desc != "" { - body["description"] = desc - } - if due := ctx.Arg("due-date"); due != "" { - body["effective_date"] = due - } - env, err := ctx.CallAPI("POST", v1Path(ctx)+"/milestones", body) + env, err := ctx.CallAPI("POST", milestonePath(ctx), payload) if err != nil { return err } @@ -73,10 +74,17 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View milestone details with associated issues", + Description: "View milestone details and linked issues", Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, - {Name: "category", Short: "c", Usage: "Issue filter: all, opened, closed", Default: "all"}, + {Name: "category", Short: "c", Usage: "Filter issues by category: all, opened, closed"}, + {Name: "author-id", Usage: "Filter issues by author ID"}, + {Name: "assigner-id", Usage: "Filter issues by assignee ID"}, + {Name: "issue-tag-ids", Usage: "Comma-separated issue tag IDs"}, + {Name: "sort-by", Usage: "Sort field: issues.created_on, issues.updated_on, issue_priorities.position"}, + {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"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -87,10 +95,15 @@ func Shortcuts() []*common.Shortcut { return err } q := url.Values{} - if c := ctx.Arg("category"); c != "all" { - q.Set("category", c) - } - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/milestones/%s", v1Path(ctx), id), q) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + setQueryIfPresent(q, "category", ctx.Arg("category")) + setQueryIfPresent(q, "author_id", ctx.Arg("author-id")) + setQueryIfPresent(q, "assigner_id", ctx.Arg("assigner-id")) + setQueryIfPresent(q, "issue_tag_ids", normalizeCSV(ctx.Arg("issue-tag-ids"))) + setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) + setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) + env, err := ctx.CallAPIWithQuery("GET", milestoneItemPath(ctx, id), q) if err != nil { return err } @@ -98,10 +111,13 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "close", - Description: "Close a milestone", + Name: "update", + Description: "Update a milestone", Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + {Name: "name", Short: "n", Usage: "Milestone name"}, + {Name: "description", Short: "d", Usage: "Milestone description"}, + {Name: "due-date", Usage: "Due date in YYYY-MM-DD format"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -111,10 +127,11 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - body := map[string]interface{}{ - "status": "closed", + payload, err := milestonePayload(ctx, false) + if err != nil { + return err } - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/milestones/%s/update_status", v1Path(ctx), id), body) + env, err := ctx.CallAPI("PATCH", milestoneItemPath(ctx, id), payload) if err != nil { return err } @@ -135,16 +152,96 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/milestones/%s", v1Path(ctx), id), nil) + env, err := ctx.CallAPI("DELETE", milestoneItemPath(ctx, id), nil) if err != nil { return err } return ctx.Output(env) }, }, + newStatusShortcut("close", "Close a milestone", "closed"), + newStatusShortcut("reopen", "Reopen a milestone", "open"), } } -func v1Path(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) +func milestonePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo) +} + +func milestoneItemPath(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("%s/%s", milestonePath(ctx), url.PathEscape(id)) +} + +func milestoneStatusPath(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("%s/milestones/%s/update_status", ctx.RepoPath(), url.PathEscape(id)) +} + +func milestonePayload(ctx *common.RuntimeContext, requireAll bool) (map[string]interface{}, error) { + payload := map[string]interface{}{} + if name := ctx.Arg("name"); name != "" { + payload["name"] = name + } + if description := ctx.Arg("description"); description != "" { + payload["description"] = description + } + if dueDate := ctx.Arg("due-date"); dueDate != "" { + payload["effective_date"] = dueDate + } + + if requireAll { + for _, name := range []string{"name", "description", "due-date"} { + if _, err := ctx.RequireArg(name); err != nil { + return nil, err + } + } + return payload, nil + } + if len(payload) == 0 { + return nil, fmt.Errorf("at least one of --name, --description, or --due-date is required") + } + return payload, nil +} + +func newStatusShortcut(name, description, status string) *common.Shortcut { + return &common.Shortcut{ + Name: name, + Description: description, + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", milestoneStatusPath(ctx, id), map[string]interface{}{ + "status": status, + }) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +func setQueryIfPresent(q url.Values, name, value string) { + if value != "" { + q.Set(name, value) + } +} + +func normalizeCSV(value string) string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + return strings.Join(result, ",") } diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 4e45561..4733853 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 @@ -51,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 @@ -64,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"), diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 65e833d..a4129e9 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 @@ -104,10 +112,14 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } title, _ := ctx.RequireArg("title") head, _ := ctx.RequireArg("head") + base := ctx.Arg("base") + if base == "" { + base = "master" + } payload := map[string]interface{}{ "title": title, "head": head, - "base": ctx.Arg("base"), + "base": base, } if b := ctx.Arg("body"); b != "" { payload["body"] = b @@ -227,29 +239,17 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "diff", Description: tr.T("cmd.pr.diff.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "file", Short: "f", Usage: "Filter diff to a specific file path"}, - {Name: "stat", Usage: "Show only diff stat summary", Bool: true}, + {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") - - // v0 API:返回变更文件列表及 patch 内容。 - q := url.Values{} - if f := ctx.Arg("file"); f != "" { - q.Set("filepath", f) - } - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), q) + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) if err != nil { return err } - - if ctx.Arg("stat") == "true" { - return ctx.Output(formatDiffStat(env)) - } return ctx.Output(env) }, }, @@ -345,10 +345,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.pr.review.short"), Flags: []common.Flag{ {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, - {Name: "content", Short: "c", Usage: tr.T("flag.pr.review_content")}, - {Name: "body", Short: "b", Usage: tr.T("flag.pr.review_content")}, {Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status"), Default: "common"}, - {Name: "commit-id", Usage: tr.T("flag.pr.review_commit")}, + {Name: "content", Short: "c", Usage: tr.T("flag.pr.review_content"), Required: true}, + {Name: "commit", Short: "m", Usage: tr.T("flag.pr.review_commit")}, {Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { @@ -359,13 +358,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - // --content 和 --body 互为别名,至少传一个。 - content := ctx.Arg("content") - if content == "" { - content, err = ctx.RequireArg("body") - if err != nil { - return err - } + content, err := ctx.RequireArg("content") + if err != nil { + return err } status := ctx.Arg("status") if status == "" { @@ -378,7 +373,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { "content": content, "status": status, } - if commit := ctx.Arg("commit-id"); commit != "" { + if commit := ctx.Arg("commit"); commit != "" { payload["commit_id"] = commit } if ctx.Arg("dry-run") == "true" { @@ -395,6 +390,19 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return err } + // Also post a journal comment so the review is visible in the PR conversation. + prEnv, journalErr := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) + if journalErr == nil { + if issueID, extractErr := extractIssueID(prEnv); extractErr == nil { + statusLabel := map[string]string{ + "approved": "approved", "rejected": "rejected", "common": "commented", + }[status] + summary := fmt.Sprintf("## Review: %s\n\n%s", statusLabel, content) + ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), + map[string]interface{}{"notes": summary}) + } + } + return ctx.Output(env) }, }, @@ -414,7 +422,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) if err != nil { - return fmt.Errorf("获取 PR 详情失败: %w", err) + return fmt.Errorf("fetch PR: %w", err) } issueID, err := extractIssueID(prEnv) if err != nil { @@ -432,23 +440,19 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "check-merge", - Description: "Check if branches can be merged", + Name: "commits", + Description: tr.T("cmd.pr.commits.short"), Flags: []common.Flag{ - {Name: "head", Usage: "Source branch", Required: true}, - {Name: "base", Short: "b", Usage: "Target branch", Required: true}, + {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 } - head, _ := ctx.RequireArg("head") - base, _ := ctx.RequireArg("base") - payload := map[string]interface{}{ - "head": head, - "base": base, - } - env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls/check_can_merge", payload) + 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 } @@ -456,13 +460,19 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "branches", - Description: "List available branches for PR", + 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 } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/pulls/get_branches", nil) + 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 } @@ -472,67 +482,24 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } } -// getLatestVersionID calls the versions API and returns the latest version's ID. -// GitLink returns versions in reverse chronological order, so the first is latest. -func getLatestVersionID(ctx *common.RuntimeContext, prID string) (string, error) { - env, err := ctx.CallAPI("GET", - fmt.Sprintf("/v1%s/pulls/%s/versions", ctx.RepoPath(), prID), nil) - if err != nil { - return "", err +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] } - - data, ok := env.Data.(map[string]interface{}) - if !ok { - return "", fmt.Errorf("unexpected versions response format") - } - - versions, ok := data["versions"].([]interface{}) - if !ok || len(versions) == 0 { - return "", fmt.Errorf("PR #%s 没有找到版本信息", prID) - } - - latest, ok := versions[0].(map[string]interface{}) - if !ok { - return "", fmt.Errorf("unexpected version format") - } - - idFloat, ok := latest["id"].(float64) - if !ok { - return "", fmt.Errorf("version missing id field") - } - - return fmt.Sprintf("%d", int64(idFloat)), nil + return i18n.Default() } -// formatDiffStat extracts add/delete statistics from the diff response. -func formatDiffStat(env *output.Envelope) *output.Envelope { - data, ok := env.Data.(map[string]interface{}) - if !ok { - return env - } +func prV1Path(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id) +} - stat := map[string]interface{}{ - "file_nums": data["file_nums"], - "total_addition": data["total_addition"], - "total_deletion": data["total_deletion"], +func validatePRReviewStatus(status string) error { + switch status { + case "common", "approved", "rejected": + return nil + default: + return fmt.Errorf("invalid --status value %q: use common, approved, or rejected", status) } - - if files, ok := data["files"].([]interface{}); ok { - var fileStats []map[string]interface{} - for _, f := range files { - if fm, ok := f.(map[string]interface{}); ok { - fileStats = append(fileStats, map[string]interface{}{ - "name": fm["name"], - "addition": fm["addition"], - "deletion": fm["deletion"], - "type": fm["type"], - }) - } - } - stat["files"] = fileStats - } - - return output.SuccessEnvelope(stat, nil) } func extractIssueID(env *output.Envelope) (int64, error) { @@ -640,25 +607,3 @@ func numberField(m map[string]interface{}, key string) (float64, bool) { return 0, false } } - -// prV1Path returns the v1 API path for a specific PR. -func prV1Path(ctx *common.RuntimeContext, id string) string { - return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id) -} - -// validatePRReviewStatus validates the review status value. -func validatePRReviewStatus(status string) error { - switch strings.ToLower(strings.TrimSpace(status)) { - case "common", "approved", "rejected", "": - return nil - default: - return fmt.Errorf("invalid review status %q: use common, approved, or rejected", status) - } -} - -func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { - if len(translators) > 0 && translators[0] != nil { - return translators[0] - } - return i18n.Default() -} diff --git a/shortcuts/register.go b/shortcuts/register.go index 8855031..50d7a7a 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" @@ -24,6 +25,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" @@ -51,6 +53,8 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "org": org.Shortcuts(tr), "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), @@ -77,6 +81,8 @@ 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", + "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 592c412..3e633cf 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", "commit", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", "dataset", "health", "ignore", "wiki", "feishu", } diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index c9e9e93..612157f 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -55,6 +55,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 { @@ -63,6 +64,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 diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index ca58aaa..360dc69 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 @@ -236,6 +244,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", @@ -578,6 +615,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 @@ -589,6 +635,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/search/search.go b/shortcuts/search/search.go index 5814783..ab083ce 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -18,6 +18,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") @@ -25,6 +26,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 @@ -39,6 +47,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") @@ -46,6 +55,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 diff --git a/shortcuts/tag/tag.go b/shortcuts/tag/tag.go index f1522d8..885ac69 100644 --- a/shortcuts/tag/tag.go +++ b/shortcuts/tag/tag.go @@ -1,164 +1,86 @@ package tag import ( + "encoding/json" "fmt" "net/url" - "strconv" - "strings" - "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { - tr := i18n.Default() - if len(translators) > 0 && translators[0] != nil { - tr = translators[0] - } +// 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: tr.T("cmd.tag.list.short"), + Description: "List repository git tags", 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: "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: runList, - }, - { - Name: "names", - Description: tr.T("cmd.tag.names.short"), - Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: tr.T("flag.tag.keyword")}, + 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) }, - Run: runNames, }, { Name: "view", - Description: tr.T("cmd.tag.view.short"), + Description: "Show a git tag by name", Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: tr.T("flag.tag.name"), Required: true}, + {Name: "name", Short: "n", Usage: "Tag name", Required: true}, }, - Run: runView, - }, - { - Name: "delete", - Description: tr.T("cmd.tag.delete.short"), - Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: tr.T("flag.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 }, - Run: runDelete, }, } } - -func runList(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - query, err := paginationQuery(ctx) - if err != nil { - return err - } - env, err := ctx.CallAPIWithQuery("GET", tagPath(ctx), query) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runNames(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - query := url.Values{} - query.Set("only_name", "true") - if keyword := strings.TrimSpace(ctx.Arg("keyword")); keyword != "" { - query.Set("name", keyword) - } - env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/tags", query) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runView(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - name, err := requireTagName(ctx) - if err != nil { - return err - } - env, err := ctx.CallAPI("GET", tagItemPath(ctx, name), nil) - if err != nil { - return err - } - return ctx.Output(env) -} - -func runDelete(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - name, err := requireTagName(ctx) - if err != nil { - return err - } - env, err := ctx.CallAPI("DELETE", tagItemPath(ctx, name), nil) - if err != nil { - return err - } - return ctx.Output(env) -} - -func tagPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo) -} - -func tagItemPath(ctx *common.RuntimeContext, name string) string { - return fmt.Sprintf("%s/%s", tagPath(ctx), url.PathEscape(name)) -} - -func paginationQuery(ctx *common.RuntimeContext) (url.Values, error) { - page, err := positiveIntArg(defaultString(ctx.Arg("page"), "1"), "page") - if err != nil { - return nil, err - } - limit, err := positiveIntArg(defaultString(ctx.Arg("limit"), "20"), "limit") - if err != nil { - return nil, err - } - query := url.Values{} - query.Set("page", strconv.Itoa(page)) - query.Set("limit", strconv.Itoa(limit)) - return query, nil -} - -func defaultString(value, fallback string) string { - if strings.TrimSpace(value) == "" { - return fallback - } - return value -} - -func positiveIntArg(value, name string) (int, error) { - parsed, err := strconv.Atoi(strings.TrimSpace(value)) - if err != nil || parsed <= 0 { - return 0, fmt.Errorf("invalid --%s %q: use a positive integer", name, value) - } - return parsed, nil -} - -func requireTagName(ctx *common.RuntimeContext) (string, error) { - name, err := ctx.RequireArg("name") - if err != nil { - return "", err - } - name = strings.TrimSpace(name) - if name == "" { - return "", fmt.Errorf("required flag --name is empty") - } - return name, nil -} diff --git a/shortcuts/tag/tag_test.go b/shortcuts/tag/tag_test.go index 004273e..62940b1 100644 --- a/shortcuts/tag/tag_test.go +++ b/shortcuts/tag/tag_test.go @@ -2,7 +2,6 @@ package tag import ( "encoding/json" - "fmt" "net/http" "net/http/httptest" "testing" @@ -11,169 +10,93 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func TestTagListUsesV1Pagination(t *testing.T) { - server := newTagTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/tags.json") - assertEqual(t, r.URL.Query().Get("page"), "2") - assertEqual(t, r.URL.Query().Get("limit"), "50") - writeJSON(t, w, map[string]interface{}{"total_count": 1, "tags": []interface{}{}}) - }) +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() - err := runTagShortcut(t, server, "list", map[string]string{ - "page": "2", - "limit": "50", - }) - if err != nil { + if err := runTagShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}); err != nil { t.Fatalf("list shortcut failed: %v", err) } } -func TestTagListUsesDefaultPagination(t *testing.T) { - server := newTagTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/tags.json") - assertEqual(t, r.URL.Query().Get("page"), "1") - assertEqual(t, r.URL.Query().Get("limit"), "20") - writeJSON(t, w, map[string]interface{}{"total_count": 0, "tags": []interface{}{}}) - }) +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", nil); err != nil { - t.Fatalf("list shortcut failed: %v", err) - } -} - -func TestTagNamesUsesNameSearch(t *testing.T) { - server := newTagTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/owner/repo/tags.json") - assertEqual(t, r.URL.Query().Get("only_name"), "true") - assertEqual(t, r.URL.Query().Get("name"), "v1") - writeJSON(t, w, map[string]interface{}{ - "total_count": 1, - "tags": []interface{}{map[string]interface{}{"name": "v1.0"}}, - }) - }) - defer server.Close() - - if err := runTagShortcut(t, server, "names", map[string]string{"keyword": " v1 "}); err != nil { - t.Fatalf("names shortcut failed: %v", err) - } -} - -func TestTagViewEscapesTagName(t *testing.T) { - server := newTagTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "GET", "/v1/owner/repo/tags/release%2Fv1.0.json") - writeJSON(t, w, map[string]interface{}{"name": "release/v1.0"}) - }) - defer server.Close() - - if err := runTagShortcut(t, server, "view", map[string]string{"name": "release/v1.0"}); err != nil { - t.Fatalf("view shortcut failed: %v", err) - } -} - -func TestTagDeleteEscapesTagName(t *testing.T) { - server := newTagTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "DELETE", "/v1/owner/repo/tags/release%2Fv1.0.json") - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - if err := runTagShortcut(t, server, "delete", map[string]string{"name": "release/v1.0"}); err != nil { - t.Fatalf("delete shortcut failed: %v", err) - } -} - -func TestTagValidation(t *testing.T) { - server := newTagTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("invalid input should not call API, got: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - cases := []struct { - name string - shortcut string - args map[string]string - }{ - { - name: "invalid page", - shortcut: "list", - args: map[string]string{"page": "0", "limit": "20"}, - }, - { - name: "invalid limit", - shortcut: "list", - args: map[string]string{"page": "1", "limit": "many"}, - }, - { - name: "empty tag name", - shortcut: "view", - args: map[string]string{"name": " "}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if err := runTagShortcut(t, server, tc.shortcut, tc.args); err == nil { - t.Fatal("expected validation error") - } - }) + 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() - shortcut := findTagShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - if ctx.Args == nil { - ctx.Args = map[string]string{} - } - return shortcut.Run(ctx) -} - -func findTagShortcut(t *testing.T, name string) *common.Shortcut { t.Helper() for _, shortcut := range Shortcuts() { if shortcut.Name == name { - return shortcut + 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 newTagTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { - t.Helper() - return httptest.NewServer(handler) -} +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() -func assertRequest(t *testing.T, r *http.Request, method, path string) { - t.Helper() - if r.Method != method || r.URL.EscapedPath() != path { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.EscapedPath()) + if err := runTagShortcut(t, server, "view", map[string]string{"name": "v1.0"}); err != nil { + t.Fatalf("view failed: %v", err) } } -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) - } -} +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() -func assertEqual(t *testing.T, got interface{}, want interface{}) { - t.Helper() - if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { - t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + if err := runTagShortcut(t, server, "view", map[string]string{"name": "mytag"}); err != nil { + t.Fatalf("view fallback failed: %v", err) } } diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 1321786..3f313cd 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -9,19 +9,31 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// Shortcuts returns all shortcuts for webhook management. +var allowedWebhookTypes = map[string]bool{ + "gitea": true, "slack": true, "discord": true, "dingtalk": true, "telegram": true, + "msteams": true, "feishu": true, "matrix": true, "jianmu": true, "softbot": true, +} + +var allowedWebhookContentTypes = map[string]bool{"json": true, "form": true} +var allowedWebhookMethods = map[string]bool{"GET": true, "POST": true} + +var allowedWebhookEvents = map[string]bool{ + "push": true, "create": true, "delete": true, + "issues_only": true, "issue_assign": true, "issue_label": true, "issue_comment": true, + "pull_request_only": true, "pull_request_assign": true, "pull_request_comment": true, +} + +// Shortcuts returns webhook management shortcuts. func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { - tr := i18n.Default() - if len(translators) > 0 && translators[0] != nil { - tr = translators[0] - } + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", Description: tr.T("cmd.webhook.list.short"), 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: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", 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 { @@ -30,50 +42,14 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", v1Path(ctx)+"/webhooks", q) - if err != nil { - return err + 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)) } - return ctx.Output(env) - }, - }, - { - Name: "create", - Description: tr.T("cmd.webhook.create.short"), - Flags: []common.Flag{ - {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url"), Required: true}, - {Name: "content-type", Usage: tr.T("flag.webhook.content_type"), Default: "json"}, - {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret")}, - {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events")}, - {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter")}, - {Name: "active", Usage: tr.T("flag.webhook.active"), Default: "true"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - webhookURL, err := ctx.RequireArg("url") - if err != nil { - return err - } - body := map[string]interface{}{ - "url": webhookURL, - "content_type": ctx.Arg("content-type"), - "http_method": "POST", - "active": true, - } - if secret := ctx.Arg("secret"); secret != "" { - body["secret"] = secret - } - if events := ctx.Arg("events"); events != "" { - body["events"] = strings.Split(events, ",") - } else { - body["events"] = []string{"push"} - } - if bf := ctx.Arg("branch-filter"); bf != "" { - body["branch_filter"] = bf - } - env, err := ctx.CallAPI("POST", v1Path(ctx)+"/webhooks", body) + env, err := ctx.CallAPIWithQuery("GET", webhookPath(ctx), q) if err != nil { return err } @@ -94,58 +70,47 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", v1Path(ctx), id), nil) + env, err := ctx.CallAPI("GET", webhookItemPath(ctx, id), nil) if err != nil { return err } return ctx.Output(env) }, }, + { + Name: "create", + Description: tr.T("cmd.webhook.create.short"), + Flags: []common.Flag{ + {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url"), Required: true}, + {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events"), Required: true}, + {Name: "type", Short: "t", Usage: tr.T("flag.webhook.type"), Default: "gitea"}, + {Name: "content-type", Usage: tr.T("flag.webhook.content_type"), Default: "json"}, + {Name: "http-method", Usage: tr.T("flag.webhook.http_method"), Default: "POST"}, + {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret")}, + {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter"), Default: "*"}, + {Name: "active", Usage: tr.T("flag.webhook.active"), Default: "true"}, + }, + Run: runCreate, + }, { Name: "update", Description: tr.T("cmd.webhook.update.short"), Flags: []common.Flag{ {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url")}, - {Name: "content-type", Usage: tr.T("flag.webhook.content_type"), Default: "json"}, - {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret")}, {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events")}, + {Name: "type", Short: "t", Usage: tr.T("flag.webhook.type")}, + {Name: "content-type", Usage: tr.T("flag.webhook.content_type")}, + {Name: "http-method", Usage: tr.T("flag.webhook.http_method")}, + {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret_update")}, {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter")}, - {Name: "active", Usage: tr.T("flag.webhook.active"), Default: "true"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - body := map[string]interface{}{ - "content_type": ctx.Arg("content-type"), - "http_method": "POST", - "active": true, - "branch_filter": ctx.Arg("branch-filter"), - "secret": ctx.Arg("secret"), - } - if webhookURL := ctx.Arg("url"); webhookURL != "" { - body["url"] = webhookURL - } - if events := ctx.Arg("events"); events != "" { - body["events"] = strings.Split(events, ",") - } else { - body["events"] = []string{"push"} - } - env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", v1Path(ctx), id), body) - if err != nil { - return err - } - return ctx.Output(env) + {Name: "active", Usage: tr.T("flag.webhook.active")}, }, + Run: runUpdate, }, { - Name: "history", - Description: tr.T("cmd.webhook.tasks.short"), + Name: "delete", + Description: tr.T("cmd.webhook.delete.short"), Flags: []common.Flag{ {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, @@ -157,7 +122,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s/hooktasks", v1Path(ctx), id), nil) + env, err := ctx.CallAPI("DELETE", webhookItemPath(ctx, id), nil) if err != nil { return err } @@ -178,7 +143,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", v1Path(ctx), id), nil) + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/tests", webhookItemPath(ctx, id)), nil) if err != nil { return err } @@ -186,8 +151,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "delete", - Description: tr.T("cmd.webhook.delete.short"), + Name: "tasks", + Description: tr.T("cmd.webhook.tasks.short"), Flags: []common.Flag{ {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, @@ -199,7 +164,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", v1Path(ctx), id), nil) + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil) if err != nil { return err } @@ -209,6 +174,221 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } } -func v1Path(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + +func runCreate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + payload, err := webhookPayloadFromArgs(ctx, nil) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", webhookPath(ctx), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + + current, err := fetchWebhook(ctx, id) + if err != nil { + return fmt.Errorf("fetch webhook: %w", err) + } + payload, err := webhookPayloadFromArgs(ctx, current) + if err != nil { + return err + } + env, err := ctx.CallAPI("PUT", webhookItemPath(ctx, id), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func webhookPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s/webhooks", ctx.Owner, ctx.Repo) +} + +func webhookItemPath(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("%s/%s", webhookPath(ctx), id) +} + +func fetchWebhook(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) { + env, err := ctx.CallAPI("GET", webhookItemPath(ctx, id), nil) + if err != nil { + return nil, err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("failed to parse webhook data") + } + return data, nil +} + +func webhookPayloadFromArgs(ctx *common.RuntimeContext, current map[string]interface{}) (map[string]interface{}, error) { + url := firstNonEmpty(ctx.Arg("url"), stringFromMap(current, "url")) + if url == "" { + return nil, fmt.Errorf("required flag --url is missing") + } + + eventValue := ctx.Arg("events") + var events []string + var err error + if eventValue != "" { + events, err = parseWebhookEvents(eventValue) + if err != nil { + return nil, err + } + } else { + events, err = eventsFromMap(current) + if err != nil { + return nil, err + } + } + if len(events) == 0 { + return nil, fmt.Errorf("required flag --events is missing") + } + + webhookType := strings.ToLower(firstNonEmpty(ctx.Arg("type"), stringFromMap(current, "type"), "gitea")) + if err := validateOneOf("type", webhookType, allowedWebhookTypes); err != nil { + return nil, err + } + contentType := strings.ToLower(firstNonEmpty(ctx.Arg("content-type"), stringFromMap(current, "content_type"), "json")) + if err := validateOneOf("content-type", contentType, allowedWebhookContentTypes); err != nil { + return nil, err + } + httpMethod := strings.ToUpper(firstNonEmpty(ctx.Arg("http-method"), stringFromMap(current, "http_method"), "POST")) + if err := validateOneOf("http-method", httpMethod, allowedWebhookMethods); err != nil { + return nil, err + } + branchFilter := firstNonEmpty(ctx.Arg("branch-filter"), stringFromMap(current, "branch_filter"), "*") + active, err := activeFromArgs(ctx.Arg("active"), current) + if err != nil { + return nil, err + } + + payload := map[string]interface{}{ + "type": webhookType, + "active": active, + "content_type": contentType, + "http_method": httpMethod, + "url": url, + "branch_filter": branchFilter, + "events": events, + } + if secret := firstNonEmpty(ctx.Arg("secret"), stringFromMap(current, "secret")); secret != "" { + payload["secret"] = secret + } + return payload, nil +} + +func parseWebhookEvents(value string) ([]string, error) { + parts := strings.Split(value, ",") + events := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, part := range parts { + event := strings.TrimSpace(part) + if event == "" { + continue + } + if !allowedWebhookEvents[event] { + return nil, fmt.Errorf("invalid --events value %q", event) + } + if seen[event] { + continue + } + seen[event] = true + events = append(events, event) + } + if len(events) == 0 { + return nil, fmt.Errorf("required flag --events is missing") + } + return events, nil +} + +func eventsFromMap(values map[string]interface{}) ([]string, error) { + if values == nil { + return nil, nil + } + raw, ok := values["events"] + if !ok || raw == nil { + return nil, nil + } + switch events := raw.(type) { + case []interface{}: + result := make([]string, 0, len(events)) + for _, event := range events { + name, ok := event.(string) + if !ok { + return nil, fmt.Errorf("failed to parse webhook events") + } + result = append(result, name) + } + return result, nil + case []string: + return events, nil + default: + return nil, fmt.Errorf("failed to parse webhook events") + } +} + +func activeFromArgs(value string, current map[string]interface{}) (bool, error) { + if value != "" { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("invalid --active value %q: use true or false", value) + } + } + if current != nil { + if active, ok := current["active"].(bool); ok { + return active, nil + } + if active, ok := current["is_active"].(bool); ok { + return active, nil + } + } + return true, nil +} + +func stringFromMap(values map[string]interface{}, key string) string { + if values == nil { + return "" + } + value, _ := values[key].(string) + return value +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func validateOneOf(name, value string, allowed map[string]bool) error { + if allowed[value] { + return nil + } + return fmt.Errorf("invalid --%s value %q", name, value) }