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:
commit
b2d9c504eb
16
README.md
16
README.md
|
|
@ -311,6 +311,22 @@ gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role develope
|
|||
# List issues
|
||||
gitlink-cli issue +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# Fetch all pages automatically (works on all paginated list commands:
|
||||
# issue/pr/branch/release/milestone/org/repo/label/member/webhook/tag/commit +list,
|
||||
# issue +comments, repo +watchers/+stargazers/+forks, search +repos/+users)
|
||||
gitlink-cli issue +list --owner Gitlink --repo forgeplus --all
|
||||
|
||||
# Commit history and single commit details
|
||||
gitlink-cli commit +list --owner Gitlink --repo forgeplus --ref develop --all
|
||||
gitlink-cli commit +view --owner Gitlink --repo forgeplus --sha <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..."
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "复现步骤..."
|
||||
|
||||
|
|
|
|||
132
cmd/api/api.go
132
cmd/api/api.go
|
|
@ -7,14 +7,12 @@ import (
|
|||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
repoContext "github.com/gitlink-org/gitlink-cli/internal/context"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
|
@ -30,10 +28,9 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
|
|||
Long: tr.T("cmd.api.long"),
|
||||
Example: ` gitlink-cli api GET /users/me
|
||||
gitlink-cli api GET /projects --query 'page=1&limit=10'
|
||||
gitlink-cli api GET /:owner/:repo/issues --paginate
|
||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
|
||||
gitlink-cli api POST /:owner/:repo/issues --body-file issue.json
|
||||
gitlink-cli api POST /v1/{{owner}}/{{repo}}/issues/{{number}}/journals --body '{"notes":"handled by {{actor}}"}' --var owner=Gitlink --var repo=gitlink-cli --var number=42 --var actor=bot
|
||||
gitlink-cli api POST /v1/{{owner}}/{{repo}}/issues --body-file issue.json --var owner=Gitlink --var repo=gitlink-cli --dry-run
|
||||
gitlink-cli api --batch-file plan.json --dry-run
|
||||
gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=gitlink-cli`,
|
||||
Args: validateAPIArgs,
|
||||
|
|
@ -44,6 +41,7 @@ func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
|
|||
apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file"))
|
||||
apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin"))
|
||||
apiCmd.Flags().String("query", "", tr.T("flag.api.query"))
|
||||
apiCmd.Flags().Bool("paginate", false, tr.T("flag.api.paginate"))
|
||||
apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header"))
|
||||
apiCmd.Flags().String("batch-file", "", tr.T("flag.api.batch_file"))
|
||||
apiCmd.Flags().Bool("dry-run", false, tr.T("flag.api.batch_dry_run"))
|
||||
|
|
@ -66,7 +64,11 @@ func validateAPIArgs(c *cobra.Command, args []string) error {
|
|||
|
||||
func runAPI(c *cobra.Command, args []string) error {
|
||||
batchFile, _ := c.Flags().GetString("batch-file")
|
||||
paginate, _ := c.Flags().GetBool("paginate")
|
||||
if batchFile != "" {
|
||||
if paginate {
|
||||
return fmt.Errorf("--paginate cannot be used with --batch-file")
|
||||
}
|
||||
return runAPIBatch(c, batchFile)
|
||||
}
|
||||
|
||||
|
|
@ -77,23 +79,13 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
path = "/" + path
|
||||
}
|
||||
|
||||
vars, err := parseBatchVars(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addRepoContextVars(vars)
|
||||
path, err = renderSinglePath(path, vars)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli, err := client.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cli.Debug = cmdutil.Debug
|
||||
|
||||
body, err := readJSONBody(c, vars)
|
||||
body, err := readJSONBody(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -101,22 +93,30 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
var query url.Values
|
||||
queryStr, _ := c.Flags().GetString("query")
|
||||
if queryStr != "" {
|
||||
query, err = renderSingleQuery(queryStr, vars)
|
||||
var err error
|
||||
query, err = url.ParseQuery(queryStr)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("invalid query string: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
dryRun, _ := c.Flags().GetBool("dry-run")
|
||||
if dryRun {
|
||||
return output.Print(output.SuccessEnvelope(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"method": method,
|
||||
"path": path,
|
||||
"query": query,
|
||||
"body": body,
|
||||
"variables": sortedVars(vars),
|
||||
}, nil), resolveFormat())
|
||||
if paginate {
|
||||
if method != "GET" {
|
||||
return fmt.Errorf("--paginate only supports GET requests, got %s", method)
|
||||
}
|
||||
if body != nil {
|
||||
return fmt.Errorf("--paginate cannot be used with a request body")
|
||||
}
|
||||
items, err := cli.PaginateAll(path, query)
|
||||
if err != nil {
|
||||
var apiErr *client.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "")
|
||||
return output.Print(errEnv, resolveFormat())
|
||||
}
|
||||
return err
|
||||
}
|
||||
return output.Print(paginatedEnvelope(items), resolveFormat())
|
||||
}
|
||||
|
||||
env, err := cli.Do(method, path, body, query)
|
||||
|
|
@ -132,7 +132,24 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
return output.Print(env, resolveFormat())
|
||||
}
|
||||
|
||||
func readJSONBody(c *cobra.Command, vars map[string]string) (interface{}, error) {
|
||||
// paginatedEnvelope wraps merged pages in the same shape as a single-page
|
||||
// list response: {"total_count": N, "items": [...]}.
|
||||
func paginatedEnvelope(items []json.RawMessage) *output.Envelope {
|
||||
decoded := make([]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(item, &v); err == nil {
|
||||
decoded = append(decoded, v)
|
||||
}
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"total_count": len(decoded),
|
||||
"items": decoded,
|
||||
}
|
||||
return output.SuccessEnvelope(data, &output.Meta{TotalCount: len(decoded)})
|
||||
}
|
||||
|
||||
func readJSONBody(c *cobra.Command) (interface{}, error) {
|
||||
bodyStr, _ := c.Flags().GetString("body")
|
||||
bodyFile, _ := c.Flags().GetString("body-file")
|
||||
bodyStdin, _ := c.Flags().GetBool("body-stdin")
|
||||
|
|
@ -172,64 +189,7 @@ func readJSONBody(c *cobra.Command, vars map[string]string) (interface{}, error)
|
|||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
rendered, err := renderBatchValue(body, vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render JSON body: %w", err)
|
||||
}
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
func renderSinglePath(path string, vars map[string]string) (string, error) {
|
||||
rendered, err := renderTemplate(rewriteColonPlaceholders(path), vars)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("render path: %w", err)
|
||||
}
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
func renderSingleQuery(raw string, vars map[string]string) (url.Values, error) {
|
||||
rendered, err := renderTemplate(raw, vars)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render query: %w", err)
|
||||
}
|
||||
query, err := url.ParseQuery(rendered)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid query string: %w", err)
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func addRepoContextVars(vars map[string]string) {
|
||||
if vars == nil {
|
||||
return
|
||||
}
|
||||
if vars["owner"] != "" && vars["repo"] != "" {
|
||||
return
|
||||
}
|
||||
owner, repo, err := repoContext.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if vars["owner"] == "" {
|
||||
vars["owner"] = owner
|
||||
}
|
||||
if vars["repo"] == "" {
|
||||
vars["repo"] = repo
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteColonPlaceholders(path string) string {
|
||||
return colonPathVarPattern.ReplaceAllString(path, `$1{{$2}}`)
|
||||
}
|
||||
|
||||
var colonPathVarPattern = templatePattern
|
||||
|
||||
func init() {
|
||||
colonPathVarPattern = mustCompileColonPattern()
|
||||
}
|
||||
|
||||
func mustCompileColonPattern() *regexp.Regexp {
|
||||
return regexp.MustCompile(`(^|/):([A-Za-z0-9_.-]+)`)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func resolveFormat() string {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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` 全部通过。
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ",")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue