Merge PR #338: feat(list): issue/pr/branch/release +list 新增 --all 自动翻页(翻页助手

# Conflicts:
#	cmd/api/api.go
#	internal/client/pagination.go
#	internal/i18n/locales/en-US.json
#	internal/i18n/locales/zh-CN.json
#	shortcuts/branch/branch.go
#	shortcuts/commit/commit.go
#	shortcuts/commit/commit_test.go
#	shortcuts/issue/issue.go
#	shortcuts/label/label.go
#	shortcuts/member/member.go
#	shortcuts/member/member_test.go
#	shortcuts/milestone/milestone.go
#	shortcuts/pr/pr.go
#	shortcuts/tag/tag.go
#	shortcuts/tag/tag_test.go
#	shortcuts/webhook/webhook.go
This commit is contained in:
wbtiger 2026-07-14 22:50:19 +08:00
commit b2d9c504eb
29 changed files with 2857 additions and 1271 deletions

View File

@ -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 <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..."

View File

@ -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 <sha>
# Git 标签
gitlink-cli tag +list --owner Gitlink --repo forgeplus --all
# 创建 Issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."

View File

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

View File

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

View File

@ -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` 全部通过。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, "<listKey>": [...]}.
func NewListEnvelope(listKey string, items []json.RawMessage) *output.Envelope {
decoded := make([]interface{}, 0, len(items))
for _, item := range items {
var v interface{}
if err := json.Unmarshal(item, &v); err == nil {
decoded = append(decoded, v)
}
}
data := map[string]interface{}{
"total_count": len(decoded),
listKey: decoded,
}
return output.SuccessEnvelope(data, &output.Meta{TotalCount: len(decoded)})
}
// Output prints the envelope in the configured format.
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
return output.Print(env, ctx.Format)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ func TestRegisterAll(t *testing.T) {
expectedGroups := []string{
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
"org", "user", "search", "ci", "workflow",
"org", "user", "search", "tag", "commit", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"dataset", "health", "ignore", "wiki", "feishu",
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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