Compare commits

...

100 Commits
master ... z2cc

Author SHA1 Message Date
s2_cc 42c5d11a62 feat(skills): 完善 issue-triage 与 code-review Skill,补充 gitlink-shared 已知坑
CI / test (pull_request) Failing after 1m12s Details
本次赛题2(编写和丰富 GitLink Skills)的产出,聚焦两个场景:

gitlink-issue-triage(1.0→1.2):
- SKILL.md/REFERENCE.md/examples 重写:语义分类为主、建议→确认→执行、幂等
- 关键修正(端到端实测):打标签改用 Raw API issue_tag_ids(+update --label 不可用、
  +batch-update --label 集合式);label +list 返回 data.issue_tags[]、+view 标签字段为 tags
- 新增 REFERENCE.md

gitlink-code-review(1.0→1.2):
- SKILL.md/REFERENCE.md/examples:评审改用 pr +review --status(common/approved/rejected),
  diff 改用 pr +versions + pr +version-diff(pr +diff 已移除);建议→dry-run→提交
- 修正原 GitHub 风格 Raw API(event/position) 错误;行内评论锚定失效时改用 pr +comment
- 删除越界工作流(仓库健康度/Issue 分拣,属其他场景)

gitlink-shared/SKILL.md:
- 已知坑表新增 3 条:PR Diff 命令已变更、Issue 打标签需用 Raw API、PR 行内评论锚定失效

均在 Claude Code 端到端实测验证(见 my_docs/ 验证记录,已 gitignore)。
2026-06-24 08:18:27 +08:00
刘焱 75a8ed235a fix(collaborator): 修复 change-role/batch-role 请求体
CI / test (push) Failing after 39m6s Details
forgeplus change_role 要求顶层 {user_id: 整数, role: Manager},原先嵌套 {user:{user_id:用户名, role_name:admin}} 导致未找到用户。改为经协作者列表解析用户名到数字 ID,role 映射 admin/write/read 到 Manager/Developer/Reporter。
2026-06-10 11:28:45 +08:00
刘焱 e9f36c29fb fix(branch): 修复保护分支接口路径与列表输出
protect/unprotect/batch-protect 走无 /v1 的老接口(DELETE /api/:owner/:repo/protected_branches/:name),原先漏或误加 /v1 均导致 404;branch +list 投影到核心列 name/protected/last_commit/commit_time,避免字段过多导致表格不稳定。
2026-06-10 11:28:45 +08:00
刘焱 f248ed9d04 fix(output): 表头取所有行字段并集并固定核心列顺序
printSliceTable 原先只从第一行取表头,导致列时有时无;改为取所有行并集。新增 protected/last_commit/commit_time 到优先表头,固定分支核心列顺序。
2026-06-10 11:28:45 +08:00
s2_cc f9efae89a3 docs: add comprehensive Chinese comments and mark bug fixes (P1-P4)
CI / test (push) Failing after 2m6s Details
- Add detailed Chinese comments across all shortcut modules explaining
  parameter mappings (CLI→API field names), batch processing patterns,
  and design decisions
- Mark P1 Bug#1: issue +create --label not written to request body
- Mark P1 Bug#2: hardcoded 'master' replaced with GetDefaultBranch()
- Mark P1 Bug#3: +diff and +files deduplicated into +version-diff
- Add P2 parameter annotations for sort/filter/milestone fields
- Add P4 improvement notes for HTTP error suggestions
- Reorder ProcessBatch function for better readability
2026-06-10 10:13:45 +08:00
刘焱 d12ce0fb57 fix(interactive): 命令面板选中带可选参数的命令也弹出表单
CI / test (push) Failing after 2m4s Details
根因:updatePalette 只在有缺失的 Required flag 时才弹参数表单。但很多
命令的参数是'至少一个'关系(如 branch +batch-protect 的 --names/--from
二选一),无法单独标记 Required,导致 missingRequiredFlags 返回空,
表单被跳过,命令直接执行后报 'no branch names provided'。

修复:把表单触发条件从'有缺失必填参数'改为'命令有任何 flag'。这样
选中任何带参数的命令都弹表单,用户能填可选参数(留空即跳过)。
无 flag 的命令(如 wiki +list、repo +recommend)仍直接执行。

回归测试 TestPaletteSelectionOpensFormForOptionalFlags 验证模拟的
batch-protect(全可选参数)选中后进入 stateForm 而非直接执行。
2026-06-08 21:01:04 +08:00
刘焱 c7a2ca3685 fix(file): 移除 +tree 无效的 --recursive flag
GitLink 的 /git/trees/:sha 端点实测不支持 recursive 参数(无论传
true/false、sha 用分支名还是 commit,都报'Recursive不包含于列表中')。
移除 --recursive flag 避免误导,更新 help 说明改用 +entries --path 逐层
展开子目录。
2026-06-08 20:52:21 +08:00
刘焱 386e3c92b8 fix(file): 修复文件管理命令的端点和参数问题
经实测 Gitlink/gitlink-cli 与自有 fork 仓库,file 组存在多处问题:

1. +get 端点错误:原 /files?filepath= 返回整个仓库文件列表而非
   指定文件。改为用 /entries 取目录再按文件名提取单文件元信息
   (GitLink 无'按路径取原始内容'端点,需配合 +blob --sha 取内容)

2. +tree recursive bug:传 recursive=false 被 API 拒绝
   ('Recursive不包含于列')。改为仅当用户显式 --recursive 时才传参

3. +create/+update/+delete 缺必需参数:
   - 补 --new-branch(保护分支场景下创建新分支)
   - +update/+delete 的 --sha 改为必填并加中文错误提示
   - 校验 branch 或 new_branch 至少一个

4. 移除 +replace:端点 /replace_file 返回'接口方法异常'(废弃),
   功能与 +create/+update 重复

新增 pathDir/pathBase/findEntryByName 辅助函数。实测 +create
--new-branch 在自有 fork 成功创建文件。
2026-06-08 20:46:09 +08:00
刘焱 612deddb57 fix(output): 本地构造的 map slice 也渲染为表格,不再显示 [N items]
根因:findLargestSlice 只识别 []interface{},而 wiki +list 等本地
构造数据的命令用 Go 原生类型 []map[string]string。这类列表不被
识别为可表格化的列表,于是在单对象详情视图里被 formatValue 折叠
成 '[2 items]',用户看不到实际页面名。

修复(防御性,覆盖所有类似命令):
- findLargestSlice 用 reflect 处理任意元素为 map 的 slice,
  经 normalizeMap 归一化为 map[string]interface{}
- formatValue 的 map 分支也调用 normalizeMap,使原生 map 同样摘要
- 新增 normalizeMap 辅助函数

回归测试 TestPrintTable_NativeMapStringSlice 验证 []map[string]string
渲染为表格而非 [N items]。
2026-06-08 20:39:36 +08:00
刘焱 fb051af813 feat(output): 单对象详情渲染为可读的层级表格,不再回退 JSON
之前含嵌套 map 的单对象详情(如 tag +view、repo +info)会回退到
JSON 输出,难以阅读。

改进 printMapTable 为详情视图:
- 顶层字段按 collectKeys 排序(id/name/status 等关键字段在前)
- 嵌套 map 用 └ 缩进递归展开子字段,深层嵌套也清晰展示
- 长值(URL/sha/message)仍截断,保持终端宽度友好

移除 hasOnlyNestedMaps 回退逻辑(dead code)。新增测试
TestPrintTable_NestedMapRendersAsDetailTable 验证嵌套 map 渲染为
表格而非 JSON,且子字段被展开。
2026-06-08 20:35:25 +08:00
刘焱 c345b5af5d fix(tag): tag +view 改用列表过滤,GitLink 无单标签 API 端点
根因:GitLink API 文档(Repositories 章节)只有'仓库标签列表'
(GET /api/:owner/:repo/tags.json),没有'获取单个标签'端点。
原 tag +view 调用的 /v1/{owner}/{repo}/tags/{name} 是不存在的端点,
落到 web 路由返回'标签不存在'错误。

修复:tag +view 改为先调用 tags 列表端点,再用 findTagByName
按名称过滤出目标标签。找不到时返回明确的中文错误。

新增 findTagByName 辅助函数及 4 个单元测试覆盖:
找到/未找到/nil envelope/无 tags key。
2026-06-08 20:30:47 +08:00
刘焱 c192b26da8 docs(repo): 子命令描述和注释中文化 2026-06-08 19:59:27 +08:00
刘焱 5a5db6d298 docs(pr): 子命令描述和注释中文化 2026-06-08 19:59:25 +08:00
刘焱 d20b3b09e1 docs(issue): 子命令描述和注释中文化 2026-06-08 19:59:22 +08:00
刘焱 0ff97fd213 docs(attachment): 子命令描述和注释中文化 2026-06-08 19:58:57 +08:00
刘焱 f5f1a34310 docs(dataset): 子命令描述和注释中文化 2026-06-08 19:58:54 +08:00
刘焱 a8be1a8100 docs(template): 子命令描述和注释中文化 2026-06-08 19:58:48 +08:00
刘焱 46eff83ef5 docs(ci): 子命令描述和注释中文化 2026-06-08 19:58:43 +08:00
刘焱 01e4515cb9 docs(search): 子命令描述和注释中文化 2026-06-08 19:58:36 +08:00
刘焱 fd647b7e75 docs(org): 子命令描述和注释中文化 2026-06-08 19:58:32 +08:00
刘焱 5c8cc3462c docs(util): 子命令描述和注释中文化 2026-06-08 19:58:30 +08:00
刘焱 a31b8e5b11 docs(sshkey): 子命令描述和注释中文化 2026-06-08 19:58:24 +08:00
刘焱 7fa93cb3c1 docs(label): 子命令描述和注释中文化 2026-06-08 19:58:20 +08:00
刘焱 68e7994d08 docs(commit): 子命令描述和注释中文化 2026-06-08 19:58:17 +08:00
刘焱 81cfcfb3e4 docs(file): 子命令描述和注释中文化 2026-06-08 19:58:09 +08:00
刘焱 596fb81a55 docs(milestone): 子命令描述和注释中文化 2026-06-08 19:56:30 +08:00
刘焱 84aae8554b docs(release): 子命令描述和注释中文化 2026-06-08 19:56:26 +08:00
刘焱 be152b9fa1 docs(collaborator): 子命令描述和注释中文化 2026-06-08 19:56:25 +08:00
刘焱 5c9e24c725 docs(tag): 子命令描述和注释中文化 2026-06-08 19:56:24 +08:00
刘焱 59240d18c5 docs(branch): 子命令描述和注释中文化 2026-06-08 19:56:23 +08:00
刘焱 799af5f3cf docs(wiki): 子命令描述和注释中文化 2026-06-08 19:56:21 +08:00
刘焱 cafde7d2f4 docs(user): 子命令描述和注释中文化 2026-06-08 19:56:18 +08:00
刘焱 2aa1b2ffe4 docs(snippet): 子命令描述和注释中文化 2026-06-08 19:56:17 +08:00
刘焱 bc12b43fc0 docs(webhook): 子命令描述和注释中文化 2026-06-08 19:56:15 +08:00
刘焱 30f16b9613 feat(interactive): 默认使用 table 格式输出,提升可读性 2026-06-08 19:53:20 +08:00
刘焱 461b28e6c8 test(interactive): smoke-test all registered commands execute without panic 2026-06-08 19:50:50 +08:00
刘焱 d66c88ecca feat(interactive): add scrollable viewport for long command output 2026-06-08 19:48:17 +08:00
刘焱 295eda5a4e feat(output): improve table readability — truncate values, limit columns, summarize nested maps 2026-06-08 19:44:22 +08:00
刘焱 25ee6cc5fb feat(output): render nested list data as table instead of falling back to JSON
When envelope.Data is a map containing a list of objects (e.g. tag +list
returns {tags: [...], total_count}), printTable previously fell back to
JSON because the map had complex values. Now it scans the map for the
largest []interface{} of map[string]interface{} and renders that list
via printSliceTable, so users see a table again.

- Add findLargestSlice helper to pick the largest table-renderable slice
- Add hasOnlyNestedMaps to distinguish scalar-only maps from nested-map maps
- Keep JSON fallback for genuinely nested (non-list) structures
- Add formatter_test.go covering all 5 branches of printTable
2026-06-08 19:32:06 +08:00
刘焱 f1dc2f8bc9 docs(shortcuts): 给每个命令组添加中文注释和中文描述
GetAllShortcuts 每个命令组加中文行内注释,说明该组用途;
GetDescriptions 描述改为中文,使交互式命令面板直接显示中文。
2026-06-08 19:18:46 +08:00
刘焱 b2136559bf fix(interactive): initialize RuntimeContext via NewRuntimeContext to prevent nil-Client panic
Selecting a command showed no output because Execute built the
RuntimeContext as a bare struct literal (&RuntimeContext{Args: args}),
leaving Client == nil. Any command calling ctx.CallAPI then panicked with
a nil pointer dereference inside the executor goroutine; the panic aborted
the goroutine before w.Close()/stdout restore, so the execResultMsg never
fired and the REPL hung silently.

Fix: use common.NewRuntimeContext(args) (initializes Client/Format/Owner/Repo,
matching runner.go), and wrap s.Run in safeRun() so a panic converts to an
error and stdout is always restored.

Adds regression tests: nil-Client assertion, panic-safe stdout restore, and
an end-to-end test that runs the real repo +list shortcut and asserts
Execute never returns (empty output, nil error).
2026-06-08 19:16:40 +08:00
刘焱 16787b8f8c fix(interactive): trigger command palette immediately on '/' keypress
Previously the palette only opened after pressing Enter because the trigger
logic lived in the KeyEnter branch. The spec requires '/' to open the palette
immediately. Now KeyRunes with '/' as the first character of an empty input
switches to statePalette right away, carrying any trailing characters (e.g.
'/iss') as the initial search query.

Adds regression tests covering: immediate '/', '/' with query, and ordinary
text staying in input state.
2026-06-08 19:09:02 +08:00
刘焱 2596d1dc70 fix(interactive): correct Esc behavior and direct-exec for optional flags 2026-06-08 18:59:00 +08:00
刘焱 65a4e60d6d feat(interactive): add REPL with command palette, form, and cobra entry 2026-06-08 18:52:23 +08:00
刘焱 db8f3c7cae feat(interactive): add command executor with stdout capture and flag parsing 2026-06-08 15:55:33 +08:00
刘焱 76cb5be259 chore: add bubbletea, bubbles, huh dependencies 2026-06-08 15:53:30 +08:00
刘焱 cbbec86b49 feat(shortcuts): export GetAllShortcuts and GetDescriptions for interactive mode 2026-06-08 15:51:21 +08:00
刘焱 503a5aeead docs: add interactive REPL implementation plan 2026-06-08 15:49:05 +08:00
刘焱 1d4e177152 docs: add interactive REPL command palette design spec 2026-06-08 14:50:21 +08:00
赵昌 d1cc68b6d9 docs: add workflow examples for issue-triage, project-health, newcomer-guide
CI / test (push) Failing after 2m9s Details
2026-06-08 11:44:09 +08:00
赵昌 6c0ad3b7ae feat: add three new skills - issue triage, project health, newcomer guide
CI / test (push) Has been cancelled Details
2026-06-08 11:39:29 +08:00
赵昌 9a51db0989 feat: add wiki and snippet skills, update webhook skill with new commands
CI / test (push) Waiting to run Details
2026-06-08 11:35:45 +08:00
wqer a4587f95c4 添加Python示例
CI / test (push) Failing after 2m5s Details
2026-06-08 11:07:01 +08:00
赵昌 38658fb7b1 feat: task-view supports both numeric ID and UUID lookup
CI / test (push) Failing after 2m8s Details
2026-06-08 10:01:56 +08:00
赵昌 5eb67055fd feat: add webhook +failed and +task-view commands
CI / test (push) Failing after 2m4s Details
- webhook +failed: list failed deliveries with --limit support
- webhook +task-view: view detailed delivery content
- Original webhook +tasks unchanged
2026-06-08 09:57:13 +08:00
赵昌 dd905fb045 docs: update report - remove hook-runner, update webhook section
CI / test (push) Failing after 2m3s Details
2026-06-04 17:39:03 +08:00
赵昌 e60e876fad merge: integrate hook-runner into webhook commands
CI / test (push) Failing after 2m6s Details
- Add webhook +failed: list failed deliveries
- Add webhook +task-view: view task details
- Enhance webhook +tasks: add --limit, show UUID
- Remove standalone hook-runner package
- Update register.go
2026-06-04 17:33:26 +08:00
赵昌 a115c97dde fix: show uuid instead of numeric id in hook-runner list output
CI / test (push) Failing after 2m6s Details
2026-06-04 17:23:06 +08:00
赵昌 66ec432036 fix: simplify hook-runner list output to essential fields only
CI / test (push) Failing after 2m7s Details
2026-06-04 17:09:59 +08:00
赵昌 cb238f132c feat: add --limit flag to hook-runner list/failed commands
CI / test (push) Failing after 1m3s Details
2026-06-04 16:23:17 +08:00
赵昌 240a4ff6b7 test: add hook-runner unit tests (list/view/failed)
CI / test (push) Failing after 2m3s Details
2026-06-04 16:21:12 +08:00
赵昌 d46a0cc48b feat: add hook-runner shortcut for webhook delivery monitoring
CI / test (push) Failing after 2m7s Details
hook-runner +list   - List webhook delivery tasks
hook-runner +view   - View specific delivery task details
hook-runner +failed - List only failed deliveries

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:15:30 +08:00
赵昌 59552d9286 remove: project board shortcut (duplicated with issue commands)
CI / test (push) Failing after 2m7s Details
2026-06-04 16:04:00 +08:00
s2_cc 811bae1cd5 触发流水线
CI / test (push) Failing after 1m2s Details
2026-06-04 14:33:58 +08:00
赵昌 015a30caa5 chore: remove unused testdata file
CI / test (push) Failing after 2m6s Details
2026-06-04 11:42:23 +08:00
赵昌 e3470ba592 fix: delete _Sidebar.md on wiki +delete to prevent orphaned references
CI / test (push) Failing after 2m6s Details
When a wiki page is deleted, also remove _Sidebar.md so that GitLink
regenerates it from the current file list on next web visit. This
prevents orphaned page references from accumulating in the sidebar.

Use git add -A to safely stage all changes (deleted page + sidebar)
without failing when _Sidebar.md doesn't exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:01:10 +08:00
s2_cc 167a2ff12c fix: set GOPROXY to goproxy.cn for China network
CI / test (push) Failing after 1m2s Details
2026-06-04 10:52:46 +08:00
s2_cc 63cbb2cef8 feat: add DevOps CI pipeline for build and verification
CI / test (push) Failing after 2m6s Details
2026-06-04 10:45:32 +08:00
s2_cc 9f479a27db feat: optimize existing commands (P1-P4)
P1 Bug fixes:
- Fix issue +create --label not being sent to API
- Replace hardcoded "master" default branch with dynamic API query
- Add GetDefaultBranch() helper (common/types.go)

P2 Complete missing command parameters (10 items, 39 new flags):
- issue +list: --milestone, --assignee, --label, --keyword, --sort, --sort-direction
- issue +update: --milestone, --assignee, --label, --priority (expanded validation)
- issue +close: --comment (post journal on close)
- pr +list: --keyword, --milestone, --tag, --reviewer, --assignee, --sort, --sort-direction
- pr +create: --priority, --assignee, --milestone, --label (multi-label support)
- pr +merge: validate --method values, add --title, --body
- ci +builds: --status, --branch, --event, --since, --until
- search +repos: --language, --sort, --order
- search +users: --sort, --order
- release +list: --prerelease-only, --latest (client-side filtering)

P3 Batch operations (9 new commands with generic batch framework):
- Add common/batch.go: BatchSummary, CollectNumbers, ProcessBatch, CSV parsing
- Add common/confirm.go: ConfirmAction with --yes skip
- issue +batch-update: native batch_update API with ID mapping
- issue +batch-destroy: native batch_destroy API
- Refactor issue +batch-close to use common batch utilities
- pr +batch-close, pr +batch-merge
- collaborator +batch-add, +batch-remove, +batch-role
- branch +batch-delete, +batch-protect

P4 Help docs, confirm prompts, and error messages:
- Add Long description and Example to all 18 command groups
- Add confirmation prompt to destructive operations (repo/branch/release delete, ci stop)
- Improve HTTP error messages with contextual hints (401/403/404/422/500)
- Replace hardcoded Chinese strings with English in release +delete
2026-06-04 10:45:32 +08:00
赵昌 f18eea273f revert: remove sidebar cleanup from wiki +delete
CI / test (push) Failing after 2m3s Details
Modifying _Sidebar.md causes wiki page rendering to break on the
web UI. Delete now only removes the .md file and leaves sidebar
management to GitLink's web interface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:38:29 +08:00
赵昌 6fda83bf38 fix: clean _Sidebar.md reference on wiki +delete
CI / test (push) Failing after 1m2s Details
Add safe sidebar cleanup using a separate helper file (sidebar.go)
to avoid encoding issues. Only removes the page reference, never
adds or modifies other content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:24:20 +08:00
赵昌 92a953ba56 test: update snippet tests for Git-based operations
CI / test (push) Failing after 2m7s Details
Rewrite tests to use local Git repositories for list/view (Git-based)
and HTTP test servers for create/delete (API-based). Add
repoGitURLOverride variable for test isolation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:13:57 +08:00
wqer 9a2e6a3280 Delete snippet "hello.py"
CI / test (push) Failing after 1m2s Details
2026-06-04 08:54:21 +08:00
wqer 3a47936f9f 创建测试
CI / test (push) Has been cancelled Details
2026-06-04 08:53:36 +08:00
赵昌 15110df6f1 fix: use Git operations for snippet list/view/delete
CI / test (push) Failing after 2m5s Details
Snippet list and view now clone the repo (read-only) instead of
using broken API endpoints. Delete auto-fetches file SHA via
git hash-object for proper API verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:53:55 +08:00
wqer b9638fe0b6 Delete snippet "hello-cli.go"
CI / test (push) Has been cancelled Details
2026-06-04 08:51:27 +08:00
wqer 1762a24851 Delete snippet "test.py"
CI / test (push) Has been cancelled Details
2026-06-04 08:51:15 +08:00
wqer 5ae0a134bb test
CI / test (push) Failing after 2m6s Details
2026-06-04 08:41:36 +08:00
赵昌 2669c632d0 revert: remove automatic _Sidebar.md management
CI / test (push) Failing after 2m7s Details
Automatic sidebar updates caused wiki rendering issues on the web UI.
The sidebar is best left managed by GitLink's web interface directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:21:28 +08:00
赵昌 3ee71a1ffc fix: update _Sidebar.md on wiki page create/delete
CI / test (push) Failing after 2m6s Details
When creating a wiki page, add [[PageName]] to _Sidebar.md.
When deleting a wiki page, remove [[PageName]] from _Sidebar.md.
This ensures the web UI properly reflects page changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:11:08 +08:00
赵昌 87277cbf4f feat: add code snippet management shortcut (snippet +list/+view/+create/+delete)
CI / test (push) Failing after 11m14s Details
Store snippets as files in the snippets/ directory of the repository
using the repo file API. Supports language tagging in content format
and branch selection for commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:19:11 +08:00
wqer fef44dc5e3 Add hello snippet via CLI
CI / test (push) Has been cancelled Details
2026-06-03 21:06:13 +08:00
赵昌 d1e44867c3 feat: add project board shortcut (board +view/+columns/+move)
CI / test (push) Failing after 2m2s Details
Implement a kanban-style project board shortcut group that groups
issues by status. Supports filtering by state and milestone, moving
issues between columns with English or Chinese status names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:55:21 +08:00
刘焱 8d3d6cc5ce ci: add PR testing workflow and auto-update package manager configs on release
CI / test (push) Failing after 10m48s Details
2026-06-02 08:57:53 +08:00
刘焱 9598142c2f infra: add Homebrew, Scoop, Chocolatey package manager configs 2026-06-02 08:57:52 +08:00
刘焱 52dc8a9967 improve: npm install checksum verification, error messages, and version check 2026-06-02 08:57:52 +08:00
刘焱 2ddad05646 feat: add color detection and CJK width support for cross-platform terminals 2026-06-02 08:57:52 +08:00
刘焱 44b184723e feat: add user account settings shortcuts and OAuth2 authentication 2026-06-02 08:57:52 +08:00
刘焱 c7d374ab32 feat: add CI pipeline shortcuts, wiki import/export, repo join/quit/migrate/topics 2026-06-02 08:57:51 +08:00
刘焱 aebb24d51c feat: add util, dataset, template, attachment shortcut groups 2026-06-02 08:56:34 +08:00
刘焱 9f99247422 feat(user): add 22 shortcuts for messages, stats, transfers, settings, feedback 2026-06-02 08:56:34 +08:00
刘焱 637be3cb4b feat(repo): add 22 new shortcuts for settings, stats, social, transfer, invite 2026-06-02 08:56:34 +08:00
刘焱 4121524100 feat: add branch all/default/restore, release edit/update, org team-projects, file batch/entries/replace 2026-06-02 08:56:33 +08:00
刘焱 c2e0b0da18 feat(pr): add comments CRUD, commits, reopen, update shortcuts 2026-06-02 08:56:33 +08:00
刘焱 a94088e4ee feat(issue): add comments CRUD, delete, batch-update, batch-destroy shortcuts 2026-06-02 08:56:33 +08:00
刘焱 94661010ac docs: add implementation plan for full API coverage and cross-platform improvements 2026-06-02 08:56:33 +08:00
刘焱 ca7f5aacce docs: add design spec for full API coverage and cross-platform improvements 2026-06-02 08:56:32 +08:00
赵昌 667f28a618 refactor: reimplement wiki commands using Git operations
Replace REST API-based wiki implementation with direct Git operations
on the wiki repository (https://gitlink.org.cn/{owner}/{repo}.wiki.git).
This fixes wiki management which was broken due to REST API limitations.

List and view work without authentication (public repo).
Create, update, delete require a Personal Access Token for Git push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:53:32 +08:00
赵昌 8df76dcb7a feat: add wiki shortcut commands (list/view/create/update/delete)
Implement wiki management shortcuts with full CRUD operations.
Includes comprehensive unit tests covering success paths, error
handling, partial updates, project ID auto-resolution, and
parameter validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:08:58 +08:00
刘焱 b64a5104a8 feat: improve cross-platform compatibility and expand API shortcut coverage
Cross-platform improvements:
- Config/credential paths now use platform-aware directories:
  Windows (%APPDATA%), macOS (~/Library/Application Support),
  Linux (XDG_CONFIG_HOME) instead of hardcoded ~/.config
- npm install script: add retry (3x), HTTP proxy support (HTTP_PROXY/HTTPS_PROXY),
  download progress indicator, Windows tar fallback extraction
- Makefile: add cross-build (6 platforms), checksums (SHA256), lint targets
- Release workflow: auto-detect Go version from go.mod, upload SHA256 checksums

New API shortcut groups (36 sub-commands across 7 domains):
- collaborator: +list, +add, +remove, +change-role
- tag: +list, +view, +delete
- milestone: +list, +create, +view, +update, +delete, +close
- file: +get, +readme, +create, +update, +delete, +tree, +blob
- commit: +list, +view, +diff, +blame, +compare
- label: +list, +create, +update, +delete, +priorities, +statuses, +authors, +assigners
- sshkey: +list, +create, +delete

Total shortcut commands increased from ~50 to ~86 (+72% coverage).
2026-05-28 15:11:25 +08:00
99 changed files with 21462 additions and 613 deletions

View File

@ -0,0 +1,37 @@
version: 2
name: 构建流水线
description: "gitlink-cli CI 流水线:拉取代码、构建、验证"
trigger:
webhook: gitlink@1.0.0
event:
- ref: push
ruleset-operator: AND
global:
concurrent: 1
param:
- ref: remote_url
name: ""
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
required: false
type: STRING
hidden: false
workflow:
- ref: start
name: 开始
task: start
- ref: ssh_cmd_0
name: 拉取代码并构建验证
task: ssh_cmd@1.1.1
input:
ssh_pass: ((work_together.ssh_key))
ssh_ip: '"121.41.216.243"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_cmd: '"export GOPROXY=https://goproxy.cn,direct && cd /root/gitlink-cli || git clone https://gitlink.org.cn/z2_cc/gitlink-cli.git /root/gitlink-cli; cd /root/gitlink-cli && git pull origin master && go build -o gitlink-cli . && ./gitlink-cli version"'
needs:
- start
- ref: end
name: 结束
task: end
needs:
- ssh_cmd_0

20
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
- name: Build
run: go build -ldflags "-s -w" .

View File

@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version-file: 'go.mod'
- uses: actions/setup-node@v4
with:
@ -57,6 +57,10 @@ jobs:
rm -rf "$BUILD_DIR"
done
# Generate SHA256 checksums
cd dist
sha256sum *.tar.gz *.zip > checksums-sha256.txt
cd ..
ls -lh dist
- name: Create Release
@ -65,8 +69,44 @@ jobs:
files: |
dist/*.tar.gz
dist/*.zip
dist/checksums-sha256.txt
generate_release_notes: true
- name: Update Scoop manifest
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd dist
# Parse SHA256 hashes from checksums file
declare -A HASHES
while read -r hash file; do
HASHES["$file"]="$hash"
done < checksums-sha256.txt
# Update bucket/gitlink-cli.json
cd ..
SCOOP_FILE="bucket/gitlink-cli.json"
if [ -f "$SCOOP_FILE" ]; then
# Update version
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" "$SCOOP_FILE"
# Update amd64 hash and URL
AMD64_FILE="gitlink-cli_${VERSION}_windows_amd64.zip"
if [ -n "${HASHES[$AMD64_FILE]}" ]; then
sed -i "/64bit/,/}/ s|\"url\": \".*\"|\"url\": \"https://github.com/gitlink-org/gitlink-cli/releases/download/v${VERSION}/${AMD64_FILE}\"|" "$SCOOP_FILE"
sed -i "/64bit/,/}/ s|\"hash\": \".*\"|\"hash\": \"${HASHES[$AMD64_FILE]}\"|" "$SCOOP_FILE"
fi
# Update arm64 hash and URL
ARM64_FILE="gitlink-cli_${VERSION}_windows_arm64.zip"
if [ -n "${HASHES[$ARM64_FILE]}" ]; then
sed -i "/arm64/,/}/ s|\"url\": \".*\"|\"url\": \"https://github.com/gitlink-org/gitlink-cli/releases/download/v${VERSION}/${ARM64_FILE}\"|" "$SCOOP_FILE"
sed -i "/arm64/,/}/ s|\"hash\": \".*\"|\"hash\": \"${HASHES[$ARM64_FILE]}\"|" "$SCOOP_FILE"
fi
echo "Updated $SCOOP_FILE for version $VERSION"
fi
- name: Build npm package
run: |
VERSION=${GITHUB_REF#refs/tags/v}

20
.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
# 编译输出
*.exe
# IDE
.vscode/
.idea/
# 系统
.DS_Store
Thumbs.db
# 临时文件
*.tmp
*.log
# 个人工作区
my_docs/
CLAUDE.md
.claude/
docs/

View File

@ -3,7 +3,9 @@ BINARY := gitlink-cli
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -s -w -X '$(MODULE)/cmd.Version=$(VERSION)'
.PHONY: build install clean test
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
.PHONY: build install clean test lint cross-build dist checksums
build:
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
@ -13,6 +15,43 @@ install:
clean:
rm -f $(BINARY)
rm -rf dist/
test:
go test ./...
lint:
go vet ./...
# Cross-compile for all supported platforms
cross-build:
@mkdir -p dist
@for pair in $(PLATFORMS); do \
GOOS=$${pair%/*}; \
GOARCH=$${pair#*/}; \
OUT=$(BINARY); \
if [ "$$GOOS" = "windows" ]; then OUT=$(BINARY).exe; fi; \
echo "Building $$GOOS/$$GOARCH..."; \
BUILD_DIR="dist/$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}"; \
mkdir -p "$$BUILD_DIR"; \
CGO_ENABLED=0 GOOS=$$GOOS GOARCH=$$GOARCH \
go build -ldflags "$(LDFLAGS)" -o "$$BUILD_DIR/$$OUT" .; \
if [ "$$GOOS" = "windows" ]; then \
(cd "$$BUILD_DIR" && zip -q "../$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}.zip" "$$OUT"); \
else \
tar -czf "dist/$(BINARY)_$(VERSION)_$${GOOS}_$${GOARCH}.tar.gz" -C "$$BUILD_DIR" "$$OUT"; \
fi; \
rm -rf "$$BUILD_DIR"; \
done
@echo "Build complete:"
@ls -lh dist/*.tar.gz dist/*.zip 2>/dev/null
# Generate SHA256 checksums for all archives in dist/
checksums: cross-build
@cd dist && \
sha256sum *.tar.gz *.zip 2>/dev/null > checksums-sha256.txt && \
echo "Checksums written to dist/checksums-sha256.txt" && \
cat checksums-sha256.txt
# Full distribution build: cross-compile + checksums
dist: checksums

33
bucket/gitlink-cli.json Normal file
View File

@ -0,0 +1,33 @@
{
"version": "0.1.13",
"description": "CLI tool for GitLink platform",
"homepage": "https://www.gitlink.org.cn/Gitlink/gitlink-cli",
"license": "MulanPSL-2.0",
"architecture": {
"64bit": {
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_windows_amd64.zip",
"hash": "PLACEHOLDER_SHA256"
},
"arm64": {
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_windows_arm64.zip",
"hash": "PLACEHOLDER_SHA256"
}
},
"bin": "gitlink-cli.exe",
"checkver": {
"github": "https://github.com/gitlink-org/gitlink-cli"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_amd64.zip"
},
"arm64": {
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_arm64.zip"
}
},
"hash": {
"url": "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/checksums-sha256.txt"
}
}
}

16
choco/gitlink-cli.nuspec Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>gitlink-cli</id>
<version>0.1.13</version>
<title>GitLink CLI</title>
<authors>GitLink</authors>
<projectUrl>https://www.gitlink.org.cn/Gitlink/gitlink-cli</projectUrl>
<licenseUrl>https://opensource.org/licenses/MulanPSL-2.0</licenseUrl>
<description>CLI tool for GitLink platform</description>
<tags>gitlink cli devops</tags>
</metadata>
<files>
<file src="tools/**" target="tools" />
</files>
</package>

View File

@ -0,0 +1,14 @@
$ErrorActionPreference = 'Stop'
$packageName = 'gitlink-cli'
$version = '0.1.13'
$url = "https://github.com/gitlink-org/gitlink-cli/releases/download/v$version/gitlink-cli_$version_windows_amd64.zip"
$checksum = 'PLACEHOLDER_SHA256'
$installDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
$packageArgs = @{
packageName = $packageName
url = $url
checksum = $checksum
checksumType = 'sha256'
unzipLocation = $installDir
}
Install-ChocolateyZipPackage @packageArgs

View File

@ -23,11 +23,13 @@ func NewAuthCmd() *cobra.Command {
cmd.AddCommand(newLoginCmd())
cmd.AddCommand(newLogoutCmd())
cmd.AddCommand(newStatusCmd())
cmd.AddCommand(newRefreshCmd())
return cmd
}
func newLoginCmd() *cobra.Command {
var tokenMode bool
var oauthMode bool
cmd := &cobra.Command{
Use: "login",
@ -36,10 +38,14 @@ func newLoginCmd() *cobra.Command {
if tokenMode {
return loginWithToken()
}
if oauthMode {
return loginWithOAuth()
}
return loginWithPassword()
},
}
cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token")
cmd.Flags().BoolVar(&oauthMode, "oauth", false, "Login using OAuth2 password grant")
return cmd
}
@ -140,3 +146,42 @@ func newStatusCmd() *cobra.Command {
},
}
}
func loginWithOAuth() error {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Username: ")
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
fmt.Print("Password: ")
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
fmt.Println()
password := string(passwordBytes)
result, err := internalAuth.LoginOAuth(username, password)
if err != nil {
return fmt.Errorf("OAuth2 login failed: %w", err)
}
fmt.Printf("✓ Logged in as %s\n", result.Login)
return nil
}
func newRefreshCmd() *cobra.Command {
return &cobra.Command{
Use: "refresh",
Short: "Refresh OAuth2 token",
RunE: func(cmd *cobra.Command, args []string) error {
result, err := internalAuth.RefreshOAuthToken()
if err != nil {
return fmt.Errorf("failed to refresh token: %w", err)
}
fmt.Printf("✓ Token refreshed for %s (expires in %d seconds)\n", result.Login, result.ExpiresIn)
return nil
},
}
}

View File

@ -2,8 +2,10 @@ package cmdutil
// Global flags shared across all commands.
var (
Owner string
Repo string
Format string
Debug bool
Owner string
Repo string
Format string
Debug bool
ColorMode string
AutoConfirm bool
)

View File

@ -0,0 +1,70 @@
package interactive
import (
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts"
)
// TestAllCommandsExecuteWithoutPanic 验证所有注册的命令都能安全执行:
// 不 panic、恢复 stdout、且产生输出或合理的错误。
// 这是回归测试,防止之前 "选择命令后无输出" 的 nil-Client panic 复发。
func TestAllCommandsExecuteWithoutPanic(t *testing.T) {
all := shortcuts.GetAllShortcuts()
if len(all) == 0 {
t.Fatal("GetAllShortcuts returned empty registry")
}
exec := &Executor{}
totalCommands := 0
silentFailures := 0
for group, shortcuts := range all {
for _, s := range shortcuts {
if s.Run == nil {
continue
}
totalCommands++
// 用空参数执行;需要 owner/repo/必填参数的命令会返回错误,这是正常的
out, err := exec.Execute(s, map[string]string{})
// 断言1不能既无输出又无错误静默失败
if strings.TrimSpace(out) == "" && err == nil {
silentFailures++
t.Errorf("[%s +%s] 静默失败:无输出且无错误", group, s.Name)
}
// 断言2错误信息不应包含 "panic"(说明 recover 被触发,命令有 bug
if err != nil && strings.Contains(err.Error(), "panic") {
t.Errorf("[%s +%s] 命令 panic: %v", group, s.Name, err)
}
}
}
t.Logf("测试了 %d 个命令,%d 个静默失败", totalCommands, silentFailures)
if totalCommands < 50 {
t.Errorf("命令总数 %d 偏少,预期至少 50 个", totalCommands)
}
}
// TestAllCommandsHaveMetadata 验证所有命令都有名称、描述和 Run 函数。
func TestAllCommandsHaveMetadata(t *testing.T) {
all := shortcuts.GetAllShortcuts()
for group, shortcuts := range all {
if len(shortcuts) == 0 {
t.Errorf("命令组 %q 没有任何子命令", group)
}
for _, s := range shortcuts {
if s.Name == "" {
t.Errorf("[%s] 有命令名称为空", group)
}
if s.Description == "" {
t.Errorf("[%s +%s] 描述为空", group, s.Name)
}
if s.Run == nil {
t.Errorf("[%s +%s] Run 函数为空", group, s.Name)
}
}
}
}

View File

@ -0,0 +1,42 @@
package interactive
import (
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// TestE2E_ExecutingRealCommandProducesOutput is an end-to-end regression for
// "selecting a command shows no output". It drives the real shortcut registry
// (repo +list) through the executor and asserts that Execute returns either
// captured stdout OR a non-nil error — never both empty, which is what left
// the REPL silent before the RuntimeContext fix.
func TestE2E_ExecutingRealCommandProducesOutput(t *testing.T) {
all := shortcuts.GetAllShortcuts()
s, ok := findShortcut(all, "repo", "list")
if !ok {
t.Fatal("repo +list not found in registry")
}
if s.Run == nil {
t.Fatal("repo +list has no Run function")
}
exec := &Executor{}
// repo +list needs an owner; without --owner it tries git remote detection.
// Either way the executor must return something (output or error), not hang.
out, err := exec.Execute(s, map[string]string{})
// The contract: Execute never returns (empty, nil) for a real command —
// either it produced output, or it returned an error explaining the failure.
if out == "" && err == nil {
t.Fatal("Execute returned empty output AND nil error — REPL would show nothing (regression)")
}
// A non-empty result (output or error message) proves the command actually
// ran through a properly-initialized RuntimeContext instead of panicking.
t.Logf("output=%q err=%v", out, err)
_ = common.RuntimeContext{} // keep import
_ = strings.TrimSpace
}

237
cmd/interactive/executor.go Normal file
View File

@ -0,0 +1,237 @@
package interactive
import (
"bytes"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Executor runs shortcut commands and captures their stdout output.
type Executor struct{}
// Execute runs the given shortcut with the provided arguments, capturing
// anything written to os.Stdout during execution and returning it as a string.
//
// It builds the RuntimeContext via common.NewRuntimeContext so that Client,
// Format, Owner and Repo are properly initialized — a bare
// `&RuntimeContext{Args: args}` would leave Client nil and panic as soon as a
// command calls ctx.CallAPI. A deferred recover guards against panics raised
// inside the command so the REPL is never left with stdout pointing at a
// closed pipe.
func (e *Executor) Execute(s *common.Shortcut, args map[string]string) (output string, err error) {
// Guard against panics inside the command: always restore stdout even if
// s.Run panics, and surface the panic as a regular error so the REPL can
// display it instead of hanging with a corrupted stdout.
defer func() {
if r := recover(); r != nil {
output = ""
err = fmt.Errorf("command panicked: %v", r)
}
}()
// Save original stdout
oldStdout := os.Stdout
// Create a pipe: writes go to w, reads come from r
r, w, perr := os.Pipe()
if perr != nil {
return "", fmt.Errorf("failed to create pipe: %w", perr)
}
// Redirect stdout to the write end of the pipe
os.Stdout = w
// Channel to signal that the goroutine has finished reading
done := make(chan struct{})
var buf bytes.Buffer
// Read from the pipe in a goroutine so that writes don't block
go func() {
io.Copy(&buf, r)
close(done)
}()
// Build a fully-initialized context (Client/Format/Owner/Repo).
ctx, cerr := common.NewRuntimeContext(args)
if cerr != nil {
// Restore stdout and drain the pipe before returning.
os.Stdout = oldStdout
w.Close()
<-done
return "", fmt.Errorf("failed to initialize runtime context: %w", cerr)
}
// Execute the shortcut's Run function. Recover protects the path so that
// a panic still leaves the deferred cleanup below runnable.
runErr := safeRun(s, ctx)
// Close the writer to signal EOF to the reader goroutine, wait for it to
// finish copying into the buffer, then restore stdout.
w.Close()
<-done
os.Stdout = oldStdout
return buf.String(), runErr
}
// safeRun invokes a shortcut's Run function and converts any panic into an
// error, so a panicking command cannot crash the executor goroutine or leave
// os.Stdout redirected at a closed pipe.
func safeRun(s *common.Shortcut, ctx *common.RuntimeContext) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("command panicked: %v", r)
}
}()
return s.Run(ctx)
}
// parseDirectCommand parses a direct command string like "issue +list --state open"
// into its components: group, cmd (without +), flagStr, and whether parsing succeeded.
func parseDirectCommand(input string) (group, cmd, flagStr string, ok bool) {
input = strings.TrimSpace(input)
if input == "" {
return "", "", "", false
}
parts := strings.Fields(input)
if len(parts) < 2 {
return "", "", "", false
}
group = parts[0]
rawCmd := parts[1]
if !strings.HasPrefix(rawCmd, "+") {
return "", "", "", false
}
cmd = strings.TrimPrefix(rawCmd, "+")
if len(parts) > 2 {
flagStr = strings.Join(parts[2:], " ")
}
return group, cmd, flagStr, true
}
// parseFlagString parses a flag string like "--title hello -n 42" into a map.
// shortMap is used to expand short flag names to their long equivalents.
func parseFlagString(flagStr string, shortMap map[string]string) map[string]string {
result := make(map[string]string)
if flagStr == "" {
return result
}
parts := strings.Fields(flagStr)
for i := 0; i < len(parts); i++ {
part := parts[i]
if strings.HasPrefix(part, "--") {
// Long flag
flagPart := strings.TrimPrefix(part, "--")
if strings.Contains(flagPart, "=") {
// --flag=value format
kv := strings.SplitN(flagPart, "=", 2)
result[kv[0]] = kv[1]
} else if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
// --flag value format
result[flagPart] = parts[i+1]
i++
} else {
// --flag without value (boolean-like)
result[flagPart] = "true"
}
} else if strings.HasPrefix(part, "-") && len(part) > 1 {
// Short flag
shortName := strings.TrimPrefix(part, "-")
// Expand short name to long name if mapping exists
longName := shortName
if mapped, ok := shortMap[shortName]; ok {
longName = mapped
}
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
result[longName] = parts[i+1]
i++
} else {
result[longName] = "true"
}
}
}
return result
}
// findShortcut looks up a shortcut by group and command name in the registry.
func findShortcut(all map[string][]*common.Shortcut, group, cmd string) (*common.Shortcut, bool) {
shortcuts, ok := all[group]
if !ok {
return nil, false
}
for _, s := range shortcuts {
if s.Name == cmd {
return s, true
}
}
return nil, false
}
// buildShortMap creates a mapping from short flag names to long flag names.
func buildShortMap(s *common.Shortcut) map[string]string {
m := make(map[string]string)
for _, f := range s.Flags {
if f.Short != "" {
m[f.Short] = f.Name
}
}
return m
}
// missingRequiredFlags returns the names (with -- prefix) of flags that are
// marked as required but not present in args.
func missingRequiredFlags(s *common.Shortcut, args map[string]string) []string {
var missing []string
for _, f := range s.Flags {
if f.Required {
if _, ok := args[f.Name]; !ok {
missing = append(missing, "--"+f.Name)
}
}
}
return missing
}
// formatCommandDisplay formats a command for display, e.g. "issue +create --title hello --body world".
func formatCommandDisplay(group string, s *common.Shortcut, args map[string]string) string {
var b strings.Builder
b.WriteString(group)
b.WriteString(" +")
b.WriteString(s.Name)
// Sort flag names for deterministic output
keys := make([]string, 0, len(args))
for k := range args {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b.WriteString(" --")
b.WriteString(k)
b.WriteString(" ")
b.WriteString(args[k])
}
return b.String()
}

View File

@ -0,0 +1,69 @@
package interactive
import (
"os"
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// TestExecutor_UsesRealRuntimeContext verifies that Execute builds the
// RuntimeContext via NewRuntimeContext (which initializes Client/Format/etc.)
// rather than a bare struct literal that leaves Client == nil.
//
// Regression: previously Execute did `&common.RuntimeContext{Args: args}`,
// so any command calling ctx.CallAPI panicked with a nil pointer dereference
// inside the executor goroutine, which swallowed the execResultMsg and left
// the REPL showing no output at all.
func TestExecutor_UsesRealRuntimeContext(t *testing.T) {
called := false
s := &common.Shortcut{
Name: "probe",
Run: func(ctx *common.RuntimeContext) error {
called = true
// A real command uses ctx.Client / ctx.Format. If Execute built the
// context correctly, Client must be non-nil and Format non-empty.
if ctx.Client == nil {
t.Error("ctx.Client is nil — Execute did not use NewRuntimeContext")
}
if ctx.Format == "" {
t.Error("ctx.Format is empty — Execute did not initialize format")
}
return nil
},
}
exec := &Executor{}
out, err := exec.Execute(s, map[string]string{})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if !called {
t.Fatal("Run was not invoked")
}
if out != "" {
t.Fatalf("expected empty output, got %q", out)
}
}
// TestExecutor_PanicDoesNotCorruptStdout verifies that if a command panics,
// Execute recovers, returns the panic as an error, and — critically — restores
// os.Stdout so the REPL is not left with stdout pointing at a closed pipe.
func TestExecutor_PanicDoesNotCorruptStdout(t *testing.T) {
original := os.Stdout
s := &common.Shortcut{
Name: "boom",
Run: func(ctx *common.RuntimeContext) error {
panic("simulated command failure")
},
}
exec := &Executor{}
_, err := exec.Execute(s, map[string]string{})
if err == nil {
t.Fatal("expected Execute to return an error for a panicking command")
}
if os.Stdout != original {
t.Fatal("os.Stdout was not restored after a panic — REPL would be left broken")
}
}

View File

@ -0,0 +1,296 @@
package interactive
import (
"fmt"
"os"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// TestExecutor_CaptureOutput verifies that Execute captures stdout output
// even when the Run function writes directly to os.Stdout.
func TestExecutor_CaptureOutput(t *testing.T) {
e := &Executor{}
s := &common.Shortcut{
Name: "test",
Description: "test command",
Run: func(ctx *common.RuntimeContext) error {
// Do nothing — output should be empty
return nil
},
}
output, err := e.Execute(s, map[string]string{})
if err != nil {
t.Fatalf("Execute returned error: %v", err)
}
if output != "" {
t.Fatalf("expected empty output, got %q", output)
}
}
// TestExecutor_CaptureOutputPrints verifies that stdout written inside Run
// is captured and returned as a string.
func TestExecutor_CaptureOutputPrints(t *testing.T) {
e := &Executor{}
s := &common.Shortcut{
Name: "test",
Description: "test command",
Run: func(ctx *common.RuntimeContext) error {
fmt.Fprint(os.Stdout, "hello from command")
return nil
},
}
output, err := e.Execute(s, map[string]string{})
if err != nil {
t.Fatalf("Execute returned error: %v", err)
}
if output != "hello from command" {
t.Fatalf("expected %q, got %q", "hello from command", output)
}
}
// TestExecutor_RestoresStdout verifies that os.Stdout is restored after Execute.
func TestExecutor_RestoresStdout(t *testing.T) {
original := os.Stdout
e := &Executor{}
s := &common.Shortcut{
Name: "test",
Description: "test command",
Run: func(ctx *common.RuntimeContext) error {
return nil
},
}
_, _ = e.Execute(s, map[string]string{})
if os.Stdout != original {
t.Fatal("os.Stdout was not restored after Execute")
}
}
// TestParseDirectCommand uses table-driven tests for parseDirectCommand.
func TestParseDirectCommand(t *testing.T) {
tests := []struct {
name string
input string
group string
cmd string
flagStr string
ok bool
}{
{
name: "empty string",
input: "",
ok: false,
},
{
name: "single word only",
input: "issue",
ok: false,
},
{
name: "second word without plus",
input: "issue list",
ok: false,
},
{
name: "two words with plus",
input: "issue +list",
group: "issue",
cmd: "list",
flagStr: "",
ok: true,
},
{
name: "with flags",
input: "issue +list --state open",
group: "issue",
cmd: "list",
flagStr: "--state open",
ok: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
group, cmd, flagStr, ok := parseDirectCommand(tt.input)
if ok != tt.ok {
t.Errorf("ok = %v, want %v", ok, tt.ok)
}
if ok {
if group != tt.group {
t.Errorf("group = %q, want %q", group, tt.group)
}
if cmd != tt.cmd {
t.Errorf("cmd = %q, want %q", cmd, tt.cmd)
}
if flagStr != tt.flagStr {
t.Errorf("flagStr = %q, want %q", flagStr, tt.flagStr)
}
}
})
}
}
// TestParseFlags uses table-driven tests for parseFlagString.
func TestParseFlags(t *testing.T) {
tests := []struct {
name string
flagStr string
shortMap map[string]string
want map[string]string
}{
{
name: "long flags only",
flagStr: "--title hello --body world",
shortMap: map[string]string{},
want: map[string]string{
"title": "hello",
"body": "world",
},
},
{
name: "short flag expanded",
flagStr: "-n 42",
shortMap: map[string]string{"n": "number"},
want: map[string]string{
"number": "42",
},
},
{
name: "equals syntax",
flagStr: "--title=hello",
shortMap: map[string]string{},
want: map[string]string{
"title": "hello",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseFlagString(tt.flagStr, tt.shortMap)
if len(got) != len(tt.want) {
t.Fatalf("got %d flags, want %d flags: %v", len(got), len(tt.want), got)
}
for k, v := range tt.want {
if got[k] != v {
t.Errorf("flag %q = %q, want %q", k, got[k], v)
}
}
})
}
}
// TestFindShortcut covers three scenarios: found, wrong group, wrong cmd.
func TestFindShortcut(t *testing.T) {
listShortcut := &common.Shortcut{Name: "list", Description: "list issues"}
createShortcut := &common.Shortcut{Name: "create", Description: "create issue"}
all := map[string][]*common.Shortcut{
"issue": {listShortcut, createShortcut},
"pr": {{Name: "list", Description: "list PRs"}},
}
// Scenario 1: found
s, ok := findShortcut(all, "issue", "list")
if !ok {
t.Fatal("expected to find issue +list")
}
if s != listShortcut {
t.Fatal("returned wrong shortcut")
}
// Scenario 2: group not found
_, ok = findShortcut(all, "repo", "list")
if ok {
t.Fatal("expected not found for unknown group")
}
// Scenario 3: group found but cmd not found
_, ok = findShortcut(all, "issue", "close")
if ok {
t.Fatal("expected not found for unknown cmd in valid group")
}
}
// TestBuildShortMap verifies short-to-long flag mapping construction.
func TestBuildShortMap(t *testing.T) {
s := &common.Shortcut{
Name: "list",
Flags: []common.Flag{
{Name: "state", Short: "s"},
{Name: "number", Short: "n"},
{Name: "verbose"},
},
}
m := buildShortMap(s)
if len(m) != 2 {
t.Fatalf("expected 2 entries in short map, got %d", len(m))
}
if m["s"] != "state" {
t.Errorf("short 's' = %q, want 'state'", m["s"])
}
if m["n"] != "number" {
t.Errorf("short 'n' = %q, want 'number'", m["n"])
}
}
// TestMissingRequiredFlags checks that required-but-missing flags are reported.
func TestMissingRequiredFlags(t *testing.T) {
s := &common.Shortcut{
Name: "create",
Flags: []common.Flag{
{Name: "title", Required: true},
{Name: "body", Required: true},
{Name: "state", Required: false},
},
}
// Provide title but not body
args := map[string]string{"title": "hello"}
missing := missingRequiredFlags(s, args)
if len(missing) != 1 {
t.Fatalf("expected 1 missing flag, got %d: %v", len(missing), missing)
}
if missing[0] != "--body" {
t.Errorf("missing flag = %q, want '--body'", missing[0])
}
// Provide all required
args2 := map[string]string{"title": "hello", "body": "world"}
missing2 := missingRequiredFlags(s, args2)
if len(missing2) != 0 {
t.Fatalf("expected 0 missing flags, got %d: %v", len(missing2), missing2)
}
}
// TestFormatCommandDisplay verifies the display string format.
func TestFormatCommandDisplay(t *testing.T) {
s := &common.Shortcut{
Name: "create",
Description: "create an issue",
}
args := map[string]string{
"title": "hello",
"body": "world",
}
got := formatCommandDisplay("issue", s, args)
// The order of flags may vary, so check that all parts are present
if !strings.HasPrefix(got, "issue +create") {
t.Fatalf("expected prefix 'issue +create', got %q", got)
}
if !strings.Contains(got, "--title hello") {
t.Fatalf("expected '--title hello' in output, got %q", got)
}
if !strings.Contains(got, "--body world") {
t.Fatalf("expected '--body world' in output, got %q", got)
}
}

296
cmd/interactive/form.go Normal file
View File

@ -0,0 +1,296 @@
package interactive
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// formField represents a single field in the argument form.
type formField struct {
flag common.Flag
input textinput.Model
boolValue bool
isBool bool
}
// formModel is the parameter form sub-component.
type formModel struct {
group string
shortcut *common.Shortcut
fields []formField
focusIdx int
submitted bool
cancelled bool
values map[string]string
width int
height int
errors []string
}
var formTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
var formErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
var formHelpStyle = lipgloss.NewStyle().Faint(true)
var formHintStyle = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("11"))
var formLabelStyle = lipgloss.NewStyle().Bold(true)
var formRequiredMark = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
// newFormModel creates a new form model from a shortcut's flags.
func newFormModel(group string, shortcut *common.Shortcut, width, height int, prefill map[string]string) formModel {
fields := make([]formField, 0, len(shortcut.Flags))
for _, f := range shortcut.Flags {
if f.Bool {
// Bool field: no text input, just toggle
val := f.Default
if v, ok := prefill[f.Name]; ok {
val = v
}
fields = append(fields, formField{
flag: f,
boolValue: val == "true",
isBool: true,
})
} else {
// Text field
ti := textinput.New()
ti.Placeholder = f.Usage
if f.Default != "" {
ti.Placeholder = f.Default
}
ti.Width = width - 30
if ti.Width < 20 {
ti.Width = 20
}
// Pre-fill from provided values
if v, ok := prefill[f.Name]; ok {
ti.SetValue(v)
}
fields = append(fields, formField{
flag: f,
input: ti,
isBool: false,
})
}
}
m := formModel{
group: group,
shortcut: shortcut,
fields: fields,
width: width,
height: height,
errors: []string{},
}
// Focus the first non-bool field, or first field
m.focusFirstField()
return m
}
// focusFirstField sets focus to the first focusable field.
func (m *formModel) focusFirstField() {
for i := range m.fields {
m.focusIdx = i
if !m.fields[i].isBool {
m.fields[i].input.Focus()
}
return
}
}
// validate checks that all required fields have values.
func (m formModel) validate() []string {
var errs []string
for _, f := range m.fields {
if f.flag.Required {
if f.isBool {
// Bool fields are always valid
continue
}
if strings.TrimSpace(f.input.Value()) == "" {
errs = append(errs, fmt.Sprintf("--%s 为必填参数,不能为空", f.flag.Name))
}
}
}
return errs
}
// collectValues gathers all field values into a map.
func (m *formModel) collectValues() map[string]string {
values := make(map[string]string)
for _, f := range m.fields {
if f.isBool {
if f.boolValue {
values[f.flag.Name] = "true"
}
} else {
v := f.input.Value()
if v == "" && f.flag.Default != "" {
v = f.flag.Default
}
if v != "" {
values[f.flag.Name] = v
}
}
}
return values
}
// Update handles messages for the form.
func (m formModel) Update(msg tea.Msg) (formModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
m.cancelled = true
return m, nil
case tea.KeyTab, tea.KeyDown:
m.nextField()
return m, nil
case tea.KeyShiftTab, tea.KeyUp:
m.prevField()
return m, nil
case tea.KeyEnter:
if len(m.fields) > 0 && m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].boolValue = !m.fields[m.focusIdx].boolValue
return m, nil
}
// Validate
errs := m.validate()
if len(errs) > 0 {
m.errors = errs
return m, nil
}
m.values = m.collectValues()
m.submitted = true
return m, nil
case tea.KeySpace:
if len(m.fields) > 0 && m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].boolValue = !m.fields[m.focusIdx].boolValue
return m, nil
}
// Fall through to text input
}
// Forward to focused text input
if len(m.fields) > 0 && !m.fields[m.focusIdx].isBool {
var cmd tea.Cmd
m.fields[m.focusIdx].input, cmd = m.fields[m.focusIdx].input.Update(msg)
return m, cmd
}
}
return m, nil
}
// nextField moves focus to the next field.
func (m *formModel) nextField() {
if len(m.fields) == 0 {
return
}
// Blur current
if !m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].input.Blur()
}
m.focusIdx = (m.focusIdx + 1) % len(m.fields)
// Focus new
if !m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].input.Focus()
}
}
// prevField moves focus to the previous field.
func (m *formModel) prevField() {
if len(m.fields) == 0 {
return
}
// Blur current
if !m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].input.Blur()
}
m.focusIdx--
if m.focusIdx < 0 {
m.focusIdx = len(m.fields) - 1
}
// Focus new
if !m.fields[m.focusIdx].isBool {
m.fields[m.focusIdx].input.Focus()
}
}
// View renders the form.
func (m formModel) View() string {
var sb strings.Builder
// Title
title := formTitleStyle.Render(fmt.Sprintf("%s +%s", m.group, m.shortcut.Name))
sb.WriteString(title)
sb.WriteString("\n\n")
// Fields
for i, f := range m.fields {
label := f.flag.Name
if f.flag.Required {
label = label + formRequiredMark.Render(" *")
}
if f.isBool {
// Bool field
check := "[ ]"
if f.boolValue {
check = "[✓]"
}
line := fmt.Sprintf(" %s %s", check, label)
if i == m.focusIdx {
line = selectedStyle.Render(line)
}
sb.WriteString(line)
sb.WriteString("\n")
if f.flag.Usage != "" {
sb.WriteString(formHintStyle.Render(fmt.Sprintf(" %s", f.flag.Usage)))
sb.WriteString("\n")
}
} else {
// Text field
prompt := fmt.Sprintf(" %s: ", label)
if i == m.focusIdx {
prompt = formLabelStyle.Render(prompt)
}
sb.WriteString(prompt)
sb.WriteString(f.input.View())
sb.WriteString("\n")
if f.flag.Usage != "" {
sb.WriteString(formHintStyle.Render(fmt.Sprintf(" %s", f.flag.Usage)))
sb.WriteString("\n")
}
}
sb.WriteString("\n")
}
// Errors
if len(m.errors) > 0 {
sb.WriteString("\n")
for _, e := range m.errors {
sb.WriteString(formErrorStyle.Render(fmt.Sprintf(" ✗ %s", e)))
sb.WriteString("\n")
}
}
// Help
sb.WriteString("\n")
sb.WriteString(formHelpStyle.Render(" Enter submit · Tab/↑↓ next field · Shift+Tab prev field · Space toggle bool · Esc cancel"))
return sb.String()
}

View File

@ -0,0 +1,36 @@
package interactive
import (
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/context"
)
// NewInteractiveCmd creates the Cobra command for the interactive REPL.
func NewInteractiveCmd() *cobra.Command {
return &cobra.Command{
Use: "interactive",
Short: "Start interactive REPL with command palette",
Long: `Start an interactive REPL session with a command palette.
Type / to open the command palette with fuzzy search.
Use arrow keys to navigate, Enter to select, Tab to fill parameters.
Type "exit" or press Ctrl+D to quit.`,
Aliases: []string{"i"},
Example: ` gitlink interactive
gitlink i`,
RunE: func(cmd *cobra.Command, args []string) error {
// 交互模式默认用表格输出,更易阅读;用户显式指定 --format 时优先采用用户的值。
if cmdutil.Format == "" {
cmdutil.Format = "table"
}
owner, repo, err := context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo)
if err != nil {
owner = cmdutil.Owner
repo = cmdutil.Repo
}
return Run(owner, repo)
},
}
}

View File

@ -0,0 +1,120 @@
package interactive
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// makeShortcutForViewport builds a minimal shortcut so formatCommandDisplay has
// something to render in the display header line of the output viewer.
func makeShortcutForViewport() *common.Shortcut {
return &common.Shortcut{
Name: "list",
}
}
// TestLongOutputEntersViewport verifies that when command output exceeds the
// available terminal height, the REPL switches into the scrollable output
// viewer (stateOutput) instead of falling back to the inline input state.
func TestLongOutputEntersViewport(t *testing.T) {
m := newReplModel("owner", "repo")
// Small terminal: m.height-4 == 1 line of room, so any multi-line output
// must overflow into the viewport.
m.height = 5
m.width = 80
m.state = stateExecuting
longOutput := strings.Repeat("line of output\n", 20)
msg := execResultMsg{
group: "issue",
shortcut: makeShortcutForViewport(),
args: map[string]string{},
output: longOutput,
err: nil,
}
newModel, _ := m.Update(msg)
repl, ok := newModel.(replModel)
if !ok {
t.Fatalf("expected replModel, got %T", newModel)
}
if repl.state != stateOutput {
t.Fatalf("expected stateOutput for long output, got %v", repl.state)
}
if repl.viewport.Height != m.height-2 {
t.Fatalf("expected viewport height %d, got %d", m.height-2, repl.viewport.Height)
}
}
// TestShortOutputStaysInput verifies that short command output keeps the
// existing behaviour: store lastOutput and return to stateInput.
func TestShortOutputStaysInput(t *testing.T) {
m := newReplModel("owner", "repo")
m.height = 40 // plenty of room
m.width = 80
m.state = stateExecuting
shortOutput := "only one line"
msg := execResultMsg{
group: "issue",
shortcut: makeShortcutForViewport(),
args: map[string]string{},
output: shortOutput,
err: nil,
}
newModel, _ := m.Update(msg)
repl, ok := newModel.(replModel)
if !ok {
t.Fatalf("expected replModel, got %T", newModel)
}
if repl.state != stateInput {
t.Fatalf("expected stateInput for short output, got %v", repl.state)
}
if repl.lastOutput == "" {
t.Fatalf("expected lastOutput to be set for short output")
}
if !strings.Contains(repl.lastOutput, shortOutput) {
t.Fatalf("expected lastOutput to contain %q, got %q", shortOutput, repl.lastOutput)
}
}
// TestViewportQuitReturnsToInput verifies that pressing 'q' in the output
// viewer returns the REPL to the input state.
func TestViewportQuitReturnsToInput(t *testing.T) {
m := newReplModel("owner", "repo")
m.state = stateOutput
keyQ := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}
newModel, _ := m.Update(keyQ)
repl, ok := newModel.(replModel)
if !ok {
t.Fatalf("expected replModel, got %T", newModel)
}
if repl.state != stateInput {
t.Fatalf("expected stateInput after pressing 'q', got %v", repl.state)
}
}
// TestViewportEscReturnsToInput verifies that Esc also exits the output viewer.
func TestViewportEscReturnsToInput(t *testing.T) {
m := newReplModel("owner", "repo")
m.state = stateOutput
keyEsc := tea.KeyMsg{Type: tea.KeyEsc}
newModel, _ := m.Update(keyEsc)
repl, ok := newModel.(replModel)
if !ok {
t.Fatalf("expected replModel, got %T", newModel)
}
if repl.state != stateInput {
t.Fatalf("expected stateInput after pressing Esc, got %v", repl.state)
}
}

305
cmd/interactive/palette.go Normal file
View File

@ -0,0 +1,305 @@
package interactive
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// paletteLevel tracks which level of the palette we are browsing.
type paletteLevel int
const (
levelGroup paletteLevel = iota // browsing command groups
levelCommand // browsing commands within a group
)
// PaletteResult holds the user's selection from the palette.
type PaletteResult struct {
Group string
Shortcut *common.Shortcut
}
// commandItem implements list.Item and list.DefaultItem.
type commandItem struct {
title string
description string
shortcut *common.Shortcut // nil for group items
group string // empty for group items
}
func (i commandItem) Title() string { return i.title }
func (i commandItem) Description() string { return i.description }
func (i commandItem) FilterValue() string { return i.title + " " + i.description }
// paletteModel is the command palette sub-component.
type paletteModel struct {
level paletteLevel
search textinput.Model
list list.Model
groups map[string][]*common.Shortcut
descs map[string]string
groupKeys []string
selected string // currently selected group name (when levelCommand)
result *PaletteResult
quitting bool
width int
height int
}
var paletteTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
var selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
var paletteHelpStyle = lipgloss.NewStyle().Faint(true)
// newPaletteModel creates a new palette model.
func newPaletteModel(groups map[string][]*common.Shortcut, descs map[string]string, width, height int) paletteModel {
search := textinput.New()
search.Prompt = "> "
search.Placeholder = "Search..."
search.Focus()
search.Width = width - 4
// Sort group keys for deterministic ordering
keys := make([]string, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Strings(keys)
// Build group items
items := make([]list.Item, 0, len(keys))
for _, k := range keys {
desc := descs[k]
items = append(items, commandItem{
title: k,
description: desc,
})
}
delegate := list.NewDefaultDelegate()
l := list.New(items, delegate, width, height-4)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetShowFilter(false)
l.SetFilteringEnabled(false)
return paletteModel{
level: levelGroup,
search: search,
list: l,
groups: groups,
descs: descs,
groupKeys: keys,
width: width,
height: height,
}
}
// switchToCommands switches the list to show commands for the given group.
func (m *paletteModel) switchToCommands(group string) {
m.selected = group
m.level = levelCommand
shortcuts := m.groups[group]
items := make([]list.Item, 0, len(shortcuts))
for _, s := range shortcuts {
items = append(items, commandItem{
title: "+" + s.Name,
description: s.Description,
shortcut: s,
group: group,
})
}
m.list.SetItems(items)
m.list.ResetSelected()
m.search.SetValue("")
}
// backToGroups returns to the group list.
func (m *paletteModel) backToGroups() {
m.level = levelGroup
m.selected = ""
items := make([]list.Item, 0, len(m.groupKeys))
for _, k := range m.groupKeys {
items = append(items, commandItem{
title: k,
description: m.descs[k],
})
}
m.list.SetItems(items)
m.list.ResetSelected()
m.search.SetValue("")
}
// filterItems filters the current list items by the given query.
func (m *paletteModel) filterItems(query string) {
var items []list.Item
if m.level == levelGroup {
for _, k := range m.groupKeys {
desc := m.descs[k]
if matchesQuery(k, desc, query) {
items = append(items, commandItem{
title: k,
description: desc,
})
}
}
} else {
shortcuts := m.groups[m.selected]
for _, s := range shortcuts {
name := "+" + s.Name
if matchesQuery(name, s.Description, query) {
items = append(items, commandItem{
title: name,
description: s.Description,
shortcut: s,
group: m.selected,
})
}
}
}
if items == nil {
items = []list.Item{}
}
m.list.SetItems(items)
m.list.ResetSelected()
}
// matchesQuery checks if the query is a subsequence (fzy-style fuzzy match)
// of either the title or the description, case-insensitively. An empty query
// matches everything.
func matchesQuery(title, description, query string) bool {
return subsequenceMatch(query, title) || subsequenceMatch(query, description)
}
// subsequenceMatch returns true if every character of query appears in target
// in the same order (not necessarily contiguously), ignoring case. An empty
// query always matches.
func subsequenceMatch(query, target string) bool {
query = strings.ToLower(query)
target = strings.ToLower(target)
if query == "" {
return true
}
i := 0
for j := 0; j < len(target) && i < len(query); j++ {
if target[j] == query[i] {
i++
}
}
return i == len(query)
}
// Update handles messages for the palette.
func (m paletteModel) Update(msg tea.Msg) (paletteModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc:
if m.level == levelCommand {
m.backToGroups()
return m, nil
}
// levelGroup: signal the outer REPL to return to stateInput, not quit
m.quitting = true
return m, nil
case tea.KeyEnter:
selected := m.list.SelectedItem()
if selected == nil {
return m, nil
}
item := selected.(commandItem)
if m.level == levelGroup {
m.switchToCommands(item.title)
return m, nil
}
// levelCommand: set result
m.result = &PaletteResult{
Group: m.selected,
Shortcut: item.shortcut,
}
return m, nil
case tea.KeyUp, tea.KeyDown:
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
default:
// Forward to search input
var cmd tea.Cmd
m.search, cmd = m.search.Update(msg)
m.filterItems(m.search.Value())
return m, cmd
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.list.SetWidth(msg.Width)
m.list.SetHeight(msg.Height - 4)
m.search.Width = msg.Width - 4
}
return m, nil
}
// View renders the palette.
func (m paletteModel) View() string {
if m.quitting {
return ""
}
var title string
if m.level == levelGroup {
title = paletteTitleStyle.Render("Command Palette — Select a group")
} else {
title = paletteTitleStyle.Render(fmt.Sprintf("Command Palette — %s", m.selected))
}
searchView := m.search.View()
// Render list items manually for better control
var items strings.Builder
listItems := m.list.Items()
idx := m.list.Index()
for i, item := range listItems {
ci := item.(commandItem)
if i == idx {
items.WriteString(selectedStyle.Render(fmt.Sprintf(" %s", ci.title)))
if ci.description != "" {
items.WriteString(" ")
items.WriteString(paletteHelpStyle.Render(ci.description))
}
} else {
items.WriteString(fmt.Sprintf(" %s", ci.title))
if ci.description != "" {
items.WriteString(" ")
items.WriteString(paletteHelpStyle.Render(ci.description))
}
}
items.WriteString("\n")
}
if len(listItems) == 0 {
items.WriteString(paletteHelpStyle.Render(" No matches found"))
items.WriteString("\n")
}
help := paletteHelpStyle.Render("Enter select · Esc back · ↑↓ navigate · type to search")
return fmt.Sprintf("%s\n%s\n%s\n%s", title, searchView, items.String(), help)
}

View File

@ -0,0 +1,57 @@
package interactive
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// TestPaletteSelectionOpensFormForOptionalFlags 验证:选中一个"只有可选参数、
// 无必填参数"的命令(如 branch +batch-protect 的 names/from 二选一)时,
// 仍然弹出参数表单,而非直接执行导致 "no ... provided" 错误。
//
// 回归:之前 updatePalette 只在有缺失的 Required flag 时才弹表单,
// 导致 names/from 这类"至少一个"型参数(无法单独标 Required的命令
// 被直接执行,用户没机会输入参数。
func TestPaletteSelectionOpensFormForOptionalFlags(t *testing.T) {
// 构造一个模拟 branch +batch-protect 的 shortcut所有 flag 都非必填。
shortcuts := map[string][]*common.Shortcut{
"branch": {
{
Name: "batch-protect",
Description: "批量保护分支",
Flags: []common.Flag{
{Name: "names", Usage: "分支名(逗号分隔)"}, // 无 Required
{Name: "from", Usage: "文件路径"}, // 无 Required
},
Run: func(ctx *common.RuntimeContext) error { return nil },
},
},
}
m := newReplModel("owner", "repo")
// 注入测试 registry
m.shortcuts = shortcuts
// 1) 输入 / 触发命令面板
m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
repl := m2.(replModel)
if repl.state != statePalette {
t.Fatalf("输入 / 后应在 statePalette实际 %v", repl.state)
}
// 2) 模拟用户选中 branch 组
repl.palette.result = &PaletteResult{
Group: "branch",
Shortcut: shortcuts["branch"][0],
}
// 3) 派发一条任意消息触发 updatePalette 的结果检查
m3, _ := repl.Update(tea.KeyMsg{Type: tea.KeyEnter})
final := m3.(replModel)
if final.state != stateForm {
t.Fatalf("选中带可选参数的命令应进入 stateForm实际 %v命令被直接执行了用户没机会填参数", final.state)
}
}

433
cmd/interactive/repl.go Normal file
View File

@ -0,0 +1,433 @@
package interactive
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gitlink-org/gitlink-cli/shortcuts"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// replState tracks the current state of the REPL.
type replState int
const (
stateInput replState = iota // waiting for user text input
statePalette // command palette is active
stateForm // parameter form is active
stateExecuting // executing a command
stateError // showing an error
stateOutput // scrolling through long command output
)
// execResultMsg is sent when command execution completes.
type execResultMsg struct {
group string
shortcut *common.Shortcut
args map[string]string
output string
err error
}
// replModel is the main REPL state machine.
type replModel struct {
state replState
input textinput.Model
palette paletteModel
form formModel
executor Executor
shortcuts map[string][]*common.Shortcut
descs map[string]string
lastOutput string
lastError string
spinner spinner.Model
viewport viewport.Model // 可滚动查看器,用于浏览超出一屏的长输出
owner string
repo string
width int
height int
quitting bool
showWelcome bool
}
var promptStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
var successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10"))
var errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
var welcomeStyle = lipgloss.NewStyle().Faint(true)
// newReplModel creates a new REPL model.
func newReplModel(owner, repo string) replModel {
ti := textinput.New()
ti.Prompt = buildPrompt(owner, repo)
ti.Focus()
ti.Width = 60
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
return replModel{
state: stateInput,
input: ti,
executor: Executor{},
shortcuts: shortcuts.GetAllShortcuts(),
descs: shortcuts.GetDescriptions(),
spinner: sp,
owner: owner,
repo: repo,
showWelcome: true,
}
}
// buildPrompt creates the prompt string.
func buildPrompt(owner, repo string) string {
if owner != "" && repo != "" {
return fmt.Sprintf("gitlink (%s/%s)> ", owner, repo)
}
return "gitlink> "
}
// Init initializes the REPL.
func (m replModel) Init() tea.Cmd {
return textinput.Blink
}
// Update is the main message dispatcher.
func (m replModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tea.WindowSizeMsg:
wmsg := msg.(tea.WindowSizeMsg)
m.width = wmsg.Width
m.height = wmsg.Height
// 保持 viewport 尺寸与终端同步(底部留 2 行给提示符/帮助行)。
m.viewport.Width = wmsg.Width
m.viewport.Height = wmsg.Height - 2
return m, nil
}
switch m.state {
case stateInput:
return m.updateInput(msg)
case statePalette:
return m.updatePalette(msg)
case stateForm:
return m.updateForm(msg)
case stateExecuting:
return m.updateExecuting(msg)
case stateError:
return m.updateError(msg)
case stateOutput:
return m.updateOutput(msg)
}
return m, nil
}
// updateInput handles the text input state.
func (m replModel) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, isKey := msg.(tea.KeyMsg)
if isKey {
switch keyMsg.Type {
case tea.KeyRunes:
// Trigger the command palette immediately when the user types "/"
// as the first character of a fresh input — no Enter required.
if m.input.Value() == "" && len(keyMsg.Runes) > 0 && keyMsg.Runes[0] == '/' {
m.showWelcome = false
m.input.SetValue("")
m.palette = newPaletteModel(m.shortcuts, m.descs, m.width, m.height)
// Carry any extra characters typed alongside "/" as the initial query.
if len(keyMsg.Runes) > 1 {
query := string(keyMsg.Runes[1:])
m.palette.search.SetValue(query)
m.palette.filterItems(query)
}
m.state = statePalette
return m, nil
}
case tea.KeyEnter:
val := strings.TrimSpace(m.input.Value())
m.input.SetValue("")
m.showWelcome = false
if val == "" {
return m, nil
}
// Exit commands
if val == "exit" || val == "quit" {
m.quitting = true
return m, tea.Quit
}
// Palette mode (also reachable via "/query" + Enter)
if strings.HasPrefix(val, "/") {
m.palette = newPaletteModel(m.shortcuts, m.descs, m.width, m.height)
if len(val) > 1 {
query := val[1:]
m.palette.search.SetValue(query)
m.palette.filterItems(query)
}
m.state = statePalette
return m, nil
}
// Direct command: "group +cmd ..."
group, cmd, flagStr, ok := parseDirectCommand(val)
if ok {
s, found := findShortcut(m.shortcuts, group, cmd)
if !found {
m.lastError = fmt.Sprintf("Unknown command: %s +%s", group, cmd)
m.state = stateError
return m, nil
}
shortMap := buildShortMap(s)
args := parseFlagString(flagStr, shortMap)
missing := missingRequiredFlags(s, args)
if len(missing) > 0 {
// Show form with prefilled values
m.form = newFormModel(group, s, m.width, m.height, args)
m.state = stateForm
return m, nil
}
// Execute directly
m.state = stateExecuting
return m, m.executeCommand(group, s, args)
}
// Unrecognized input
m.lastError = fmt.Sprintf("Unknown input: %q\nType a command like \"issue +list\" or \"/\" to open the command palette.", val)
m.state = stateError
return m, nil
case tea.KeyCtrlD, tea.KeyCtrlC:
m.quitting = true
return m, tea.Quit
}
}
// Forward to text input
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
// executeCommand returns a tea.Cmd that runs a shortcut asynchronously.
func (m replModel) executeCommand(group string, s *common.Shortcut, args map[string]string) tea.Cmd {
return func() tea.Msg {
output, err := m.executor.Execute(s, args)
return execResultMsg{
group: group,
shortcut: s,
args: args,
output: output,
err: err,
}
}
}
// updatePalette delegates to the palette sub-component.
func (m replModel) updatePalette(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.palette, cmd = m.palette.Update(msg)
if m.palette.quitting {
m.palette.quitting = false
m.state = stateInput
return m, textinput.Blink
}
if m.palette.result != nil {
result := m.palette.result
s := result.Shortcut
// Open the form whenever the command has ANY flags — not just required
// ones. Many commands have "at-least-one-of" parameters (e.g. branch
// +batch-protect needs --names OR --from) that can't be marked Required
// individually, so skipping the form for "optional-only" commands left
// users unable to supply them and the command failed at runtime.
// Commands with no flags at all execute directly.
if len(s.Flags) > 0 {
m.form = newFormModel(result.Group, s, m.width, m.height, nil)
m.state = stateForm
return m, nil
}
// No flags → execute directly
m.state = stateExecuting
return m, tea.Batch(m.executeCommand(result.Group, s, map[string]string{}), m.spinner.Tick)
}
return m, cmd
}
// updateForm delegates to the form sub-component.
func (m replModel) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.form, cmd = m.form.Update(msg)
if m.form.cancelled {
m.form.cancelled = false
m.state = stateInput
return m, textinput.Blink
}
if m.form.submitted {
args := m.form.values
group := m.form.group
s := m.form.shortcut
m.form.submitted = false
m.state = stateExecuting
return m, m.executeCommand(group, s, args)
}
return m, cmd
}
// updateExecuting waits for the execResultMsg.
func (m replModel) updateExecuting(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case execResultMsg:
if msg.err != nil {
m.lastError = fmt.Sprintf("命令执行失败: %s", msg.err)
m.lastOutput = ""
m.state = stateError
return m, nil
}
display := formatCommandDisplay(msg.group, msg.shortcut, msg.args)
content := fmt.Sprintf(" ✓ 执行: %s\n\n%s", display, msg.output)
// 输出超过可用高度(留 4 行给提示符与边距)时进入可滚动查看器,
// 否则保持原行为:直接显示在输入状态。
lineCount := strings.Count(content, "\n") + 1
if m.height > 0 && lineCount > m.height-4 {
m.viewport = viewport.New(m.width, m.height-2)
m.viewport.SetContent(content)
m.viewport.GotoTop()
m.lastOutput = ""
m.lastError = ""
m.state = stateOutput
return m, nil
}
m.lastOutput = content
m.lastError = ""
m.state = stateInput
m.input.Focus()
return m, textinput.Blink
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
}
// updateError waits for Enter to return to input state.
func (m replModel) updateError(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, isKey := msg.(tea.KeyMsg)
if isKey {
switch keyMsg.Type {
case tea.KeyEnter, tea.KeyEsc:
m.lastError = ""
m.state = stateInput
m.input.Focus()
return m, textinput.Blink
case tea.KeyCtrlD, tea.KeyCtrlC:
m.quitting = true
return m, tea.Quit
}
}
return m, nil
}
// updateOutput 处理输出查看器状态:用方向键/PageUp/PageDown 浏览长输出,
// 按 q 或 Esc 返回输入状态。
func (m replModel) updateOutput(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEsc, tea.KeyCtrlC:
m.state = stateInput
m.input.Focus()
return m, textinput.Blink
case tea.KeyRunes:
// q / Q 返回输入
if len(msg.Runes) > 0 && (msg.Runes[0] == 'q' || msg.Runes[0] == 'Q') {
m.state = stateInput
m.input.Focus()
return m, textinput.Blink
}
}
}
// 其余按键方向键、PageUp/Down、Home/End转发给 viewport
// viewport 内置响应这些按键,无需手动处理。
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
// View renders the REPL based on current state.
func (m replModel) View() string {
if m.quitting {
return ""
}
switch m.state {
case stateInput:
var sb strings.Builder
if m.showWelcome {
sb.WriteString(welcomeStyle.Render("Welcome to gitlink-cli interactive mode."))
sb.WriteString("\n")
sb.WriteString(welcomeStyle.Render("Type / to open the command palette, \"exit\" or Ctrl+D to quit."))
sb.WriteString("\n\n")
}
if m.lastOutput != "" {
sb.WriteString(successStyle.Render(m.lastOutput))
sb.WriteString("\n")
}
sb.WriteString(m.input.View())
return sb.String()
case statePalette:
return m.palette.View()
case stateForm:
return m.form.View()
case stateExecuting:
return fmt.Sprintf("\n %s Executing command...\n", m.spinner.View())
case stateError:
var sb strings.Builder
sb.WriteString("\n")
sb.WriteString(errorStyle.Render(m.lastError))
sb.WriteString("\n\n")
sb.WriteString(formHelpStyle.Render(" Press Enter to continue"))
sb.WriteString("\n")
return sb.String()
case stateOutput:
return m.viewport.View() + "\n" +
formHelpStyle.Render(" ↑↓/PgUp/PgDn 滚动 q/Esc 返回")
}
return ""
}
// Run starts the interactive REPL.
func Run(owner, repo string) error {
m := newReplModel(owner, repo)
p := tea.NewProgram(m, tea.WithAltScreen())
_, err := p.Run()
return err
}

View File

@ -0,0 +1,66 @@
package interactive
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
)
// TestSlashTriggersPaletteImmediately verifies that typing "/" as the first
// character (KeyRunes) switches the REPL into the palette state without
// requiring Enter. This is the regression test for the bug where the
// command palette never appeared.
func TestSlashTriggersPaletteImmediately(t *testing.T) {
m := newReplModel("owner", "repo")
if m.state != stateInput {
t.Fatalf("expected initial state stateInput, got %v", m.state)
}
// Simulate the user pressing the "/" key.
// bubbletea delivers printable chars as KeyRunes with the runes populated.
keySlash := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
newModel, _ := m.Update(keySlash)
repl, ok := newModel.(replModel)
if !ok {
t.Fatalf("expected replModel, got %T", newModel)
}
if repl.state != statePalette {
t.Fatalf("expected state statePalette after typing '/', got %v", repl.state)
}
}
// TestSlashWithQueryCarriesIntoSearch verifies "/iss" pre-fills the palette search.
func TestSlashWithQueryCarriesIntoSearch(t *testing.T) {
m := newReplModel("owner", "repo")
keySlashQuery := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/', 'i', 's', 's'}}
newModel, _ := m.Update(keySlashQuery)
repl, ok := newModel.(replModel)
if !ok {
t.Fatalf("expected replModel, got %T", newModel)
}
if repl.state != statePalette {
t.Fatalf("expected state statePalette, got %v", repl.state)
}
if got := repl.palette.search.Value(); got != "iss" {
t.Fatalf("expected palette search 'iss', got %q", got)
}
}
// TestNonSlashInputStaysInInputState verifies ordinary text doesn't open the palette.
func TestNonSlashInputStaysInInputState(t *testing.T) {
m := newReplModel("owner", "repo")
keyA := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}
newModel, _ := m.Update(keyA)
repl, ok := newModel.(replModel)
if !ok {
t.Fatalf("expected replModel, got %T", newModel)
}
if repl.state != stateInput {
t.Fatalf("typing 'a' should stay in stateInput, got %v", repl.state)
}
}

View File

@ -10,17 +10,23 @@ import (
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
interactiveCmd "github.com/gitlink-org/gitlink-cli/cmd/interactive"
"github.com/gitlink-org/gitlink-cli/shortcuts"
)
var Version = "dev"
var colorMode string
var rootCmd = &cobra.Command{
Use: "gitlink-cli",
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`,
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
cmdutil.ColorMode = colorMode
},
}
func init() {
@ -28,6 +34,8 @@ func init() {
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)")
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
rootCmd.PersistentFlags().StringVar(&colorMode, "color", "auto", "Color output: auto, always, never")
rootCmd.PersistentFlags().BoolVar(&cmdutil.AutoConfirm, "yes", false, "Skip confirmation prompts")
rootCmd.AddCommand(authCmd.NewAuthCmd())
rootCmd.AddCommand(apiCmd.NewAPICmd())
@ -35,6 +43,8 @@ func init() {
rootCmd.AddCommand(versionCmd)
shortcuts.RegisterAll(rootCmd)
rootCmd.AddCommand(interactiveCmd.NewInteractiveCmd())
}
var versionCmd = &cobra.Command{

View File

@ -1,6 +1,6 @@
# Webhook Shortcut
新增 `webhook` Shortcut 组,支持
新增 `webhook` Shortcut 组,支持
- `webhook +list`
- `webhook +create`

View File

@ -0,0 +1,127 @@
# Shortcut 命令实现报告
## 总体设计模式
三个 Shortcut 组(`wiki`、`snippet`、`hook-runner`)虽然功能不同,但整体架构遵循一致的设计模式:
1. **命令注册**:每个 Shortcut 组通过 `Shortcuts()` 函数返回 `[]*common.Shortcut`,在 `shortcuts/register.go` 中统一挂载到 cobra 根命令
2. **上下文注入**:通过 `RuntimeContext` 提供 API 客户端、owner/repo 解析、输出格式化等基础设施
3. **owner/repo 解析**:每个命令先调用 `ctx.ResolveOwnerRepo()` 从 flags 或 git remote 自动解析
4. **数据访问**:读操作用公开方式(无需认证),写操作用 Token 认证
5. **输出格式**:统一支持 `json` / `table` 格式,通过 `ctx.Output()` / `ctx.OutputData()` 输出
6. **错误处理**:参数校验置前(必填 flag 检查API 错误直接传播
> **注意**:以下所有演示命令假设在 `gitlink-cli` 仓库目录下运行。`--owner` 和 `--repo` 参数会自动从 Git remote 解析,无需手动传入。
---
## 1. Wiki 管理(`wiki`
### 实现思路
GitLink 的 Wiki 本质是一个**独立的 Git 仓库**`https://gitlink.org.cn/{owner}/{repo}.wiki.git`),每个页面是一个 `.md` 文件,侧边栏由 `_Sidebar.md` 控制。
| 操作 | 方式 | 认证 |
|------|------|------|
| 读list/view | `git clone` 到临时目录,读取文件 | ❌ 公开仓库,无需认证 |
| 写create/update/delete | `git clone` → 修改文件 → `git commit/push` | ✅ Personal Access Token |
删除时额外删除 `_Sidebar.md`,让 GitLink 网页端自动从文件列表重建侧边栏,防止残留引用。
### 遇到的问题
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 修改 `_Sidebar.md` 内容导致网页渲染崩溃 | `_Sidebar.md` 的编码/格式与 GitLink 网页端的预期不一致 | 不修改内容,直接删除文件,让 GitLink 自动重建 |
| `git add _Sidebar.md` 文件不存在时报错 | `_Sidebar.md` 尚未被网页端生成时不存在 | 用 `git add -A` 替代,自动识别所有变更 |
### 演示指令
```powershell
# 列出 Wiki 页面Git 克隆 → 读取目录 → 输出)
./gitlink-cli.exe wiki +list --format table
# 查看 Wiki 页面内容Git 克隆 → 读取文件内容 + Git log
./gitlink-cli.exe wiki +view --page-name "Wiki测试" --format json
# 创建 Wiki 页面Git 克隆 → 写 .md 文件 → git add/commit/push
./gitlink-cli.exe wiki +create --page-name "demo" --title "Demo" --content "Hello World" --message "创建Wiki页面"
# 更新 Wiki 页面Git 克隆 → 修改文件 → git add/commit/push
./gitlink-cli.exe wiki +update --page-name "demo" --title "Updated" --content "New content"
# 删除 Wiki 页面Git 克隆 → 删文件+删_Sidebar.md → git add -A/commit/push
./gitlink-cli.exe wiki +delete --page-name "demo"
```
---
## 2. 代码片段(`snippet`
### 实现思路
代码片段存放在项目仓库的 `snippets/` 目录下,每个片段是一个文件,读操作用 Git 克隆,写操作用文件 API。
| 操作 | 方式 | 说明 |
|------|------|------|
| 列出片段 | `git clone` → 读取 `snippets/` 目录 | 读操作无需认证 |
| 查看片段 | `git clone` → 读取指定文件内容 | 读操作无需认证 |
| 创建片段 | `POST /{owner}/{repo}/create_file` API | 写操作需 Token |
| 删除片段 | `git hash-object` 获取 SHA → `DELETE /delete_file` API | 先获取文件 SHA 再删除 |
创建时自动用代码块语法包裹内容(支持 `--language` 指定语言)。
### 演示指令
```powershell
# 创建代码片段POST 文件 API → 写入 snippets/ 目录)
./gitlink-cli.exe snippet +create --name "hello.py" --content 'print("Hello World!")' --language python --message "添加Python示例"
# 列出代码片段Git 克隆 → 读取 snippets/ 目录 → 输出)
./gitlink-cli.exe snippet +list --format table
# 查看代码片段Git 克隆 → 读取指定文件内容 → 输出)
./gitlink-cli.exe snippet +view --name "hello.py"
# 删除代码片段Git 获取 SHA → DELETE 文件 API
./gitlink-cli.exe snippet +delete --name "hello.py"
```
---
## 3. Webhook 投递监控(`webhook`
> 以下功能已合并到 `webhook` 命令组中,`hook-runner` 已删除。
### 实现思路
Webhook 每次触发事件push、Issue 操作、PR 操作等GitLink 会向配置的 URL 发送 HTTP 请求,这个过程称为"投递"。通过 Webhook 历史推送列表 API 获取投递记录。
| 命令 | API 路径 | 说明 |
|------|---------|------|
| `webhook +tasks` | `GET /v1/{owner}/{repo}/webhooks/{id}/hooktasks` | 查看投递历史(原有命令,保持不变) |
| `webhook +failed` | 从投递列表中过滤失败项 | 仅查看投递失败的记录 |
| `webhook +task-view` | 从投递列表中按 ID 筛选 | 查看某次投递的请求体、响应状态等 |
### 演示指令
```powershell
# 查看投递历史(原有命令不变)
./gitlink-cli.exe webhook +tasks --id 51102
# 查看投递失败的记录
./gitlink-cli.exe webhook +failed --id 51102
# 查看某次投递的详细内容
./gitlink-cli.exe webhook +task-view --id 51102 --task-id 4775684
```
---
## 测试
```powershell
# 运行全部 Shortcut 测试
cd E:/gitlink-cli/gitlink-cli
go test ./shortcuts/... -count=1
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,363 @@
# gitlink-cli API 全量封装 + 跨平台兼容性设计
**日期:** 2026-06-01
**状态:** 已批准
**范围:** 补全 Raw API 封装(对齐 GitLink OpenAPI 约 200 个端点)、提升跨平台兼容性和安装体验
---
## 背景
gitlink-cli 当前封装了约 90 个 GitLink OpenAPI 端点18 个快捷方式域),但 API 参考文档记录了约 200 个端点。同时,跨平台体验存在改进空间:终端输出在 Windows 上可能渲染不佳、npm 安装缺少 checksum 验证、缺少常见包管理器支持。
## 目标
1. 补全所有 GitLink OpenAPI 端点的快捷方式封装
2. 提升跨平台兼容性(终端输出、路径处理、字符宽度)
3. 改善安装体验npm 安装健壮性、包管理器分发、CI 改进)
## 实现策略
采用**分层渐进式**方案,按 L1→L2→L3→L4 四层推进,每层在功能分支上逐步提交。
---
## L1补全已有域的缺失操作~35 个端点)
### 1.1 Issue 评论 CRUD 补全
文件:`shortcuts/issue/issue.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `issue +comments` | GET | `/v1/{owner}/{repo}/issues/{index}/journals` | 评论/活动列表 |
| `issue +update-comment` | PATCH | `/v1/{owner}/{repo}/issues/{index}/journals/{id}` | 更新评论 |
| `issue +delete-comment` | DELETE | `/v1/{owner}/{repo}/issues/{index}/journals/{id}` | 删除评论 |
| `issue +reply-comment` | GET | `/v1/{owner}/{repo}/issues/{index}/journals/{id}/children_journals` | 子评论列表 |
| `issue +delete` | DELETE | `/v1/{owner}/{repo}/issues/{index}` | 删除 issue |
| `issue +batch-update` | PATCH | `/v1/{owner}/{repo}/issues/batch_update` | 批量更新 |
| `issue +batch-destroy` | DELETE | `/v1/{owner}/{repo}/issues/batch_destroy` | 批量删除 |
### 1.2 PR 评论 CRUD 补全
文件:`shortcuts/pr/pr.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `pr +comments` | GET | `/v1/{owner}/{repo}/pulls/{index}/journals` | 审查评论列表 |
| `pr +create-comment` | POST | `/v1/{owner}/{repo}/pulls/{index}/journals` | 创建审查评论 |
| `pr +update-comment` | PUT | `/v1/{owner}/{repo}/pulls/{index}/journals/{id}` | 更新审查评论 |
| `pr +delete-comment` | DELETE | `/v1/{owner}/{repo}/pulls/{index}/journals/{id}` | 删除审查评论 |
| `pr +commits` | GET | `/{owner}/{repo}/pulls/{id}/commits` | PR 提交列表 |
| `pr +reopen` | POST | `/v1/{owner}/{repo}/pulls/{index}/reopen` | 重新打开 PR |
| `pr +update` | PUT | `/{owner}/{repo}/pulls/{index}` | 更新 PR |
### 1.3 分支补全
文件:`shortcuts/branch/branch.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `branch +all` | GET | `/v1/{owner}/{repo}/branches/all` | 所有分支(无分页) |
| `branch +default` | PATCH | `/v1/{owner}/{repo}/branches/update_default_branch` | 更新默认分支 |
| `branch +restore` | POST | `/v1/{owner}/{repo}/branches/restore` | 恢复已删除分支 |
### 1.4 Release 补全
文件:`shortcuts/release/release.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `release +edit` | GET | `/{owner}/{repo}/releases/{id}/edit` | 编辑元数据 |
| `release +update` | PUT | `/{owner}/{repo}/releases/{id}` | 更新 release |
### 1.5 项目补全
文件:`shortcuts/repo/repo.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `repo +update` | PATCH | `/{owner}/{repo}` | 更新项目设置 |
| `repo +about` | GET | `/{owner}/{repo}/about` | 项目主页/关于 |
| `repo +menu` | GET | `/{owner}/{repo}/menu_list` | 项目导航列表 |
| `repo +units-get` | GET | `/{owner}/{repo}/project_units` | 导航设置 |
| `repo +units-set` | POST | `/{owner}/{repo}/project_units` | 更新导航设置 |
| `repo +code-stats` | GET | `/v1/{owner}/{repo}/code_stats` | 代码统计 |
| `repo +languages` | GET | `/{owner}/{repo}/languages` | 项目语言 |
| `repo +contributors` | GET | `/{owner}/{repo}/contributors` | 贡献者列表 |
| `repo +contributors-stat` | GET | `/v1/{owner}/{repo}/contributors/stat` | 贡献者代码统计 |
| `repo +recommend` | GET | `/projects/recommend` | 推荐项目 |
| `repo +star` | POST | `/projects/{id}/praise_tread/like` | 点赞 |
| `repo +unstar` | DELETE | `/projects/{id}/praise_tread/unlike` | 取消点赞 |
| `repo +watch` | POST | `/watchers/follow` | 关注 |
| `repo +unwatch` | DELETE | `/watchers/unfollow` | 取消关注 |
| `repo +stargazers` | GET | `/{owner}/{repo}/stargazers` | 点赞者列表 |
| `repo +watchers` | GET | `/{owner}/{repo}/watchers` | 关注者列表 |
| `repo +transfer` | POST | `/{owner}/{repo}/applied_transfer_projects` | 传输项目 |
| `repo +cancel-transfer` | POST | `/{owner}/{repo}/applied_transfer_projects/cancel` | 取消传输 |
| `repo +transfer-orgs` | GET | `/{owner}/{repo}/applied_transfer_projects/organizations` | 可传输的组织 |
| `repo +invite-link` | GET | `/{owner}/{repo}/project_invite_links/current_link` | 邀请链接 |
| `repo +invite-info` | GET | `/{owner}/{repo}/project_invite_links/show_link` | 邀请链接信息 |
| `repo +edit-detail` | GET | `/{owner}/{repo}/edit` | 编辑详情 |
| `repo +simple` | GET | `/{owner}/{repo}/simple` | 简化详情 |
### 1.6 用户消息与统计
文件:`shortcuts/user/user.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `user +update` | PUT | `/users/{owner}` | 更新用户信息 |
| `user +messages` | GET | `/users/{owner}/messages` | 消息列表 |
| `user +delete-message` | DELETE | `/users/{owner}/messages` | 删除消息 |
| `user +create-message` | POST | `/users/{owner}/messages` | 创建消息 |
| `user +read-message` | POST | `/users/{owner}/messages/read` | 标记已读 |
| `user +msg-settings` | GET | `/users/{owner}/template_message_settings` | 消息设置 |
| `user +update-msg-settings` | POST | `/users/{owner}/template_message_settings/update_setting` | 更新消息设置 |
| `user +pinned-projects` | GET | `/users/{owner}/is_pinned_projects` | 置顶项目 |
| `user +pin-project` | POST | `/users/{owner}/is_pinned_projects/pin` | 置顶 |
| `user +reorder-pinned` | PUT | `/users/{owner}/is_pinned_projects/{id}` | 重排置顶 |
| `user +activity` | GET | `/users/{owner}/statistics/activity` | 活动统计 |
| `user +headmap` | GET | `/users/{owner}/headmaps` | 贡献热力图 |
| `user +develop-stats` | GET | `/users/{owner}/statistics/develop` | 开发能力 |
| `user +role-stats` | GET | `/users/{owner}/statistics/role` | 角色定位 |
| `user +major-stats` | GET | `/users/{owner}/statistics/major` | 专业定位 |
| `user +applied-transfers` | GET | `/users/{owner}/applied_transfer_projects` | 待处理传输 |
| `user +accept-transfer` | POST | `/users/{owner}/applied_transfer_projects/{id}/accept` | 接受传输 |
| `user +refuse-transfer` | POST | `/users/{owner}/applied_transfer_projects/{id}/refuse` | 拒绝传输 |
| `user +applied-projects` | GET | `/users/{owner}/applied_projects` | 待处理加入申请 |
| `user +accept-join` | POST | `/users/{owner}/applied_projects/{id}/accept` | 接受加入 |
| `user +refuse-join` | POST | `/users/{owner}/applied_projects/{id}/refuse` | 拒绝加入 |
| `user +feedback` | POST | `/v1/{owner}/feedbacks` | 反馈建议 |
### 1.7 组织补全
文件:`shortcuts/org/org.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `org +add-team-projects` | POST | `/organizations/{org}/teams/{id}/team_projects/create_all` | 添加所有项目到团队 |
| `org +remove-team-projects` | DELETE | `/organizations/{org}/teams/{id}/team_projects/destroy_all` | 从团队删除所有项目 |
### 1.8 文件操作补全
文件:`shortcuts/file/file.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `file +batch` | POST | `/v1/{owner}/{repo}/contents/batch` | 批量提交文件 |
| `file +entries` | GET | `/{owner}/{repo}/entries` | 目录列表 |
| `file +sub-entries` | GET | `/{owner}/{repo}/sub_entries` | 子目录/文件 |
| `file +replace` | POST | `/{owner}/{repo}/replace_file` | 替换文件 |
### 1.9 辅助工具
文件:`shortcuts/util/util.go`(新建)
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `util +licenses` | GET | `/licenses` | 许可证模板列表 |
| `util +ignores` | GET | `/ignores` | 忽略文件模板列表 |
| `util +platform-msg-settings` | GET | `/template_message_settings` | 平台消息设置 |
---
## L2新增领域快捷方式~19 个端点)
### 2.1 数据集
新建文件:`shortcuts/dataset/dataset.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `dataset +create` | POST | `/v1/{owner}/{repo}/dataset` | 创建数据集 |
| `dataset +update` | PUT | `/v1/{owner}/{repo}/dataset` | 更新数据集 |
| `dataset +view` | GET | `/v1/{owner}/{repo}/dataset` | 数据集详情 + 附件 |
| `dataset +list` | GET | `/v1/project_datasets` | 全局数据集列表 |
### 2.2 模板
新建文件:`shortcuts/template/template.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `template +list` | GET | `/v1/{owner}/project_templates` | 模板列表 |
| `template +create` | POST | `/v1/{owner}/project_templates` | 创建模板 |
| `template +view` | GET | `/v1/{owner}/{repo}/project_templates/{id}` | 模板详情 |
| `template +update` | PUT | `/v1/{owner}/project_templates/{id}` | 更新模板 |
| `template +delete` | DELETE | `/v1/{owner}/project_templates/{id}` | 删除模板 |
### 2.3 CI 流水线补全
文件:`shortcuts/ci/ci.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `ci +pipelines` | GET | `/pm/pipelines` | 流水线模板列表 |
| `ci +run-pipeline` | POST | `/v1/{owner}/{repo}/actions/runs` | 运行流水线 |
| `ci +runs` | GET | `/v1/{owner}/{repo}/actions/runs` | 运行历史 |
| `ci +save-yaml` | POST | `/v1/{owner}/{repo}/pipelines/save_yaml` | 保存 YAML |
| `ci +pipeline-detail` | GET | `/v1/{owner}/{repo}/pipelines/{id}` | 流水线详情 |
| `ci +delete-pipeline` | DELETE | `/v1/{owner}/{repo}/pipelines/{id}` | 删除流水线 |
| `ci +disable` | POST | `/v1/{owner}/{repo}/actions/disable` | 禁用流水线 |
| `ci +enable` | POST | `/v1/{owner}/{repo}/actions/enable` | 启用流水线 |
| `ci +run-log` | POST | `/v1/{owner}/{repo}/actions/runs/{run_id}/jobs/0` | 运行日志 |
| `ci +run-results` | GET | `/v1/{owner}/{repo}/pipelines/run_results` | 运行报告 |
### 2.4 Wiki 导入/导出
文件:`shortcuts/wiki/wiki.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `wiki +export` | GET | `/wikiExport/wikiExport-wrapper` | 导出 Wiki |
| `wiki +import` | POST | `/wikiExport/uploadWiki/{owner}/{repoName}/{projectId}` | 导入 Wiki |
### 2.5 附件
新建文件:`shortcuts/attachment/attachment.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `attachment +upload` | POST | `/attachments` | 上传文件 |
| `attachment +delete` | DELETE | `/attachments/{uuid}` | 删除附件 |
---
## L3用户/平台管理(~30 个端点)
### 3.1 用户个人设置
文件:`shortcuts/user/user.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `user +change-password` | POST | `/accounts/change_password` | 修改密码 |
| `user +update-email` | PATCH | `/v1/{owner}/update_email` | 更改邮箱 |
| `user +update-phone` | PATCH | `/v1/{owner}/update_phone` | 更改手机 |
| `user +send-verify` | GET | `/v1/{owner}/send_email_vefify_code` | 发送验证码 |
| `user +check-verify` | POST | `/v1/{owner}/check_email_verify_code` | 验证验证码 |
| `user +check-password` | POST | `/v1/{owner}/check_password` | 验证密码 |
| `user +check-email` | POST | `/v1/{owner}/check_email` | 验证邮箱 |
| `user +can-delete` | POST | `/v1/{owner}/check_user_can_delete` | 检查可否删除 |
| `user +delete` | DELETE | `/v1/{owner}` | 停用用户 |
### 3.2 OAuth2 认证
文件:`cmd/auth/auth.go`、`internal/auth/login.go`
| 功能 | 方法 | 端点 | 说明 |
|------|------|------|------|
| `auth login --oauth` | POST | `/oauth/token` (password grant) | OAuth2 密码模式登录 |
| `auth refresh` | POST | `/oauth/token` (refresh_token grant) | 刷新令牌 |
认证流程:`--oauth` 标志触发 OAuth2 路径而非 cookie 路径。获取到的 access_token 存储到 keyring后续请求使用 token 认证。
### 3.3 项目管理增强
文件:`shortcuts/repo/repo.go`
| 快捷方式 | 方法 | 端点 | 说明 |
|----------|------|------|------|
| `repo +join` | POST | `/applied_projects` | 申请加入项目 |
| `repo +quit` | POST | `/{owner}/{repo}/quit` | 退出项目 |
| `repo +migrate` | POST | `/projects/migrate` | 创建镜像项目 |
| `repo +sync-mirror` | POST | `/repositories/{id}/sync_mirror` | 同步镜像 |
| `repo +topics` | GET | `/v1/project_topics` | 项目主题列表 |
| `repo +create-topic` | POST | `/v1/project_topics` | 创建主题 |
| `repo +delete-topic` | DELETE | `/v1/project_topics/{id}` | 删除主题 |
---
## L4跨平台兼容性和安装体验
### 4.1 终端输出兼容性
**改动文件:** `internal/output/formatter.go`、`cmd/root.go`
**颜色处理:**
- 添加 `--color` / `--no-color` 全局标志
- 自动检测终端颜色支持:检查 `NO_COLOR` 环境变量、`COLORTERM`、Windows Terminal (`WT_SESSION`)、`ConEmuANSI`
- 不支持颜色时自动降级为纯文本输出
**表格渲染:**
- 使用 `golang.org/x/term`(已有依赖)获取终端宽度
- 列过多时自动截断长值,保证不换行
- 中文字符宽度计算:检测 rune 是否在 CJK 范围 `一-鿿`,是则宽度为 2
### 4.2 npm 安装体验
**改动文件:** `npm/scripts/install.js`
- 下载后验证 SHA256 checksum从 GitHub Release 的 checksums.txt 获取)
- 更好的错误分类和消息:
- `ENOTFOUND` → "DNS 解析失败,请检查网络连接"
- `ECONNREFUSED` → "连接被拒绝,请检查代理设置"
- `ETIMEDOUT` → "连接超时,请尝试设置 HTTP_PROXY"
- `EACCES` → "权限不足,请使用管理员权限运行或检查安装目录"
- 离线模式:检查 `npm_config_cache` 中是否已有匹配版本
- `--verbose` 日志支持
**改动文件:** `npm/bin/cli.js`
- 启动时检查新版本(可配置关闭),提示 "New version X.Y.Z available"
- 二进制不存在时输出安装指引而非报错
### 4.3 包管理器分发
新增以下配置文件:
| 平台 | 文件路径 | 说明 |
|------|----------|------|
| Homebrew | `formula/gitlink-cli.rb` | Ruby formula从 GitHub Release 下载 |
| Scoop | `bucket/gitlink-cli.json` | JSON manifest |
| Chocolatey | `choco/gitlink-cli.nuspec` + `choco/tools/chocolateyinstall.ps1` | NuGet 包 |
每个配置文件中的版本号和 SHA256 由 release workflow 自动更新。
### 4.4 GitHub Actions CI 改进
**新增:** `.github/workflows/ci.yml`
- 触发PR 和 push 到 master
- 步骤:`go test ./...`、`go vet`、`golangci-lint run`
**改进:** `.github/workflows/release.yml`
- 添加 SHA256 checksum 文件上传到 Release
- 自动更新 Homebrew formula / Scoop manifest / Chocolatey nuspec 中的版本和 hash
### 4.5 构建优化
**改动文件:** `Makefile`
- 确保 `CGO_ENABLED=0`(已有)
- 添加 `-ldflags "-s -w"` 减小二进制体积(已有 `-s -w` 在部分目标)
- 统一所有构建目标使用相同的 ldflags
---
## 架构约束
1. **所有新快捷方式遵循现有声明式框架**:使用 `common.Shortcut`、`common.Flag`、`common.RuntimeContext`
2. **不引入新的 Go 依赖**颜色检测、终端宽度、CJK 宽度均用标准库或已有依赖实现)
3. **每个新快捷方式组注册到 `shortcuts/register.go`**
4. **测试覆盖**:每个新快捷方式的非平凡逻辑都需要测试(使用 `httptest` 模拟服务器)
5. **错误处理**:遵循现有 `client.go` 的错误模式(检查 HTTP 200 + `status` 字段)
## 不在范围内
- 不修改现有快捷方式的行为(只添加新的)
- 不添加交互式向导模式
- 不实现图形界面
- 不添加国际化(保持中文/英文双语 README
---
## 预估工作量
| 层 | 新增快捷方式数 | 新增文件 | 预估工作量 |
|-----|--------------|---------|-----------|
| L1 | ~70 | 1 (`shortcuts/util/`) | 大 |
| L2 | ~19 | 3 (`dataset/`, `template/`, `attachment/`) | 中 |
| L3 | ~16 | 0 | 中 |
| L4 | N/A | ~5 (包管理器配置 + CI) | 中 |
| **合计** | ~105 个操作 | ~9 个新文件/目录 | — |

View File

@ -0,0 +1,355 @@
# gitlink-cli 交互式 REPL 命令面板设计
## 概述
为 gitlink-cli 添加交互式 REPL 模式。用户运行 `gitlink``gitlink interactive` 进入持续会话,输入 `/` 弹出两级分组的命令面板,支持模糊搜索和方向键选择;选中命令后弹出表单式参数填充界面;提交后内联显示执行结果。
## 需求总结
| 需求 | 决策 |
|---|---|
| 进入方式 | REPL 模式 — `gitlink interactive``gitlink i` 进入持续会话 |
| 命令面板触发 | 输入 `/` 弹出命令面板,输入 `/xxx` 直接进入模糊搜索 |
| 命令分组 | 两级分组先选命令组issue/pr/repo...),再选子命令(+list/+create... |
| 参数填充 | 表单式 — Tab 切换字段,必填参数 `*` 标记,校验不通过红色提示 |
| 结果展示 | 内联显示在 REPL 中,执行完回到输入提示符 |
| 退出 | 输入 `exit`/`quit` 或 Ctrl+D |
## 技术选型
**Charm 生态bubbletea + bubbles + huh**
| 库 | 版本约束 | 职责 |
|---|---|---|
| `github.com/charmbracelet/bubbletea` | latest | TUI 框架Elm 架构状态机 |
| `github.com/charmbracelet/bubbles` | latest | 列表、输入框、搜索过滤等内置组件 |
| `github.com/charmbracelet/huh` | latest | 表单系统字段校验、Tab 切换、分组表单 |
选择理由Go TUI 事实标准bubbletea 27k+ stars三个库同属 Charm 生态天然集成huh 原生支持表单校验Windows Terminal 兼容性好。
## 整体架构
```
┌─────────────────────────────────────────────────┐
│ REPL 主循环 │
│ ┌─────────────────────────────────────────────┐ │
│ │ gitlink (owner/repo)> █ │ │
│ │ │ │
│ │ 输入文本 → 判断是否以 / 开头 │ │
│ │ ├── / → 进入命令面板Level 1: 命令组) │ │
│ │ ├── /xx → 进入命令面板(模糊搜索) │ │
│ │ ├── 直接输入 → 当作快捷命令执行 │ │
│ │ └── exit/quit → 退出 REPL │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─────── 命令面板 ──────┐ ┌─────── 表单 ──────┐│
│ │ 两级分组 + 模糊搜索 │ │ 参数填充 + 校验 ││
│ │ bubbles/list 组件 │ │ huh Form 组件 ││
│ └────────────────────────┘ └───────────────────┘│
│ │
│ ┌─────── 命令执行 ──────┐ │
│ │ 复用现有 Shortcut.Run │ │
│ │ 结果内联显示在 REPL 中 │ │
│ └────────────────────────┘ │
└───────────────────────────────────────────────────┘
```
**核心原则**:纯增量新增,不修改现有命令逻辑。仅对 `cmd/root.go` 新增 1 行注册,对 `shortcuts/register.go` 新增 1 个导出函数。
## 文件结构
```
gitlink-cl/
├── cmd/
│ ├── interactive/ ← 新增包
│ │ ├── interactive.go ← Cobra 命令注册 + 启动 bubbletea
│ │ ├── repl.go ← REPL 主循环 (bubbletea Model)
│ │ ├── palette.go ← 命令面板 (两级分组 + 模糊搜索)
│ │ ├── form.go ← 参数表单 (huh Form 包装)
│ │ └── executor.go ← 命令执行器 (调用 Shortcut.Run)
│ ├── root.go ← 新增 1 行: AddCommand(interactive.NewInteractiveCmd())
│ └── ...
├── shortcuts/
│ ├── register.go ← 新增导出函数: GetAllShortcuts()
│ └── ...
└── go.mod ← 新增 bubbletea/bubbles/huh 依赖
```
## 各模块详细设计
### 1. interactive.go — 入口
注册为 cobra 子命令:
```
gitlink interactive # 完整命令
gitlink i # 短别名
```
启动时:
1. 解析全局 flagsowner、repo、format、debug
2. 调用 `shortcuts.GetAllShortcuts()` 获取命令元数据
3. 创建 bubbletea Program 并运行 REPL Model
### 2. repl.go — REPL 主循环
基于 bubbletea 的 Elm 架构Model-View-Update
**状态定义**
```go
type replState int
const (
stateInput replState = iota // 等待用户输入
statePalette // 命令面板
stateForm // 参数表单
stateExecuting // 命令执行中
)
type replModel struct {
state replState
input textinput.Model // 底部输入框
palette paletteModel // 命令面板子组件
form formModel // 参数表单子组件
executor *Executor // 命令执行器
shortcuts map[string][]*common.Shortcut // 命令元数据
output string // 上一次命令输出
width int
height int
}
```
**状态转换**
| 当前状态 | 触发 | 目标状态 |
|---|---|---|
| stateInput | 输入 `/``/xxx` | statePalette |
| stateInput | 输入命令文本(如 `issue +list` | stateExecuting |
| stateInput | 输入 `exit`/`quit`/Ctrl+D | 退出程序 |
| statePalette | 选中命令(无参数) | stateExecuting |
| statePalette | 选中命令(有参数) | stateForm |
| statePalette | Esc | stateInput |
| stateForm | 提交(校验通过) | stateExecuting |
| stateForm | Esc | statePalette |
| stateExecuting | 执行完成 | stateInput |
**直接命令执行**
用户在输入框直接输入 `issue +list --state open` 时:
1. 解析为 group=`issue`、command=`+list`、flags=`--state open`
2. 查找匹配的 Shortcut
3. 构建 flag map直接执行
4. 如果参数不完整,进入 stateForm 补充必填参数
### 3. palette.go — 命令面板
基于 `bubbles/list` 组件。
**两级分组实现**
```go
type paletteLevel int
const (
levelGroup paletteLevel = iota // 选择命令组
levelCommand // 选择子命令
)
type paletteModel struct {
list list.Model // bubbles/list 组件
level paletteLevel // 当前层级
groups []string // 所有命令组名
selected string // Level1 选中的组名
shortcuts map[string][]*common.Shortcut
}
```
**Level 1命令组列表**
- 每个 item 显示:组名 + 描述
- 支持模糊搜索过滤
**Level 2子命令列表**
- 标题显示当前组名(如 "issue"
- 每个 item 显示:命令名(+list+ 描述
- 支持模糊搜索过滤
- Esc 返回 Level 1
**模糊搜索**:使用 bubbles/list 内置的 Filter按关键词匹配 item 的标题和描述。
### 4. form.go — 参数表单
基于 `huh` 库动态生成表单。
```go
type formModel struct {
form *huh.Form
shortcut *common.Shortcut
group string
values map[string]string // 用户填写的参数值
}
```
**表单生成逻辑**
```
对于选中的 Shortcut:
1. 遍历 s.Flags
2. 每个 Flag 生成一个 huh.Field:
- Required=true → huh.NewInput().Title("参数名 *").Validate(非空校验)
- Required=false → huh.NewInput().Title("参数名")
- Bool=true → huh.NewConfirm().Title("参数名")
- Default!="" → 设置默认值
3. 按 huh.NewGroup(fields...).Title("group +command") 组织
4. 创建 huh.NewForm(group)
```
**参数校验**
- 必填参数:提交时检查非空,为空则显示 "XX 为必填参数,不能为空"
- 提交成功后返回 `values` map 传递给执行器
### 5. executor.go — 命令执行器
桥接层:将表单收集的参数转换为 RuntimeContext 调用现有 Shortcut.Run。
```go
type Executor struct {
owner string
repo string
format string
client *client.Client
}
func (e *Executor) Execute(s *common.Shortcut, args map[string]string) (string, error) {
// 1. 创建 RuntimeContext
ctx, err := common.NewRuntimeContext(args)
// 2. 捕获 stdout重定向 fmt.Print 输出)
// 3. 调用 s.Run(ctx)
// 4. 返回捕获的输出字符串
}
```
**输出捕获**
- 执行命令时将 `os.Stdout` 临时重定向到 `bytes.Buffer`
- 执行完成后恢复
- 将 Buffer 内容作为字符串返回给 REPL 显示
### 6. 对现有代码的修改
**cmd/root.go**(新增 1 行):
```go
rootCmd.AddCommand(interactive.NewInteractiveCmd())
```
**shortcuts/register.go**(新增 1 个导出函数):
```go
// GetAllShortcuts 返回所有命令组的元数据映射,供交互模式使用
func GetAllShortcuts() map[string][]*common.Shortcut {
// 返回现有的 allShortcuts 映射
}
```
## UI 视觉规范
### REPL 提示符
```
gitlink (owner/repo)> █ ← 在 git 仓库中
gitlink> █ ← 不在 git 仓库中
```
### 命令面板 — Level 1
```
╭─────────────────────────────────────────╮
│ 搜索: iss█ │
├─────────────────────────────────────────┤
issue Issue 管理 │
│ snippet 代码片段管理 │
│ │
│ │
│ ↑↓ 选择 Enter 确认 Esc 返回 │
╰─────────────────────────────────────────╯
```
### 命令面板 — Level 2
```
╭─ issue ─────────────────────────────────╮
│ 搜索: █ │
├─────────────────────────────────────────┤
+list 列出 issue │
│ +create 创建 issue │
│ +view 查看 issue 详情 │
│ +close 关闭 issue │
│ +update 更新 issue │
│ +comment 评论 issue │
│ │
│ ↑↓ 选择 Enter 确认 Esc 返回上级 │
╰─────────────────────────────────────────╯
```
### 参数表单
```
╭─ issue +create ─────────────────────────╮
│ │
│ Title * [我的 issue 标题 ] │
│ Body [描述内容... ] │
│ Labels [bug, enhancement ] │
│ Assignee [ ] │
│ │
│ * 为必填参数 │
│ │
│ Tab 下一字段 Enter 执行 Esc 取消 │
╰─────────────────────────────────────────╯
```
### 命令执行结果
```
✓ 执行: issue +create --title "我的 issue 标题"
#123 我的 issue 标题
状态: 开启
作者: username
创建时间: 2026-06-08 15:30
gitlink (owner/repo)> █
```
### 错误处理
```
╭─ 错误 ──────────────────────────────────╮
│ ✗ 参数不完整 │
│ │
│ --title 为必填参数,不能为空 │
│ │
│ 按 Enter 返回表单重新填写 │
╰─────────────────────────────────────────╯
```
## 错误处理
| 场景 | 处理方式 |
|---|---|
| 未登录就执行命令 | 显示 "请先登录gitlink auth login" 并提示如何操作 |
| 命令执行 API 错误 | 内联显示错误信息(红色),回到输入提示符 |
| 表单必填参数为空 | 字段边框变红,底部显示 "XX 为必填参数" |
| 网络错误 | 显示 "网络错误: ...",回到输入提示符 |
| 无效命令 | 显示 "未知命令: xxx输入 / 查看所有命令" |
## 测试策略
1. **单元测试**executor 的参数转换和输出捕获
2. **集成测试**palette 的模糊搜索过滤、form 的校验逻辑
3. **手动测试**:完整 REPL 交互流程
## 不在范围内
- 命令历史记录(上下箭头翻历史)— 可作为后续增强
- 命令别名/快捷方式
- 多选批量操作
- 输出结果分页(使用 less
- 语法高亮

35
formula/gitlink-cli.rb Normal file
View File

@ -0,0 +1,35 @@
class GitlinkCli < Formula
desc "CLI tool for GitLink platform"
homepage "https://www.gitlink.org.cn/Gitlink/gitlink-cli"
version "0.1.13"
on_macos do
on_arm do
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_darwin_arm64.tar.gz"
sha256 "PLACEHOLDER_SHA256"
end
on_intel do
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_darwin_amd64.tar.gz"
sha256 "PLACEHOLDER_SHA256"
end
end
on_linux do
on_arm do
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_linux_arm64.tar.gz"
sha256 "PLACEHOLDER_SHA256"
end
on_intel do
url "https://github.com/gitlink-org/gitlink-cli/releases/download/v0.1.13/gitlink-cli_0.1.13_linux_amd64.tar.gz"
sha256 "PLACEHOLDER_SHA256"
end
end
def install
bin.install "gitlink-cli"
end
test do
system "#{bin}/gitlink-cli", "version"
end
end

26
go.mod
View File

@ -1,8 +1,11 @@
module github.com/gitlink-org/gitlink-cli
go 1.26.1
go 1.25.0
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/term v0.41.0
@ -10,11 +13,32 @@ require (
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.23.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)

58
go.sum
View File

@ -1,9 +1,39 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@ -12,12 +42,32 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@ -27,13 +77,21 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -150,3 +150,157 @@ func GetCurrentUser() (map[string]interface{}, error) {
return result, nil
}
// OAuthTokenResult holds the response from an OAuth2 token request.
type OAuthTokenResult struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Login string `json:"login"`
}
// LoginOAuth authenticates via OAuth2 password grant and stores the access token.
func LoginOAuth(username, password string) (*OAuthTokenResult, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
// Derive the base URL by stripping the /api suffix
baseURL := strings.TrimSuffix(cfg.BaseURL, "/api")
tokenURL := baseURL + "/oauth/token"
data := url.Values{}
data.Set("grant_type", "password")
data.Set("username", username)
data.Set("password", password)
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("OAuth2 token request failed (HTTP %d): %s", resp.StatusCode, string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
if tokenResp.AccessToken == "" {
return nil, fmt.Errorf("no access_token in OAuth2 response")
}
// Store the access token
if err := StoreToken(tokenResp.AccessToken); err != nil {
return nil, fmt.Errorf("failed to store token: %w", err)
}
// Get current user info to display login name
user, err := GetCurrentUser()
if err != nil {
return nil, fmt.Errorf("token stored but failed to fetch user info: %w", err)
}
login, _ := user["login"].(string)
return &OAuthTokenResult{
AccessToken: tokenResp.AccessToken,
TokenType: tokenResp.TokenType,
ExpiresIn: tokenResp.ExpiresIn,
Login: login,
}, nil
}
// RefreshOAuthToken refreshes the current OAuth2 token using a refresh_token grant.
// Note: This requires the server to have issued a refresh_token during the initial login.
func RefreshOAuthToken() (*OAuthTokenResult, error) {
currentToken, err := LoadToken()
if err != nil || currentToken == "" {
return nil, fmt.Errorf("no stored token found; please login first")
}
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
baseURL := strings.TrimSuffix(cfg.BaseURL, "/api")
tokenURL := baseURL + "/oauth/token"
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", currentToken)
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("OAuth2 token refresh failed (HTTP %d): %s", resp.StatusCode, string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
if tokenResp.AccessToken == "" {
return nil, fmt.Errorf("no access_token in OAuth2 response")
}
// Store the new access token
if err := StoreToken(tokenResp.AccessToken); err != nil {
return nil, fmt.Errorf("failed to store token: %w", err)
}
// Get current user info
user, err := GetCurrentUser()
if err != nil {
return nil, fmt.Errorf("token refreshed but failed to fetch user info: %w", err)
}
login, _ := user["login"].(string)
return &OAuthTokenResult{
AccessToken: tokenResp.AccessToken,
TokenType: tokenResp.TokenType,
ExpiresIn: tokenResp.ExpiresIn,
Login: login,
}, nil
}

View File

@ -3,6 +3,7 @@ package auth
import (
"os"
"path/filepath"
"runtime"
"github.com/zalando/go-keyring"
)
@ -41,8 +42,30 @@ func DeleteToken() error {
// File-based fallback
func credentialPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
return filepath.Join(configDir(), "credentials")
}
func configDir() string {
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
return dir
}
switch runtime.GOOS {
case "windows":
if appData := os.Getenv("APPDATA"); appData != "" {
return filepath.Join(appData, "gitlink-cli")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, "AppData", "Roaming", "gitlink-cli")
case "darwin":
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Application Support", "gitlink-cli")
default:
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "gitlink-cli")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli")
}
}
func storeTokenFile(token string) error {

View File

@ -24,9 +24,14 @@ type APIError struct {
StatusCode int
Code interface{}
Message string
Method string
Path string
}
func (e *APIError) Error() string {
if e.Method != "" && e.Path != "" {
return fmt.Sprintf("%s %s: [%v] %s", e.Method, e.Path, e.Code, e.Message)
}
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
}
@ -102,6 +107,8 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
Method: method,
Path: path,
}
}
@ -123,11 +130,14 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
}
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
msg, _ := raw["message"].(string)
// [P4 改进] 增加 HTTP 状态码到友好提示的映射,替换原来的裸错误信息
suggestion := suggestFix(int(statusCode))
return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
Method: method,
Path: path,
}
}
}
@ -174,17 +184,23 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
return c.Do("DELETE", path, nil, query)
}
// suggestFix 返回 HTTP 状态码对应的友好修复建议P4 改进)
// 当 API 返回 4xx/5xx 时,在错误信息中附加这些提示,帮助用户快速定位问题
func suggestFix(code int) string {
// HTTP 状态码 → 中文友好提示映射
// 注意:这些提示直接展示给终端用户,使用英文以便国际化兼容
switch code {
case 401:
return "请先运行 gitlink-cli auth login 登录"
return "Run 'gitlink auth login' to authenticate" // 未认证
case 403:
return "权限不足,请确认账户权限或联系项目管理员"
return "Permission denied. Check your account permissions or contact the project admin" // 权限不足
case 404:
return "资源不存在,请检查 owner/repo/id 是否正确"
return "Resource not found. Verify --owner, --repo, and the resource ID" // 资源不存在
case 422:
return "参数校验失败,请检查请求参数"
return "Validation failed. Check the request parameters" // 参数校验失败
case 500:
return "Server error. Try again later or contact the platform admin" // 服务端错误
default:
return ""
return "" // 其他状态码不给出具体建议
}
}

View File

@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"runtime"
"gopkg.in/yaml.v3"
)
@ -26,12 +27,32 @@ func DefaultConfig() *Config {
}
}
// ConfigDir returns the platform-appropriate configuration directory.
// - Windows: %APPDATA%\gitlink-cli
// - macOS: ~/Library/Application Support/gitlink-cli
// - Linux: ~/.config/gitlink-cli (XDG_CONFIG_HOME respected)
func ConfigDir() string {
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
return dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli")
switch runtime.GOOS {
case "windows":
if appData := os.Getenv("APPDATA"); appData != "" {
return filepath.Join(appData, "gitlink-cli")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, "AppData", "Roaming", "gitlink-cli")
case "darwin":
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Application Support", "gitlink-cli")
default:
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "gitlink-cli")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli")
}
}
func ConfigPath() string {

View File

@ -6,8 +6,10 @@ import (
"io"
"os"
"reflect"
"strconv"
"strings"
"text/tabwriter"
"unicode"
"gopkg.in/yaml.v3"
)
@ -71,10 +73,13 @@ func printTable(w io.Writer, envelope *Envelope) error {
case []interface{}:
return printSliceTable(w, data)
case map[string]interface{}:
// For maps with nested structures, prefer JSON
if hasComplexValues(data) {
return printJSON(w, envelope)
// If the map contains a list of objects, render that list as a table
// (picking the largest such list, which is usually the primary payload).
if slice := findLargestSlice(data); slice != nil {
return printSliceTable(w, slice)
}
// Single-object detail: render as a key/value table with nested maps
// expanded inline (indented sub-rows) for readability.
return printMapTable(w, data)
default:
// Fallback to JSON
@ -82,32 +87,91 @@ func printTable(w io.Writer, envelope *Envelope) error {
}
}
func hasComplexValues(m map[string]interface{}) bool {
// findLargestSlice scans the values of m for the largest slice whose elements
// are maps (e.g. []interface{} of map[string]interface{}, or native
// []map[string]interface{}/[]map[string]string built locally by some commands).
// Elements are normalized to map[string]interface{} so the table renderer has a
// consistent shape. Returns nil if no suitable slice is found.
func findLargestSlice(m map[string]interface{}) []interface{} {
var best []interface{}
for _, v := range m {
switch v.(type) {
case map[string]interface{}, []interface{}:
return true
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Slice || rv.Len() == 0 {
continue
}
// Every element must be a map for it to be table-renderable.
normalized := make([]interface{}, 0, rv.Len())
ok := true
for i := 0; i < rv.Len(); i++ {
el, alright := normalizeMap(rv.Index(i).Interface())
if !alright {
ok = false
break
}
normalized = append(normalized, el)
}
if !ok {
continue
}
if best == nil || len(normalized) > len(best) {
best = normalized
}
}
return false
return best
}
// normalizeMap coerces a map value (map[string]interface{} or a native
// map[string]X built by command code) into map[string]interface{} for uniform
// rendering. Returns ok=false if v is not a string-keyed map.
func normalizeMap(v interface{}) (map[string]interface{}, bool) {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Map {
return nil, false
}
out := make(map[string]interface{}, rv.Len())
for _, key := range rv.MapKeys() {
ks, ok := key.Interface().(string)
if !ok {
return nil, false
}
out[ks] = rv.MapIndex(key).Interface()
}
return out, true
}
// maxTableColumns caps the number of columns rendered in a slice table so the
// output stays readable on standard terminal widths. Fields beyond this limit
// are dropped (priority fields are kept first; see collectKeys).
const maxTableColumns = 8
func printSliceTable(w io.Writer, items []interface{}) error {
if len(items) == 0 {
fmt.Fprintln(w, "No results")
return nil
}
// Collect headers from first item
first, ok := items[0].(map[string]interface{})
if !ok {
// If the first item isn't a map, fall back to JSON (unchanged behavior).
if _, ok := items[0].(map[string]interface{}); !ok {
data, _ := json.MarshalIndent(items, "", " ")
fmt.Fprintln(w, string(data))
return nil
}
headers := collectKeys(first)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
// Headers are the UNION of every row's keys, so a column appears whenever
// any row carries the field. GitLink payloads (e.g. branch lists) do not
// always include every key on every item; taking headers from items[0]
// alone made columns like `protected` appear or vanish depending on which
// item happened to rank first.
headers := collectKeysUnion(items)
omitted := 0
if len(headers) > maxTableColumns {
omitted = len(headers) - maxTableColumns
headers = headers[:maxTableColumns]
}
// minwidth=0, tabwidth=2, padding=2 -> tighter columns than the old
// minwidth=4 setting, which helps when there are several wide fields.
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
// Print headers
fmt.Fprintln(tw, strings.Join(headers, "\t"))
@ -129,31 +193,75 @@ func printSliceTable(w io.Writer, items []interface{}) error {
}
fmt.Fprintln(tw, strings.Join(vals, "\t"))
}
return tw.Flush()
tw.Flush()
if omitted > 0 {
// Note appended after the table so it does not perturb column alignment.
fmt.Fprintf(w, "(已省略 %d 列)\n", omitted)
}
return nil
}
// printMapTable renders a single-object detail as a two-column KEY/VALUE table.
// Top-level keys are ordered by collectKeys (identity/status fields first).
// Nested map values are expanded as indented sub-rows so rich detail (e.g. the
// commit/tagger block of a tag) stays readable instead of collapsing to a
// summary token or a JSON blob.
func printMapTable(w io.Writer, m map[string]interface{}) error {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "KEY\tVALUE")
fmt.Fprintln(tw, "---\t-----")
for k, v := range m {
fmt.Fprintf(tw, "%s\t%s\n", k, formatValue(v))
for _, k := range collectKeys(m) {
writeMapRow(tw, k, m[k], 0)
}
return tw.Flush()
}
func collectKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
// Prefer common keys first
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
// writeMapRow writes one row for a key/value pair. Nested maps recurse with
// increasing indent so sub-fields appear under their parent.
func writeMapRow(tw *tabwriter.Writer, key string, v interface{}, depth int) {
indent := strings.Repeat(" ", depth)
displayKey := key
if depth > 0 {
displayKey = "└ " + key
}
if m, ok := v.(map[string]interface{}); ok {
// Parent header row, then each child indented one level deeper.
fmt.Fprintf(tw, "%s%s\t\n", indent, displayKey)
for _, ck := range collectKeys(m) {
writeMapRow(tw, ck, m[ck], depth+1)
}
return
}
fmt.Fprintf(tw, "%s%s\t%s\n", indent, displayKey, formatValue(v))
}
// preferredHeaders lists keys that are surfaced first (and therefore survive the
// maxTableColumns cap). Keep the most human-meaningful identity/status fields
// at the top so that even when a row has 11+ keys the table still shows the
// columns a user actually scans.
var preferredHeaders = []string{
"id", "name", "login", "title", "subject", "number",
"status", "state", "protected", "last_commit", "commit_time",
"type", "time_ago", "created_time", "created_at", "updated_at",
}
// orderKeySet returns the keys of keySet ordered by preferredHeaders: priority
// fields come first in their defined order, the rest follow in set-iteration
// order. Keys absent from preferredHeaders still appear, just after the
// priority fields.
func orderKeySet(keySet map[string]bool) []string {
keys := make([]string, 0, len(keySet))
seen := map[string]bool{}
for _, k := range priority {
if _, ok := m[k]; ok {
keys = append(keys, k)
seen[k] = true
for _, p := range preferredHeaders {
if keySet[p] {
keys = append(keys, p)
seen[p] = true
}
}
for k := range m {
for k := range keySet {
if !seen[k] {
keys = append(keys, k)
}
@ -161,20 +269,180 @@ func collectKeys(m map[string]interface{}) []string {
return keys
}
// collectKeysUnion merges the keys of every map in items into a single
// priority-ordered header list. Used by printSliceTable so a column shows
// whenever any row has the field — preventing columns from disappearing when
// the first row happens to omit a key (e.g. `protected` on GitLink branches).
func collectKeysUnion(items []interface{}) []string {
keySet := map[string]bool{}
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
for k := range m {
keySet[k] = true
}
}
return orderKeySet(keySet)
}
func collectKeys(m map[string]interface{}) []string {
keySet := make(map[string]bool, len(m))
for k := range m {
keySet[k] = true
}
return orderKeySet(keySet)
}
func formatValue(v interface{}) string {
if v == nil {
return ""
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Map, reflect.Slice:
data, _ := json.Marshal(v)
s := string(data)
if len(s) > 60 {
return s[:57] + "..."
case reflect.Map:
if m, ok := v.(map[string]interface{}); ok {
return formatNestedMap(m)
}
return s
// Native map (e.g. map[string]string built locally): normalize then summarize.
if m, ok := normalizeMap(v); ok {
return formatNestedMap(m)
}
// Non-string-keyed map: fall back to JSON, truncated.
data, _ := json.Marshal(v)
return truncateString(string(data), 40)
case reflect.Slice:
// For slices, show the element count rather than an inline JSON blob,
// which is almost always wider than the terminal.
return fmt.Sprintf("[%d items]", rv.Len())
case reflect.Float32, reflect.Float64:
f := rv.Float()
// Render whole-valued floats as integers (e.g. 1779200970 instead of
// 1.77920097e+09); otherwise use plain 'f' formatting to avoid the
// scientific notation %v picks for large magnitudes.
if f == float64(int64(f)) {
return strconv.FormatInt(int64(f), 10)
}
return strconv.FormatFloat(f, 'f', -1, 64)
default:
return fmt.Sprintf("%v", v)
return truncateString(fmt.Sprintf("%v", v), 40)
}
}
// truncateString shortens s to at most maxLen bytes, appending "..." when it is
// truncated. Strings already within the limit are returned unchanged.
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
// formatNestedMap produces a compact, human-readable summary of a nested map by
// extracting a few well-known fields (name/login/title/message/sha/...) instead
// of dumping the full JSON. Unknown shapes collapse to "<object>". This keeps
// table cells narrow even for richly-nested API payloads (e.g. commit metadata).
func formatNestedMap(m map[string]interface{}) string {
// Ordered candidates: earlier keys win and become the leading part.
type candidate struct {
key string
max int // 0 = no truncation
}
cands := []candidate{
{"name", 0},
{"login", 0},
{"title", 30},
{"subject", 30},
{"message", 30},
{"sha", 8},
{"id", 0},
}
var parts []string
for _, c := range cands {
raw, ok := m[c.key]
if !ok || raw == nil {
continue
}
s := formatValue(raw)
if c.max > 0 {
if c.key == "sha" {
// Git-style short SHA: bare prefix, no ellipsis.
if len(s) > c.max {
s = s[:c.max]
}
} else {
s = truncateString(s, c.max)
}
}
if s == "" {
continue
}
// For text-ish fields, join with ":"; for sha/id use a bare token.
switch c.key {
case "sha", "id":
parts = append(parts, s)
default:
parts = append(parts, c.key+":"+s)
}
}
if len(parts) == 0 {
return "<object>"
}
return strings.Join(parts, " -> ")
}
// ShouldUseColor returns true if colorized output should be used.
func ShouldUseColor(mode string) bool {
switch mode {
case "always":
return true
case "never":
return false
default: // "auto"
if os.Getenv("NO_COLOR") != "" {
return false
}
if os.Getenv("TERM") == "dumb" {
return false
}
if os.Getenv("WT_SESSION") != "" || os.Getenv("ConEmuANSI") == "ON" {
return true
}
if os.Getenv("COLORTERM") != "" {
return true
}
if os.Getenv("TERM") != "" {
return true
}
return false
}
}
// runeWidth returns the display width of a rune (2 for CJK, 1 for others).
func runeWidth(r rune) int {
if unicode.Is(unicode.Han, r) {
return 2
}
if r >= 0xFF01 && r <= 0xFF60 {
return 2
}
if r >= 0x3000 && r <= 0x303F {
return 2
}
return 1
}
// StringWidth returns the display width of a string accounting for CJK characters.
func StringWidth(s string) int {
w := 0
for _, r := range s {
w += runeWidth(r)
}
return w
}

View File

@ -0,0 +1,368 @@
package output
import (
"bytes"
"strings"
"testing"
)
func TestPrintTable_NestedSliceRendersAsTable(t *testing.T) {
data := map[string]interface{}{
"tags": []interface{}{
map[string]interface{}{"name": "v1", "id": "a"},
map[string]interface{}{"name": "v2", "id": "b"},
},
"total_count": 2,
}
env := SuccessEnvelope(data, nil)
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo returned error: %v", err)
}
out := buf.String()
for _, want := range []string{"name", "v1", "v2", "id"} {
if !strings.Contains(out, want) {
t.Errorf("expected output to contain %q, got:\n%s", want, out)
}
}
// Must NOT start with JSON envelope.
if strings.HasPrefix(strings.TrimSpace(out), "{") {
t.Errorf("expected table output, not JSON; got:\n%s", out)
}
}
// TestPrintTable_NestedMapRendersAsDetailTable 验证含嵌套 map 的单对象详情
// 现在渲染为易读的 KEY/VALUE 表格(嵌套字段用缩进展开),而非回退 JSON。
func TestPrintTable_NestedMapRendersAsDetailTable(t *testing.T) {
data := map[string]interface{}{
"name": "v1.0.0",
"commit": map[string]interface{}{"sha": "abc123", "message": "release"},
"tagger": map[string]interface{}{"login": "alice", "name": "Alice"},
}
env := SuccessEnvelope(data, nil)
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo returned error: %v", err)
}
out := buf.String()
// Should be a key/value table, NOT JSON.
if strings.HasPrefix(strings.TrimSpace(out), "{") {
t.Errorf("expected table output for nested map, not JSON; got:\n%s", out)
}
// Top-level key present.
for _, want := range []string{"name", "v1.0.0", "KEY", "VALUE"} {
if !strings.Contains(out, want) {
t.Errorf("expected output to contain %q; got:\n%s", want, out)
}
}
// Nested fields should be expanded inline (not collapsed to <object>).
for _, want := range []string{"sha", "abc123", "login", "alice"} {
if !strings.Contains(out, want) {
t.Errorf("expected nested field %q to be expanded; got:\n%s", want, out)
}
}
}
func TestPrintTable_SimpleMapRendersAsMapTable(t *testing.T) {
data := map[string]interface{}{
"name": "x",
"count": 5,
}
env := SuccessEnvelope(data, nil)
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo returned error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "KEY") || !strings.Contains(out, "VALUE") {
t.Errorf("expected KEY/VALUE map table; got:\n%s", out)
}
}
// TestPrintTable_NativeMapStringSlice 验证本地构造的 []map[string]string
// (如 wiki +list 的 pages也能渲染为表格而非折叠成 "[N items]"。
// 回归测试:之前 findLargestSlice 只认 []interface{},导致这类列表被当
// 单对象详情的 slice 字段,显示为 "[2 items]"。
func TestPrintTable_NativeMapStringSlice(t *testing.T) {
data := map[string]interface{}{
"total_count": 2,
"pages": []map[string]string{
{"name": "Home"},
{"name": "About"},
},
}
env := SuccessEnvelope(data, nil)
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo returned error: %v", err)
}
out := buf.String()
if strings.Contains(out, "[2 items]") {
t.Errorf("native map slice should render as table, not [N items]; got:\n%s", out)
}
for _, want := range []string{"name", "Home", "About"} {
if !strings.Contains(out, want) {
t.Errorf("expected output to contain %q; got:\n%s", want, out)
}
}
}
func TestPrintTable_ErrorEnvelope(t *testing.T) {
env := ErrorEnvelope("E_FAIL", "boom", "")
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo returned error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "Error:") {
t.Errorf("expected output to contain \"Error:\"; got:\n%s", out)
}
if !strings.Contains(out, "boom") {
t.Errorf("expected output to contain error message \"boom\"; got:\n%s", out)
}
}
func TestPrintTable_PicksLargestSlice(t *testing.T) {
data := map[string]interface{}{
"small": []interface{}{
map[string]interface{}{"a": 1},
},
"big": []interface{}{
map[string]interface{}{"x": 1},
map[string]interface{}{"x": 2},
map[string]interface{}{"x": 3},
},
}
env := SuccessEnvelope(data, nil)
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo returned error: %v", err)
}
out := buf.String()
// Must not be a JSON envelope fallback.
if strings.HasPrefix(strings.TrimSpace(out), "{") {
t.Errorf("expected table output, not JSON; got:\n%s", out)
}
if !strings.Contains(out, "x") {
t.Errorf("expected output to render \"big\" list with x column; got:\n%s", out)
}
for _, want := range []string{"1", "2", "3"} {
if !strings.Contains(out, want) {
t.Errorf("expected output to contain row value %q; got:\n%s", want, out)
}
}
lines := strings.Split(strings.TrimSpace(out), "\n")
// header + dash line + 3 rows = 5 lines minimum
if len(lines) < 5 {
t.Errorf("expected at least 5 lines (header/dash/3 rows); got %d:\n%s", len(lines), out)
}
// The "a" column from the smaller slice must not appear as a header.
for _, line := range lines {
// header line is tab-separated; ensure "a" is not one of the columns.
cols := strings.Split(line, "\t")
for _, c := range cols {
if strings.TrimSpace(c) == "a" {
t.Errorf("did not expect \"a\" column from smaller slice; got line: %q", line)
}
}
}
}
// --- Readability improvements (truncation, column limits, nested-map summaries) ---
func TestFormatValue_TruncatesLongString(t *testing.T) {
long := strings.Repeat("a", 100)
got := formatValue(long)
if len(got) > 40 {
t.Errorf("expected output <= 40 chars, got %d: %q", len(got), got)
}
if !strings.HasSuffix(got, "...") {
t.Errorf("expected output to end with \"...\", got %q", got)
}
}
func TestFormatValue_ShortStringUntouched(t *testing.T) {
got := formatValue("hello")
if got != "hello" {
t.Errorf("expected short string unchanged, got %q", got)
}
}
func TestFormatValue_NestedMapShowsSummary(t *testing.T) {
m := map[string]interface{}{
"login": "alice",
"id": 123,
"image_url": "http://example.com/avatar.png",
}
got := formatValue(m)
if !strings.Contains(got, "alice") {
t.Errorf("expected summary to contain \"alice\", got %q", got)
}
if strings.Contains(got, "image_url") {
t.Errorf("expected summary to omit noisy field \"image_url\", got %q", got)
}
}
func TestFormatValue_NestedMapWithoutKnownFields(t *testing.T) {
m := map[string]interface{}{
"foo": "bar",
"baz": 42,
}
got := formatValue(m)
// No recognized key -> should be a short placeholder, not a JSON blob.
if strings.HasPrefix(got, "{") {
t.Errorf("expected placeholder for object without known fields, got JSON: %q", got)
}
}
func TestFormatValue_FloatNoScientificNotation(t *testing.T) {
got := formatValue(1.77920097e+09)
if strings.Contains(got, "e+") || strings.Contains(got, "E+") {
t.Errorf("expected float without scientific notation, got %q", got)
}
if !strings.Contains(got, "1779200970") {
t.Errorf("expected full decimal expansion, got %q", got)
}
}
func TestPrintSliceTable_LimitsColumns(t *testing.T) {
// Build a single row with 12 columns.
row := map[string]interface{}{}
cols := []string{"id", "name", "login", "title", "subject", "number",
"status", "state", "extra1", "extra2", "extra3", "extra4"}
for _, c := range cols {
row[c] = "v_" + c
}
var buf bytes.Buffer
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
t.Fatalf("printSliceTable returned error: %v", err)
}
out := buf.String()
// The header line is the first non-empty line.
lines := strings.Split(out, "\n")
var header string
for _, l := range lines {
if strings.TrimSpace(l) != "" {
header = l
break
}
}
colCount := len(strings.Split(header, "\t"))
if colCount > 8 {
t.Errorf("expected <= 8 columns in header, got %d:\n%s", colCount, header)
}
// And the output should note that columns were omitted.
if !strings.Contains(out, "省略") && !strings.Contains(strings.ToLower(out), "omitted") {
t.Errorf("expected output to mention omitted columns (省略/omitted), got:\n%s", out)
}
}
func TestPrintSliceTable_PreservesPriorityHeaders(t *testing.T) {
// Even with >8 columns, priority fields like id/name should survive.
row := map[string]interface{}{}
cols := []string{"a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "id", "name"}
for _, c := range cols {
row[c] = "v"
}
var buf bytes.Buffer
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
t.Fatalf("printSliceTable returned error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "id") || !strings.Contains(out, "name") {
t.Errorf("expected priority headers id/name to survive; got:\n%s", out)
}
}
func TestTruncateString(t *testing.T) {
if got := truncateString("abc", 10); got != "abc" {
t.Errorf("expected short string unchanged, got %q", got)
}
got := truncateString("abcdefghij", 5)
if len(got) > 5 {
t.Errorf("expected len <= 5, got %d: %q", len(got), got)
}
if !strings.HasSuffix(got, "...") {
t.Errorf("expected ... suffix, got %q", got)
}
}
func TestFormatNestedMap_LoginAndSha(t *testing.T) {
m := map[string]interface{}{
"login": "bob",
"sha": "0123456789abcdef0123456789abcdef",
}
got := formatNestedMap(m)
if !strings.Contains(got, "bob") {
t.Errorf("expected login in summary, got %q", got)
}
if !strings.Contains(got, "0123456") {
t.Errorf("expected truncated sha prefix in summary, got %q", got)
}
// Should not contain the full sha.
if strings.Contains(got, "0123456789abcdef0123456789abcdef") {
t.Errorf("expected sha to be truncated, got %q", got)
}
}
// TestPrintSliceTable_HeaderUnionAcrossRows 验证当各行字段集合不一致时
// GitLink 分支列表的典型情况:并非每个分支都返回 protected 字段),
// 表头应取所有行的字段并集,而不是仅取第一行。否则 protected 列会因落在
// 第一位的样本恰好缺失而「时有时无」。
func TestPrintSliceTable_HeaderUnionAcrossRows(t *testing.T) {
items := []interface{}{
map[string]interface{}{"name": "develop"}, // 第一行缺 protected
map[string]interface{}{"name": "main", "protected": false}, // 第二行带 protected
}
var buf bytes.Buffer
if err := printSliceTable(&buf, items); err != nil {
t.Fatalf("printSliceTable returned error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "protected") {
t.Errorf("expected header to include \"protected\" from row union (not just first row); got:\n%s", out)
}
if !strings.Contains(out, "develop") || !strings.Contains(out, "main") {
t.Errorf("expected both rows present in output; got:\n%s", out)
}
}
// TestPrintSliceTable_ProtectedSurvivesColumnCap 验证当列数超过上限时,
// protected是否保护分支作为优先字段会被保留、不被截断丢弃——否则在
// 字段较多的分支列表里这一列仍可能消失。
func TestPrintSliceTable_ProtectedSurvivesColumnCap(t *testing.T) {
row := map[string]interface{}{}
cols := []string{"a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "id", "name", "protected"}
for _, c := range cols {
row[c] = "v"
}
var buf bytes.Buffer
if err := printSliceTable(&buf, []interface{}{row}); err != nil {
t.Fatalf("printSliceTable returned error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "protected") {
t.Errorf("expected priority field \"protected\" to survive the column cap; got:\n%s", out)
}
}
// TestOrderKeySet_BranchColumnOrderIsStable 守护分支核心列的固定顺序
// name / protected / last_commit / commit_time。当前若 last_commit /
// commit_time 不在 preferredHeadersorderKeySet 会把这两个非优先键按 map
// 遍历序追加,导致列顺序随机——重复调用必暴露不一致。
func TestOrderKeySet_BranchColumnOrderIsStable(t *testing.T) {
want := []string{"name", "protected", "last_commit", "commit_time"}
keys := map[string]bool{"name": true, "protected": true, "last_commit": true, "commit_time": true}
for i := 0; i < 100; i++ {
got := orderKeySet(keys)
if len(got) != len(want) {
t.Fatalf("iteration %d: got %d keys %v, want %v", i, len(got), got, want)
}
for j, w := range want {
if got[j] != w {
t.Fatalf("iteration %d: got[%d]=%q, want %q (full %v)", i, j, got[j], w, got)
}
}
}
}

View File

@ -4,9 +4,13 @@
const fs = require("fs");
const path = require("path");
const https = require("https");
const http = require("http");
const { execFileSync } = require("child_process");
const BINARY_NAME = "gitlink-cli";
const PACKAGE = require("../package.json");
const CURRENT_VERSION = PACKAGE.version;
function getBinaryName(platform = process.platform) {
return platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME;
@ -36,6 +40,36 @@ function formatMissingBinaryError(
].join("\n");
}
function checkForUpdate() {
if (process.env.GITLINK_CLI_NO_UPDATE_CHECK) return;
const registryUrl = "https://registry.npmjs.org/@gitlink-ai/cli/latest";
const mod = registryUrl.startsWith("https") ? https : http;
try {
mod.get(registryUrl, { timeout: 3000 }, (res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
try {
const pkg = JSON.parse(body);
const latest = pkg.version;
if (latest && latest !== CURRENT_VERSION) {
process.stderr.write(
`\nA new version of ${BINARY_NAME} is available: ${latest} (current: ${CURRENT_VERSION})\n` +
`Update with: npm update -g @gitlink-ai/cli\n\n`
);
}
} catch {
// Silently ignore parse errors
}
});
}).on("error", () => {
// Silently ignore network errors
});
} catch {
// Silently ignore any errors
}
}
function run(args = process.argv.slice(2), options = {}) {
const platform = options.platform || process.platform;
const arch = options.arch || process.arch;
@ -49,10 +83,16 @@ function run(args = process.argv.slice(2), options = {}) {
return exit(1);
}
// Binary existence check before any other logic
if (!fs.existsSync(binaryPath)) {
return failMissingBinary();
}
// Run optional update check in background (fire-and-forget)
if (require.main === module) {
checkForUpdate();
}
try {
execFile(binaryPath, args, { stdio: "inherit" });
} catch (err) {
@ -76,4 +116,5 @@ module.exports = {
getBinaryPath,
formatMissingBinaryError,
run,
checkForUpdate,
};

View File

@ -5,6 +5,7 @@
const os = require("os");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const https = require("https");
const http = require("http");
const { execSync } = require("child_process");
@ -13,13 +14,14 @@ const PACKAGE = require("../package.json");
const VERSION = PACKAGE.version;
const BINARY_NAME = "gitlink-cli";
// GitLink release download base URL
// Format: https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases
// Attachment download: https://www.gitlink.org.cn/api/attachments/{attachment_id}
const RELEASE_BASE = "https://www.gitlink.org.cn";
const REPO_OWNER = "Gitlink";
const REPO_NAME = "gitlink-cli";
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
const DOWNLOAD_TIMEOUT_MS = 120000;
function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
const platformMap = {
darwin: "darwin",
@ -54,15 +56,60 @@ function getArchiveName(platform, arch) {
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${ext}`;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function fetch(url, options = {}) {
return new Promise((resolve, reject) => {
const maxRedirects = options.maxRedirects || 5;
let redirectCount = 0;
// Proxy support: respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY
function getProxy(targetUrl) {
try {
const parsed = new URL(targetUrl);
const noProxy = (process.env.NO_PROXY || process.env.no_proxy || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
for (const np of noProxy) {
if (parsed.hostname.endsWith(np) || parsed.hostname === np) {
return null;
}
}
if (parsed.protocol === "https:") {
return (
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy ||
null
);
}
return (
process.env.HTTP_PROXY ||
process.env.http_proxy ||
null
);
} catch {
return null;
}
}
function doRequest(currentUrl) {
const proxyUrl = getProxy(currentUrl);
const mod = currentUrl.startsWith("https") ? https : http;
const req = mod.get(currentUrl, (res) => {
// Follow redirects
const parsedUrl = new URL(currentUrl);
const reqOptions = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === "https:" ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
method: "GET",
timeout: options.timeout || DOWNLOAD_TIMEOUT_MS,
};
const req = mod.get(currentUrl, reqOptions, (res) => {
if (
(res.statusCode === 301 ||
res.statusCode === 302 ||
@ -103,12 +150,36 @@ function fetch(url, options = {}) {
});
} else {
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => resolve(Buffer.concat(chunks)));
const totalSize = parseInt(res.headers["content-length"], 10) || 0;
let downloaded = 0;
let lastProgress = 0;
res.on("data", (chunk) => {
chunks.push(chunk);
downloaded += chunk.length;
// Progress indicator (every 10% or every 512KB)
if (totalSize > 0) {
const pct = Math.floor((downloaded / totalSize) * 100);
if (pct >= lastProgress + 10) {
lastProgress = pct;
process.stdout.write(`\r Downloading: ${pct}%`);
}
} else if (downloaded - lastProgress > 512 * 1024) {
lastProgress = downloaded;
process.stdout.write(
`\r Downloaded: ${(downloaded / 1024 / 1024).toFixed(1)} MB`
);
}
});
res.on("end", () => {
if (totalSize > 0 || downloaded > 512 * 1024) {
process.stdout.write("\r");
}
resolve(Buffer.concat(chunks));
});
}
});
req.on("error", reject);
req.setTimeout(60000, () => {
req.setTimeout(options.timeout || DOWNLOAD_TIMEOUT_MS, () => {
req.destroy();
reject(new Error("Request timed out"));
});
@ -122,14 +193,12 @@ async function findReleaseAsset(platform, arch) {
const archiveName = getArchiveName(platform, arch);
const tagName = `v${VERSION}`;
// Try fetching release info from GitLink API
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
console.log(`Fetching release info from ${apiUrl}`);
try {
const releases = await fetch(apiUrl, { json: true });
// Find the release matching our version
let release = null;
if (Array.isArray(releases)) {
@ -137,7 +206,6 @@ async function findReleaseAsset(platform, arch) {
(r) => r.tag_name === tagName || r.tag_name === VERSION
);
if (!release && releases.length > 0) {
// Fall back to latest release
release = releases[0];
}
} else if (releases && releases.releases) {
@ -151,11 +219,9 @@ async function findReleaseAsset(platform, arch) {
}
if (release && release.attachments) {
// Try exact match first
let asset = release.attachments.find(
(a) => a.title === archiveName || a.filename === archiveName
);
// If no exact match (version mismatch on fallback release), match by platform/arch pattern
if (!asset) {
const ext = platform === "windows" ? ".zip" : ".tar.gz";
const pattern = `_${platform}_${arch}${ext}`;
@ -164,9 +230,8 @@ async function findReleaseAsset(platform, arch) {
);
}
if (asset) {
// Return the download URL for this attachment
let downloadUrl = asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
// Ensure absolute URL
let downloadUrl =
asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
if (downloadUrl.startsWith("/")) {
downloadUrl = RELEASE_BASE + downloadUrl;
}
@ -177,11 +242,63 @@ async function findReleaseAsset(platform, arch) {
console.log(`Warning: Could not fetch release info: ${e.message}`);
}
// Fallback: try direct download URL pattern
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
}
async function downloadAndExtract(url, destDir, platform) {
function extractArchive(archivePath, destDir, platform) {
const isWindows = platform === "windows";
if (isWindows) {
// Use PowerShell with properly escaped paths for Windows
const escapedArchive = archivePath.replace(/'/g, "''");
const escapedDest = destDir.replace(/'/g, "''");
try {
execSync(
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${escapedArchive}' -DestinationPath '${escapedDest}'"`,
{ stdio: "pipe" }
);
} catch {
// Fallback: try tar on modern Windows (10+)
try {
execSync(`tar -xf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
} catch (e2) {
throw new Error(
`Failed to extract archive. Tried PowerShell Expand-Archive and tar.\n${e2.message}`
);
}
}
} else {
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
}
}
function classifyError(err) {
const code = err.code || (err.cause && err.cause.code) || "";
const messages = {
ENOTFOUND: "DNS 解析失败,请检查网络连接",
ECONNREFUSED: "连接被拒绝,请检查代理设置 (HTTP_PROXY/HTTPS_PROXY)",
ETIMEDOUT: "连接超时,请尝试设置 HTTP_PROXY 环境变量",
EACCES: "权限不足,请使用管理员权限运行或检查安装目录",
EPERM: "权限不足,请使用管理员权限运行或检查安装目录",
};
return messages[code] || null;
}
function verifyChecksum(filePath, expectedHash) {
if (!expectedHash) return true;
const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
if (hash !== expectedHash) {
throw new Error(
`Checksum verification failed for ${path.basename(filePath)}\n` +
` Expected: ${expectedHash}\n` +
` Actual: ${hash}\n` +
`The downloaded file may be corrupted or tampered with.`
);
}
console.log(` Checksum verified: SHA256 OK`);
return true;
}
async function downloadAndExtract(url, destDir, platform, expectedChecksum) {
console.log(`Downloading ${BINARY_NAME} from ${url}...`);
const data = await fetch(url);
@ -192,15 +309,10 @@ async function downloadAndExtract(url, destDir, platform) {
fs.writeFileSync(archivePath, data);
console.log(`Downloaded ${(data.length / 1024 / 1024).toFixed(1)} MB`);
// Extract
if (isWindows) {
execSync(
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${destDir}'"`,
{ stdio: "pipe" }
);
} else {
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
}
// Verify checksum if provided
verifyChecksum(archivePath, expectedChecksum);
extractArchive(archivePath, destDir, platform);
fs.unlinkSync(archivePath);
// Find the binary in extracted files
@ -208,7 +320,6 @@ async function downloadAndExtract(url, destDir, platform) {
const binaryPath = path.join(destDir, binaryName);
if (!fs.existsSync(binaryPath)) {
// It might be in a subdirectory
const files = fs.readdirSync(destDir);
for (const file of files) {
const subPath = path.join(destDir, file, binaryName);
@ -223,7 +334,6 @@ async function downloadAndExtract(url, destDir, platform) {
throw new Error(`Binary "${binaryName}" not found after extraction`);
}
// Make executable (not needed on Windows)
if (!isWindows) {
fs.chmodSync(binaryPath, 0o755);
}
@ -251,22 +361,58 @@ async function main() {
// If binary already exists, check version matches
if (fs.existsSync(binaryPath)) {
try {
const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 });
const output = execSync(`"${binaryPath}" version`, {
encoding: "utf-8",
stdio: "pipe",
timeout: 5000,
});
if (output.includes(VERSION)) {
console.log(`${BINARY_NAME} v${VERSION} already installed, skipping download.`);
console.log(
`${BINARY_NAME} v${VERSION} already installed, skipping download.`
);
return;
}
console.log(`${BINARY_NAME} version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`);
} catch (e) {
console.log(`${BINARY_NAME} binary exists but is not compatible, re-downloading...`);
console.log(
`${BINARY_NAME} version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`
);
} catch {
console.log(
`${BINARY_NAME} binary exists but is not compatible, re-downloading...`
);
}
fs.unlinkSync(binaryPath);
}
const downloadUrl = await findReleaseAsset(platform, arch);
await downloadAndExtract(downloadUrl, binDir, platform);
// Download with retry
let lastError = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await downloadAndExtract(downloadUrl, binDir, platform, null);
lastError = null;
break;
} catch (err) {
lastError = err;
if (attempt < MAX_RETRIES) {
console.log(
`Download attempt ${attempt}/${MAX_RETRIES} failed: ${err.message}`
);
console.log(`Retrying in ${RETRY_DELAY_MS / 1000}s...`);
await sleep(RETRY_DELAY_MS);
}
}
}
if (lastError) {
throw lastError;
}
} catch (err) {
const classifiedMsg = classifyError(err);
console.error(`\nFailed to install ${BINARY_NAME}: ${err.message}`);
if (classifiedMsg) {
console.error(`\n诊断信息: ${classifiedMsg}`);
}
if (platformInfo) {
console.error(`Platform: ${platformInfo.platform}/${platformInfo.arch}`);
}
@ -292,4 +438,6 @@ module.exports = {
getBinaryName,
getArchiveName,
findReleaseAsset,
classifyError,
verifyChecksum,
};

View File

@ -0,0 +1,53 @@
package attachment
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +upload上传文件附件
{
Name: "upload",
Description: "上传文件附件",
Flags: []common.Flag{
{Name: "file", Short: "f", Usage: "Path to the file to upload", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
file, err := ctx.RequireArg("file")
if err != nil {
return err
}
payload := map[string]interface{}{
"file": file,
}
env, err := ctx.CallAPI("POST", "/attachments", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete根据 UUID 删除文件附件
{
Name: "delete",
Description: "根据 UUID 删除文件附件",
Flags: []common.Flag{
{Name: "uuid", Short: "u", Usage: "Attachment UUID to delete", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
uuid, err := ctx.RequireArg("uuid")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/attachments/%s", uuid), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

161
shortcuts/branch/batch.go Normal file
View File

@ -0,0 +1,161 @@
package branch
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func newBatchDeleteShortcut() *common.Shortcut {
return &common.Shortcut{
// +batch-delete批量删除多个分支
Name: "batch-delete",
Description: "批量删除多个分支",
Long: `Delete multiple branches from the current repository in a single operation.
Provide branch names via --names (comma-separated) or --from (plain-text file
with one branch name per line).
This action is irreversible. A confirmation prompt is shown before deletion
unless --yes is set.
Use --dry-run to preview which branches would be deleted without making any
changes.`,
Example: ` # Delete two branches
gitlink branch +batch-delete --names old-feature,deprecated-api
# Dry-run to preview deletions
gitlink branch +batch-delete --names old-feature,deprecated-api --dry-run
# Delete branches listed in a file
gitlink branch +batch-delete --from branches.txt`,
Flags: []common.Flag{
{Name: "names", Short: "n", Usage: "Comma-separated branch names"},
{Name: "from", Usage: "File path (one branch name per line)"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchDelete,
}
}
func newBatchProtectShortcut() *common.Shortcut {
return &common.Shortcut{
// +batch-protect批量为多个分支开启保护
Name: "batch-protect",
Description: "批量为多个分支开启保护",
Long: `Enable branch protection for multiple branches in a single operation.
Provide branch names via --names (comma-separated) or --from (plain-text file
with one branch name per line).
Protected branches restrict force-pushes and deletion.
Use --dry-run to preview which branches would be protected without making any
changes.`,
Example: ` # Protect two branches
gitlink branch +batch-protect --names main,develop
# Dry-run to preview protections
gitlink branch +batch-protect --names main,develop --dry-run
# Protect branches listed in a file
gitlink branch +batch-protect --from branches.txt`,
Flags: []common.Flag{
{Name: "names", Short: "n", Usage: "Comma-separated branch names"},
{Name: "from", Usage: "File path (one branch name per line)"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchProtect,
}
}
// --- batch-delete ---
// [B 类批量] 逐个删除分支——POST /branches/delete不可逆
// 使用 CollectStrings 而非 CollectNumbers分支名是字符串非数字
func runBatchDelete(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
names, err := common.CollectStrings(ctx.Arg("names"), ctx.Arg("from"))
if err != nil {
return err
}
if len(names) == 0 {
return fmt.Errorf("no branch names provided; use --names branch1,branch2 or --from branches.txt")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
if !dryRun {
if err := common.ConfirmAction(
fmt.Sprintf("delete %d branch(es) from %s/%s", len(names), ctx.Owner, ctx.Repo),
); err != nil {
return err
}
}
summary := common.ProcessBatch(names, dryRun, "delete", func(name string) error {
payload := map[string]interface{}{
"branch_name": name,
}
_, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d branch(es) failed to delete", summary.Failed, summary.Total)
}
return nil
}
// --- batch-protect ---
// [B 类批量] 逐个设置分支保护——POST /protected_branches
// 保护后的分支限制 force-push 和删除操作
func runBatchProtect(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
names, err := common.CollectStrings(ctx.Arg("names"), ctx.Arg("from"))
if err != nil {
return err
}
if len(names) == 0 {
return fmt.Errorf("no branch names provided; use --names branch1,branch2 or --from branches.txt")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
summary := common.ProcessBatch(names, dryRun, "protect", func(name string) error {
payload := map[string]interface{}{
"branch_name": name,
}
_, err := ctx.CallAPI("POST", protectedBranchesPath(ctx.RepoPath()), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d branch(es) failed to protect", summary.Failed, summary.Total)
}
return nil
}
// protectedBranchesPath returns the API path for a repo's protected-branches
// collection. Unlike most repository endpoints, protected_branches is served
// under the legacy /api route with NO /v1 prefix — forgeplus defines it as
// /api/:owner/:repo/protected_branches/:branch_name — so RepoPath() is used
// directly. (Empirically the /v1 form returns 404.)
func protectedBranchesPath(repoPath string) string {
return repoPath + "/protected_branches"
}

View File

@ -9,9 +9,23 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +batch-delete批量删除多个分支
newBatchDeleteShortcut(),
// +batch-protect批量为多个分支开启保护
newBatchProtectShortcut(),
// +list列出仓库中的分支
{
Name: "list",
Description: "List branches",
Description: "列出分支",
Long: `List all branches in the current repository.
Shows branch name, protection status, and other details.
Use --page and --limit for pagination.`,
Example: ` # List branches
gitlink branch +list
# Show 50 branches per page
gitlink branch +list --limit 50`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
@ -27,15 +41,31 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
return ctx.Output(env)
// Project onto stable core columns so the table stays readable
// regardless of how many fields the API returns.
branches := summarizeBranches(extractBranches(env.Data))
return ctx.OutputData(map[string]interface{}{
"branches": branches,
"total_count": len(branches),
})
},
},
// +create从指定来源创建新分支
{
Name: "create",
Description: "Create a branch",
Description: "创建分支",
Long: `Create a new branch in the current repository.
The new branch is created from the specified source branch or commit.
If --from is omitted, the repository default branch is used.`,
Example: ` # Create a branch from the default branch
gitlink branch +create --name feature/login
# Create a branch from a specific branch
gitlink branch +create --name hotfix/fix-123 --from main`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"},
{Name: "from", Short: "f", Usage: "Source branch or commit (default: repository default branch)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -43,8 +73,10 @@ func Shortcuts() []*common.Shortcut {
}
name, _ := ctx.RequireArg("name")
from := ctx.Arg("from")
// [P1 Bug#2] 原代码: from = "master"(硬编码)
// 修复: 使用 GetDefaultBranch() 动态查询仓库默认分支
if from == "" {
from = "master"
from = common.GetDefaultBranch(ctx)
}
payload := map[string]interface{}{
"new_branch_name": name,
@ -57,9 +89,15 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +delete删除一个分支
{
Name: "delete",
Description: "Delete a branch",
Description: "删除分支",
Long: `Delete a branch from the current repository.
This action is irreversible. A confirmation prompt is shown before deletion.`,
Example: ` # Delete a branch
gitlink branch +delete --name old-feature`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -68,6 +106,11 @@ func Shortcuts() []*common.Shortcut {
return err
}
name, _ := ctx.RequireArg("name")
if err := common.ConfirmAction(
fmt.Sprintf("delete branch %s", name),
); err != nil {
return err
}
payload := map[string]interface{}{
"branch_name": name,
}
@ -78,9 +121,15 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +protect为分支开启保护
{
Name: "protect",
Description: "Set branch protection",
Description: "开启分支保护",
Long: `Enable branch protection for the specified branch.
Protected branches restrict force-pushes and deletion.`,
Example: ` # Protect the main branch
gitlink branch +protect --name main`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -92,16 +141,22 @@ func Shortcuts() []*common.Shortcut {
payload := map[string]interface{}{
"branch_name": name,
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
env, err := ctx.CallAPI("POST", protectedBranchesPath(ctx.RepoPath()), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +unprotect移除分支保护
{
Name: "unprotect",
Description: "Remove branch protection",
Description: "移除分支保护",
Long: `Remove branch protection from the specified branch.
Once unprotected, the branch can be force-pushed or deleted.`,
Example: ` # Remove protection from a branch
gitlink branch +unprotect --name develop`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -110,7 +165,66 @@ func Shortcuts() []*common.Shortcut {
return err
}
name, _ := ctx.RequireArg("name")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
env, err := ctx.CallAPI("DELETE", protectedBranchesPath(ctx.RepoPath())+"/"+url.PathEscape(name), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +all不分页列出全部分支
{
Name: "all",
Description: "列出全部分支(不分页)",
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)
},
},
// +default修改仓库的默认分支
{
Name: "default",
Description: "修改默认分支",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name to set as default", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{
"default_branch": name,
}
env, err := ctx.CallAPI("PATCH", "/v1"+ctx.RepoPath()+"/branches/update_default_branch", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +restore恢复一个已删除的分支
{
Name: "restore",
Description: "恢复已删除的分支",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name to restore", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{
"branch_name": name,
}
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/restore", payload)
if err != nil {
return err
}

View File

@ -0,0 +1,107 @@
package branch
import (
"fmt"
"strings"
)
// summarizeBranches projects raw branch objects onto a stable set of core
// columns — name / protected / last_commit / commit_time — so the table no
// longer depends on how many fields the API returns or their ordering. Noisy
// fields (URLs, full SHA, etc.) are dropped; they remain available via the
// single-branch detail command.
func summarizeBranches(items []interface{}) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(items))
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
out = append(out, map[string]interface{}{
"name": m["name"],
"protected": m["protected"],
"last_commit": formatLastCommit(m),
"commit_time": formatCommitTimeField(m["commit_time"]),
})
}
return out
}
// formatLastCommit renders the last-commit column as "short_sha · time_ago",
// reading the stable top-level commit_id / commit_time_from_now fields rather
// than the inconsistently-shaped nested last_commit object.
func formatLastCommit(branch map[string]interface{}) string {
sha := ""
if v, ok := branch["commit_id"]; ok && v != nil {
sha = shortSHA(fmt.Sprintf("%v", v))
}
rel := ""
if v, ok := branch["commit_time_from_now"]; ok && v != nil {
rel = strings.TrimSpace(fmt.Sprintf("%v", v))
}
switch {
case sha != "" && rel != "":
return sha + " · " + rel
case sha != "":
return sha
default:
return rel
}
}
// shortSHA returns the first 8 chars of a git SHA, like git's short names.
func shortSHA(s string) string {
s = strings.TrimSpace(s)
if len(s) > 8 {
return s[:8]
}
return s
}
// formatCommitTimeField trims an ISO-8601 timestamp to a readable
// "YYYY-MM-DD HH:MM" form. nil/short values are returned as-is (or "").
func formatCommitTimeField(v interface{}) string {
if v == nil {
return ""
}
s := strings.TrimSpace(fmt.Sprintf("%v", v))
if len(s) < 16 {
return s
}
return strings.Replace(s[:16], "T", " ", 1)
}
// extractBranches pulls the list of branch objects from an API payload that is
// either a top-level array or a map wrapping the list under a key.
func extractBranches(data interface{}) []interface{} {
switch d := data.(type) {
case []interface{}:
return d
case map[string]interface{}:
var best []interface{}
for _, v := range d {
var slice []interface{}
switch sv := v.(type) {
case []interface{}:
slice = sv
case []map[string]interface{}:
for _, x := range sv {
slice = append(slice, x)
}
default:
continue
}
if len(slice) == 0 {
continue
}
if _, ok := slice[0].(map[string]interface{}); !ok {
continue
}
if best == nil || len(slice) > len(best) {
best = slice
}
}
return best
}
return nil
}

View File

@ -0,0 +1,72 @@
package branch
import "testing"
// TestProtectedBranchesPath_NoV1Prefix 验证保护分支接口走的是老 API无 /v1 前缀)。
// 实测 DELETE /v1/.../protected_branches/<name>.json 返回 404forgeplus 路由为
// /api/:owner/:repo/protected_branches/:branch_name无 v1故直接用 RepoPath() 拼接。
func TestProtectedBranchesPath_NoV1Prefix(t *testing.T) {
got := protectedBranchesPath("/owner/repo")
want := "/owner/repo/protected_branches"
if got != want {
t.Errorf("protectedBranchesPath(%q) = %q, want %q (protected_branches is the legacy /api route, no /v1)",
"/owner/repo", got, want)
}
}
// TestSummarizeBranches_ProjectsCoreColumns 验证分支列表被投影到固定的 4 列
// name/protected/last_commit/commit_time且噪声字段URL、完整 SHA 等)被丢弃,
// 不再因字段多少或顺序导致表格不稳定。
func TestSummarizeBranches_ProjectsCoreColumns(t *testing.T) {
branches := []interface{}{
map[string]interface{}{
"name": "main",
"protected": true,
"commit_id": "e21237dc877b21b009c27554c9aa61ec819ea67d",
"commit_time_from_now": "29天前",
"commit_time": "2023-12-05T15:06:05+08:00",
"http_url": "http://example.com/repo.git",
"zip_url": "http://example.com/archive.zip",
},
}
got := summarizeBranches(branches)
if len(got) != 1 {
t.Fatalf("expected 1 row, got %d", len(got))
}
row := got[0]
if row["name"] != "main" {
t.Errorf("name = %v, want main", row["name"])
}
if row["protected"] != true {
t.Errorf("protected = %v, want true", row["protected"])
}
if row["last_commit"] != "e21237dc · 29天前" {
t.Errorf("last_commit = %v, want \"e21237dc · 29天前\"", row["last_commit"])
}
if row["commit_time"] != "2023-12-05 15:06" {
t.Errorf("commit_time = %v, want \"2023-12-05 15:06\"", row["commit_time"])
}
for _, bad := range []string{"http_url", "zip_url", "commit_id", "commit_time_from_now"} {
if _, ok := row[bad]; ok {
t.Errorf("noisy field %q should be dropped from summary", bad)
}
}
}
// TestExtractBranches_FromSliceAndMap 验证从顶层数组或 map 包裹的列表中
// 都能取出分支对象列表。
func TestExtractBranches_FromSliceAndMap(t *testing.T) {
if s := extractBranches([]interface{}{map[string]interface{}{"name": "a"}}); len(s) != 1 {
t.Errorf("top-level slice: expected 1, got %d", len(s))
}
m := extractBranches(map[string]interface{}{
"branches": []interface{}{map[string]interface{}{"name": "a"}, map[string]interface{}{"name": "b"}},
"total_count": 2,
})
if len(m) != 2 {
t.Errorf("map payload: expected 2 branches, got %d", len(m))
}
if extractBranches("not-a-list") != nil {
t.Errorf("expected nil for unsupported payload type")
}
}

View File

@ -9,12 +9,43 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +builds列出 CI 构建记录
{
Name: "builds",
Description: "List CI builds",
Description: "列出 CI 构建记录",
Long: `List CI/CD builds for the current repository.
Shows build number, status, branch, event type, and duration.
Use --status, --branch, --event, --since, --until for filtering,
and --page and --limit for pagination.`,
Example: ` # List recent builds
gitlink ci +builds
# Show 50 builds
gitlink ci +builds --limit 50
# Filter by status
gitlink ci +builds --status success
gitlink ci +builds -s failure
# Filter by branch
gitlink ci +builds --branch main
gitlink ci +builds -b develop
# Filter by trigger event
gitlink ci +builds --event push
gitlink ci +builds --event pull_request
# Filter by time range
gitlink ci +builds --since 1700000000 --until 1700086400`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "status", Short: "s", Usage: "Filter by build status (success, failure, running)"},
{Name: "branch", Short: "b", Usage: "Filter by branch"},
{Name: "event", Usage: "Filter by trigger event (push, pull_request, tag)"},
{Name: "since", Usage: "Start time (UNIX timestamp)"},
{Name: "until", Usage: "End time (UNIX timestamp)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -23,6 +54,21 @@ func Shortcuts() []*common.Shortcut {
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("status"); s != "" {
q.Set("status", s)
}
if b := ctx.Arg("branch"); b != "" {
q.Set("branch", b)
}
if e := ctx.Arg("event"); e != "" {
q.Set("event", e)
}
if s := ctx.Arg("since"); s != "" {
q.Set("since", s)
}
if u := ctx.Arg("until"); u != "" {
q.Set("until", u)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
if err != nil {
return err
@ -30,9 +76,19 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +logs查看构建日志
{
Name: "logs",
Description: "View build logs",
Description: "查看构建日志",
Long: `View logs for a specific CI/CD build step.
Requires the build number. Use --stage and --step to navigate
to a particular stage and step within the build.`,
Example: ` # View logs for build #42
gitlink ci +logs --build 42
# View logs for stage 2, step 3 of build #42
gitlink ci +logs --build 42 --stage 2 --step 3`,
Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true},
{Name: "stage", Short: "s", Usage: "Stage number", Default: "1"},
@ -58,9 +114,15 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +restart重新触发构建
{
Name: "restart",
Description: "Restart a build",
Description: "重新触发构建",
Long: `Restart a previously completed or failed CI/CD build.
The build will be re-triggered with the same commit and configuration.`,
Example: ` # Restart build #42
gitlink ci +restart --build 42`,
Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true},
},
@ -76,9 +138,15 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +stop停止构建
{
Name: "stop",
Description: "Stop a build",
Description: "停止构建",
Long: `Stop a currently running CI/CD build.
Prompts for confirmation before stopping the build.`,
Example: ` # Stop build #42
gitlink ci +stop --build 42`,
Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true},
},
@ -87,6 +155,11 @@ func Shortcuts() []*common.Shortcut {
return err
}
build, _ := ctx.RequireArg("build")
if err := common.ConfirmAction(
fmt.Sprintf("stop build #%s", build),
); err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
if err != nil {
return err
@ -94,5 +167,218 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// --- Pipeline / Actions group ---
// +pipelines列出 CI 流水线模板
{
Name: "pipelines",
Description: "列出 CI 流水线模板",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/pm/pipelines", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +run-pipeline运行 CI 流水线
{
Name: "run-pipeline",
Description: "运行 CI 流水线",
Flags: []common.Flag{
{Name: "pipeline", Short: "p", Usage: "Pipeline name or ID", Required: true},
{Name: "branch", Short: "b", Usage: "Branch to run on"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pipeline, err := ctx.RequireArg("pipeline")
if err != nil {
return err
}
body := map[string]interface{}{
"pipeline": pipeline,
}
if branch := ctx.Arg("branch"); branch != "" {
body["branch"] = branch
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/runs", ctx.RepoPath()), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +runs列出 CI 运行历史
{
Name: "runs",
Description: "列出 CI 运行历史",
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", fmt.Sprintf("/v1%s/actions/runs", ctx.RepoPath()), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +save-yaml保存流水线 YAML 配置
{
Name: "save-yaml",
Description: "保存流水线 YAML 配置",
Flags: []common.Flag{
{Name: "yaml", Short: "y", Usage: "YAML content", Required: true},
{Name: "name", Short: "n", Usage: "Pipeline name"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
yamlContent, err := ctx.RequireArg("yaml")
if err != nil {
return err
}
body := map[string]interface{}{
"yaml": yamlContent,
}
if name := ctx.Arg("name"); name != "" {
body["name"] = name
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/pipelines/save_yaml", ctx.RepoPath()), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +pipeline-detail查看流水线详情
{
Name: "pipeline-detail",
Description: "查看流水线详情",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Pipeline 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("GET", fmt.Sprintf("/v1%s/pipelines/%s", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete-pipeline删除流水线
{
Name: "delete-pipeline",
Description: "删除流水线",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Pipeline 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("DELETE", fmt.Sprintf("/v1%s/pipelines/%s", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +disable为仓库禁用 CI
{
Name: "disable",
Description: "为仓库禁用 CI",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/disable", ctx.RepoPath()), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +enable为仓库启用 CI
{
Name: "enable",
Description: "为仓库启用 CI",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/enable", ctx.RepoPath()), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +run-log获取 CI 运行日志
{
Name: "run-log",
Description: "获取 CI 运行日志",
Flags: []common.Flag{
{Name: "run", Short: "r", Usage: "Run ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
run, err := ctx.RequireArg("run")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/actions/runs/%s/jobs/0", ctx.RepoPath(), run), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +run-results获取 CI 运行结果报告
{
Name: "run-results",
Description: "获取 CI 运行结果报告",
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", fmt.Sprintf("/v1%s/pipelines/run_results", ctx.RepoPath()), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,335 @@
package collaborator
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// userRole 保存用户名和对应角色,支持 CSV 每行指定不同 role
// 这是 collaborator 批量命令特有的需求:不同用户可能分配不同的权限
// 而 --users + --role 方式所有用户同一个 roleCSV 方式每行可不同
type userRole struct {
User string
Role string
}
func newBatchAddShortcut() *common.Shortcut {
return &common.Shortcut{
// +batch-add批量添加多名协作者
Name: "batch-add",
Description: "批量添加多名协作者",
Long: `Add multiple collaborators to the repository in a single operation.
Provide collaborators via --users (comma-separated usernames) or --from (CSV
file with user_id and role columns). When --from supplies a role column that
value is used; otherwise the --role flag (default: write) applies.
Use --dry-run to preview which collaborators would be added without making
any changes.
Available roles: admin, write, read.`,
Example: ` # Add two collaborators with the default write role
gitlink collaborator +batch-add --users zhangsan,lisi
# Add collaborators with admin role
gitlink collaborator +batch-add --users zhangsan,lisi --role admin
# Dry-run to preview additions from a CSV file
gitlink collaborator +batch-add --from collaborators.csv --dry-run
# Combine both sources
gitlink collaborator +batch-add --users zhangsan --from collaborators.csv`,
Flags: []common.Flag{
{Name: "users", Short: "u", Usage: "Comma-separated usernames to add"},
{Name: "from", Usage: "CSV file path (columns: user_id, role)"},
{Name: "role", Short: "r", Usage: "Default role for --users: admin, write, read", Default: "write"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchAdd,
}
}
func newBatchRemoveShortcut() *common.Shortcut {
return &common.Shortcut{
// +batch-remove批量移除多名协作者
Name: "batch-remove",
Description: "批量移除多名协作者",
Long: `Remove multiple collaborators from the repository in a single operation.
Provide collaborators via --users (comma-separated usernames) or --from (CSV
file with a user_id column).
This action is irreversible. A confirmation prompt is shown before removal
unless --yes is set.
Use --dry-run to preview which collaborators would be removed without making
any changes.`,
Example: ` # Remove two collaborators
gitlink collaborator +batch-remove --users zhangsan,lisi
# Dry-run to preview removals
gitlink collaborator +batch-remove --users zhangsan,lisi --dry-run
# Remove collaborators listed in a CSV file
gitlink collaborator +batch-remove --from users.csv`,
Flags: []common.Flag{
{Name: "users", Short: "u", Usage: "Comma-separated usernames to remove"},
{Name: "from", Usage: "CSV file path (column: user_id)"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchRemove,
}
}
func newBatchRoleShortcut() *common.Shortcut {
return &common.Shortcut{
// +batch-role批量修改多名协作者的角色
Name: "batch-role",
Description: "批量修改多名协作者的角色",
Long: `Change the role of multiple collaborators in a single operation.
Provide collaborators via --users (comma-separated usernames) combined with
--role, or via --from (CSV file with user_id and role columns).
Use --dry-run to preview which role changes would be applied without making
any changes.
Available roles: admin, write, read.`,
Example: ` # Change two collaborators to admin
gitlink collaborator +batch-role --users zhangsan,lisi --role admin
# Dry-run to preview role changes from a CSV file
gitlink collaborator +batch-role --from roles.csv --dry-run
# Combine both sources
gitlink collaborator +batch-role --users zhangsan --role read --from roles.csv`,
Flags: []common.Flag{
{Name: "users", Short: "u", Usage: "Comma-separated usernames"},
{Name: "from", Usage: "CSV file path (columns: user_id, role)"},
{Name: "role", Short: "r", Usage: "New role for --users: admin, write, read"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchRole,
}
}
// --- batch-add ---
// [B 类批量] 逐个添加协作者——POST /collaborators
// 支持 --users + --role 统一角色 或 --from CSV 每行不同角色
func runBatchAdd(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pairs, err := collectUserRoles(ctx.Arg("users"), ctx.Arg("from"), ctx.Arg("role"))
if err != nil {
return err
}
if len(pairs) == 0 {
return fmt.Errorf("no users provided; use --users user1,user2 or --from collaborators.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
items := make([]string, 0, len(pairs))
for _, p := range pairs {
items = append(items, p.User)
}
summary := common.ProcessBatch(items, dryRun, "add", func(user string) error {
role := roleForUser(pairs, user)
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
"role_name": role,
},
}
_, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d collaborator(s) failed to add", summary.Failed, summary.Total)
}
return nil
}
// --- batch-remove ---
// [B 类批量] 逐个移除协作者——DELETE /collaborators/remove不可逆
func runBatchRemove(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
users, err := collectUsers(ctx.Arg("users"), ctx.Arg("from"))
if err != nil {
return err
}
if len(users) == 0 {
return fmt.Errorf("no users provided; use --users user1,user2 or --from users.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
if !dryRun {
if err := common.ConfirmAction(
fmt.Sprintf("remove %d collaborator(s) from %s/%s", len(users), ctx.Owner, ctx.Repo),
); err != nil {
return err
}
}
summary := common.ProcessBatch(users, dryRun, "remove", func(user string) error {
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
},
}
_, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", ctx.Owner, ctx.Repo), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d collaborator(s) failed to remove", summary.Failed, summary.Total)
}
return nil
}
// --- batch-role ---
// [B 类批量] 逐个修改协作者角色——PUT /collaborators/change_role
func runBatchRole(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
usersValue := ctx.Arg("users")
fromValue := ctx.Arg("from")
roleValue := ctx.Arg("role")
// When --users is used without --from, --role is required.
if usersValue != "" && fromValue == "" && roleValue == "" {
return fmt.Errorf("--role is required when using --users without --from")
}
pairs, err := collectUserRoles(usersValue, fromValue, roleValue)
if err != nil {
return err
}
if len(pairs) == 0 {
return fmt.Errorf("no users provided; use --users user1,user2 --role admin or --from roles.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
items := make([]string, 0, len(pairs))
for _, p := range pairs {
items = append(items, p.User)
}
// Resolve all logins to numeric ids once via the collaborators list.
memberEnv, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
memberData, _ := memberEnv.Data.(map[string]interface{})
members, _ := memberData["members"].([]interface{})
summary := common.ProcessBatch(items, dryRun, "change-role", func(user string) error {
role := roleForUser(pairs, user)
id, found := findUserIDByLogin(members, user)
if !found {
return fmt.Errorf("collaborator %q not found", user)
}
payload := map[string]interface{}{
"user_id": id,
"role": mapRole(role),
}
_, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/collaborators/change_role", ctx.Owner, ctx.Repo), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d collaborator(s) failed to change role", summary.Failed, summary.Total)
}
return nil
}
// --- helpers ---
// collectUserRoles 收集用户-角色对(支持 --users + --role 和 --from CSV 两种输入)
// 与 CollectStrings 不同,此函数需要同时处理 {用户, 角色} 成对数据
// --from CSV 要求有 user_id 和 role 两列,每行可指定不同角色
func collectUserRoles(usersValue, fromPath, defaultRole string) ([]userRole, error) {
var pairs []userRole
// From --users flag
for _, u := range common.ParseStringList(usersValue) {
pairs = append(pairs, userRole{User: u, Role: defaultRole})
}
// From CSV
if fromPath != "" {
rows, err := common.ReadRowsFromCSV(fromPath, []string{"user_id", "role"})
if err != nil {
return nil, err
}
for _, row := range rows {
role := row["role"]
if role == "" {
role = defaultRole
}
pairs = append(pairs, userRole{User: row["user_id"], Role: role})
}
}
// Deduplicate by user
seen := map[string]bool{}
deduped := make([]userRole, 0, len(pairs))
for _, p := range pairs {
if seen[p.User] {
continue
}
seen[p.User] = true
deduped = append(deduped, p)
}
return deduped, nil
}
// collectUsers 收集用户名(仅用于 batch-remove不需要角色信息
func collectUsers(usersValue, fromPath string) ([]string, error) {
users := common.ParseStringList(usersValue)
if fromPath != "" {
csvUsers, err := common.ReadColumnFromCSV(fromPath, []string{"user_id"})
if err != nil {
return nil, err
}
users = append(users, csvUsers...)
}
return common.DedupeStrings(users), nil
}
// roleForUser 在 userRole 列表中查找指定用户的角色(线性查找)
func roleForUser(pairs []userRole, user string) string {
for _, p := range pairs {
if p.User == user {
return p.Role
}
}
return "write"
}

View File

@ -0,0 +1,157 @@
package collaborator
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return append([]*common.Shortcut{
// +list列出仓库的协作者
{
Name: "list",
Description: "列出仓库协作者",
Long: `List repository collaborators.
Shows all users who have access to the repository along with their
username, role, and permission level.`,
Example: ` # List collaborators of the current repository
gitlink collaborator +list`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +add向仓库添加一名协作者
{
Name: "add",
Description: "添加仓库协作者",
Long: `Add a collaborator to the repository.
Invites a user to collaborate on the repository with the specified role.
Available roles: admin, write (default), read.`,
Example: ` # Add a collaborator with write access
gitlink collaborator +add -u zhangsan -r write
# Add a collaborator with admin access
gitlink collaborator +add -u zhangsan -r admin`,
Flags: []common.Flag{
{Name: "user", Short: "u", Usage: "Username to add", Required: true},
{Name: "role", Short: "r", Usage: "Role: admin or write or read", Default: "write"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
user, err := ctx.RequireArg("user")
if err != nil {
return err
}
role := ctx.Arg("role")
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
"role_name": role,
},
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +remove从仓库移除一名协作者
{
Name: "remove",
Description: "移除仓库协作者",
Long: `Remove a collaborator from the repository.
Revokes the user's access to the repository.`,
Example: ` # Remove a collaborator
gitlink collaborator +remove -u zhangsan`,
Flags: []common.Flag{
{Name: "user", Short: "u", Usage: "Username to remove", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
user, err := ctx.RequireArg("user")
if err != nil {
return err
}
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
},
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +change-role修改协作者的角色
{
Name: "change-role",
Description: "修改协作者角色",
Long: `Change a collaborator's role.
Updates the permission level of an existing collaborator.
Available roles: admin, write, read.`,
Example: ` # Change a collaborator to admin role
gitlink collaborator +change-role -u zhangsan -r admin
# Change a collaborator to read-only access
gitlink collaborator +change-role -u zhangsan -r read`,
Flags: []common.Flag{
{Name: "user", Short: "u", Usage: "Username", Required: true},
{Name: "role", Short: "r", Usage: "New role: admin or write or read", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
user, err := ctx.RequireArg("user")
if err != nil {
return err
}
role, err := ctx.RequireArg("role")
if err != nil {
return err
}
// forgeplus change_role expects top-level {user_id: <int>, role: "Manager"}.
// Resolve the username to its numeric id via the collaborators list.
userID, err := resolveUserID(ctx, user)
if err != nil {
return err
}
payload := map[string]interface{}{
"user_id": userID,
"role": mapRole(role),
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/collaborators/change_role", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
},
// +batch-add批量添加多名协作者
newBatchAddShortcut(),
// +batch-remove批量移除多名协作者
newBatchRemoveShortcut(),
// +batch-role批量修改多名协作者的角色
newBatchRoleShortcut(),
)
}

View File

@ -0,0 +1,69 @@
package collaborator
import (
"fmt"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// mapRole maps friendly role names (admin/write/read) to forgeplus's role enum
// (Manager/Developer/Reporter). Values already in enum form pass through
// unchanged; matching is case-insensitive.
func mapRole(r string) string {
switch strings.ToLower(strings.TrimSpace(r)) {
case "admin", "manager":
return "Manager"
case "write", "developer":
return "Developer"
case "read", "reporter":
return "Reporter"
default:
return r
}
}
// findUserIDByLogin scans the members list returned by GET /collaborators and
// returns the numeric user_id for the given login. forgeplus's change_role
// endpoint requires an integer id, but the CLI accepts a username (--user), so
// the id must be resolved first.
func findUserIDByLogin(members []interface{}, login string) (int, bool) {
for _, m := range members {
mm, ok := m.(map[string]interface{})
if !ok {
continue
}
l, _ := mm["login"].(string)
if l != login {
continue
}
switch id := mm["id"].(type) {
case float64: // JSON numbers decode to float64
return int(id), true
case float32:
return int(id), true
case int:
return id, true
}
}
return 0, false
}
// resolveUserID looks up a collaborator's numeric id by login name via the
// collaborators list API. Required because change_role expects an integer id.
func resolveUserID(ctx *common.RuntimeContext, login string) (int, error) {
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), nil)
if err != nil {
return 0, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("cannot read collaborators list for %s/%s", ctx.Owner, ctx.Repo)
}
members, _ := data["members"].([]interface{})
id, found := findUserIDByLogin(members, login)
if !found {
return 0, fmt.Errorf("collaborator %q not found in %s/%s", login, ctx.Owner, ctx.Repo)
}
return id, nil
}

View File

@ -0,0 +1,40 @@
package collaborator
import "testing"
// TestMapRole 验证友好角色名映射到 forgeplus 的 Manager/Developer/Reporter
// 且直接传入枚举值或大小写不同也能正确处理。
func TestMapRole(t *testing.T) {
cases := []struct{ in, want string }{
{"admin", "Manager"},
{"write", "Developer"},
{"read", "Reporter"},
{"Manager", "Manager"},
{"ADMIN", "Manager"},
{"developer", "Developer"},
}
for _, c := range cases {
if got := mapRole(c.in); got != c.want {
t.Errorf("mapRole(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestFindUserIDByLogin 验证从协作者列表按 login 解析数字 user_id。
// change_role 接口的 user_id 要整数 ID而 CLI --user 收的是用户名。
func TestFindUserIDByLogin(t *testing.T) {
members := []interface{}{
map[string]interface{}{"login": "z2_cc", "id": float64(148913)},
map[string]interface{}{"login": "wqer", "id": float64(148900)},
}
id, ok := findUserIDByLogin(members, "wqer")
if !ok {
t.Fatal("expected to find wqer")
}
if id != 148900 {
t.Errorf("wqer id = %d, want 148900", id)
}
if _, ok := findUserIDByLogin(members, "ghost"); ok {
t.Error("expected not found for missing user")
}
}

172
shortcuts/commit/commit.go Normal file
View File

@ -0,0 +1,172 @@
package commit
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出仓库提交记录
{
Name: "list",
Description: "列出仓库提交记录",
Long: `List repository commits.
Shows commit SHA, author, message, and date.
Use --page and --limit for pagination.
Filter by path, date range, or branch with respective flags.`,
Example: ` # List recent commits
gitlink commit +list
# List commits on a specific branch
gitlink commit +list --sha develop
# List commits touching a specific file
gitlink commit +list --path src/main.go`,
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Branch or SHA to list commits from"},
{Name: "path", Short: "p", Usage: "Filter commits by file path"},
{Name: "page", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "since", Usage: "Only commits after this date (ISO 8601)"},
{Name: "until", Usage: "Only commits before this date (ISO 8601)"},
},
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)
env, err := ctx.CallAPIWithQuery("GET", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +view查看提交详情含变更文件
{
Name: "view",
Description: "查看提交详情(含变更文件)",
Long: `View commit details including changed files.
Shows the full commit information and the list of files changed
in the specified commit.`,
Example: ` # View a commit
gitlink commit +view --sha abc123def456`,
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +diff查看提交的 diff 内容
{
Name: "diff",
Description: "查看提交的 diff 内容",
Long: `View the diff of a commit.
Returns the patch/diff content for the specified commit,
showing exact line-level additions and deletions.`,
Example: ` # View the diff of a commit
gitlink commit +diff --sha abc123def456`,
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +blame获取文件的逐行提交追溯信息
{
Name: "blame",
Description: "获取文件的逐行提交追溯信息",
Long: `Get blame information for a file.
Shows which commit and author last modified each line of a file.
Use --page and --limit for pagination.`,
Example: ` # Blame a file on the default branch
gitlink commit +blame --path src/main.go
# Blame a file at a specific commit
gitlink commit +blame --path src/main.go --sha abc123def`,
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "sha", Short: "s", Usage: "Commit SHA or branch"},
{Name: "page", 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
}
apiPath := fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery("GET", apiPath, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +compare比较两个引用分支、标签或 SHA
{
Name: "compare",
Description: "比较两个引用(分支、标签或 SHA",
Long: `Compare two refs (branches, tags, or SHAs).
Returns the commits and file changes between a base and head ref.
Both --base and --head are required.`,
Example: ` # Compare two branches
gitlink commit +compare --base main --head develop
# Compare two commit SHAs
gitlink commit +compare --base abc123 --head def456`,
Flags: []common.Flag{
{Name: "head", Short: "H", Usage: "Head ref", Required: true},
{Name: "base", Short: "B", Usage: "Base ref", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
head, err := ctx.RequireArg("head")
if err != nil {
return err
}
base, err := ctx.RequireArg("base")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/compare/%s...%s", ctx.Owner, ctx.Repo, base, head), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

304
shortcuts/common/batch.go Normal file
View File

@ -0,0 +1,304 @@
package common
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
)
// --- Result structures 批量操作输出结构 ---
// BatchResult 记录批量操作中单个项目的处理结果
type BatchResult struct {
Item string `json:"item" yaml:"item"` // 项目标识issue/PR 编号、分支名、用户名等)
Action string `json:"action" yaml:"action"` // 操作类型(如 "close"、"delete"、"add"
Status string `json:"status" yaml:"status"` // 状态:"planned"(dry-run)、"ok"(成功)、"failed"(失败)
Error string `json:"error,omitempty" yaml:"error,omitempty"` // 失败原因
}
// BatchSummary 是所有批量命令的统一输出格式
// 无论操作哪种资源,输出结构都一致,方便脚本处理
type BatchSummary struct {
Repository string `json:"repository" yaml:"repository"` // 仓库路径 owner/repo
DryRun bool `json:"dry_run" yaml:"dry_run"` // 是否为预览模式
Total int `json:"total" yaml:"total"` // 总数
Succeeded int `json:"succeeded" yaml:"succeeded"` // 成功数
Failed int `json:"failed" yaml:"failed"` // 失败数
Results []BatchResult `json:"results" yaml:"results"` // 每个项目的详细结果
}
// --- Batch processing loop 核心处理循环 ---
// ProcessBatch 是批处理的"发动机"——对每项执行操作,支持 dry-run错误不中断
// items: 待处理项列表(如 ["1","2","3"]
// dryRun: true 时只展示计划,不执行任何 API 调用
// action: 操作名称(如 "close"、"delete"),会填入 BatchResult.Action
// fn: 业务逻辑函数(回调/控制反转模式),调用方定义"对单个项做什么操作"
// 返回值: BatchSummary 包含每项详细结果
func ProcessBatch(items []string, dryRun bool, action string, fn func(string) error) *BatchSummary {
summary := &BatchSummary{
DryRun: dryRun,
Total: len(items),
Results: make([]BatchResult, 0, len(items)),
}
// 逐项处理dry-run 模式只标记不执行;否则调用 fn 执行实际操作
for _, item := range items {
r := BatchResult{Item: item, Action: action}
if dryRun {
r.Status = "planned" // 预览模式:不调 API仅标记
summary.Succeeded++
} else if err := fn(item); err != nil {
r.Status = "failed" // 执行失败:记录错误,继续下一项
r.Error = err.Error()
summary.Failed++
} else {
r.Status = "ok" // 执行成功
summary.Succeeded++
}
// 单项失败不中断循环——所有项都会处理完,结果全在 summary 中
summary.Results = append(summary.Results, r)
}
return summary
}
// --- Input collection ---
// CollectNumbers 收集整数标识符issue/PR 编号),支持两种输入方式合并去重
// numbersValue: 来自 --numbers 参数(逗号分隔,如 "1,2,3"
// csvPath: 来自 --from 参数CSV 文件路径)
// 两种方式可组合使用CollectNumbers("1,2", "data.csv") → ["1","2","5","6"]
func CollectNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := ParseNumberList(numbersValue)
if err != nil {
return nil, err
}
if csvPath == "" {
return numbers, nil
}
csvNumbers, err := ReadColumnFromCSV(csvPath, []string{"number", "issue_number", "project_issues_index"})
if err != nil {
return nil, err
}
return DedupeStrings(append(numbers, csvNumbers...)), nil
}
// CollectStrings 收集字符串标识符(分支名、用户名等),支持 --names 和 --from 文件合并去重
// 与 CollectNumbers 不同,此类标识符不需要是整数
// 文件格式:每行一个名称(纯文本,不是 CSV
func CollectStrings(namesValue, filePath string) ([]string, error) {
names := ParseStringList(namesValue)
if filePath == "" {
return names, nil
}
fileNames, err := ReadLinesFromFile(filePath)
if err != nil {
return nil, err
}
return DedupeStrings(append(names, fileNames...)), nil
}
// --- Parsing helpers 解析辅助函数 ---
// ParseNumberList 解析逗号分隔数字列表 → 校验每个值都是整数 + 去重
// 输入 "7,8,9" → ["7","8","9"];空字符串 → nil合法空值
// 输入 "7,abc" → 报错 "invalid number \"abc\": must be an integer"
func ParseNumberList(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return NormalizeIntStrings(strings.Split(value, ","))
}
// ParseStringList 解析逗号分隔字符串列表,自动去除空格和空值
// 输入 "branch1, branch2, " → ["branch1","branch2"]
// 注意:此函数不做整数校验,适用于分支名/用户名等非数字标识符
func ParseStringList(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}
// --- CSV / file reading ---
// ReadColumnFromCSV 从 CSV 文件中读取单列数据(支持表头自动检测)
// columnNames: 可接受的列名列表,按优先级排序
// 列名检测逻辑:依次匹配 number / issue_number / project_issues_index
// 都不匹配则默认使用第一列有表头从第2行读无表头从第1行读
func ReadColumnFromCSV(path string, columnNames []string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV %s: %w", path, err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV %s: %w", path, err)
}
if len(records) == 0 {
return nil, nil
}
column := -1
startRow := 0
for i, cell := range records[0] {
for _, name := range columnNames {
if strings.EqualFold(strings.TrimSpace(cell), name) {
column = i
startRow = 1
break
}
}
if column >= 0 {
break
}
}
if column < 0 {
column = 0
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if column >= len(record) {
continue
}
v := strings.TrimSpace(record[column])
if v != "" {
values = append(values, v)
}
}
return values, nil
}
// ReadRowsFromCSV 从 CSV 文件读取多列数据,返回 [{col:val, ...}, ...]
// 适用于需要同时读取多列的批量操作,如 collaborator 需要 user_id + role 两列
// requiredCols: 需要的列名列表,函数会校验这些列是否都存在,不存在则报错
func ReadRowsFromCSV(path string, requiredCols []string) ([]map[string]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV %s: %w", path, err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV %s: %w", path, err)
}
if len(records) == 0 {
return nil, nil
}
// 步骤1从表头行构建列名→列索引的映射表
header := records[0]
colIndex := make(map[string]int)
for i, cell := range header {
normalized := strings.ToLower(strings.TrimSpace(cell))
for _, req := range requiredCols {
if normalized == req {
colIndex[req] = i // 记录该列在第几列
}
}
}
// 步骤2校验所有必需的列是否都在表头中找到缺列直接报错
for _, req := range requiredCols {
if _, ok := colIndex[req]; !ok {
return nil, fmt.Errorf("CSV missing required column %q; found headers: %v", req, header)
}
}
// 步骤3逐行读取数据按列名取值
rows := make([]map[string]string, 0, len(records)-1)
for _, record := range records[1:] { // 跳过表头行
row := make(map[string]string, len(requiredCols))
for _, req := range requiredCols {
idx := colIndex[req]
if idx < len(record) {
row[req] = strings.TrimSpace(record[idx])
}
}
rows = append(rows, row)
}
return rows, nil
}
// ReadLinesFromFile 从纯文本文件逐行读取非CSV自动忽略空行
// 用于支持 --from 参数传入分支名、用户名列表(每行一个)
func ReadLinesFromFile(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file %s: %w", path, err)
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
result := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result, nil
}
// --- Validation & deduplication ---
// NormalizeIntStrings 校验每个值都是合法整数 + 去重,批处理的输入"安检"
// 输入 ["7","8","8","abc"] → 报错("abc" 不是整数)
// 输入 ["7","8","8","9"] → ["7","8","9"](重复的 "8" 被去掉)
func NormalizeIntStrings(values []string) ([]string, error) {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue // 忽略空字符串
}
// 核心校验strconv.ParseInt 尝试将字符串解析为 64 位整数
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
return nil, fmt.Errorf("invalid number %q: must be an integer", v)
}
if seen[v] {
continue // 重复值跳过
}
seen[v] = true
result = append(result, v)
}
return result, nil
}
// DedupeStrings 字符串去重(保持原始顺序,保留第一个出现的)
func DedupeStrings(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, v := range values {
if seen[v] {
continue
}
seen[v] = true
result = append(result, v)
}
return result
}
// --- Utility ---
// ParseBool 解析布尔字符串,用于处理 --prerelease 等布尔参数
func ParseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
}

View File

@ -0,0 +1,35 @@
package common
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
)
// ConfirmAction 在破坏性操作前弹出确认提示,防止误操作
// 检查流程: ① 全局 --yes 参数(用于自动化脚本,跳过确认)
// ② 否则打印 "Are you sure... [y/N]:" 等待用户输入
// ③ 只有 y/yes 才继续,其他全部取消
// 覆盖 4 个单操作repo/branch/release delete, ci stop+ 4 个批量命令
func ConfirmAction(action string) error {
// 第①步:检查全局 --yes 标志,为 true 则跳过确认
if cmdutil.AutoConfirm {
return nil
}
// 第②步:打印提示并读取键盘输入
fmt.Printf("Are you sure you want to %s? [y/N]: ", action)
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("operation cancelled")
}
// 第③步:规范化输入,只接受 y/yes
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
return fmt.Errorf("operation cancelled")
}
return nil
}

View File

@ -34,6 +34,12 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
return s.Run(ctx)
},
}
if s.Long != "" {
cmd.Long = s.Long
}
if s.Example != "" {
cmd.Example = s.Example
}
for _, f := range s.Flags {
if f.Bool {

View File

@ -15,6 +15,8 @@ import (
type Shortcut struct {
Name string
Description string
Long string
Example string
Flags []Flag
Run func(ctx *RuntimeContext) error
}
@ -117,3 +119,29 @@ func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
}
return v, nil
}
// [P1 Bug#2 修复] 原代码在 branch +create 和 release +create 中硬编码 "master" 作为默认分支
// 当仓库默认分支是 "main" 时会出错。此函数动态查询仓库的默认分支,失败时回退到 "master"
// 设计模式:动态查询 + 静态兜底graceful degradation
func GetDefaultBranch(ctx *RuntimeContext) string {
if ctx.Owner == "" || ctx.Repo == "" {
return "master" // owner/repo 未设置,直接回退
}
// 调用 GET /{owner}/{repo} API 获取仓库元信息
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return "master" // API 调用失败(网络错误等),回退
}
// 将返回数据断言为 map[string]interface{}JSON 对象)
data, ok := env.Data.(map[string]interface{})
if !ok {
return "master" // 返回格式异常,回退
}
// 依次尝试两种可能的字段名GitLink API 有时返回 snake_case有时 camelCase
for _, key := range []string{"default_branch", "defaultBranch"} {
if db, ok := data[key].(string); ok && db != "" {
return db // 成功获取默认分支名
}
}
return "master" // 字段不存在或为空,回退
}

View File

@ -0,0 +1,123 @@
package dataset
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +create在仓库中创建数据集
{
Name: "create",
Description: "在仓库中创建数据集",
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Dataset title", Required: true},
{Name: "description", Short: "d", Usage: "Dataset description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title")
if err != nil {
return err
}
payload := map[string]interface{}{
"title": title,
}
if desc := ctx.Arg("description"); desc != "" {
payload["description"] = desc
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update更新仓库中的数据集
{
Name: "update",
Description: "更新仓库中的数据集",
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "New dataset title"},
{Name: "description", Short: "d", Usage: "New dataset description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title := ctx.Arg("title")
description := ctx.Arg("description")
if title == "" && description == "" {
return fmt.Errorf("at least one of --title or --description is required")
}
payload := map[string]interface{}{}
if title != "" {
payload["title"] = title
}
if description != "" {
payload["description"] = description
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +view查看仓库的数据集详情
{
Name: "view",
Description: "查看仓库的数据集详情",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +list列出项目数据集
{
Name: "list",
Description: "列出项目数据集",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{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
}
path := fmt.Sprintf("/v1/%s/%s/dataset", ctx.Owner, ctx.Repo)
q := buildQuery(ctx)
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func buildQuery(ctx *common.RuntimeContext) map[string][]string {
q := map[string][]string{}
if v := ctx.Arg("keyword"); v != "" {
q["keyword"] = []string{v}
}
if v := ctx.Arg("page"); v != "" {
q["page"] = []string{v}
}
if v := ctx.Arg("limit"); v != "" {
q["limit"] = []string{v}
}
return q
}

490
shortcuts/file/file.go Normal file
View File

@ -0,0 +1,490 @@
package file
import (
"encoding/base64"
"encoding/json"
"fmt"
"path"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +get获取仓库文件内容
{
Name: "get",
Description: "获取仓库文件内容",
Long: `Get file contents from the repository.
Retrieves the content and metadata of a file at the specified path.
Optionally specify a branch, tag, or commit SHA with --ref.`,
Example: ` # Get file contents on the default branch
gitlink file +get --path README.md
# Get file contents on a specific branch
gitlink file +get --path src/main.go --ref develop`,
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
// GitLink has no "get raw file content by path" endpoint. The
// legacy /files endpoint returned the whole repo listing, and
// /v1/contents/{path} falls through to the web router (HTML).
// Use /entries?filepath=<dir>&ref= to fetch the file's metadata
// (name/path/sha/size); to read raw content, follow up with
// `file +blob --sha <sha>`.
dir := pathDir(filePath)
base := pathBase(filePath)
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/%s/%s/entries", ctx.Owner, ctx.Repo), buildQuery(map[string]string{
"filepath": dir,
"ref": ctx.Arg("ref"),
}))
if err != nil {
return err
}
// Pick the requested file out of the directory listing.
if entry := findEntryByName(env, base); entry != nil {
return ctx.OutputData(entry)
}
return fmt.Errorf("文件 %q 不存在", filePath)
},
},
// +readme获取仓库 README
{
Name: "readme",
Description: "获取仓库 README",
Long: `Get the repository README.
Fetches the README file of the current repository.`,
Example: ` # Show the repository README
gitlink file +readme`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/readme", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +create在仓库中新建文件
{
Name: "create",
Description: "在仓库中新建文件",
Long: `Create a new file in the repository.
Creates a file at the given path with the provided content.
The content is automatically base64-encoded before submission.
A commit message is required.`,
Example: ` # Create a new file
gitlink file +create --path docs/guide.md --content "# Guide" --message "add guide"
# Create a file on a specific branch
gitlink file +create --path config.yaml --content "key: value" --message "add config" --branch develop`,
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
{Name: "content", Short: "c", Usage: "File content (plain text, will be base64-encoded)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch (defaults to default branch)"},
{Name: "new-branch", Usage: "Create a new branch with this name (use when --branch is protected)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
// The API requires either branch or new_branch; without one it
// returns "branch和new_branch必须存在一个".
branch := ctx.Arg("branch")
newBranch := ctx.Arg("new-branch")
if branch == "" && newBranch == "" {
return fmt.Errorf("必须指定 --branch已有分支或 --new-branch新建分支目标分支为保护分支时请用 --new-branch")
}
payload := map[string]interface{}{
"filepath": filePath,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
}
if branch != "" {
payload["branch"] = branch
}
if newBranch != "" {
payload["new_branch"] = newBranch
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/create_file", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update更新仓库中的已有文件
{
Name: "update",
Description: "更新仓库中的已有文件",
Long: `Update an existing file in the repository.
Replaces the content of a file at the given path.
The new content is automatically base64-encoded before submission.
Provide --sha to detect conflicting mid-air edits.`,
Example: ` # Update a file
gitlink file +update --path docs/guide.md --content "# Updated Guide" --message "update guide"
# Update with conflict detection
gitlink file +update --path config.yaml --content "key: new" --message "update config" --sha abc123`,
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path in the repository", Required: true},
{Name: "content", Short: "c", Usage: "New file content (plain text, will be base64-encoded)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch (defaults to default branch)"},
{Name: "new-branch", Usage: "Create a new branch with this name (use when --branch is protected)"},
{Name: "sha", Usage: "Original file SHA (required by the API for conflict detection)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
// API requires sha (and branch or new_branch).
sha := ctx.Arg("sha")
branch := ctx.Arg("branch")
newBranch := ctx.Arg("new-branch")
if sha == "" {
return fmt.Errorf("必须指定 --sha原文件 SHA可用 file +entries 获取")
}
if branch == "" && newBranch == "" {
return fmt.Errorf("必须指定 --branch 或 --new-branch目标分支为保护分支时请用 --new-branch")
}
payload := map[string]interface{}{
"filepath": filePath,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
"sha": sha,
}
if branch != "" {
payload["branch"] = branch
}
if newBranch != "" {
payload["new_branch"] = newBranch
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/update_file", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete删除仓库中的文件
{
Name: "delete",
Description: "删除仓库中的文件",
Long: `Delete a file from the repository.
Removes the file at the given path from the repository.
A commit message is required.
Provide --sha to detect conflicting mid-air edits.`,
Example: ` # Delete a file
gitlink file +delete --path docs/old.md --message "remove old docs"
# Delete with conflict detection
gitlink file +delete --path config.yaml --message "remove config" --sha abc123`,
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path to delete", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch (defaults to default branch)"},
{Name: "new-branch", Usage: "Create a new branch with this name (use when --branch is protected)"},
{Name: "sha", Usage: "File SHA (required by the API for conflict detection)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
// API requires sha (and branch or new_branch).
sha := ctx.Arg("sha")
branch := ctx.Arg("branch")
newBranch := ctx.Arg("new-branch")
if sha == "" {
return fmt.Errorf("必须指定 --sha文件 SHA可用 file +entries 获取")
}
if branch == "" && newBranch == "" {
return fmt.Errorf("必须指定 --branch 或 --new-branch目标分支为保护分支时请用 --new-branch")
}
payload := map[string]interface{}{
"filepath": filePath,
"message": message,
"sha": sha,
}
if branch != "" {
payload["branch"] = branch
}
if newBranch != "" {
payload["new_branch"] = newBranch
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/delete_file", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +tree获取指定 SHA 的 git 树对象(顶层目录)
// 注意GitLink 的 /git/trees/:sha 端点不支持 recursive 参数(实测无论传
// true/false 都报"Recursive不包含于列表中"),故只返回顶层条目。
// 要遍历子目录,请用 file +entries --path <子目录> 逐层展开。
{
Name: "tree",
Description: "获取指定 SHA 的 git 树对象(顶层目录)",
Long: `Get the top-level git tree for a given SHA (branch, tag, or commit).
Returns the directory/file listing at the repository root for that ref.
GitLink's /git/trees endpoint does not support recursive listing; to
descend into a subdirectory use 'file +entries --path <dir>'.`,
Example: ` # List top-level entries on master
gitlink file +tree --sha master
# List entries at a specific commit
gitlink file +tree --sha 7947d4dfd67bbf64265dd89465dfe9ce6a92bf83`,
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Branch name, tag, or commit SHA", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
apiPath := fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha)
env, err := ctx.CallAPIWithQuery("GET", apiPath, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +blob根据 SHA 获取 git blob 内容
{
Name: "blob",
Description: "根据 SHA 获取 git blob 内容",
Long: `Get a git blob by SHA.
Returns the raw blob content for the specified SHA.`,
Example: ` # Get a git blob
gitlink file +blob --sha abc123def456`,
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Blob SHA", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/git/blobs/%s", ctx.Owner, ctx.Repo, sha), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +batch在单次提交中批量创建或更新多个文件
{
Name: "batch",
Description: "在单次提交中批量创建或更新多个文件",
Flags: []common.Flag{
{Name: "files", Short: "f", Usage: "JSON array of file objects", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filesStr, err := ctx.RequireArg("files")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
var files []interface{}
if err := json.Unmarshal([]byte(filesStr), &files); err != nil {
return fmt.Errorf("invalid JSON for --files: %w", err)
}
payload := map[string]interface{}{
"files": files,
"message": message,
}
if branch := ctx.Arg("branch"); branch != "" {
payload["branch"] = branch
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/contents/batch", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +entries获取仓库目录列表
{
Name: "entries",
Description: "获取仓库目录列表",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "Directory path"},
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
apiPath := fmt.Sprintf("/%s/%s/entries", ctx.Owner, ctx.Repo)
query := buildQuery(map[string]string{
"path": ctx.Arg("path"),
"ref": ctx.Arg("ref"),
})
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +sub-entries获取仓库子目录列表
{
Name: "sub-entries",
Description: "获取仓库子目录列表",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "Directory path"},
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
apiPath := fmt.Sprintf("/%s/%s/sub_entries", ctx.Owner, ctx.Repo)
query := buildQuery(map[string]string{
"path": ctx.Arg("path"),
"ref": ctx.Arg("ref"),
})
env, err := ctx.CallAPIWithQuery("GET", apiPath, query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// 注:原 +replace 命令已移除。其端点 /replace_file 返回"接口方法异常"
//(端点废弃),且功能与 +create新文件/ +update已有文件完全重复。
// 如需替换文件内容,请用 file +update。
}
}
func buildQuery(params map[string]string) map[string][]string {
query := map[string][]string{}
for k, v := range params {
if v != "" {
if v == "true" {
query[k] = []string{"true"}
} else {
query[k] = []string{v}
}
}
}
return query
}
// pathDir returns the directory portion of a file path ("a/b/c.md" -> "a/b",
// "c.md" -> ""). Unlike path.Dir it never returns "." for a bare filename.
func pathDir(p string) string {
dir := path.Dir(p)
if dir == "." || dir == "/" {
return ""
}
return dir
}
// pathBase returns the final element of a path ("a/b/c.md" -> "c.md").
func pathBase(p string) string {
return path.Base(p)
}
// findEntryByName scans an /entries envelope for an entry whose name matches.
// Used by file +get to extract a single file's metadata from a directory listing.
func findEntryByName(env *output.Envelope, name string) map[string]interface{} {
if env == nil || env.Data == nil {
return nil
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil
}
entries, ok := data["entries"].([]interface{})
if !ok {
return nil
}
for _, e := range entries {
entry, ok := e.(map[string]interface{})
if !ok {
continue
}
if n, _ := entry["name"].(string); n == name {
return entry
}
// Some payloads key the filename under "path".
if p, _ := entry["path"].(string); p != "" && pathBase(p) == name {
return entry
}
}
return nil
}
// keep strings import used (pathBase fallback path uses path, but strings is
// referenced implicitly nowhere — guard against unused import if helpers shrink).
var _ = strings.TrimSpace

View File

@ -1,37 +1,95 @@
package issue
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const closedIssueStatusID = 5
type batchCloseResult struct {
Number string `json:"number" yaml:"number"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
// ---------------------------------------------------------------------------
// Shared: resolve database IDs from user-facing issue numbers
// ---------------------------------------------------------------------------
// resolveIssueDBIDs 将用户可见的项目编号映射为数据库主键 ID
// 用户输入的是 issue #1、#2项目级别编号但 GitLink 批量 API 需要数据库 ID如 143372
// 流程GET /issues 获取全量列表 → 构建 project_issues_index → id 映射表 → 按用户输入查找
// 仅 A 类命令batch-update/batch-destroy需要此映射B 类命令逐个调 API 时直接用项目编号
func resolveIssueDBIDs(ctx *common.RuntimeContext, numbers []string) ([]interface{}, error) {
q := url.Values{}
q.Set("limit", "200")
q.Set("page", "1")
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return nil, fmt.Errorf("fetch issues for ID mapping: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected issues response format")
}
issues, ok := data["issues"].([]interface{})
if !ok {
return nil, fmt.Errorf("unexpected issues list format")
}
// 构建 project_issues_index → 数据库ID 的映射表
// 例如: {"3": 143372, "4": 143373, "5": 143374}
indexToID := make(map[string]interface{}, len(issues))
for _, item := range issues {
issue, ok := item.(map[string]interface{})
if !ok {
continue
}
idx, ok := issue["project_issues_index"] // 用户可见的编号(如 3
if !ok {
continue
}
id, ok := issue["id"] // 数据库主键 ID如 143372
if !ok {
continue
}
// 统一转为字符串作为 key确保数字类型匹配
indexToID[fmt.Sprintf("%v", idx)] = id
}
// 用户输入的编号 → 查找对应数据库 ID找不到则报错
dbIDs := make([]interface{}, 0, len(numbers))
for _, n := range numbers {
id, found := indexToID[n]
if !found {
return nil, fmt.Errorf("issue #%s not found in repository", n)
}
dbIDs = append(dbIDs, id)
}
return dbIDs, nil
}
type batchCloseSummary struct {
Repository string `json:"repository" yaml:"repository"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Results []batchCloseResult `json:"results" yaml:"results"`
}
// ---------------------------------------------------------------------------
// batch-close
// ---------------------------------------------------------------------------
func newBatchCloseShortcut() *common.Shortcut {
return &common.Shortcut{
// +batch-close按疑修编号或 CSV 文件批量关闭多个疑修
Name: "batch-close",
Description: "Close multiple issues by issue numbers or a CSV file",
Description: "按疑修编号或 CSV 文件批量关闭疑修",
Long: `Close multiple issues in a single operation.
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
Use --dry-run to preview which issues would be closed without making changes.
The CSV file may have a "number", "issue_number", or "project_issues_index"
header column; otherwise the first column is used.`,
Example: ` # Close issues 1, 2, and 3
gitlink issue +batch-close --numbers 1,2,3
# Dry-run to preview
gitlink issue +batch-close --numbers 1,2,3 --dry-run
# Close issues listed in a CSV file
gitlink issue +batch-close --from issues.csv`,
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
@ -41,12 +99,14 @@ func newBatchCloseShortcut() *common.Shortcut {
}
}
// [B 类批量] 逐个关闭 issue——PATCH /issues/{n},每个请求独立
// 不需要 ID 映射,单 issue API 接受项目编号而非数据库主键
func runBatchClose(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
@ -54,33 +114,11 @@ func runBatchClose(ctx *common.RuntimeContext) error {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchCloseSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchCloseResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchCloseResult{Number: number, Action: "close"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := closeIssue(ctx, number); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "closed"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
summary := common.ProcessBatch(numbers, dryRun, "close", func(number string) error {
return closeIssue(ctx, number)
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
@ -92,121 +130,211 @@ func runBatchClose(ctx *common.RuntimeContext) error {
}
func closeIssue(ctx *common.RuntimeContext, number string) error {
// 先获取 issue 当前数据——PATCH API 要求提交 subject 和 description不能为空
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("fetch issue: %w", err)
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": closedIssueStatusID,
"subject": current.Subject, // 保留原标题
"description": current.Description, // 保留原描述
"status_id": closedIssueStatusID, // 5 = closed
}
// PATCH /issues/{number} 只修改 status_id保留其他字段
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("close issue: %w", err)
}
return nil
}
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := parseIssueNumbers(numbersValue)
// ---------------------------------------------------------------------------
// batch-update
// ---------------------------------------------------------------------------
func newBatchUpdateShortcut() *common.Shortcut {
return &common.Shortcut{
// +batch-update使用原生批量接口一次性更新多个疑修支持 --dry-run 预览)
Name: "batch-update",
Description: "通过原生批量接口批量更新多个疑修",
Long: `Update multiple issues in a single API call.
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
At least one update field (--state, --milestone, --assignee, --label, or --priority)
must be specified. Use --dry-run to preview changes without applying them.`,
Example: ` # Close multiple issues
gitlink issue +batch-update --numbers 1,2,3 --state closed
# Set milestone for issues from a CSV file
gitlink issue +batch-update --from issues.csv --milestone 5
# Assign issues to a user and add labels
gitlink issue +batch-update --numbers 10,11,12 --assignee 3 --label 7,8
# Dry-run to preview
gitlink issue +batch-update --numbers 1,2,3 --priority 1 --dry-run`,
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers (e.g. 1,2,3)"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "state", Short: "s", Usage: "Set state: open, closed, or numeric status_id"},
{Name: "milestone", Short: "m", Usage: "Set milestone ID"},
{Name: "assignee", Short: "a", Usage: "Set assignee user ID"},
{Name: "label", Short: "l", Usage: "Set label ID(s), comma-separated for multiple"},
{Name: "priority", Short: "p", Usage: "Set priority ID"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchUpdate,
}
}
// [A 类批量] 原生批量 API——一次 PATCH /issues/batch_update 请求操作多个 issue
// 需要先通过 resolveIssueDBIDs 将项目编号转为数据库 ID
func runBatchUpdate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return nil, err
return err
}
if csvPath == "" {
return numbers, nil
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
state := ctx.Arg("state")
milestone := ctx.Arg("milestone")
assignee := ctx.Arg("assignee")
label := ctx.Arg("label")
priority := ctx.Arg("priority")
if state == "" && milestone == "" && assignee == "" && label == "" && priority == "" {
return fmt.Errorf("at least one of --state, --milestone, --assignee, --label, or --priority is required")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
dbIDs, err := resolveIssueDBIDs(ctx, numbers)
if err != nil {
return nil, err
return err
}
return mergeIssueNumbers(numbers, csvNumbers), nil
}
func parseIssueNumbers(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
body := map[string]interface{}{
"ids": dbIDs,
}
if state != "" {
statusID, err := normalizeIssueStatus(state)
if err != nil {
return err
}
body["status_id"] = statusID
}
if milestone != "" {
body["fixed_version_id"] = milestone
}
if assignee != "" {
body["assigned_to_id"] = assignee
}
if label != "" {
tagIDs := common.ParseStringList(label)
body["issue_tag_ids"] = tagIDs
}
if priority != "" {
body["priority_id"] = priority
}
return normalizeIssueNumbers(strings.Split(value, ","))
}
func readIssueNumbersFromCSV(path string) ([]string, error) {
file, err := os.Open(path)
if dryRun {
plan := map[string]interface{}{
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"dry_run": true,
"issues": numbers,
"updates": body,
}
return ctx.OutputData(plan)
}
env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body)
if err != nil {
return nil, fmt.Errorf("read issue numbers from CSV: %w", err)
return fmt.Errorf("batch update: %w", err)
}
defer file.Close()
return ctx.Output(env)
}
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
// ---------------------------------------------------------------------------
// batch-destroy
// ---------------------------------------------------------------------------
func newBatchDestroyShortcut() *common.Shortcut {
return &common.Shortcut{
// +batch-destroy使用原生批量接口一次性永久删除多个疑修支持 --dry-run 与确认提示)
Name: "batch-destroy",
Description: "通过原生批量接口批量删除多个疑修",
Long: `Permanently delete multiple issues in a single API call.
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
This action is irreversible. Use --dry-run to preview which issues would be deleted.
You will be prompted for confirmation unless --yes is set.`,
Example: ` # Delete issues 7, 8, and 9
gitlink issue +batch-destroy --numbers 7,8,9
# Dry-run to preview
gitlink issue +batch-destroy --numbers 7,8,9 --dry-run
# Delete issues listed in a CSV file
gitlink issue +batch-destroy --from old_issues.csv`,
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers (e.g. 1,2,3)"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview deletions without executing them", Bool: true, Default: "false"},
},
Run: runBatchDestroy,
}
}
// [A 类批量] 原生批量 API——一次 DELETE /issues/batch_destroy 删除多个 issue不可逆
// 执行前调用 ConfirmAction 弹确认框
func runBatchDestroy(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err)
return err
}
if len(records) == 0 {
return nil, nil
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
numberColumn := -1
startRow := 0
for i, cell := range records[0] {
switch strings.ToLower(strings.TrimSpace(cell)) {
case "number", "issue_number", "project_issues_index":
numberColumn = i
startRow = 1
}
}
if numberColumn == -1 {
numberColumn = 0
dryRun := common.ParseBool(ctx.Arg("dry-run"))
dbIDs, err := resolveIssueDBIDs(ctx, numbers)
if err != nil {
return err
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if numberColumn >= len(record) {
continue
if dryRun {
plan := map[string]interface{}{
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"dry_run": true,
"issues": numbers,
"database_ids": dbIDs,
}
values = append(values, record[numberColumn])
return ctx.OutputData(plan)
}
return normalizeIssueNumbers(values)
}
func normalizeIssueNumbers(values []string) ([]string, error) {
numbers := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
number := strings.TrimSpace(value)
if number == "" {
continue
}
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number)
}
if seen[number] {
continue
}
seen[number] = true
numbers = append(numbers, number)
}
return numbers, nil
}
func mergeIssueNumbers(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
for _, numbers := range values {
for _, number := range numbers {
if seen[number] {
continue
}
seen[number] = true
merged = append(merged, number)
}
}
return merged
}
func parseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
if err := common.ConfirmAction(fmt.Sprintf("batch delete %d issue(s)", len(numbers))); err != nil {
return err
}
body := map[string]interface{}{
"ids": dbIDs,
}
// Use CallAPIWithQuery for DELETE since the body needs to be sent.
// The client.Do method sends body for any method, so CallAPI works.
env, err := ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", body)
if err != nil {
return fmt.Errorf("batch destroy: %w", err)
}
return ctx.Output(env)
}

View File

@ -5,79 +5,82 @@ import (
"path/filepath"
"reflect"
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestParseIssueNumbers(t *testing.T) {
got, err := parseIssueNumbers("1, 2,2, 3")
func TestParseNumberList(t *testing.T) {
got, err := common.ParseNumberList("1, 2,2, 3")
if err != nil {
t.Fatalf("parseIssueNumbers returned error: %v", err)
t.Fatalf("ParseNumberList returned error: %v", err)
}
want := []string{"1", "2", "3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("parseIssueNumbers() = %#v, want %#v", got, want)
t.Fatalf("ParseNumberList() = %#v, want %#v", got, want)
}
}
func TestParseIssueNumbersRejectsInvalidNumber(t *testing.T) {
if _, err := parseIssueNumbers("1,abc"); err == nil {
t.Fatal("parseIssueNumbers() expected an error for a non-integer issue number")
func TestParseNumberListRejectsInvalidNumber(t *testing.T) {
if _, err := common.ParseNumberList("1,abc"); err == nil {
t.Fatal("ParseNumberList() expected an error for a non-integer issue number")
}
}
func TestReadIssueNumbersFromCSVWithHeader(t *testing.T) {
func TestReadColumnFromCSVWithHeader(t *testing.T) {
path := writeTempCSV(t, "title,number,state\nfirst,12,open\nsecond,13,open\n")
got, err := readIssueNumbersFromCSV(path)
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
if err != nil {
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
}
want := []string{"12", "13"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
}
}
func TestReadIssueNumbersFromCSVWithProjectIssuesIndexHeader(t *testing.T) {
func TestReadColumnFromCSVWithProjectIssuesIndexHeader(t *testing.T) {
path := writeTempCSV(t, "title,project_issues_index,state\nfirst,12,open\nsecond,13,open\n")
got, err := readIssueNumbersFromCSV(path)
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
if err != nil {
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
}
want := []string{"12", "13"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
}
}
func TestReadIssueNumbersFromCSVWithoutHeaderUsesFirstColumn(t *testing.T) {
func TestReadColumnFromCSVWithoutHeaderUsesFirstColumn(t *testing.T) {
path := writeTempCSV(t, "21,open\n22,closed\n21,duplicate\n")
got, err := readIssueNumbersFromCSV(path)
got, err := common.ReadColumnFromCSV(path, []string{"number", "issue_number", "project_issues_index"})
if err != nil {
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
t.Fatalf("ReadColumnFromCSV returned error: %v", err)
}
want := []string{"21", "22"}
// ReadColumnFromCSV does not deduplicate; CollectNumbers handles that.
want := []string{"21", "22", "21"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
t.Fatalf("ReadColumnFromCSV() = %#v, want %#v", got, want)
}
}
func TestCollectIssueNumbersMergesCLIAndCSV(t *testing.T) {
func TestCollectNumbersMergesCLIAndCSV(t *testing.T) {
path := writeTempCSV(t, "number\n2\n3\n")
got, err := collectIssueNumbers("1,2", path)
got, err := common.CollectNumbers("1,2", path)
if err != nil {
t.Fatalf("collectIssueNumbers returned error: %v", err)
t.Fatalf("CollectNumbers returned error: %v", err)
}
want := []string{"1", "2", "3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("collectIssueNumbers() = %#v, want %#v", got, want)
t.Fatalf("CollectNumbers() = %#v, want %#v", got, want)
}
}
func TestParseBool(t *testing.T) {
if !parseBool("true") {
t.Fatal("parseBool(true) = false, want true")
if !common.ParseBool("true") {
t.Fatal("ParseBool(true) = false, want true")
}
if parseBool("") {
t.Fatal("parseBool(empty) = true, want false")
if common.ParseBool("") {
t.Fatal("ParseBool(empty) = true, want false")
}
}

View File

@ -23,13 +23,47 @@ type existingIssue struct {
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchUpdateShortcut(),
newBatchDestroyShortcut(),
// +list列出当前仓库的疑修支持按状态/里程碑/标签筛选)
{
Name: "list",
Description: "List issues",
Description: "列出疑修",
Long: `List issues in the current repository.
Shows issue number, title, status, and other details.
Use --state to filter by status, --milestone, --assignee, --label,
--keyword for additional filtering, and --page and --limit for pagination.`,
Example: ` # List open issues (default)
gitlink issue +list
# List closed issues
gitlink issue +list --state closed
# Show 50 issues per page
gitlink issue +list --limit 50
# Filter by milestone and assignee
gitlink issue +list --milestone 3 --assignee 5
# Search by keyword
gitlink issue +list -k "login bug"
# Sort by created date
gitlink issue +list --sort created_on
# Sort by updated date descending
gitlink issue +list --sort updated_on --sort-direction desc`,
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: "milestone", Short: "m", Usage: "Filter by milestone ID"},
{Name: "assignee", Short: "a", Usage: "Filter by assignee user ID"},
{Name: "label", Usage: "Filter by label ID"},
{Name: "keyword", Short: "k", Usage: "Search by keyword"},
{Name: "sort", Usage: "Sort field (e.g. created_on, updated_on)"},
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -41,6 +75,26 @@ func Shortcuts() []*common.Shortcut {
if s := ctx.Arg("state"); s != "" {
q.Set("state", s)
}
// [P2 参数补全] 以下 6 个查询参数原 CLI 未暴露,但 GitLink API 支持
// CLI 参数名 → API 字段名的映射(两者经常不一致)
if m := ctx.Arg("milestone"); m != "" {
q.Set("fixed_version_id", m) // CLI "milestone" → API "fixed_version_id"
}
if a := ctx.Arg("assignee"); a != "" {
q.Set("assigned_to_id", a) // CLI "assignee" → API "assigned_to_id"
}
if l := ctx.Arg("label"); l != "" {
q.Set("issue_tag_id", l) // CLI "label" → API "issue_tag_id"
}
if k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k) // 关键词搜索(名称一致,无需映射)
}
if s := ctx.Arg("sort"); s != "" {
q.Set("sort", s) // 排序字段(如 created_on, updated_on
}
if d := ctx.Arg("sort-direction"); d != "" {
q.Set("sort_direction", d) // 排序方向 asc/desc实测 API 用 sort+sort_direction不是 sort_by
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return err
@ -49,9 +103,22 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +create在当前仓库创建新疑修需 --title可附带描述/指派人/里程碑/标签)
{
Name: "create",
Description: "Create a new issue",
Description: "创建新疑修",
Long: `Create a new issue in the current repository.
Requires a title (--title). You may optionally set a description,
assignee, milestone, and label at creation time.`,
Example: ` # Create an issue with just a title
gitlink issue +create --title "Fix login bug"
# Create with description and assignee
gitlink issue +create --title "Fix login bug" --body "Steps to reproduce..." --assignee zhangsan
# Create with milestone and label
gitlink issue +create --title "New feature" --milestone 3 --label 5`,
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
{Name: "body", Short: "b", Usage: "Issue description"},
@ -77,10 +144,16 @@ func Shortcuts() []*common.Shortcut {
body["description"] = desc
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a
body["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
}
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m
body["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
}
// [P1 Bug#1 修复] 原代码只在 Flags 中定义了 label 参数Run 函数里没有取值写入请求体
// 导致用户传 --label 5 不报错,但创建的 issue 没有标签
// 修复:从 ctx.Arg("label") 取值,映射到 API 字段名 "issue_tag_id"
if l := ctx.Arg("label"); l != "" {
body["issue_tag_id"] = l // CLI "label" → API "issue_tag_id"
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
@ -89,9 +162,19 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +view查看指定疑修的详情标题/描述/状态/指派人等)
{
Name: "view",
Description: "View issue details",
Description: "查看疑修详情",
Long: `View detailed information about a specific issue.
Displays the issue title, description, status, assignee,
and other metadata. Use the issue number as shown in the web URL.`,
Example: ` # View issue #42
gitlink issue +view --number 42
# Short form
gitlink issue +view -n 42`,
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
@ -110,11 +193,26 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +close关闭疑修保留标题和描述可选附上说明性评论
{
Name: "close",
Description: "Close an issue",
Description: "关闭疑修",
Long: `Close an existing issue by setting its status to closed.
The issue number is the one shown in the web URL (project-level index).
This preserves the existing title and description.
Use --comment to leave an explanation when closing.`,
Example: ` # Close issue #42
gitlink issue +close --number 42
# Short form
gitlink issue +close -n 42
# Close with a comment
gitlink issue +close -n 42 --comment "Fixed in commit abc123"`,
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "comment", Short: "c", Usage: "Comment to add when closing"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -138,17 +236,51 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
// [P2 参数补全] 关闭PATCH和评论POST journals是两个独立 API代码串行调用
// 关闭 issue 本身是一个 PATCH 请求,评论是另一个 POST 请求到 journals 端点
if comment := ctx.Arg("comment"); comment != "" {
_, _ = ctx.CallAPI("POST",
fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number),
map[string]interface{}{"notes": comment})
}
return ctx.Output(env)
},
},
// +update更新疑修的字段标题/描述/状态/里程碑/指派人/标签/优先级等)
{
Name: "update",
Description: "Update an issue",
Description: "更新疑修",
Long: `Update the title, description, status, or other fields of an existing issue.
At least one of --title, --body, --state, --milestone, --assignee,
--label, or --priority must be provided.
The --state flag accepts "open", "closed", or a numeric status_id.`,
Example: ` # Change the title
gitlink issue +update --number 42 --title "Updated title"
# Change description
gitlink issue +update -n 42 --body "New description"
# Reopen a closed issue
gitlink issue +update -n 42 --state open
# Update title and close at the same time
gitlink issue +update -n 42 --title "Fixed" --state closed
# Assign to a user and set milestone
gitlink issue +update -n 42 --assignee 5 --milestone 3
# Set label and priority
gitlink issue +update -n 42 --label 7 --priority 1`,
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: "milestone", Short: "m", Usage: "Milestone ID"},
{Name: "assignee", Short: "a", Usage: "Assignee user ID"},
{Name: "label", Usage: "Label ID"},
{Name: "priority", Usage: "Priority ID"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -161,8 +293,13 @@ func Shortcuts() []*common.Shortcut {
title := ctx.Arg("title")
description := ctx.Arg("body")
state := ctx.Arg("state")
if title == "" && description == "" && state == "" {
return fmt.Errorf("at least one of --title, --body, or --state is required")
milestone := ctx.Arg("milestone")
assignee := ctx.Arg("assignee")
label := ctx.Arg("label")
priority := ctx.Arg("priority")
if title == "" && description == "" && state == "" &&
milestone == "" && assignee == "" && label == "" && priority == "" {
return fmt.Errorf("at least one of --title, --body, --state, --milestone, --assignee, --label, or --priority is required")
}
current, err := fetchExistingIssue(ctx, number)
@ -170,6 +307,8 @@ func Shortcuts() []*common.Shortcut {
return err
}
// [P2 参数补全] 保留原值,只修改用户指定的字段
// GitLink PATCH API 要求 subject 和 description 不能为空,所以必须先查出原值
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
@ -187,6 +326,19 @@ func Shortcuts() []*common.Shortcut {
}
body["status_id"] = statusID
}
// [P2 参数补全] 以下 4 个参数原 CLI 未暴露
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
}
if l := ctx.Arg("label"); l != "" {
body["issue_tag_id"] = l // CLI "label" → API "issue_tag_id"
}
if p := ctx.Arg("priority"); p != "" {
body["priority_id"] = p // CLI "priority" → API "priority_id"
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
@ -194,9 +346,18 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +comment在指定疑修下添加一条评论--number 和 --body 必填)
{
Name: "comment",
Description: "Add a comment to an issue",
Description: "在疑修下添加评论",
Long: `Add a comment (journal entry) to an existing issue.
Both --number and --body are required.`,
Example: ` # Comment on issue #42
gitlink issue +comment --number 42 --body "This is now fixed."
# Short form
gitlink issue +comment -n 42 -b "LGTM"`,
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},
@ -223,6 +384,206 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +comments列出指定疑修下的评论与动态记录
{
Name: "comments",
Description: "列出疑修的评论与动态",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{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
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update-comment修改疑修下的某条评论内容
{
Name: "update-comment",
Description: "修改疑修下的评论",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Comment journal ID", Required: true},
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete-comment删除疑修下的某条评论
{
Name: "delete-comment",
Description: "删除疑修下的评论",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Comment journal ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +reply-comment列出某条评论的子回复
{
Name: "reply-comment",
Description: "列出评论的子回复",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Parent comment journal ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/journals/%s/children_journals", v1RepoPath(ctx), number, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete删除指定疑修
{
Name: "delete",
Description: "删除疑修",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +batch-update通过原生批量接口批量更新多个疑修按数据库 ID
{
Name: "batch-update",
Description: "批量更新多个疑修",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
{Name: "state", Short: "s", Usage: "New state: open, closed"},
{Name: "assignee", Short: "a", Usage: "Assignee login"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
idsStr, err := ctx.RequireArg("ids")
if err != nil {
return err
}
body := map[string]interface{}{
"ids": parseIDList(idsStr),
}
if s := ctx.Arg("state"); s != "" {
statusID, err := normalizeIssueStatus(s)
if err != nil {
return err
}
body["status_id"] = statusID
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a
}
env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +batch-destroy通过原生批量接口批量删除多个疑修按数据库 ID
{
Name: "batch-destroy",
Description: "批量删除多个疑修",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
idsStr, err := ctx.RequireArg("ids")
if err != nil {
return err
}
body := map[string]interface{}{
"ids": parseIDList(idsStr),
}
env, err := ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
@ -289,3 +650,19 @@ func normalizeIssueStatus(state string) (interface{}, error) {
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
}
}
// parseIDList splits a comma-separated string into an int slice.
func parseIDList(s string) []int {
parts := strings.Split(s, ",")
ids := make([]int, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if id, err := strconv.Atoi(p); err == nil {
ids = append(ids, id)
}
}
return ids
}

View File

@ -186,3 +186,182 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func TestIssueDelete(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{"ok": true})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "delete", map[string]string{"number": "42"})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestIssueComments(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42/journals.json":
if r.URL.Query().Get("page") != "2" || r.URL.Query().Get("limit") != "10" {
t.Fatalf("unexpected query params: %v", r.URL.Query())
}
writeJSON(t, w, map[string]interface{}{
"journals": []interface{}{
map[string]interface{}{"id": 1, "notes": "first comment"},
map[string]interface{}{"id": 2, "notes": "second comment"},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "comments", map[string]string{
"number": "42",
"page": "2",
"limit": "10",
})
if err != nil {
t.Fatalf("comments shortcut failed: %v", err)
}
}
func TestIssueUpdateComment(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "update-comment", map[string]string{
"number": "42",
"id": "99",
"body": "updated comment text",
})
if err != nil {
t.Fatalf("update-comment shortcut failed: %v", err)
}
assertEqual(t, updatePayload["notes"], "updated comment text")
}
func TestIssueDeleteComment(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99.json":
writeJSON(t, w, map[string]interface{}{"ok": true})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "delete-comment", map[string]string{
"number": "42",
"id": "99",
})
if err != nil {
t.Fatalf("delete-comment shortcut failed: %v", err)
}
}
func TestIssueReplyComment(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99/children_journals.json":
writeJSON(t, w, map[string]interface{}{
"journals": []interface{}{
map[string]interface{}{"id": 100, "notes": "reply"},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "reply-comment", map[string]string{
"number": "42",
"id": "99",
})
if err != nil {
t.Fatalf("reply-comment shortcut failed: %v", err)
}
}
func TestIssueBatchUpdate(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/batch_update.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-update", map[string]string{
"ids": "1,2,3",
"state": "closed",
"assignee": "someone",
})
if err != nil {
t.Fatalf("batch-update shortcut failed: %v", err)
}
ids, ok := updatePayload["ids"].([]interface{})
if !ok {
t.Fatalf("expected ids to be a slice, got %T", updatePayload["ids"])
}
assertEqual(t, len(ids), 3)
assertEqual(t, ids[0], float64(1))
assertEqual(t, ids[1], float64(2))
assertEqual(t, ids[2], float64(3))
assertEqual(t, updatePayload["status_id"], float64(5))
assertEqual(t, updatePayload["assigned_to_id"], "someone")
}
func TestIssueBatchDestroy(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/batch_destroy.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-destroy", map[string]string{
"ids": "10,20,30",
})
if err != nil {
t.Fatalf("batch-destroy shortcut failed: %v", err)
}
ids, ok := updatePayload["ids"].([]interface{})
if !ok {
t.Fatalf("expected ids to be a slice, got %T", updatePayload["ids"])
}
assertEqual(t, len(ids), 3)
assertEqual(t, ids[0], float64(10))
assertEqual(t, ids[1], float64(20))
assertEqual(t, ids[2], float64(30))
}

218
shortcuts/label/label.go Normal file
View File

@ -0,0 +1,218 @@
package label
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出仓库的疑修标签
{
Name: "list",
Description: "列出仓库的疑修标签",
Long: `List repository issue labels (tags).
Returns all labels defined in the repository.`,
Example: ` # List all labels
gitlink label +list`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +create创建疑修标签
{
Name: "create",
Description: "创建疑修标签",
Long: `Create an issue label.
Creates a new label with the given name.
Optionally specify a color in hex format (without #).`,
Example: ` # Create a label
gitlink label +create --name bug
# Create a label with color
gitlink label +create --name enhancement --color 00ff00`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Label name", Required: true},
{Name: "color", Short: "c", Usage: "Label color (hex without #, e.g. ff0000)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
payload := map[string]interface{}{
"name": name,
}
if color := ctx.Arg("color"); color != "" {
payload["color"] = color
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update更新疑修标签
{
Name: "update",
Description: "更新疑修标签",
Long: `Update an issue label.
Updates the name and/or color of an existing label.
At least one of --name or --color should be provided.`,
Example: ` # Rename a label
gitlink label +update --id 42 --name "new name"
# Change label color
gitlink label +update --id 42 --color ff0000`,
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 without #)"},
},
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 name := ctx.Arg("name"); name != "" {
payload["name"] = name
}
if color := ctx.Arg("color"); color != "" {
payload["color"] = color
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/%s/issue_tags/%s", ctx.Owner, ctx.Repo, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete删除疑修标签
{
Name: "delete",
Description: "删除疑修标签",
Long: `Delete an issue label.
Permanently removes the label from the repository.`,
Example: ` # Delete a label
gitlink label +delete --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Label 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("DELETE", fmt.Sprintf("/v1/%s/%s/issue_tags/%s", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +priorities列出可用的疑修优先级
{
Name: "priorities",
Description: "列出可用的疑修优先级",
Long: `List available issue priorities.
Returns the priority levels that can be assigned to issues.`,
Example: ` # List issue priorities
gitlink label +priorities`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_priorities", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +statuses列出可用的疑修状态
{
Name: "statuses",
Description: "列出可用的疑修状态",
Long: `List available issue statuses.
Returns the status values that issues can have
(e.g., open, closed, in progress).`,
Example: ` # List issue statuses
gitlink label +statuses`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_statues", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +authors列出可用的疑修作者
{
Name: "authors",
Description: "列出可用的疑修作者",
Long: `List available issue authors.
Returns the users who have authored issues in the repository.`,
Example: ` # List issue authors
gitlink label +authors`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_authors", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +assigners列出可用的疑修复办人
{
Name: "assigners",
Description: "列出可用的疑修复办人",
Long: `List available issue assigners.
Returns the users who can be assigned to issues in the repository.`,
Example: ` # List issue assigners
gitlink label +assigners`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issue_assigners", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,224 @@
package milestone
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出仓库的所有里程碑
{
Name: "list",
Description: "列出仓库的所有里程碑",
Long: `List repository milestones.
Displays all milestones for the repository with optional filtering by state.
Supports pagination with --page and --limit flags.`,
Example: ` # List open milestones
gitlink milestone +list
# List closed milestones
gitlink milestone +list -s closed
# List all milestones with pagination
gitlink milestone +list -s all -p 2 -l 10`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "state", Short: "s", Usage: "Filter: open or closed or all", Default: "open"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
path := fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery("GET", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +create创建里程碑
{
Name: "create",
Description: "创建里程碑",
Long: `Create a milestone.
Creates a new milestone with the given title. Optionally add a description
and due date.`,
Example: ` # Create a basic milestone
gitlink milestone +create -t "v1.0"
# Create a milestone with description and due date
gitlink milestone +create -t "v2.0" -d "Second major release" --due-date 2025-12-31`,
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Milestone title", Required: true},
{Name: "description", Short: "d", Usage: "Milestone description"},
{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title")
if err != nil {
return err
}
payload := map[string]interface{}{
"name": title,
}
if desc := ctx.Arg("description"); desc != "" {
payload["description"] = desc
}
if dueDate := ctx.Arg("due-date"); dueDate != "" {
payload["effective_date"] = dueDate
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +view查看里程碑的详细信息
{
Name: "view",
Description: "查看里程碑的详细信息",
Long: `View milestone details.
Shows detailed information about a specific milestone including its
title, description, status, due date, and completion progress.`,
Example: ` # View milestone details by ID
gitlink milestone +view -i 42`,
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("GET", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update更新里程碑
{
Name: "update",
Description: "更新里程碑",
Long: `Update a milestone.
Modifies an existing milestone's title, description, due date, or status.
Only the fields you specify will be changed.`,
Example: ` # Update a milestone title
gitlink milestone +update -i 42 -t "v1.0 Final"
# Update description and due date
gitlink milestone +update -i 42 -d "Final release" --due-date 2025-06-30
# Close a milestone via update
gitlink milestone +update -i 42 --status close`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
{Name: "title", Short: "t", Usage: "New title"},
{Name: "description", Short: "d", Usage: "New description"},
{Name: "due-date", Usage: "New due date (YYYY-MM-DD)"},
{Name: "status", Usage: "Status: open or close"},
},
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 title := ctx.Arg("title"); title != "" {
payload["name"] = title
}
if desc := ctx.Arg("description"); desc != "" {
payload["description"] = desc
}
if dueDate := ctx.Arg("due-date"); dueDate != "" {
payload["effective_date"] = dueDate
}
if status := ctx.Arg("status"); status != "" {
payload["status"] = status
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete删除里程碑
{
Name: "delete",
Description: "删除里程碑",
Long: `Delete a milestone.
Permanently removes a milestone from the repository. This action cannot be undone.`,
Example: ` # Delete a milestone by ID
gitlink milestone +delete -i 42`,
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("DELETE", fmt.Sprintf("/v1/%s/%s/milestones/%s", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +close关闭里程碑
{
Name: "close",
Description: "关闭里程碑",
Long: `Close a milestone.
Marks a milestone as closed. Closed milestones are still visible but
can no longer receive new issues.`,
Example: ` # Close a milestone by ID
gitlink milestone +close -i 42`,
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
}
payload := map[string]interface{}{
"status": "close",
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/milestones/%s/update_status", ctx.Owner, ctx.Repo, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -9,9 +9,19 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出组织
{
Name: "list",
Description: "List organizations",
Description: "列出组织",
Long: `List all organizations visible to the authenticated user.
Returns organization ID, name, and description for each entry.
Use --page and --limit for pagination.`,
Example: ` # List organizations
gitlink org +list
# Show 50 organizations per page
gitlink org +list --limit 50`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
@ -27,9 +37,19 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +info查看组织详情
{
Name: "info",
Description: "Show organization details",
Description: "查看组织详情",
Long: `Show detailed information about an organization.
Returns the full organization profile including name, description,
member count, and repository list.`,
Example: ` # Show details for an organization by ID
gitlink org +info --id 123
# Show details for an organization by login
gitlink org +info --id my-org`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
},
@ -42,9 +62,19 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +members列出组织成员
{
Name: "members",
Description: "List organization members",
Description: "列出组织成员",
Long: `List all members of an organization.
Shows each member's login name, role, and join date.
Use --page and --limit for pagination.`,
Example: ` # List members of an organization
gitlink org +members --id 123
# Show 50 members per page
gitlink org +members --id 123 --limit 50`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
@ -62,9 +92,19 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +create创建组织
{
Name: "create",
Description: "Create an organization",
Description: "创建组织",
Long: `Create a new organization on GitLink.
Requires a name for the organization. An optional description
can be provided with --description.`,
Example: ` # Create an organization
gitlink org +create --name my-org
# Create with a description
gitlink org +create --name my-org --description "My team organization"`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Organization name", Required: true},
{Name: "description", Short: "d", Usage: "Description"},
@ -84,5 +124,41 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +add-team-projects为团队添加全部项目
{
Name: "add-team-projects",
Description: "为团队添加全部项目",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization login", Required: true},
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org")
team, _ := ctx.RequireArg("team")
env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams/%s/team_projects/create_all", org, team), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +remove-team-projects移除团队的全部项目
{
Name: "remove-team-projects",
Description: "移除团队的全部项目",
Flags: []common.Flag{
{Name: "org", Short: "o", Usage: "Organization login", Required: true},
{Name: "team", Short: "t", Usage: "Team ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
org, _ := ctx.RequireArg("org")
team, _ := ctx.RequireArg("team")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s/team_projects/destroy_all", org, team), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

166
shortcuts/pr/batch.go Normal file
View File

@ -0,0 +1,166 @@
package pr
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func newBatchCloseShortcut() *common.Shortcut {
return &common.Shortcut{
// +batch-close按编号或 CSV 文件批量关闭多个合并请求(支持 --dry-run 预览)
Name: "batch-close",
Description: "批量关闭多个合并请求",
Long: `Close multiple pull requests in a single operation.
Provide PR numbers via --numbers (comma-separated) or --from (CSV file).
Use --dry-run to preview which pull requests would be closed without
making changes.
The CSV file may have a "number", "issue_number", or "project_issues_index"
header column; otherwise the first column is used.`,
Example: ` # Close pull requests 1, 2, and 3
gitlink pr +batch-close --numbers 1,2,3
# Dry-run to preview
gitlink pr +batch-close --numbers 1,2,3 --dry-run
# Close pull requests listed in a CSV file
gitlink pr +batch-close --from prs.csv`,
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated PR numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read PR numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "dry-run", Usage: "Preview the pull requests that would be closed without changing them", Bool: true, Default: "false"},
},
Run: runBatchClose,
}
}
// [B 类批量] 逐个关闭 PR——POST /pulls/{n}/refuse_merge每个请求独立
func runBatchClose(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no PR numbers provided; use --numbers 1,2,3 or --from prs.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
summary := common.ProcessBatch(numbers, dryRun, "close", func(number string) error {
_, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), number), nil)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d pull request(s) failed to close", summary.Failed, summary.Total)
}
return nil
}
func newBatchMergeShortcut() *common.Shortcut {
return &common.Shortcut{
// +batch-merge按编号或 CSV 文件批量合并多个合并请求(不可逆,默认需确认)
Name: "batch-merge",
Description: "批量合并多个合并请求",
Long: `Merge multiple pull requests in a single operation.
Provide PR numbers via --numbers (comma-separated) or --from (CSV file).
Use --method to choose the merge strategy: merge (default), rebase, or squash.
Use --dry-run to preview which pull requests would be merged without
making changes.
WARNING: Merging is irreversible. A confirmation prompt is shown before
execution (use --yes to auto-confirm).
The CSV file may have a "number", "issue_number", or "project_issues_index"
header column; otherwise the first column is used.`,
Example: ` # Merge pull requests 1, 2, and 3 using the default method
gitlink pr +batch-merge --numbers 1,2,3
# Squash-merge multiple pull requests
gitlink pr +batch-merge --numbers 1,2,3 --method squash
# Rebase-merge pull requests listed in a CSV file
gitlink pr +batch-merge --from prs.csv --method rebase
# Dry-run to preview
gitlink pr +batch-merge --numbers 1,2,3 --dry-run
# Auto-confirm without prompt
gitlink pr +batch-merge --numbers 1,2,3 --yes`,
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated PR numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read PR numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
{Name: "dry-run", Usage: "Preview the pull requests that would be merged without changing them", Bool: true, Default: "false"},
},
Run: runBatchMerge,
}
}
// [B 类批量] 逐个合并 PR——POST /pulls/{n}/pr_merge
// 不可逆操作:执行前调用 ConfirmAction 确认(除非 --yes
func runBatchMerge(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
method := ctx.Arg("method")
if method == "" {
method = "merge" // 默认使用 merge 方式
}
// P2 补全的 validateMergeMethod只允许 merge/rebase/squash 三种
if err := validateMergeMethod(method); err != nil {
return err
}
numbers, err := common.CollectNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no PR numbers provided; use --numbers 1,2,3 or --from prs.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
if dryRun {
// dry-run 直接返回预览,不给 fn 传实际逻辑
summary := common.ProcessBatch(numbers, true, "merge", nil)
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
return ctx.OutputData(summary)
}
// Merging is irreversible — require explicit confirmation
if err := common.ConfirmAction(fmt.Sprintf("batch merge %d pull requests", len(numbers))); err != nil {
return err
}
// 逐个调用 POST /pulls/{n}/pr_merge每个请求带合并方式参数
summary := common.ProcessBatch(numbers, false, "merge", func(number string) error {
payload := map[string]interface{}{
"do": method, // merge/rebase/squash
}
_, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), number), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d pull request(s) failed to merge", summary.Failed, summary.Total)
}
return nil
}

View File

@ -3,6 +3,7 @@ package pr
import (
"fmt"
"net/url"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -10,15 +11,51 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchMergeShortcut(),
// +list列出当前仓库的合并请求支持按状态/里程碑/标签/评审人筛选)
{
Name: "list",
Description: "List pull requests",
Description: "列出合并请求",
Long: `List pull requests in the current repository.
Shows PR number, title, status, author, and other details.
Use --state to filter by status (open, merged, closed).
Use --keyword, --milestone, --tag, --reviewer, --assignee for
additional filtering, and --sort to control ordering.`,
Example: ` # List open pull requests
gitlink pr +list
# List merged pull requests
gitlink pr +list --state merged
# List closed pull requests with pagination
gitlink pr +list --state closed --page 2 --limit 10
# Search by keyword
gitlink pr +list -k "feature"
# Filter by milestone and assignee
gitlink pr +list --milestone 3 --assignee 5
# Sort by created date
gitlink pr +list --sort created_on
# Sort by updated date descending
gitlink pr +list --sort updated_on --sort-direction desc`,
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
{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 {
{Name: "keyword", Short: "k", Usage: "Search by keyword"},
{Name: "milestone", Short: "m", Usage: "Filter by milestone ID"},
{Name: "tag", Usage: "Filter by tag ID"},
{Name: "reviewer", Usage: "Filter by reviewer ID"},
{Name: "assignee", Short: "a", Usage: "Filter by assignee ID"},
{Name: "sort", Usage: "Sort field (e.g. created_on, updated_on)"},
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
@ -28,6 +65,28 @@ func Shortcuts() []*common.Shortcut {
if s := ctx.Arg("state"); s != "" {
q.Set("state", s)
}
// [P2 参数补全] 以下 7 个查询参数原 CLI 未暴露
if k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k) // 关键词搜索
}
if m := ctx.Arg("milestone"); m != "" {
q.Set("version_id", m) // CLI "milestone" → API "version_id"PR 的里程碑字段名和 issue 不同)
}
if t := ctx.Arg("tag"); t != "" {
q.Set("issue_tag_id", t) // CLI "tag" → API "issue_tag_id"
}
if r := ctx.Arg("reviewer"); r != "" {
q.Set("reviewer_id", r) // CLI "reviewer" → API "reviewer_id"
}
if a := ctx.Arg("assignee"); a != "" {
q.Set("assign_user_id", a) // CLI "assignee" → API "assign_user_id"
}
if s := ctx.Arg("sort"); s != "" {
q.Set("sort", s) // 排序字段
}
if d := ctx.Arg("sort-direction"); d != "" {
q.Set("sort_direction", d) // 排序方向
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if err != nil {
return err
@ -35,14 +94,36 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +create创建新合并请求需 --title 和 --head 源分支,目标分支默认仓库默认分支)
{
Name: "create",
Description: "Create a pull request",
Description: "创建合并请求",
Long: `Create a new pull request in the current repository.
Requires a title and source branch (--head). The target branch
(--base) defaults to the repository default branch if not specified.
Use --assignee, --milestone, --label, and --priority to set
additional fields at creation time.`,
Example: ` # Create a pull request
gitlink pr +create --title "Add new feature" --head feature-branch
# Create a PR with description and target branch
gitlink pr +create --title "Fix bug" --body "Fixes #123" --head fix-branch --base main
# Create with assignee and milestone
gitlink pr +create --title "Feature" --head feat --assignee 5 --milestone 3
# Create with labels and priority
gitlink pr +create --title "Feature" --head feat --label 7 --priority 1`,
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "PR title", Required: true},
{Name: "body", Short: "b", Usage: "PR description"},
{Name: "head", Usage: "Source branch", Required: true},
{Name: "base", Usage: "Target branch", Default: "master"},
{Name: "base", Usage: "Target branch (default: repository default branch)"},
{Name: "priority", Usage: "Priority ID"},
{Name: "assignee", Short: "a", Usage: "Assignee user ID"},
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
{Name: "label", Short: "l", Usage: "Label ID (comma-separated for multiple)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -51,8 +132,10 @@ func Shortcuts() []*common.Shortcut {
title, _ := ctx.RequireArg("title")
head, _ := ctx.RequireArg("head")
base := ctx.Arg("base")
// [P1 Bug#2] 原代码: base = "master"(硬编码)
// 修复: 使用 GetDefaultBranch() 动态查询仓库默认分支
if base == "" {
base = "master"
base = common.GetDefaultBranch(ctx)
}
payload := map[string]interface{}{
"title": title,
@ -62,6 +145,32 @@ func Shortcuts() []*common.Shortcut {
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
// [P2 参数补全] PR 创建时支持 priority、assignee、milestone、label
if p := ctx.Arg("priority"); p != "" {
payload["priority_id"] = p // CLI "priority" → API "priority_id"
}
if a := ctx.Arg("assignee"); a != "" {
payload["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
}
if m := ctx.Arg("milestone"); m != "" {
payload["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
}
// [P2 参数补全] PR 的标签需要数组格式 issue_tag_ids和 issue 的单值 issue_tag_id 不同)
// 支持逗号分隔多标签:--label "1,2,3" → [1, 2, 3]
if l := ctx.Arg("label"); l != "" {
if strings.Contains(l, ",") {
// 多标签:拆分、去空格、转为 []interface{} 数组
parts := strings.Split(l, ",")
ids := make([]interface{}, len(parts))
for i, p := range parts {
ids[i] = strings.TrimSpace(p)
}
payload["issue_tag_ids"] = ids
} else {
// 单标签也用数组格式
payload["issue_tag_ids"] = []interface{}{l}
}
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
if err != nil {
return err
@ -69,9 +178,16 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +view查看指定合并请求的详情标题/描述/状态/作者/分支等)
{
Name: "view",
Description: "View pull request details",
Description: "查看合并请求详情",
Long: `View detailed information about a specific pull request.
Displays PR title, description, status, author, branches,
and other metadata.`,
Example: ` # View details of pull request #42
gitlink pr +view --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
@ -87,12 +203,30 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +merge合并合并请求支持 merge/rebase/squash 三种方式,可自定义提交信息)
{
Name: "merge",
Description: "Merge a pull request",
Description: "合并合并请求",
Long: `Merge a pull request using the specified merge method.
Supported merge methods: merge (default), rebase, squash.
Use --title and --body to customize the merge commit message.`,
Example: ` # Merge a pull request using the default method
gitlink pr +merge --id 42
# Squash-merge a pull request
gitlink pr +merge --id 42 --method squash
# Rebase-merge a pull request
gitlink pr +merge --id 42 --method rebase
# Merge with custom commit title and description
gitlink pr +merge --id 42 --title "Merge feature X" --body "Combines feature X into main"`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
{Name: "title", Usage: "Merge commit title"},
{Name: "body", Usage: "Merge commit description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -103,9 +237,18 @@ func Shortcuts() []*common.Shortcut {
if method == "" {
method = "merge"
}
if err := validateMergeMethod(method); err != nil {
return err
}
payload := map[string]interface{}{
"do": method,
}
if t := ctx.Arg("title"); t != "" {
payload["title"] = t
}
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
if err != nil {
return err
@ -113,9 +256,16 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +close关闭合并请求不合并关闭后需重新开启才能合并
{
Name: "close",
Description: "Close a pull request",
Description: "关闭合并请求",
Long: `Close a pull request without merging.
The PR will be marked as closed and cannot be merged afterwards
unless it is reopened.`,
Example: ` # Close pull request #42
gitlink pr +close --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
@ -131,27 +281,16 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +files列出合并请求中的变更文件路径/变更类型/增删行数)
{
Name: "files",
Description: "List changed files in a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "diff",
Description: "Show diff for a pull request",
Description: "列出合并请求的变更文件",
Long: `List all files changed in a pull request.
Shows the file path, change type (added, modified, deleted),
and the number of additions and deletions for each file.`,
Example: ` # List files changed in pull request #42
gitlink pr +files --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
@ -167,9 +306,16 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +versions列出合并请求的补丁集版本历史
{
Name: "versions",
Description: "List pull request patchset versions",
Description: "列出合并请求的补丁集版本",
Long: `List all patchset versions of a pull request.
Each time a PR is updated with new commits, a new patchset version
is created. This command shows the version history.`,
Example: ` # List patchset versions for pull request #42
gitlink pr +versions --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
@ -188,9 +334,21 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// [P1 Bug#3 修复] 原代码中 +diff 和 +files 调用了同一个 API 端点,功能完全重复
// 修复: 将 +diff 改名为 +version-diff指向 PR 补丁集版本的差异 API
// +version-diff查看合并请求某补丁集版本的差异可用 --file 过滤指定文件)
// +files 仍然用于查看 PR 文件列表,两者功能区分开
{
Name: "version-diff",
Description: "Show diff for a pull request patchset version",
Description: "查看合并请求补丁集版本的差异",
Long: `Show the diff for a specific patchset version of a pull request.
Use --file to filter the diff to a specific file path.`,
Example: ` # Show diff for version 3 of pull request #42
gitlink pr +version-diff --id 42 --version-id 3
# Show diff for a specific file in a patchset version
gitlink pr +version-diff --id 42 --version-id 3 --file src/main.go`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true},
@ -225,9 +383,19 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +reviews列出合并请求的评审记录可按评审状态筛选
{
Name: "reviews",
Description: "List pull request reviews",
Description: "列出合并请求的评审",
Long: `List all reviews submitted for a pull request.
Use --status to filter reviews by their status:
common, approved, or rejected.`,
Example: ` # List all reviews for pull request #42
gitlink pr +reviews --id 42
# List only approved reviews
gitlink pr +reviews --id 42 --status approved`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"},
@ -254,9 +422,23 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +review对合并请求提交评审common/approved/rejected支持 --dry-run 预览)
{
Name: "review",
Description: "Create a pull request review",
Description: "创建合并请求评审",
Long: `Submit a review on a pull request.
Supported review statuses: common (comment), approved, rejected.
Use --commit to attach the review to a specific commit.
Use --dry-run to preview the review without submitting it.`,
Example: ` # Submit an approving review
gitlink pr +review --id 42 --status approved --content "Looks good!"
# Request changes on a PR
gitlink pr +review --id 42 --status rejected --content "Needs fixes"
# Preview a review without creating it
gitlink pr +review --id 42 --status common --content "Note" --dry-run`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"},
@ -306,9 +488,218 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +comments列出合并请求下的评审评论
{
Name: "comments",
Description: "列出合并请求的评审评论",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", 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("GET", prV1Path(ctx, id)+"/journals", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +create-comment在合并请求下创建一条评审评论可附行号与文件路径
{
Name: "create-comment",
Description: "创建合并请求的评审评论",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
{Name: "line", Short: "l", Usage: "Line number"},
{Name: "path", Short: "p", Usage: "File path"},
},
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, err := ctx.RequireArg("body")
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
}
if line := ctx.Arg("line"); line != "" {
payload["line"] = line
}
if path := ctx.Arg("path"); path != "" {
payload["path"] = path
}
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/journals", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update-comment修改合并请求下的某条评审评论
{
Name: "update-comment",
Description: "修改合并请求的评审评论",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
{Name: "body", Short: "b", Usage: "New comment body", 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
}
commentID, err := ctx.RequireArg("comment-id")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/journals/%s", prV1Path(ctx, id), commentID), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete-comment删除合并请求下的某条评审评论
{
Name: "delete-comment",
Description: "删除合并请求的评审评论",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Comment 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
}
commentID, err := ctx.RequireArg("comment-id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/journals/%s", prV1Path(ctx, id), commentID), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +commits列出合并请求中包含的提交记录
{
Name: "commits",
Description: "列出合并请求的提交",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", 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("GET", fmt.Sprintf("%s/pulls/%s/commits", ctx.RepoPath(), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +reopen重新打开已关闭的合并请求
{
Name: "reopen",
Description: "重新打开合并请求",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", 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", prV1Path(ctx, id)+"/reopen", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update更新合并请求的标题和/或描述
{
Name: "update",
Description: "更新合并请求的标题或描述",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "title", Short: "t", Usage: "New title"},
{Name: "body", Short: "b", Usage: "New description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
title := ctx.Arg("title")
body := ctx.Arg("body")
if title == "" && body == "" {
return fmt.Errorf("at least one of --title or --body is required")
}
payload := map[string]interface{}{}
if title != "" {
payload["title"] = title
}
if body != "" {
payload["body"] = body
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +comment在合并请求下添加评论作为关联疑修的日志条目发布
{
Name: "comment",
Description: "Add a comment to a pull request",
Description: "在合并请求下添加评论",
Long: `Add a comment to a pull request.
The comment is posted as a journal entry on the underlying issue
associated with the pull request.`,
Example: ` # Add a comment to pull request #42
gitlink pr +comment --id 42 --body "This looks great, thanks!"`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
@ -355,6 +746,17 @@ func validatePRReviewStatus(status string) error {
}
}
// [P2 参数补全] 校验合并方式参数,只允许 merge/rebase/squash 三种合法值
// 这是纯前端校验,在发送 API 请求之前拦截非法输入
func validateMergeMethod(method string) error {
switch method {
case "merge", "rebase", "squash":
return nil
default:
return fmt.Errorf("invalid --method %q: use merge, rebase, or squash", method)
}
}
func extractIssueID(env *output.Envelope) (int64, error) {
data, ok := env.Data.(map[string]interface{})
if !ok {

View File

@ -263,6 +263,283 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) {
}
}
// --- Task 2: PR Comments CRUD, Commits, Reopen, Update tests ---
func TestPRCommentsListsReviewComments(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"total_count": float64(1),
"journals": []map[string]interface{}{
{
"id": float64(501),
"notes": "Looks good here",
"line": float64(42),
"path": "main.go",
},
},
})
}))
defer server.Close()
err := runPRShortcut(t, server, "comments", map[string]string{
"id": "13",
})
if err != nil {
t.Fatalf("comments shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals.json")
}
func TestPRCreateCommentPostsJournal(t *testing.T) {
var journalPayload map[string]interface{}
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
journalPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(502),
"notes": "nit: use camelCase",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "create-comment", map[string]string{
"id": "13",
"body": "nit: use camelCase",
"line": "42",
"path": "main.go",
})
if err != nil {
t.Fatalf("create-comment shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals.json")
assertEqual(t, journalPayload["notes"], "nit: use camelCase")
assertEqual(t, journalPayload["line"], float64(42))
assertEqual(t, journalPayload["path"], "main.go")
}
func TestPRCreateCommentRequiresBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when body is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "create-comment", map[string]string{
"id": "13",
})
if err == nil {
t.Fatal("expected error when body is missing, got nil")
}
}
func TestPRUpdateCommentPutsJournal(t *testing.T) {
var journalPayload map[string]interface{}
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/501.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
journalPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(501),
"notes": "updated comment text",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "update-comment", map[string]string{
"id": "13",
"comment-id": "501",
"body": "updated comment text",
})
if err != nil {
t.Fatalf("update-comment shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals/501.json")
assertEqual(t, journalPayload["notes"], "updated comment text")
}
func TestPRUpdateCommentRequiresCommentID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when comment-id is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "update-comment", map[string]string{
"id": "13",
"body": "some text",
})
if err == nil {
t.Fatal("expected error when comment-id is missing, got nil")
}
}
func TestPRDeleteCommentDeletesJournal(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/501.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"status": 0,
})
}))
defer server.Close()
err := runPRShortcut(t, server, "delete-comment", map[string]string{
"id": "13",
"comment-id": "501",
})
if err != nil {
t.Fatalf("delete-comment shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/journals/501.json")
}
func TestPRDeleteCommentRequiresCommentID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when comment-id is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "delete-comment", map[string]string{
"id": "13",
})
if err == nil {
t.Fatal("expected error when comment-id is missing, got nil")
}
}
func TestPRCommitsListsCommits(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/13/commits.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"total_count": float64(2),
"commits": []map[string]interface{}{
{
"sha": "abc123",
"author": "dev1",
},
{
"sha": "def456",
"author": "dev2",
},
},
})
}))
defer server.Close()
err := runPRShortcut(t, server, "commits", map[string]string{
"id": "13",
})
if err != nil {
t.Fatalf("commits shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/pulls/13/commits.json")
}
func TestPRReopenReopensPR(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/reopen.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"id": float64(13),
"status": "open",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "reopen", map[string]string{
"id": "13",
})
if err != nil {
t.Fatalf("reopen shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/reopen.json")
}
func TestPRUpdateUpdatesTitle(t *testing.T) {
var updatePayload map[string]interface{}
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" || r.URL.Path != "/owner/repo/pulls/13.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(13),
"title": "new title",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "update", map[string]string{
"id": "13",
"title": "new title",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/pulls/13.json")
assertEqual(t, updatePayload["title"], "new title")
}
func TestPRUpdateUpdatesBody(t *testing.T) {
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" || r.URL.Path != "/owner/repo/pulls/13.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(13),
"body": "new body",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "update", map[string]string{
"id": "13",
"body": "new body",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, updatePayload["body"], "new body")
}
func TestPRUpdateRequiresTitleOrBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when both title and body are missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "update", map[string]string{
"id": "13",
})
if err == nil {
t.Fatal("expected error when both title and body are missing, got nil")
}
}
func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findPRShortcut(t, name)

View File

@ -3,45 +3,84 @@ package shortcuts
import (
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/shortcuts/attachment"
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/collaborator"
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/dataset"
"github.com/gitlink-org/gitlink-cli/shortcuts/file"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
"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/snippet"
"github.com/gitlink-org/gitlink-cli/shortcuts/sshkey"
"github.com/gitlink-org/gitlink-cli/shortcuts/tag"
"github.com/gitlink-org/gitlink-cli/shortcuts/template"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/util"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
)
// RegisterAll mounts all shortcut groups onto the root command.
func RegisterAll(root *cobra.Command) {
groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"webhook": webhook.Shortcuts(),
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"webhook": webhook.Shortcuts(),
"wiki": wiki.Shortcuts(),
"snippet": snippet.Shortcuts(),
"collaborator": collaborator.Shortcuts(),
"tag": tag.Shortcuts(),
"milestone": milestone.Shortcuts(),
"file": file.Shortcuts(),
"commit": commit.Shortcuts(),
"label": label.Shortcuts(),
"sshkey": sshkey.Shortcuts(),
"util": util.Shortcuts(),
"dataset": dataset.Shortcuts(),
"template": template.Shortcuts(),
"attachment": attachment.Shortcuts(),
}
descriptions := map[string]string{
"repo": "Repository operations",
"issue": "Issue operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"webhook": "Webhook operations",
"repo": "Repository operations",
"issue": "Issue operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"webhook": "Webhook operations",
"wiki": "Wiki operations",
"snippet": "Code snippet management",
"collaborator": "Collaborator management",
"tag": "Git tag operations",
"milestone": "Milestone management",
"file": "File content operations",
"commit": "Commit operations",
"label": "Issue label management",
"sshkey": "SSH key management",
"util": "Utility operations (licenses, ignores, settings)",
"dataset": "Dataset management",
"template": "Project template management",
"attachment": "File attachment operations",
}
for name, shortcuts := range groups {
@ -53,3 +92,62 @@ func RegisterAll(root *cobra.Command) {
root.AddCommand(groupCmd)
}
}
// GetAllShortcuts 返回全部命令组注册表,供交互式模式读取。
// 每个命令组(如 issue包含若干子命令如 +list / +create
func GetAllShortcuts() map[string][]*common.Shortcut {
return map[string][]*common.Shortcut{
"repo": repo.Shortcuts(), // 仓库管理:列表/详情/创建/Fork/删除/更新/Star/Watch 等
"issue": issue.Shortcuts(), // 疑修Issue管理列表/创建/查看/关闭/更新/评论等
"pr": pr.Shortcuts(), // 合并请求Pull Request管理列表/创建/合并/评审/评论等
"release": release.Shortcuts(), // 发行版Release管理列表/创建/查看/删除/更新等
"branch": branch.Shortcuts(), // 分支管理:列表/创建/删除/保护/切换默认分支等
"org": org.Shortcuts(), // 组织Organization管理信息/成员/项目等
"user": user.Shortcuts(), // 用户管理:个人信息/关注/粉丝等
"search": search.Shortcuts(), // 搜索:仓库/疑修/用户等全局搜索
"ci": ci.Shortcuts(), // CI/CD 流水线:构建状态/触发/日志等
"webhook": webhook.Shortcuts(), // Webhook 管理:列表/创建/更新/删除等
"wiki": wiki.Shortcuts(), // Wiki 文档管理:页面/列表/编辑等
"snippet": snippet.Shortcuts(), // 代码片段Snippet管理创建/列表/查看/删除等
"collaborator": collaborator.Shortcuts(), // 协作者Collaborator管理添加/移除/权限等
"tag": tag.Shortcuts(), // Git 标签管理:列表/创建/删除等
"milestone": milestone.Shortcuts(), // 里程碑Milestone管理列表/创建/更新等
"file": file.Shortcuts(), // 文件内容操作:读取/创建/更新/删除等
"commit": commit.Shortcuts(), // 提交Commit操作列表/详情/对比等
"label": label.Shortcuts(), // 疑修标签Label管理列表/创建/更新等
"sshkey": sshkey.Shortcuts(), // SSH 公钥管理:列表/添加/删除等
"util": util.Shortcuts(), // 工具命令:开源许可证/忽略模板/设置等
"dataset": dataset.Shortcuts(), // 数据集Dataset管理列表/创建等
"template": template.Shortcuts(), // 项目模板管理:列表/创建等
"attachment": attachment.Shortcuts(), // 文件附件操作:上传/下载等
}
}
// GetDescriptions 返回每个命令组的中文描述,用于交互式命令面板展示。
func GetDescriptions() map[string]string {
return map[string]string{
"repo": "仓库管理", // 仓库的增删改查、Star、Watch 等
"issue": "疑修 (Issue) 管理", // 疑修的创建、查看、关闭、评论等
"pr": "合并请求 (PR) 管理", // 合并请求的创建、合并、评审等
"release": "发行版管理", // 版本发布的创建与管理
"branch": "分支管理", // 分支的创建、删除、保护等
"org": "组织管理", // 组织信息与成员管理
"user": "用户管理", // 用户信息与社交关系
"search": "全局搜索", // 仓库/疑修/用户搜索
"ci": "CI/CD 流水线", // 持续集成与部署
"webhook": "Webhook 管理", // Web 钩子配置
"wiki": "Wiki 文档", // 项目文档页面管理
"snippet": "代码片段管理", // 代码片段的创建与分享
"collaborator": "协作者管理", // 仓库协作者与权限
"tag": "Git 标签管理", // 版本标签操作
"milestone": "里程碑管理", // 版本里程碑管理
"file": "文件内容操作", // 仓库文件的读写
"commit": "提交管理", // 提交记录查看
"label": "疑修标签管理", // 标签的创建与管理
"sshkey": "SSH 公钥管理", // SSH 公钥配置
"util": "工具命令", // 许可证、忽略模板等工具
"dataset": "数据集管理", // 数据集管理
"template": "项目模板管理", // 项目模板管理
"attachment": "文件附件操作", // 附件上传与下载
}
}

View File

@ -10,12 +10,32 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出仓库的所有发行版
{
Name: "list",
Description: "List releases",
Description: "列出仓库的所有发行版",
Long: `List all releases in the current repository.
Shows release tag, name, prerelease status, and creation date.
Use --page and --limit for pagination.
Use --prerelease-only to show only pre-release versions.
Use --latest to show only the most recent release.`,
Example: ` # List releases
gitlink release +list
# Show 50 releases per page
gitlink release +list --limit 50
# Show only pre-release versions
gitlink release +list --prerelease-only
# Show only the latest release
gitlink release +list --latest`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "prerelease-only", Usage: "Show only pre-release versions", Bool: true, Default: "false"},
{Name: "latest", Usage: "Show only the latest release", Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -28,17 +48,38 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
// [P2 参数补全] API 不支持"只看预发布版本"的过滤参数,改为客户端过滤
// 拿到全量数据后遍历,只保留 prerelease=true 的条目
if ctx.Arg("prerelease-only") == "true" {
filterReleasesByPrerelease(env)
}
// [P2 参数补全] --latest: 拿到列表后只保留第一条(最新)
if ctx.Arg("latest") == "true" {
keepOnlyLatestRelease(env)
}
return ctx.Output(env)
},
},
// +create创建发行版
{
Name: "create",
Description: "Create a release",
Description: "创建发行版",
Long: `Create a new release for the current repository.
A release is associated with a Git tag. If the tag does not exist,
it will be created on the target branch. Use --body for release notes
and --prerelease to mark it as a pre-release version.`,
Example: ` # Create a release
gitlink release +create --tag v1.0.0 --name "Version 1.0.0"
# Create a prerelease with notes targeting a specific branch
gitlink release +create --tag v2.0.0-beta --name "v2.0 Beta" \
--body "Beta release for testing" --target develop --prerelease true`,
Flags: []common.Flag{
{Name: "tag", Short: "t", Usage: "Tag name", Required: true},
{Name: "name", Short: "n", Usage: "Release name", Required: true},
{Name: "body", Short: "b", Usage: "Release notes"},
{Name: "target", Usage: "Target branch", Default: "master"},
{Name: "target", Usage: "Target branch (default: repository default branch)"},
{Name: "prerelease", Usage: "Mark as prerelease (true/false)", Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
@ -54,8 +95,12 @@ func Shortcuts() []*common.Shortcut {
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
// [P1 Bug#2] 原代码: payload["target_commitish"] = "master"(硬编码)
// 修复: 用户未指定 --target 时,使用 GetDefaultBranch() 动态查询
if t := ctx.Arg("target"); t != "" {
payload["target_commitish"] = t
payload["target_commitish"] = t // 用户显式指定了目标分支
} else {
payload["target_commitish"] = common.GetDefaultBranch(ctx) // 动态查询默认分支
}
if ctx.Arg("prerelease") == "true" {
payload["prerelease"] = true
@ -67,9 +112,18 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +view查看发行版的详细信息
{
Name: "view",
Description: "View release details",
Description: "查看发行版的详细信息",
Long: `Show detailed information about a specific release.
Provide the release ID or tag name to view its details.`,
Example: ` # View a release by ID
gitlink release +view --id 42
# View a release by tag
gitlink release +view --id v1.0.0`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID or tag", Required: true},
},
@ -85,9 +139,15 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +delete删除发行版
{
Name: "delete",
Description: "Delete a release",
Description: "删除发行版",
Long: `Delete a release from the current repository.
This action is irreversible. A confirmation prompt is shown before deletion.`,
Example: ` # Delete a release
gitlink release +delete --id 42`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
},
@ -96,6 +156,11 @@ func Shortcuts() []*common.Shortcut {
return err
}
id, _ := ctx.RequireArg("id")
if err := common.ConfirmAction(
fmt.Sprintf("delete release #%s", id),
); err != nil {
return err
}
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if delErr != nil {
// GitLink API bug: delete succeeds but returns error status.
@ -104,16 +169,120 @@ func Shortcuts() []*common.Shortcut {
if viewErr != nil {
// Release no longer exists — delete actually succeeded
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功",
"message": "Release deleted successfully",
}, nil))
}
// Release still exists — delete truly failed
return delErr
}
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功",
"message": "Release deleted successfully",
}, nil))
},
},
// +edit获取发行版编辑页详情
{
Name: "edit",
Description: "获取发行版编辑页详情",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/releases/%s/edit", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update更新发行版
{
Name: "update",
Description: "更新发行版",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
{Name: "name", Short: "n", Usage: "New release name"},
{Name: "body", Short: "b", Usage: "New release body"},
{Name: "prerelease", Usage: "Mark as prerelease (true/false)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
payload := map[string]interface{}{}
if n := ctx.Arg("name"); n != "" {
payload["name"] = n
}
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
if p := ctx.Arg("prerelease"); p != "" {
payload["prerelease"] = p == "true"
}
if len(payload) == 0 {
return fmt.Errorf("at least one of --name, --body, or --prerelease is required")
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/releases/%s", ctx.Owner, ctx.Repo, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
// filterReleasesByPrerelease removes non-prerelease entries from the envelope data.
func filterReleasesByPrerelease(env *output.Envelope) {
data, ok := env.Data.(map[string]interface{})
if !ok {
return
}
releases, ok := data["releases"].([]interface{})
if !ok {
return
}
filtered := make([]interface{}, 0, len(releases))
for _, r := range releases {
m, ok := r.(map[string]interface{})
if !ok {
continue
}
if isPrerelease(m) {
filtered = append(filtered, m)
}
}
data["releases"] = filtered
}
// keepOnlyLatestRelease keeps only the first (most recent) release.
func keepOnlyLatestRelease(env *output.Envelope) {
data, ok := env.Data.(map[string]interface{})
if !ok {
return
}
releases, ok := data["releases"].([]interface{})
if !ok || len(releases) == 0 {
return
}
data["releases"] = []interface{}{releases[0]}
}
// isPrerelease checks whether a release map has prerelease=true.
func isPrerelease(m map[string]interface{}) bool {
v, ok := m["prerelease"]
if !ok {
return false
}
switch val := v.(type) {
case bool:
return val
default:
return fmt.Sprint(val) == "true"
}
}

View File

@ -1,17 +1,33 @@
package repo
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出当前用户或指定用户的仓库支持按类别筛选manage/mirror/sync/fork/all
{
Name: "list",
Description: "List repositories for a user or organization",
Description: "列出用户或组织的仓库",
Long: `List repositories visible to the current user.
By default shows repositories you manage. Use --user to list
another user's repositories. Use --category to filter by type
(manage, mirror, sync, fork, or all).`,
Example: ` # List your managed repositories
gitlink repo +list
# List all repositories for a specific user
gitlink repo +list --user zhangsan --category all
# List forked repositories with pagination
gitlink repo +list --category fork --page 2 --limit 10`,
Flags: []common.Flag{
{Name: "user", Short: "u", Usage: "User login (default: current user)"},
{Name: "category", Short: "c", Usage: "Filter: manage/mirror/sync/fork/all (default: manage)", Default: "manage"},
@ -38,9 +54,15 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +info显示当前仓库的详情名称/描述/可见性/默认分支等)
{
Name: "info",
Description: "Show repository details",
Description: "显示仓库详情",
Long: `Display detailed information about the current repository.
Shows name, description, visibility, default branch, and other metadata.`,
Example: ` # Show details of the current repository
gitlink repo +info`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -52,9 +74,19 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +create在当前账户下创建新仓库需 --name可选 --description 与 --private
{
Name: "create",
Description: "Create a new repository",
Description: "创建新仓库",
Long: `Create a new repository under your account.
The repository name is required. Optionally provide a description
and set visibility to private.`,
Example: ` # Create a public repository
gitlink repo +create --name my-project
# Create a private repository with a description
gitlink repo +create --name my-project --description "A cool project" --private true`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
{Name: "description", Short: "d", Usage: "Repository description"},
@ -94,9 +126,15 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +fork将当前仓库 fork 到自己的命名空间下
{
Name: "fork",
Description: "Fork a repository",
Description: "Fork 仓库",
Long: `Fork the current repository into your account.
This creates a copy of the repository under your namespace.`,
Example: ` # Fork the current repository
gitlink repo +fork`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -108,13 +146,25 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +delete永久删除当前仓库不可逆删除前需确认
{
Name: "delete",
Description: "Delete a repository",
Description: "删除仓库",
Long: `Delete the current repository permanently.
This action is irreversible and removes all data including issues,
pull requests, and wiki. A confirmation prompt is shown before deletion.`,
Example: ` # Delete the current repository
gitlink repo +delete`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
if err := common.ConfirmAction(
fmt.Sprintf("delete repository %s/%s", ctx.Owner, ctx.Repo),
); err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
if err != nil {
return err
@ -122,5 +172,558 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// --- Settings/Info group ---
// +update更新当前仓库的设置名称/描述/可见性/网站)
{
Name: "update",
Description: "更新仓库设置",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "New repository name"},
{Name: "description", Short: "d", Usage: "Repository description"},
{Name: "private", Usage: "Make repository private (true/false)"},
{Name: "website", Short: "w", Usage: "Repository website URL"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
body := map[string]interface{}{}
if v := ctx.Arg("name"); v != "" {
body["name"] = v
}
if v := ctx.Arg("description"); v != "" {
body["description"] = v
}
if v := ctx.Arg("private"); v != "" {
body["private"] = v == "true"
}
if v := ctx.Arg("website"); v != "" {
body["website"] = v
}
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +about显示仓库的关于页面
{
Name: "about",
Description: "显示仓库关于页面",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/about", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +menu列出仓库的导航菜单
{
Name: "menu",
Description: "列出仓库导航菜单",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/menu_list", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +units-get获取仓库的导航单元设置
{
Name: "units-get",
Description: "获取仓库导航单元设置",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_units", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +units-set更新仓库的导航单元设置--units 传 JSON 数组)
{
Name: "units-set",
Description: "更新仓库导航单元设置",
Flags: []common.Flag{
{Name: "units", Short: "u", Usage: "JSON array of unit settings", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
unitsRaw, err := ctx.RequireArg("units")
if err != nil {
return err
}
var units []interface{}
if err := json.Unmarshal([]byte(unitsRaw), &units); err != nil {
return fmt.Errorf("invalid JSON for --units: %w", err)
}
body := map[string]interface{}{
"units": units,
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/project_units", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +edit-detail获取仓库编辑页面的详情
{
Name: "edit-detail",
Description: "获取仓库编辑详情",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/edit", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +simple获取仓库的简化详情信息
{
Name: "simple",
Description: "获取仓库简化详情",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/simple", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Stats group ---
// +code-stats获取仓库的代码统计信息
{
Name: "code-stats",
Description: "获取仓库代码统计",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/code_stats", ctx.RepoPath()), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +languages获取仓库的编程语言占比
{
Name: "languages",
Description: "获取仓库语言占比",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/languages", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +contributors列出仓库的贡献者
{
Name: "contributors",
Description: "列出仓库贡献者",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/contributors", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +contributors-stat获取贡献者的代码统计信息
{
Name: "contributors-stat",
Description: "获取贡献者代码统计",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/contributors/stat", ctx.RepoPath()), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Social/Interaction group ---
// +recommend获取推荐项目列表
{
Name: "recommend",
Description: "获取推荐项目",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/projects/recommend", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +star点赞加星指定项目
{
Name: "star",
Description: "点赞项目",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/projects/%s/praise_tread/like", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +unstar取消点赞取消加星指定项目
{
Name: "unstar",
Description: "取消点赞项目",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/projects/%s/praise_tread/unlike", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +watch关注指定项目
{
Name: "watch",
Description: "关注项目",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
idInt, err := strconv.Atoi(id)
if err != nil {
return fmt.Errorf("invalid project id: %w", err)
}
body := map[string]interface{}{
"project_id": idInt,
}
env, err := ctx.CallAPI("POST", "/watchers/follow", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +unwatch取消关注指定项目
{
Name: "unwatch",
Description: "取消关注项目",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/watchers/unfollow?project_id=%s", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +stargazers列出对当前仓库加星的用户
{
Name: "stargazers",
Description: "列出仓库的加星用户",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/stargazers", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +watchers列出关注当前仓库的用户
{
Name: "watchers",
Description: "列出仓库的关注者",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/watchers", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Transfer group ---
// +transfer将仓库转移给其他所有者或组织
{
Name: "transfer",
Description: "转移仓库所有权",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Target owner/organization", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
body := map[string]interface{}{
"owner": owner,
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +cancel-transfer取消待处理的仓库转移申请
{
Name: "cancel-transfer",
Description: "取消待处理的仓库转移",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/applied_transfer_projects/cancel", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +transfer-orgs列出可用于转移目标组织
{
Name: "transfer-orgs",
Description: "列出可转移的组织",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/applied_transfer_projects/organizations", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +invite-link获取当前仓库的邀请链接
{
Name: "invite-link",
Description: "获取仓库邀请链接",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_invite_links/current_link", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- L3 Operations: Join/Quit/Mirror/Topics ---
// +join申请加入指定项目可指定期望角色
{
Name: "join",
Description: "申请加入项目",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Project ID", Required: true},
{Name: "role", Short: "r", Usage: "Desired role"},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body := map[string]interface{}{
"project_id": id,
}
if role := ctx.Arg("role"); role != "" {
body["role"] = role
}
env, err := ctx.CallAPI("POST", "/applied_projects", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +quit退出当前项目
{
Name: "quit",
Description: "退出项目",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/quit", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +migrate创建镜像项目从上游 URL 镜像克隆)
{
Name: "migrate",
Description: "创建镜像项目",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
{Name: "clone-url", Short: "u", Usage: "Upstream clone URL", Required: true},
{Name: "private", Usage: "Make repository private (true/false)", Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
cloneURL, err := ctx.RequireArg("clone-url")
if err != nil {
return err
}
body := map[string]interface{}{
"repository_name": name,
"clone_addr": cloneURL,
}
if ctx.Arg("private") == "true" {
body["private"] = true
}
env, err := ctx.CallAPI("POST", "/projects/migrate", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +sync-mirror同步镜像仓库从上游拉取更新
{
Name: "sync-mirror",
Description: "同步镜像仓库",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Repository ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/repositories/%s/sync_mirror", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +topics列出项目话题
{
Name: "topics",
Description: "列出项目话题",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/v1/project_topics", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +create-topic创建项目话题需 --name
{
Name: "create-topic",
Description: "创建项目话题",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Topic name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
body := map[string]interface{}{
"name": name,
}
env, err := ctx.CallAPI("POST", "/v1/project_topics", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete-topic删除指定项目话题
{
Name: "delete-topic",
Description: "删除项目话题",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Topic ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/project_topics/%s", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

543
shortcuts/repo/repo_test.go Normal file
View File

@ -0,0 +1,543 @@
package repo
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// --- Helpers ---
func runRepoShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findRepoShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findRepoShortcut(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 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 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)
}
}
// --- Required tests ---
func TestRepoUpdateSendsPATCH(t *testing.T) {
var calledMethod string
var calledPath string
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"name": "new-name",
"description": "updated desc",
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "update", map[string]string{
"name": "new-name",
"description": "updated desc",
"private": "true",
"website": "https://example.com",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "PATCH")
assertEqual(t, calledPath, "/owner/repo.json")
assertEqual(t, payload["name"], "new-name")
assertEqual(t, payload["description"], "updated desc")
assertEqual(t, payload["private"], true)
assertEqual(t, payload["website"], "https://example.com")
}
func TestRepoAboutCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"name": "test-repo",
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "about", nil)
if err != nil {
t.Fatalf("about shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "GET")
assertEqual(t, calledPath, "/owner/repo/about.json")
}
func TestRepoStarCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"status": 0,
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "star", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("star shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/projects/42/praise_tread/like.json")
}
func TestRepoLanguagesCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"Go": float64(80),
"Java": float64(20),
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "languages", nil)
if err != nil {
t.Fatalf("languages shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "GET")
assertEqual(t, calledPath, "/owner/repo/languages.json")
}
// --- Additional coverage tests ---
func TestRepoMenuCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"menus": []interface{}{}})
}))
defer server.Close()
err := runRepoShortcut(t, server, "menu", nil)
if err != nil {
t.Fatalf("menu shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/menu_list.json")
}
func TestRepoUnitsGetCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"units": []interface{}{}})
}))
defer server.Close()
err := runRepoShortcut(t, server, "units-get", nil)
if err != nil {
t.Fatalf("units-get shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/project_units.json")
}
func TestRepoUnitsSetPostsCorrectPayload(t *testing.T) {
var calledMethod string
var calledPath string
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "units-set", map[string]string{
"units": `[{"type":"code","enabled":true}]`,
})
if err != nil {
t.Fatalf("units-set shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/owner/repo/project_units.json")
// The units JSON string should be unmarshaled into a slice
units, ok := body["units"].([]interface{})
if !ok || len(units) == 0 {
t.Fatalf("expected units to be a non-empty array, got %v", body["units"])
}
}
func TestRepoCodeStatsUsesV1Endpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"additions": float64(100),
"deletions": float64(20),
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "code-stats", nil)
if err != nil {
t.Fatalf("code-stats shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/code_stats.json")
}
func TestRepoContributorsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"contributors": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "contributors", nil)
if err != nil {
t.Fatalf("contributors shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/contributors.json")
}
func TestRepoContributorsStatUsesV1Endpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"contributors": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "contributors-stat", nil)
if err != nil {
t.Fatalf("contributors-stat shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/contributors/stat.json")
}
func TestRepoRecommendCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"projects": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "recommend", nil)
if err != nil {
t.Fatalf("recommend shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/projects/recommend.json")
}
func TestRepoUnstarCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "unstar", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("unstar shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "DELETE")
assertEqual(t, calledPath, "/projects/42/praise_tread/unlike.json")
}
func TestRepoWatchCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "watch", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("watch shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/watchers/follow.json")
assertEqual(t, body["project_id"], float64(42))
}
func TestRepoUnwatchCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "unwatch", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("unwatch shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "DELETE")
assertEqual(t, calledPath, "/watchers/unfollow.json")
}
func TestRepoStargazersCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"stargazers": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "stargazers", nil)
if err != nil {
t.Fatalf("stargazers shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/stargazers.json")
}
func TestRepoWatchersCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"watchers": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "watchers", nil)
if err != nil {
t.Fatalf("watchers shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/watchers.json")
}
func TestRepoTransferCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "transfer", map[string]string{
"owner": "new-org",
})
if err != nil {
t.Fatalf("transfer shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects.json")
}
func TestRepoCancelTransferCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runRepoShortcut(t, server, "cancel-transfer", nil)
if err != nil {
t.Fatalf("cancel-transfer shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects/cancel.json")
}
func TestRepoTransferOrgsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"organizations": []interface{}{},
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "transfer-orgs", nil)
if err != nil {
t.Fatalf("transfer-orgs shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/applied_transfer_projects/organizations.json")
}
func TestRepoInviteLinkCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"link": "https://example.com/invite/abc",
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "invite-link", nil)
if err != nil {
t.Fatalf("invite-link shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/project_invite_links/current_link.json")
}
func TestRepoEditDetailCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "edit-detail", nil)
if err != nil {
t.Fatalf("edit-detail shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/edit.json")
}
func TestRepoSimpleCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
})
}))
defer server.Close()
err := runRepoShortcut(t, server, "simple", nil)
if err != nil {
t.Fatalf("simple shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/owner/repo/simple.json")
}
func TestRepoStarRequiresID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when id is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "star", nil)
if err == nil {
t.Fatal("expected error when id is missing, got nil")
}
}
func TestRepoWatchRequiresID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when id is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "watch", nil)
if err == nil {
t.Fatal("expected error when id is missing, got nil")
}
}
func TestRepoUnitsSetRequiresUnits(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when units is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "units-set", nil)
if err == nil {
t.Fatal("expected error when units is missing, got nil")
}
}
func TestRepoTransferRequiresOwner(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when owner is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "transfer", nil)
if err == nil {
t.Fatal("expected error when owner is missing, got nil")
}
}

View File

@ -8,13 +8,33 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +repos搜索仓库
{
Name: "repos",
Description: "Search repositories",
Description: "搜索仓库",
Long: `Search for repositories on GitLink by keyword.
Returns matching repository names, owners, and descriptions.
Use --language to filter by programming language, --sort and --order
to control ordering, and --page and --limit for pagination.`,
Example: ` # Search for repositories
gitlink search +repos --keyword "cli"
# Show 50 results per page
gitlink search +repos --keyword "cli" --limit 50
# Filter by language
gitlink search +repos --keyword "web" --language Go
# Sort by update time descending
gitlink search +repos --keyword "cli" --sort updated --order desc`,
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "language", Usage: "Filter by programming language"},
{Name: "sort", Usage: "Sort field (e.g. stars, updated)"},
{Name: "order", Usage: "Sort direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
keyword, _ := ctx.RequireArg("keyword")
@ -22,6 +42,17 @@ func Shortcuts() []*common.Shortcut {
q.Set("search", keyword)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
// [P2 参数补全] ⚠️ API 静默忽略CLI 把参数发给 APIAPI 返回成功但未按参数过滤
// 这是 GitLink 平台侧的问题CLI 仍保留参数,将来平台修复后可直接生效
if l := ctx.Arg("language"); l != "" {
q.Set("language", l)
}
if s := ctx.Arg("sort"); s != "" {
q.Set("sort", s)
}
if o := ctx.Arg("order"); o != "" {
q.Set("order", o)
}
env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
if err != nil {
return err
@ -29,13 +60,29 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +users搜索用户
{
Name: "users",
Description: "Search users",
Description: "搜索用户",
Long: `Search for users on GitLink by keyword.
Returns matching user login names, display names, and profile summaries.
Use --sort and --order to control ordering, and --page and --limit
for pagination.`,
Example: ` # Search for users
gitlink search +users --keyword "zhang"
# Show 50 results per page
gitlink search +users --keyword "zhang" --limit 50
# Sort results
gitlink search +users --keyword "zhang" --sort created --order desc`,
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "sort", Usage: "Sort field"},
{Name: "order", Usage: "Sort direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
keyword, _ := ctx.RequireArg("keyword")
@ -43,6 +90,13 @@ func Shortcuts() []*common.Shortcut {
q.Set("search", keyword)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
// [P2 参数补全] ⚠️ API 静默忽略(同 +repos
if s := ctx.Arg("sort"); s != "" {
q.Set("sort", s)
}
if o := ctx.Arg("order"); o != "" {
q.Set("order", o)
}
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
if err != nil {
return err

View File

@ -0,0 +1,271 @@
package snippet
import (
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const snippetsDir = "snippets"
// Shortcuts returns code snippet management shortcuts.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出仓库中的所有代码片段
{
Name: "list",
Description: "列出仓库中的所有代码片段",
Run: runList,
},
// +view查看代码片段内容
{
Name: "view",
Description: "查看代码片段内容",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Snippet name", Required: true},
},
Run: runView,
},
// +create新建代码片段
{
Name: "create",
Description: "新建代码片段",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Snippet name (used as filename)", Required: true},
{Name: "content", Short: "c", Usage: "Snippet content (code)", Required: true},
{Name: "language", Short: "l", Usage: "Programming language (e.g. go, python, js)"},
{Name: "message", Short: "m", Usage: "Commit message"},
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
},
Run: runCreate,
},
// +delete删除代码片段
{
Name: "delete",
Description: "删除代码片段",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Snippet name", Required: true},
{Name: "message", Short: "m", Usage: "Commit message"},
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
},
Run: runDelete,
},
}
}
// Override repoGitURLOverride in tests to point to a local repo.
var repoGitURLOverride string
// repoGitURL returns the public Git URL for the main repository.
func repoGitURL(owner, repo string) string {
if repoGitURLOverride != "" {
return repoGitURLOverride
}
return fmt.Sprintf("https://gitlink.org.cn/%s/%s.git", owner, repo)
}
// runGit runs a git command in the specified directory.
func runGit(dir string, args ...string) (stdout, stderr string, err error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
var outBuf, errBuf strings.Builder
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err = cmd.Run()
return outBuf.String(), errBuf.String(), err
}
// snippetFileName returns the full file path in the repo for a snippet.
func snippetFileName(name string) string {
if strings.Contains(name, ".") {
return fmt.Sprintf("%s/%s", snippetsDir, name)
}
return fmt.Sprintf("%s/%s.md", snippetsDir, name)
}
// buildSnippetContent builds the markdown content for a snippet.
func buildSnippetContent(language, code string) string {
var b strings.Builder
b.WriteString("```")
b.WriteString(language)
b.WriteString("\n")
b.WriteString(code)
if !strings.HasSuffix(code, "\n") {
b.WriteString("\n")
}
b.WriteString("```\n")
return b.String()
}
// repoFilePath returns the API path prefix for repo file operations.
func repoFilePath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/%s/%s", ctx.Owner, ctx.Repo)
}
func runList(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// Clone repo to temp dir (read-only for public repos)
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
if err != nil {
return fmt.Errorf("clone repo: %s%s", stderr, err)
}
// List files in the snippets directory
snippetPath := filepath.Join(tmpDir, snippetsDir)
entries, err := os.ReadDir(snippetPath)
if err != nil {
return ctx.OutputData(map[string]interface{}{
"snippets": []interface{}{},
"total": 0,
})
}
snippets := make([]map[string]interface{}, 0)
for _, e := range entries {
if !e.IsDir() {
snippets = append(snippets, map[string]interface{}{
"name": e.Name(),
})
}
}
return ctx.OutputData(map[string]interface{}{
"snippets": snippets,
"total": len(snippets),
})
}
func runView(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
// Clone repo to temp dir (read-only for public repos)
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
if err != nil {
return fmt.Errorf("clone repo: %s%s", stderr, err)
}
filePath := snippetFileName(name)
content, err := os.ReadFile(filepath.Join(tmpDir, filePath))
if err != nil {
return fmt.Errorf("snippet %q not found", name)
}
return ctx.OutputData(map[string]interface{}{
"name": name,
"content": string(content),
})
}
func runCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
language := ctx.Arg("language")
message := ctx.Arg("message")
if message == "" {
message = fmt.Sprintf("Add snippet %q", name)
}
filePath := snippetFileName(name)
snippetContent := buildSnippetContent(language, content)
payload := map[string]interface{}{
"filepath": filePath,
"content": base64.StdEncoding.EncodeToString([]byte(snippetContent)),
"message": message,
"branch": ctx.Arg("branch"),
}
if _, err = ctx.CallAPI("POST", repoFilePath(ctx)+"/create_file", payload); err != nil {
return fmt.Errorf("create snippet: %w", err)
}
return ctx.OutputData(map[string]interface{}{
"name": name,
"message": "Snippet created",
})
}
func runDelete(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
message := ctx.Arg("message")
if message == "" {
message = fmt.Sprintf("Delete snippet %q", name)
}
filePath := snippetFileName(name)
// Auto-fetch file SHA using Git (public repo)
tmpDir, err := os.MkdirTemp("", "gitlink-snippet-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
_, stderr, err := runGit("", "clone", "--depth", "1", repoGitURL(ctx.Owner, ctx.Repo), tmpDir)
if err != nil {
return fmt.Errorf("clone repo: %s%s", stderr, err)
}
// Get blob SHA of the file
shaOut, _, err := runGit(tmpDir, "hash-object", filePath)
sha := strings.TrimSpace(shaOut)
payload := map[string]interface{}{
"filepath": filePath,
"message": message,
"branch": ctx.Arg("branch"),
"sha": sha,
}
if _, err = ctx.CallAPI("DELETE", repoFilePath(ctx)+"/delete_file", payload); err != nil {
return fmt.Errorf("delete snippet: %w", err)
}
return ctx.OutputData(map[string]interface{}{
"name": name,
"message": "Snippet deleted",
})
}

View File

@ -0,0 +1,404 @@
package snippet
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// --- Git repo test helpers ---
// setupSnippetRepo creates a bare Git repo with snippets, returns the repo path.
func setupSnippetRepo(t *testing.T, files map[string]string) (bareRepo string, cleanup func()) {
t.Helper()
remoteDir, err := os.MkdirTemp("", "gitlink-snippet-remote-*")
if err != nil {
t.Fatalf("mkdir remote: %v", err)
}
runGitClean("", "init", "--bare", remoteDir)
workDir, err := os.MkdirTemp("", "gitlink-snippet-work-*")
if err != nil {
os.RemoveAll(remoteDir)
t.Fatalf("mkdir work: %v", err)
}
runGitClean("", "clone", remoteDir, workDir)
runGitClean(workDir, "config", "user.email", "test@test.com")
runGitClean(workDir, "config", "user.name", "Test User")
// Create snippets directory and files
snippetPath := filepath.Join(workDir, "snippets")
os.MkdirAll(snippetPath, 0755)
if len(files) == 0 {
os.WriteFile(filepath.Join(snippetPath, ".gitkeep"), []byte(""), 0644)
}
for name, content := range files {
os.WriteFile(filepath.Join(snippetPath, name), []byte(content), 0644)
}
runGitClean(workDir, "add", ".")
runGitClean(workDir, "commit", "-m", "Add snippets")
runGitClean(workDir, "push", "origin", "master")
os.RemoveAll(workDir)
return remoteDir, func() { os.RemoveAll(remoteDir) }
}
func runGitClean(dir string, args ...string) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
panic("git " + strings.Join(args, " ") + ": " + string(out) + ": " + err.Error())
}
}
// --- HTTP test helpers ---
func runSnippetShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findSnippetShortcut(t, name)
var cli *client.Client
if server != nil {
cli = &client.Client{HTTP: server.Client(), BaseURL: server.URL}
} else {
cli = &client.Client{HTTP: http.DefaultClient, BaseURL: ""}
}
ctx := &common.RuntimeContext{
Client: cli,
Owner: "test-owner",
Repo: "test-repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findSnippetShortcut(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 newSnippetTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
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 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
}
// --- Git-based tests (list, view) ---
func TestSnippetList(t *testing.T) {
files := map[string]string{
"hello.go": "package main\nfunc main() {}\n",
"sort.py": "def sort():\n pass\n",
"readme.md": "# Snippets\n",
}
repo, cleanup := setupSnippetRepo(t, files)
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestSnippetListEmpty(t *testing.T) {
repo, cleanup := setupSnippetRepo(t, map[string]string{})
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
t.Fatalf("list empty failed: %v", err)
}
}
func TestSnippetListNoSnippetsDir(t *testing.T) {
// Create a bare repo with no snippets directory
remoteDir, err := os.MkdirTemp("", "gitlink-snippet-empty-*")
if err != nil {
t.Fatalf("mkdir: %v", err)
}
defer os.RemoveAll(remoteDir)
runGitClean("", "init", "--bare", remoteDir)
workDir, err := os.MkdirTemp("", "gitlink-snippet-work-*")
if err != nil {
t.Fatalf("mkdir: %v", err)
}
defer os.RemoveAll(workDir)
runGitClean("", "clone", remoteDir, workDir)
runGitClean(workDir, "config", "user.email", "test@test.com")
runGitClean(workDir, "config", "user.name", "Test User")
os.WriteFile(filepath.Join(workDir, ".gitkeep"), []byte(""), 0644)
runGitClean(workDir, "add", ".")
runGitClean(workDir, "commit", "-m", "init")
runGitClean(workDir, "push", "origin", "master")
os.RemoveAll(workDir)
repoGitURLOverride = remoteDir
defer func() { repoGitURLOverride = "" }()
if err := runSnippetShortcut(t, nil, "list", nil); err != nil {
t.Fatalf("list no snippets dir failed: %v", err)
}
}
func TestSnippetView(t *testing.T) {
files := map[string]string{
"hello.go": "package main\nfunc main() {}\n",
}
repo, cleanup := setupSnippetRepo(t, files)
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
if err := runSnippetShortcut(t, nil, "view", map[string]string{"name": "hello.go"}); err != nil {
t.Fatalf("view failed: %v", err)
}
}
func TestSnippetViewNotFound(t *testing.T) {
files := map[string]string{
"hello.go": "package main\n",
}
repo, cleanup := setupSnippetRepo(t, files)
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
err := runSnippetShortcut(t, nil, "view", map[string]string{"name": "nope.go"})
if err == nil {
t.Fatal("expected error for non-existent snippet")
}
}
// --- API-based tests (create, delete) ---
func TestSnippetCreate(t *testing.T) {
var payload map[string]interface{}
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "created"})
})
defer server.Close()
err := runSnippetShortcut(t, server, "create", map[string]string{
"name": "hello.go",
"content": "package main\n\nfunc main() {}",
"language": "go",
"message": "Add hello.go snippet",
"branch": "master",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
if payload["filepath"] != "snippets/hello.go" {
t.Fatalf("filepath = %q, want snippets/hello.go", payload["filepath"])
}
if payload["branch"] != "master" {
t.Fatalf("branch = %q, want master", payload["branch"])
}
}
func TestSnippetCreateWithoutExtension(t *testing.T) {
var payload map[string]interface{}
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "created"})
})
defer server.Close()
err := runSnippetShortcut(t, server, "create", map[string]string{
"name": "myscript",
"content": "#!/bin/bash\necho hello",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
if payload["filepath"] != "snippets/myscript.md" {
t.Fatalf("filepath = %q, want snippets/myscript.md", payload["filepath"])
}
}
func TestSnippetDelete(t *testing.T) {
// Delete needs a Git repo to fetch SHA, plus an API server for the delete call
files := map[string]string{
"hello.go": "package main\nfunc main() {}\n",
}
repo, cleanup := setupSnippetRepo(t, files)
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
// API server for delete
var payload map[string]interface{}
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
})
defer server.Close()
err := runSnippetShortcut(t, server, "delete", map[string]string{
"name": "hello.go",
"message": "Remove snippet",
})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
if payload["filepath"] != "snippets/hello.go" {
t.Fatalf("filepath = %q, want snippets/hello.go", payload["filepath"])
}
if payload["sha"] == "" {
t.Fatal("sha should not be empty")
}
}
func TestSnippetDeleteDefaultMessage(t *testing.T) {
files := map[string]string{"old.py": "print('old')\n"}
repo, cleanup := setupSnippetRepo(t, files)
defer cleanup()
repoGitURLOverride = repo
defer func() { repoGitURLOverride = "" }()
var payload map[string]interface{}
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{})
})
defer server.Close()
if err := runSnippetShortcut(t, server, "delete", map[string]string{"name": "old.py"}); err != nil {
t.Fatalf("delete failed: %v", err)
}
if msg, ok := payload["message"].(string); !ok || !strings.Contains(msg, "old.py") {
t.Fatalf("message = %q, should mention old.py", payload["message"])
}
}
// --- Validation tests ---
func TestSnippetCreateRejectsMissingName(t *testing.T) {
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("should not call API")
})
defer server.Close()
err := runSnippetShortcut(t, server, "create", map[string]string{"content": "code"})
if err == nil {
t.Fatal("expected error when --name is missing")
}
}
func TestSnippetCreateRejectsMissingContent(t *testing.T) {
server := newSnippetTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("should not call API")
})
defer server.Close()
err := runSnippetShortcut(t, server, "create", map[string]string{"name": "test.go"})
if err == nil {
t.Fatal("expected error when --content is missing")
}
}
func TestSnippetViewRejectsMissingName(t *testing.T) {
err := findSnippetShortcut(t, "view").Run(&common.RuntimeContext{
Owner: "test",
Repo: "test",
Format: "json",
Args: map[string]string{},
})
if err == nil {
t.Fatal("expected error when --name is missing")
}
}
func TestSnippetDeleteRejectsMissingName(t *testing.T) {
err := findSnippetShortcut(t, "delete").Run(&common.RuntimeContext{
Owner: "test",
Repo: "test",
Format: "json",
Args: map[string]string{},
})
if err == nil {
t.Fatal("expected error when --name is missing")
}
}
// --- Unit tests ---
func TestSnippetFileName(t *testing.T) {
tests := []struct{ input, want string }{
{"hello.go", "snippets/hello.go"},
{"myscript.py", "snippets/myscript.py"},
{"myscript", "snippets/myscript.md"},
{"subdir/file.js", "snippets/subdir/file.js"},
}
for _, tt := range tests {
got := snippetFileName(tt.input)
if got != tt.want {
t.Errorf("snippetFileName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestBuildSnippetContent(t *testing.T) {
got := buildSnippetContent("go", "package main\nfunc main() {}")
if !strings.Contains(got, "```go") {
t.Errorf("expected ```go in output, got: %s", got)
}
if !strings.Contains(got, "package main") {
t.Errorf("expected content in output, got: %s", got)
}
got2 := buildSnippetContent("", "echo hello")
if !strings.Contains(got2, "```\n") {
t.Errorf("expected ``` without lang, got: %s", got2)
}
}

View File

@ -0,0 +1,96 @@
package sshkey
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出当前账号的 SSH 公钥
{
Name: "list",
Description: "列出当前账号的 SSH 公钥",
Long: `List your SSH public keys.
Shows all SSH public keys associated with your account, including
key ID, title, fingerprint, and creation date.
Supports pagination with --page and --limit flags.`,
Example: ` # List all SSH keys
gitlink sshkey +list
# List with pagination
gitlink sshkey +list -p 2 -l 10`,
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 {
env, err := ctx.CallAPI("GET", "/public_keys", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +create添加新的 SSH 公钥
{
Name: "create",
Description: "添加新的 SSH 公钥",
Long: `Add a new SSH public key.
Registers a new SSH public key to your account. The key content must
be a valid public key in ssh-rsa or ssh-ed25519 format.`,
Example: ` # Add an SSH key
gitlink sshkey +create -t "My Laptop" -k "ssh-rsa AAAAB3..."`,
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Key title", Required: true},
{Name: "key", Short: "k", Usage: "Public key content (ssh-rsa/ed25519 ...)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
title, err := ctx.RequireArg("title")
if err != nil {
return err
}
key, err := ctx.RequireArg("key")
if err != nil {
return err
}
payload := map[string]string{
"title": title,
"key": key,
}
env, err := ctx.CallAPI("POST", "/public_keys", payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete删除指定的 SSH 公钥
{
Name: "delete",
Description: "删除指定的 SSH 公钥",
Long: `Delete an SSH public key.
Removes an SSH public key from your account by its ID. This action cannot be undone.`,
Example: ` # Delete an SSH key by ID
gitlink sshkey +delete -i 123`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Key ID to delete", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/public_keys/%s", id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

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

@ -0,0 +1,138 @@
package tag
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出仓库的所有标签
{
Name: "list",
Description: "列出仓库的所有标签",
Long: `List all tags in the current repository.
Shows tag name, commit SHA, and creation date.
Use --search to filter tags by name pattern.`,
Example: ` # List all tags
gitlink tag +list
# Search for tags matching a pattern
gitlink tag +list --search "v1."
# Paginate through tags
gitlink tag +list --page 2 --limit 50`,
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "search", Short: "s", Usage: "Filter by tag name"},
},
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)
env, err := ctx.CallAPIWithQuery("GET", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +view查看标签的详细信息
// 注意GitLink API 不提供"获取单个标签"端点,这里通过列出全部标签后按名称过滤实现。
{
Name: "view",
Description: "查看标签的详细信息",
Long: `Show detailed information about a specific tag.
GitLink's API has no single-tag endpoint, so this lists all tags and
returns the one whose name matches.`,
Example: ` # View details of a tag
gitlink tag +view --name v1.0.0`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "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.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo), nil)
if err != nil {
return err
}
tag := findTagByName(env, name)
if tag == nil {
return fmt.Errorf("标签 %q 不存在", name)
}
return ctx.OutputData(tag)
},
},
// +delete删除标签
{
Name: "delete",
Description: "删除标签",
Long: `Delete a tag from the current repository.
This action is irreversible. A confirmation prompt is shown before deletion.`,
Example: ` # Delete a tag
gitlink tag +delete --name v0.9-beta`,
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Tag name to delete", 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
}
if err := common.ConfirmAction(
fmt.Sprintf("delete tag %s", name),
); err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/tags/%s", ctx.Owner, ctx.Repo, name), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
// findTagByName 在标签列表 envelope 中按名称查找单个标签。
// GitLink API 没有"获取单个标签"端点tag +view 需要先列出全部再过滤。
// 找不到时返回 nil。
func findTagByName(env *output.Envelope, name string) map[string]interface{} {
if env == nil || env.Data == nil {
return nil
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil
}
tags, ok := data["tags"].([]interface{})
if !ok {
return nil
}
for _, t := range tags {
tag, ok := t.(map[string]interface{})
if !ok {
continue
}
if n, _ := tag["name"].(string); n == name {
return tag
}
}
return nil
}

65
shortcuts/tag/tag_test.go Normal file
View File

@ -0,0 +1,65 @@
package tag
import (
"testing"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
// TestFindTagByName_Found 验证能从标签列表中按名称找到标签。
func TestFindTagByName_Found(t *testing.T) {
env := &output.Envelope{
OK: true,
Data: map[string]interface{}{
"tags": []interface{}{
map[string]interface{}{"name": "v1.0.0", "id": "abc"},
map[string]interface{}{"name": "v2.0.0", "id": "def"},
},
"total_count": 2,
},
}
tag := findTagByName(env, "v1.0.0")
if tag == nil {
t.Fatal("expected to find v1.0.0")
}
if tag["id"] != "abc" {
t.Fatalf("expected id abc, got %v", tag["id"])
}
}
// TestFindTagByName_NotFound 验证找不到时返回 nil。
func TestFindTagByName_NotFound(t *testing.T) {
env := &output.Envelope{
OK: true,
Data: map[string]interface{}{
"tags": []interface{}{
map[string]interface{}{"name": "v1.0.0"},
},
},
}
if tag := findTagByName(env, "v9.9.9"); tag != nil {
t.Fatalf("expected nil for nonexistent tag, got %v", tag)
}
}
// TestFindTagByName_NilEnvelope 验证边界情况。
func TestFindTagByName_NilEnvelope(t *testing.T) {
if tag := findTagByName(nil, "v1"); tag != nil {
t.Fatal("expected nil for nil envelope")
}
env := &output.Envelope{OK: true}
if tag := findTagByName(env, "v1"); tag != nil {
t.Fatal("expected nil for nil data")
}
}
// TestFindTagByName_NoTagsKey 验证数据结构不含 tags 时返回 nil。
func TestFindTagByName_NoTagsKey(t *testing.T) {
env := &output.Envelope{
OK: true,
Data: map[string]interface{}{"other": "value"},
}
if tag := findTagByName(env, "v1"); tag != nil {
t.Fatalf("expected nil when no tags key, got %v", tag)
}
}

View File

@ -0,0 +1,150 @@
package template
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出某拥有者的项目模板
{
Name: "list",
Description: "列出某拥有者的项目模板",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/project_templates", owner), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +create创建项目模板
{
Name: "create",
Description: "创建项目模板",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
{Name: "name", Short: "n", Usage: "Template name", Required: true},
{Name: "description", Short: "d", Usage: "Template description"},
},
Run: func(ctx *common.RuntimeContext) error {
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
payload := map[string]interface{}{
"name": name,
}
if desc := ctx.Arg("description"); desc != "" {
payload["description"] = desc
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/project_templates", owner), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +view查看项目模板
{
Name: "view",
Description: "查看项目模板",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
{Name: "repo", Short: "r", Usage: "Repository name (optional, for repo-scoped templates)"},
},
Run: func(ctx *common.RuntimeContext) error {
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
var path string
if repo := ctx.Arg("repo"); repo != "" {
path = fmt.Sprintf("/v1/%s/%s/project_templates/%s", owner, repo, id)
} else {
path = fmt.Sprintf("/v1/%s/project_templates/%s", owner, id)
}
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update更新项目模板
{
Name: "update",
Description: "更新项目模板",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
{Name: "name", Short: "n", Usage: "New template name"},
{Name: "description", Short: "d", Usage: "New template description"},
},
Run: func(ctx *common.RuntimeContext) error {
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
payload := map[string]interface{}{}
if name := ctx.Arg("name"); name != "" {
payload["name"] = name
}
if desc := ctx.Arg("description"); desc != "" {
payload["description"] = desc
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/v1/%s/project_templates/%s", owner, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete删除项目模板
{
Name: "delete",
Description: "删除项目模板",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Owner (user or organization)", Required: true},
{Name: "id", Short: "i", Usage: "Template ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
owner, err := ctx.RequireArg("owner")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/project_templates/%s", owner, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -1,16 +1,25 @@
package user
import (
"encoding/json"
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +me显示当前登录用户信息
{
Name: "me",
Description: "Show current authenticated user",
Description: "显示当前登录用户",
Long: `Show profile information for the currently authenticated user.
Displays login name, email, and account details based on the
configured API token.`,
Example: ` # Show current user profile
gitlink user +me`,
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
@ -19,9 +28,16 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +info显示指定用户的资料
{
Name: "info",
Description: "Show user profile",
Description: "显示用户资料",
Long: `Show public profile information for a GitLink user.
Looks up the user by their login name and displays their
username, bio, and other public profile fields.`,
Example: ` # Show profile for a specific user
gitlink user +info --login zhangsan`,
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
@ -37,5 +53,763 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// --- Message management ---
// +messages列出用户的私信
{
Name: "messages",
Description: "列出用户私信",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "page", Usage: "Page number", Default: "1"},
{Name: "limit", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
page := ctx.Arg("page")
if page == "" {
page = "1"
}
limit := ctx.Arg("limit")
if limit == "" {
limit = "20"
}
query := url.Values{}
query.Set("page", page)
query.Set("limit", limit)
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/messages", login), query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete-message删除一条私信
{
Name: "delete-message",
Description: "删除一条私信",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Message ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
query := url.Values{}
query.Set("id", id)
env, err := ctx.CallAPIWithQuery("DELETE", fmt.Sprintf("/users/%s/messages", login), query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +create-message发送一条私信
{
Name: "create-message",
Description: "发送一条私信",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "content", Usage: "Message content", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/messages", login), map[string]interface{}{
"content": content,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +read-message将私信标记为已读
{
Name: "read-message",
Description: "将私信标记为已读",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Message ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/messages/read", login), map[string]interface{}{
"id": id,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +msg-settings获取消息通知设置
{
Name: "msg-settings",
Description: "获取消息通知设置",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/template_message_settings", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update-msg-settings更新消息通知设置
{
Name: "update-msg-settings",
Description: "更新消息通知设置",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "settings", Usage: "Settings JSON array", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
settingsStr, err := ctx.RequireArg("settings")
if err != nil {
return err
}
var settings []interface{}
if err := json.Unmarshal([]byte(settingsStr), &settings); err != nil {
return fmt.Errorf("invalid settings JSON: %w", err)
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/template_message_settings/update_setting", login), map[string]interface{}{
"setting": settings,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Pinned projects ---
// +pinned-projects列出置顶项目
{
Name: "pinned-projects",
Description: "列出置顶项目",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/is_pinned_projects", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +pin-project置顶一个项目
{
Name: "pin-project",
Description: "置顶一个项目",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Project ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/is_pinned_projects/pin", login), map[string]interface{}{
"id": id,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +reorder-pinned调整置顶项目的顺序
{
Name: "reorder-pinned",
Description: "调整置顶项目的顺序",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Pinned project ID", Required: true},
{Name: "position", Usage: "New position", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
position, err := ctx.RequireArg("position")
if err != nil {
return err
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/users/%s/is_pinned_projects/%s", login, id), map[string]interface{}{
"position": position,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Statistics ---
// +activity获取用户活动统计
{
Name: "activity",
Description: "获取用户活动统计",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/activity", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +headmap获取用户活跃热力图数据
{
Name: "headmap",
Description: "获取用户活跃热力图数据",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/headmaps", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +develop-stats获取用户开发统计
{
Name: "develop-stats",
Description: "获取用户开发统计",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/develop", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +role-stats获取用户角色统计
{
Name: "role-stats",
Description: "获取用户角色统计",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/role", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +major-stats获取用户专业统计
{
Name: "major-stats",
Description: "获取用户专业统计",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/major", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Transfer / Apply ---
// +applied-transfers列出待处理的仓库转让申请
{
Name: "applied-transfers",
Description: "列出待处理的仓库转让申请",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/applied_transfer_projects", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +accept-transfer接受仓库转让申请
{
Name: "accept-transfer",
Description: "接受仓库转让申请",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Transfer ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_transfer_projects/%s/accept", login, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +refuse-transfer拒绝仓库转让申请
{
Name: "refuse-transfer",
Description: "拒绝仓库转让申请",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Transfer ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_transfer_projects/%s/refuse", login, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +applied-projects列出待处理的仓库加入申请
{
Name: "applied-projects",
Description: "列出待处理的仓库加入申请",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/applied_projects", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +accept-join接受仓库加入申请
{
Name: "accept-join",
Description: "接受仓库加入申请",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Application ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_projects/%s/accept", login, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +refuse-join拒绝仓库加入申请
{
Name: "refuse-join",
Description: "拒绝仓库加入申请",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "id", Usage: "Application ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/users/%s/applied_projects/%s/refuse", login, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Feedback / Update ---
// +feedback提交反馈意见
{
Name: "feedback",
Description: "提交反馈意见",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "content", Usage: "Feedback content", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/feedbacks", login), map[string]interface{}{
"content": content,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update更新用户资料
{
Name: "update",
Description: "更新用户资料",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "nickname", Usage: "New nickname"},
{Name: "description", Usage: "New description"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
body := map[string]interface{}{}
if v := ctx.Arg("nickname"); v != "" {
body["nickname"] = v
}
if v := ctx.Arg("description"); v != "" {
body["description"] = v
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/users/%s", login), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// --- Account settings ---
// +change-password修改账户密码
{
Name: "change-password",
Description: "修改账户密码",
Flags: []common.Flag{
{Name: "old-password", Usage: "Current password", Required: true},
{Name: "new-password", Usage: "New password", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
oldPwd, err := ctx.RequireArg("old-password")
if err != nil {
return err
}
newPwd, err := ctx.RequireArg("new-password")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", "/accounts/change_password", map[string]interface{}{
"old_password": oldPwd,
"new_password": newPwd,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update-email更新邮箱地址
{
Name: "update-email",
Description: "更新邮箱地址",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "email", Usage: "New email address", Required: true},
{Name: "code", Usage: "Verification code"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
email, err := ctx.RequireArg("email")
if err != nil {
return err
}
body := map[string]interface{}{
"email": email,
}
if v := ctx.Arg("code"); v != "" {
body["code"] = v
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/update_email", login), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update-phone更新手机号码
{
Name: "update-phone",
Description: "更新手机号码",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "phone", Usage: "New phone number", Required: true},
{Name: "code", Usage: "Verification code"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
phone, err := ctx.RequireArg("phone")
if err != nil {
return err
}
body := map[string]interface{}{
"phone": phone,
}
if v := ctx.Arg("code"); v != "" {
body["code"] = v
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1/%s/update_phone", login), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +send-verify发送邮箱验证码
{
Name: "send-verify",
Description: "发送邮箱验证码",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "email", Usage: "Email address"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
query := url.Values{}
if v := ctx.Arg("email"); v != "" {
query.Set("email", v)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/send_email_vefify_code", login), query)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +check-verify校验邮箱验证码
{
Name: "check-verify",
Description: "校验邮箱验证码",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "code", Usage: "Verification code", Required: true},
{Name: "email", Usage: "Email address"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
code, err := ctx.RequireArg("code")
if err != nil {
return err
}
body := map[string]interface{}{
"code": code,
}
if v := ctx.Arg("email"); v != "" {
body["email"] = v
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_email_verify_code", login), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +check-password校验密码
{
Name: "check-password",
Description: "校验密码",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "password", Usage: "Password to verify", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
password, err := ctx.RequireArg("password")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_password", login), map[string]interface{}{
"password": password,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +check-email校验邮箱
{
Name: "check-email",
Description: "校验邮箱",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "email", Usage: "Email to verify", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
email, err := ctx.RequireArg("email")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_email", login), map[string]interface{}{
"email": email,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +can-delete检查账户是否可被注销
{
Name: "can-delete",
Description: "检查账户是否可被注销",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/check_user_can_delete", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete注销停用用户账户
{
Name: "delete",
Description: "注销(停用)用户账户",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s", login), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

544
shortcuts/user/user_test.go Normal file
View File

@ -0,0 +1,544 @@
package user
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// --- Helpers ---
func runUserShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findUserShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findUserShortcut(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 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 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)
}
}
// --- Required tests ---
func TestUserMessagesListsMessages(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"messages": []interface{}{},
})
}))
defer server.Close()
err := runUserShortcut(t, server, "messages", map[string]string{
"login": "testuser",
"page": "1",
"limit": "20",
})
if err != nil {
t.Fatalf("messages shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "GET")
assertEqual(t, calledPath, "/users/testuser/messages.json")
}
func TestUserFeedbackSendsPOST(t *testing.T) {
var calledMethod string
var calledPath string
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "feedback", map[string]string{
"login": "testuser",
"content": "Great platform!",
})
if err != nil {
t.Fatalf("feedback shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/v1/testuser/feedbacks.json")
assertEqual(t, payload["content"], "Great platform!")
}
// --- Additional coverage tests ---
func TestUserDeleteMessageCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "delete-message", map[string]string{
"login": "testuser",
"id": "99",
})
if err != nil {
t.Fatalf("delete-message shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "DELETE")
assertEqual(t, calledPath, "/users/testuser/messages.json")
}
func TestUserCreateMessageSendsPOST(t *testing.T) {
var calledMethod string
var calledPath string
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "create-message", map[string]string{
"login": "testuser",
"content": "Hello!",
})
if err != nil {
t.Fatalf("create-message shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/messages.json")
assertEqual(t, payload["content"], "Hello!")
}
func TestUserReadMessageCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "read-message", map[string]string{
"login": "testuser",
"id": "42",
})
if err != nil {
t.Fatalf("read-message shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/messages/read.json")
}
func TestUserMsgSettingsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"settings": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "msg-settings", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("msg-settings shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/template_message_settings.json")
}
func TestUserUpdateMsgSettingsSendsPOST(t *testing.T) {
var calledMethod string
var calledPath string
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "update-msg-settings", map[string]string{
"login": "testuser",
"settings": `[{"id":1,"enabled":true}]`,
})
if err != nil {
t.Fatalf("update-msg-settings shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/template_message_settings/update_setting.json")
settings, ok := payload["setting"].([]interface{})
if !ok || len(settings) == 0 {
t.Fatalf("expected setting to be a non-empty array, got %v", payload["setting"])
}
}
func TestUserPinnedProjectsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"projects": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "pinned-projects", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("pinned-projects shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/is_pinned_projects.json")
}
func TestUserPinProjectCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "pin-project", map[string]string{
"login": "testuser",
"id": "10",
})
if err != nil {
t.Fatalf("pin-project shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/is_pinned_projects/pin.json")
}
func TestUserReorderPinnedCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "reorder-pinned", map[string]string{
"login": "testuser",
"id": "10",
"position": "1",
})
if err != nil {
t.Fatalf("reorder-pinned shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "PUT")
assertEqual(t, calledPath, "/users/testuser/is_pinned_projects/10.json")
}
func TestUserActivityCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"activity": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "activity", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("activity shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/statistics/activity.json")
}
func TestUserHeadmapCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"headmaps": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "headmap", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("headmap shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/headmaps.json")
}
func TestUserDevelopStatsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"develop": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "develop-stats", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("develop-stats shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/statistics/develop.json")
}
func TestUserRoleStatsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"role": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "role-stats", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("role-stats shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/statistics/role.json")
}
func TestUserMajorStatsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"major": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "major-stats", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("major-stats shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/statistics/major.json")
}
func TestUserAppliedTransfersCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"projects": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "applied-transfers", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("applied-transfers shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/applied_transfer_projects.json")
}
func TestUserAcceptTransferCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "accept-transfer", map[string]string{
"login": "testuser",
"id": "5",
})
if err != nil {
t.Fatalf("accept-transfer shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/applied_transfer_projects/5/accept.json")
}
func TestUserRefuseTransferCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "refuse-transfer", map[string]string{
"login": "testuser",
"id": "5",
})
if err != nil {
t.Fatalf("refuse-transfer shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/applied_transfer_projects/5/refuse.json")
}
func TestUserAppliedProjectsCallsCorrectEndpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"projects": []interface{}{}})
}))
defer server.Close()
err := runUserShortcut(t, server, "applied-projects", map[string]string{
"login": "testuser",
})
if err != nil {
t.Fatalf("applied-projects shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/users/testuser/applied_projects.json")
}
func TestUserAcceptJoinCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "accept-join", map[string]string{
"login": "testuser",
"id": "8",
})
if err != nil {
t.Fatalf("accept-join shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/applied_projects/8/accept.json")
}
func TestUserRefuseJoinCallsCorrectEndpoint(t *testing.T) {
var calledMethod string
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "refuse-join", map[string]string{
"login": "testuser",
"id": "8",
})
if err != nil {
t.Fatalf("refuse-join shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "POST")
assertEqual(t, calledPath, "/users/testuser/applied_projects/8/refuse.json")
}
func TestUserUpdateSendsPUT(t *testing.T) {
var calledMethod string
var calledPath string
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledMethod = r.Method
calledPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runUserShortcut(t, server, "update", map[string]string{
"login": "testuser",
"nickname": "NewNick",
"description": "Updated bio",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, calledMethod, "PUT")
assertEqual(t, calledPath, "/users/testuser.json")
assertEqual(t, payload["nickname"], "NewNick")
assertEqual(t, payload["description"], "Updated bio")
}
// --- Required-flag validation tests ---
func TestUserMessagesRequiresLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when login is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runUserShortcut(t, server, "messages", nil)
if err == nil {
t.Fatal("expected error when login is missing, got nil")
}
}
func TestUserFeedbackRequiresContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when content is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runUserShortcut(t, server, "feedback", map[string]string{
"login": "testuser",
})
if err == nil {
t.Fatal("expected error when content is missing, got nil")
}
}

46
shortcuts/util/util.go Normal file
View File

@ -0,0 +1,46 @@
package util
import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +licenses列出可用的开源许可证模板
{
Name: "licenses",
Description: "列出可用的开源许可证模板",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/licenses", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +ignores列出可用的 .gitignore 模板
{
Name: "ignores",
Description: "列出可用的 .gitignore 模板",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/ignores", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +platform-msg-settings查看平台消息通知设置
{
Name: "platform-msg-settings",
Description: "查看平台消息通知设置",
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/template_message_settings", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -2,6 +2,7 @@ package webhook
import (
"fmt"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -24,9 +25,15 @@ var allowedWebhookEvents = map[string]bool{
// Shortcuts returns webhook management shortcuts.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出仓库的所有 Web 钩子
{
Name: "list",
Description: "List repository webhooks",
Description: "列出仓库的所有 Web 钩子",
Long: `List repository webhooks.
Returns all webhooks configured for the current repository.`,
Example: ` # List all webhooks
gitlink webhook +list`,
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -38,9 +45,16 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +view查看 Web 钩子的详细信息
{
Name: "view",
Description: "View webhook details",
Description: "查看 Web 钩子的详细信息",
Long: `View webhook details.
Shows the full configuration of a specific webhook including
URL, events, type, and active status.`,
Example: ` # View a webhook
gitlink webhook +view --id 10`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
@ -59,9 +73,20 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +create创建仓库 Web 钩子
{
Name: "create",
Description: "Create a repository webhook",
Description: "创建仓库 Web 钩子",
Long: `Create a repository webhook.
Creates a new webhook that sends HTTP requests to the specified URL
when certain events occur. Supported types: gitea, slack, discord,
dingtalk, telegram, msteams, feishu, matrix, jianmu, softbot.`,
Example: ` # Create a push webhook
gitlink webhook +create --url https://example.com/hook --events push
# Create a webhook for multiple events
gitlink webhook +create --url https://example.com/hook --events push,issues_only --secret mysecret`,
Flags: []common.Flag{
{Name: "url", Short: "u", Usage: "Webhook target URL", Required: true},
{Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only", Required: true},
@ -74,9 +99,20 @@ func Shortcuts() []*common.Shortcut {
},
Run: runCreate,
},
// +update更新仓库 Web 钩子,未指定的字段尽可能保留原值
{
Name: "update",
Description: "Update a repository webhook while preserving unspecified fields when available",
Description: "更新仓库 Web 钩子,未指定的字段尽可能保留原值",
Long: `Update a repository webhook while preserving unspecified fields when available.
Fetches the current webhook configuration, merges changes from the provided
flags, and updates the webhook. Fields not specified are preserved from the
current configuration.`,
Example: ` # Update a webhook URL
gitlink webhook +update --id 10 --url https://new.example.com/hook
# Update webhook events
gitlink webhook +update --id 10 --events push,pull_request_only`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "url", Short: "u", Usage: "Webhook target URL"},
@ -90,9 +126,15 @@ func Shortcuts() []*common.Shortcut {
},
Run: runUpdate,
},
// +delete删除仓库 Web 钩子
{
Name: "delete",
Description: "Delete a repository webhook",
Description: "删除仓库 Web 钩子",
Long: `Delete a repository webhook.
Permanently removes the webhook from the repository.`,
Example: ` # Delete a webhook
gitlink webhook +delete --id 10`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
@ -111,9 +153,15 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +test触发 Web 钩子的测试投递
{
Name: "test",
Description: "Trigger a test delivery for a webhook",
Description: "触发 Web 钩子的测试投递",
Long: `Trigger a test delivery for a webhook.
Sends a test payload to the webhook URL to verify the configuration.`,
Example: ` # Test a webhook
gitlink webhook +test --id 10`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
@ -132,9 +180,16 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +tasks列出 Web 钩子的投递任务
{
Name: "tasks",
Description: "List webhook delivery tasks",
Description: "列出 Web 钩子的投递任务",
Long: `List webhook delivery tasks.
Returns the history of webhook deliveries including status,
response code, and timing information.`,
Example: ` # List delivery tasks for a webhook
gitlink webhook +tasks --id 10`,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
@ -153,6 +208,115 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +failed列出投递失败的 Web 钩子任务
{
Name: "failed",
Description: "列出投递失败的 Web 钩子任务",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "limit", Short: "l", Usage: "Number of recent failed tasks", Default: "10"},
},
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("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil)
if err != nil {
return err
}
raw, ok := env.Data.(map[string]interface{})
if !ok {
return ctx.Output(env)
}
tasks, ok := raw["hooktasks"].([]interface{})
if !ok {
return ctx.OutputData(map[string]interface{}{
"hooktasks": []interface{}{},
"total_count": 0,
})
}
limit := 0
if limitStr := ctx.Arg("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
failedTasks := make([]interface{}, 0)
for _, rawTask := range tasks {
task, ok := rawTask.(map[string]interface{})
if !ok {
continue
}
if succeed, ok := task["is_succeed"].(bool); ok && !succeed {
failedTasks = append(failedTasks, map[string]interface{}{
"uuid": task["uuid"],
"event_type": task["event_type"],
"delivered_time": task["delivered_time"],
"status": "failed",
})
if limit > 0 && len(failedTasks) >= limit {
break
}
}
}
return ctx.OutputData(map[string]interface{}{
"hooktasks": failedTasks,
"total_count": len(failedTasks),
})
},
},
// +task-view查看 Web 钩子投递任务的详细信息
{
Name: "task-view",
Description: "查看 Web 钩子投递任务的详细信息",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "task-id", Usage: "Task ID (numeric)", 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
}
taskID, err := ctx.RequireArg("task-id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil)
if err != nil {
return err
}
raw, ok := env.Data.(map[string]interface{})
if !ok {
return fmt.Errorf("failed to parse hook tasks")
}
tasks, ok := raw["hooktasks"].([]interface{})
if !ok {
return fmt.Errorf("no hook tasks found")
}
for _, rawTask := range tasks {
task, ok := rawTask.(map[string]interface{})
if !ok {
continue
}
// Support both numeric ID and UUID
if tid, ok := task["id"].(float64); ok && fmt.Sprintf("%.0f", tid) == taskID {
return ctx.OutputData(task)
}
if uuid, ok := task["uuid"].(string); ok && uuid == taskID {
return ctx.OutputData(task)
}
}
return fmt.Errorf("task %s not found", taskID)
},
},
}
}

555
shortcuts/wiki/wiki.go Normal file
View File

@ -0,0 +1,555 @@
package wiki
import (
"bytes"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// --- Overridable for testing ---
// wikiRepoURL returns the Git URL for the wiki repository.
// Override wikiRepoURLOverride in tests to point to a local repo.
var wikiRepoURLOverride string
func wikiRepoURL(owner, repo string) string {
if wikiRepoURLOverride != "" {
return wikiRepoURLOverride
}
return fmt.Sprintf("https://gitlink.org.cn/%s/%s.wiki.git", owner, repo)
}
// --- Helpers ---
// encodePageName encodes a page name for use as a Git filename (URL-encoded + .md).
func encodePageName(name string) string {
return url.PathEscape(name) + ".md"
}
// decodePageName decodes a wiki filename back to a display name.
func decodePageName(filename string) string {
name := strings.TrimSuffix(filename, ".md")
decoded, err := url.PathUnescape(name)
if err != nil {
return name
}
return decoded
}
// isWikiFile returns true if the filename is a regular wiki page (not _Sidebar, _Footer, etc.).
func isWikiFile(name string) bool {
return strings.HasSuffix(name, ".md") && !strings.HasPrefix(name, "_")
}
// wikiFiles lists all wiki page files in a cloned repo directory, sorted.
func wikiFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var files []string
for _, e := range entries {
if !e.IsDir() && isWikiFile(e.Name()) {
files = append(files, e.Name())
}
}
sort.Strings(files)
return files, nil
}
// runGit runs a git command in the specified directory.
func runGit(dir string, args ...string) (stdout, stderr string, err error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err = cmd.Run()
return outBuf.String(), errBuf.String(), err
}
// getAuthToken retrieves the auth token for Git push operations.
func getAuthToken() (string, error) {
if t := os.Getenv("GITLINK_TOKEN"); t != "" {
return t, nil
}
t, err := auth.LoadToken()
if err != nil {
return "", fmt.Errorf("not authenticated: run 'gitlink-cli auth login' first")
}
if t == "" {
return "", fmt.Errorf("not authenticated: run 'gitlink-cli auth login' first")
}
return t, nil
}
// authWikiRepoURL returns an authenticated Git URL for push operations.
func authWikiRepoURL(owner, repo, token string) (string, error) {
if strings.HasPrefix(token, "cookie:") {
return "", fmt.Errorf("cookie-based login does not support Git push. Generate a Personal Access Token on GitLink and use:\n gitlink-cli auth login --token")
}
return fmt.Sprintf("https://token:%s@gitlink.org.cn/%s/%s.wiki.git",
url.QueryEscape(token), owner, repo), nil
}
// cloneWikiReadOnly clones the wiki repo (read-only, no auth needed for public repos).
func cloneWikiReadOnly(owner, repo, dest string) error {
_, stderr, err := runGit("", "clone", "--depth", "1", wikiRepoURL(owner, repo), dest)
if err != nil {
return fmt.Errorf("clone wiki repo: %s%s", stderr, err)
}
return nil
}
// cloneWikiWithAuth clones the wiki repo with authentication for push.
func cloneWikiWithAuth(authURL, dest string) error {
_, stderr, err := runGit("", "clone", "--depth", "1", authURL, dest)
if err != nil {
return fmt.Errorf("clone wiki repo: %s%s", stderr, err)
}
return nil
}
// resolveContent reads content from --content or --file.
func resolveContent(ctx *common.RuntimeContext) (string, error) {
content := ctx.Arg("content")
file := ctx.Arg("file")
if content != "" && file != "" {
return "", fmt.Errorf("use either --content or --file, not both")
}
if file != "" {
data, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("read file: %w", err)
}
content = string(data)
}
return content, nil
}
// buildPageContent builds the markdown content, optionally prepending a title heading.
func buildPageContent(title, content string) string {
if title != "" {
return "# " + title + "\n\n" + content
}
return content
}
// --- Shortcuts ---
// Shortcuts returns wiki management shortcuts backed by Git operations.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出仓库的所有 Wiki 文档页面
{
Name: "list",
Description: "列出仓库的所有 Wiki 文档页面",
Run: runList,
},
// +view查看 Wiki 文档页面内容
{
Name: "view",
Description: "查看 Wiki 文档页面内容",
Flags: []common.Flag{
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
},
Run: runView,
},
// +create新建 Wiki 文档页面
{
Name: "create",
Description: "新建 Wiki 文档页面",
Flags: []common.Flag{
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
{Name: "title", Short: "t", Usage: "Page title (used as H1 heading)"},
{Name: "content", Short: "c", Usage: "Page content (markdown)"},
{Name: "file", Short: "f", Usage: "Read page content from file"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: runCreate,
},
// +update更新 Wiki 文档页面
{
Name: "update",
Description: "更新 Wiki 文档页面",
Flags: []common.Flag{
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
{Name: "title", Short: "t", Usage: "Page title (used as H1 heading)"},
{Name: "content", Short: "c", Usage: "Page content (markdown)"},
{Name: "file", Short: "f", Usage: "Read page content from file"},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: runUpdate,
},
// +delete删除 Wiki 文档页面
{
Name: "delete",
Description: "删除 Wiki 文档页面",
Flags: []common.Flag{
{Name: "page-name", Short: "n", Usage: "Wiki page name", Required: true},
},
Run: runDelete,
},
// +export导出 Wiki 文档
{
Name: "export",
Description: "导出 Wiki 文档",
Flags: []common.Flag{
{Name: "project-id", Usage: "Project ID (numeric)"},
},
Run: runExport,
},
// +import从文件导入 Wiki 文档
{
Name: "import",
Description: "从文件导入 Wiki 文档",
Flags: []common.Flag{
{Name: "project-id", Usage: "Project ID", Required: true},
},
Run: runImport,
},
}
}
// --- Command implementations ---
func runList(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := cloneWikiReadOnly(ctx.Owner, ctx.Repo, tmpDir); err != nil {
return err
}
files, err := wikiFiles(tmpDir)
if err != nil {
return err
}
var pages []map[string]string
for _, f := range files {
pages = append(pages, map[string]string{
"name": decodePageName(f),
})
}
data := map[string]interface{}{
"total_count": len(pages),
"pages": pages,
}
return ctx.Output(output.SuccessEnvelope(data, nil))
}
func runView(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pageName, err := ctx.RequireArg("page-name")
if err != nil {
return err
}
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := cloneWikiReadOnly(ctx.Owner, ctx.Repo, tmpDir); err != nil {
return err
}
fileName := encodePageName(pageName)
content, err := os.ReadFile(filepath.Join(tmpDir, fileName))
if err != nil {
return fmt.Errorf("page %q not found", pageName)
}
// Get last commit info for this file
log, _, _ := runGit(tmpDir, "log", "-1", "--format=%H|%an|%ae|%aI", "--", fileName)
data := map[string]interface{}{
"name": pageName,
"content": string(content),
}
if log != "" {
parts := strings.SplitN(strings.TrimSpace(log), "|", 4)
if len(parts) >= 1 {
data["last_commit_sha"] = parts[0]
}
if len(parts) >= 2 {
data["last_author"] = parts[1]
}
if len(parts) >= 4 {
data["last_commit_date"] = parts[3]
}
}
return ctx.Output(output.SuccessEnvelope(data, nil))
}
func runCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pageName, err := ctx.RequireArg("page-name")
if err != nil {
return err
}
content, err := resolveContent(ctx)
if err != nil {
return err
}
if content == "" {
return fmt.Errorf("either --content or --file is required")
}
token, err := getAuthToken()
if err != nil {
return err
}
authURL, err := authWikiRepoURL(ctx.Owner, ctx.Repo, token)
if err != nil {
return err
}
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := cloneWikiWithAuth(authURL, tmpDir); err != nil {
return err
}
fileName := encodePageName(pageName)
filePath := filepath.Join(tmpDir, fileName)
if _, err := os.Stat(filePath); err == nil {
return fmt.Errorf("page %q already exists", pageName)
}
pageContent := buildPageContent(ctx.Arg("title"), content)
if err := os.WriteFile(filePath, []byte(pageContent), 0644); err != nil {
return fmt.Errorf("write page: %w", err)
}
msg := ctx.Arg("message")
if msg == "" {
msg = fmt.Sprintf("Create wiki page %q", pageName)
}
if _, stderr, err := runGit(tmpDir, "add", fileName); err != nil {
return fmt.Errorf("git add: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "commit", "-m", msg); err != nil {
return fmt.Errorf("git commit: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "push", "origin", "master"); err != nil {
return fmt.Errorf("git push: %s%s", stderr, err)
}
return ctx.OutputData(map[string]string{
"name": pageName,
"message": "Wiki page created",
})
}
func runUpdate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pageName, err := ctx.RequireArg("page-name")
if err != nil {
return err
}
token, err := getAuthToken()
if err != nil {
return err
}
authURL, err := authWikiRepoURL(ctx.Owner, ctx.Repo, token)
if err != nil {
return err
}
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := cloneWikiWithAuth(authURL, tmpDir); err != nil {
return err
}
fileName := encodePageName(pageName)
filePath := filepath.Join(tmpDir, fileName)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return fmt.Errorf("page %q not found", pageName)
}
content, err := resolveContent(ctx)
if err != nil {
return err
}
if content != "" {
pageContent := buildPageContent(ctx.Arg("title"), content)
if err := os.WriteFile(filePath, []byte(pageContent), 0644); err != nil {
return fmt.Errorf("write page: %w", err)
}
} else if title := ctx.Arg("title"); title != "" {
// Only updating title: read existing content and replace/re-add the heading
existing, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read page: %w", err)
}
lines := strings.SplitN(string(existing), "\n", 2)
newContent := "# " + title + "\n"
if len(lines) > 1 {
// Skip old heading if present
body := lines[1]
if strings.HasPrefix(lines[0], "# ") {
newContent += body
} else {
newContent += strings.Join(lines, "\n")
}
}
if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("write page: %w", err)
}
}
msg := ctx.Arg("message")
if msg == "" {
msg = fmt.Sprintf("Update wiki page %q", pageName)
}
if _, stderr, err := runGit(tmpDir, "add", fileName); err != nil {
return fmt.Errorf("git add: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "commit", "-m", msg); err != nil {
return fmt.Errorf("git commit: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "push", "origin", "master"); err != nil {
return fmt.Errorf("git push: %s%s", stderr, err)
}
return ctx.OutputData(map[string]string{
"name": pageName,
"message": "Wiki page updated",
})
}
func runDelete(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pageName, err := ctx.RequireArg("page-name")
if err != nil {
return err
}
token, err := getAuthToken()
if err != nil {
return err
}
authURL, err := authWikiRepoURL(ctx.Owner, ctx.Repo, token)
if err != nil {
return err
}
tmpDir, err := os.MkdirTemp("", "gitlink-wiki-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmpDir)
if err := cloneWikiWithAuth(authURL, tmpDir); err != nil {
return err
}
fileName := encodePageName(pageName)
filePath := filepath.Join(tmpDir, fileName)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return fmt.Errorf("page %q not found", pageName)
}
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("delete page: %w", err)
}
// Remove _Sidebar.md so GitLink regenerates it from file list
os.Remove(filepath.Join(tmpDir, "_Sidebar.md"))
msg := ctx.Arg("message")
if msg == "" {
msg = fmt.Sprintf("Delete wiki page %q", pageName)
}
if _, stderr, err := runGit(tmpDir, "add", "-A"); err != nil {
return fmt.Errorf("git add: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "commit", "-m", msg); err != nil {
return fmt.Errorf("git commit: %s%s", stderr, err)
}
if _, stderr, err := runGit(tmpDir, "push", "origin", "master"); err != nil {
return fmt.Errorf("git push: %s%s", stderr, err)
}
return ctx.OutputData(map[string]string{
"name": pageName,
"message": "Wiki page deleted",
})
}
func runExport(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
if v := ctx.Arg("project-id"); v != "" {
q.Set("projectId", v)
}
env, err := ctx.CallAPIWithQuery("GET", "/wikiExport/wikiExport-wrapper", q)
if err != nil {
return err
}
return ctx.Output(env)
}
func runImport(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := ctx.RequireArg("project-id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/wikiExport/uploadWiki/%s/%s/%s", ctx.Owner, ctx.Repo, projectID), nil)
if err != nil {
return err
}
return ctx.Output(env)
}

274
shortcuts/wiki/wiki_test.go Normal file
View File

@ -0,0 +1,274 @@
package wiki
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// setupWikiRepo creates a bare Git repository to simulate the remote wiki repo,
// initializes it with sample wiki pages, and returns the path to the bare repo.
// The caller must set wikiRepoURLOverride to this path before running shortcuts.
func setupWikiRepo(t *testing.T) (bareRepo string, cleanup func()) {
t.Helper()
// Create a temp directory for the "remote" bare repo
remoteDir, err := os.MkdirTemp("", "gitlink-wiki-remote-*")
if err != nil {
t.Fatalf("mkdir remote: %v", err)
}
// Initialize bare repo
runGitClean("", "init", "--bare", remoteDir)
// Clone it to a working directory to add initial content
workDir, err := os.MkdirTemp("", "gitlink-wiki-work-*")
if err != nil {
os.RemoveAll(remoteDir)
t.Fatalf("mkdir work: %v", err)
}
runGitClean("", "clone", remoteDir, workDir)
// Configure git user for commits
runGitClean(workDir, "config", "user.email", "test@test.com")
runGitClean(workDir, "config", "user.name", "Test User")
// Create initial wiki pages
pages := map[string]string{
"Home.md": "# Home\n\nWelcome to the wiki!",
"Guide.md": "# Getting Started Guide\n\nThis is a guide.",
"API-Reference.md": "# API Reference\n\nAPI documentation.",
}
for name, content := range pages {
os.WriteFile(filepath.Join(workDir, name), []byte(content), 0644)
}
// Commit and push initial content
runGitClean(workDir, "add", ".")
runGitClean(workDir, "commit", "-m", "Initial wiki pages")
runGitClean(workDir, "push", "origin", "master")
// Clean up working directory
os.RemoveAll(workDir)
return remoteDir, func() {
os.RemoveAll(remoteDir)
}
}
// runGitClean runs a git command and panics on error (for test setup).
func runGitClean(dir string, args ...string) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
panic("git " + strings.Join(args, " ") + ": " + string(out) + ": " + err.Error())
}
}
// runWikiShortcut runs a wiki shortcut with the given args using a local test repo.
func runWikiShortcut(t *testing.T, name string, args map[string]string) error {
t.Helper()
shortcut := findWikiShortcut(t, name)
ctx := &common.RuntimeContext{
Owner: "test-owner",
Repo: "test-repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findWikiShortcut(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
}
// --- Tests ---
func TestWikiList(t *testing.T) {
repo, cleanup := setupWikiRepo(t)
defer cleanup()
wikiRepoURLOverride = repo
defer func() { wikiRepoURLOverride = "" }()
if err := runWikiShortcut(t, "list", nil); err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestWikiView(t *testing.T) {
repo, cleanup := setupWikiRepo(t)
defer cleanup()
wikiRepoURLOverride = repo
defer func() { wikiRepoURLOverride = "" }()
if err := runWikiShortcut(t, "view", map[string]string{
"page-name": "Home",
}); err != nil {
t.Fatalf("view failed: %v", err)
}
}
func TestWikiViewNotFound(t *testing.T) {
repo, cleanup := setupWikiRepo(t)
defer cleanup()
wikiRepoURLOverride = repo
defer func() { wikiRepoURLOverride = "" }()
err := runWikiShortcut(t, "view", map[string]string{
"page-name": "NonExistent",
})
if err == nil {
t.Fatal("expected error for non-existent page")
}
}
func TestWikiViewMissingPageName(t *testing.T) {
_, cleanup := setupWikiRepo(t)
defer cleanup()
err := runWikiShortcut(t, "view", nil)
if err == nil {
t.Fatal("expected error when --page-name is missing")
}
}
func TestWikiListWithSpecialChars(t *testing.T) {
repo, cleanup := setupWikiRepo(t)
defer cleanup()
// Add a page with Chinese characters
workDir, err := os.MkdirTemp("", "gitlink-wiki-work-*")
if err != nil {
t.Fatalf("mkdir work: %v", err)
}
defer os.RemoveAll(workDir)
runGitClean("", "clone", repo, workDir)
runGitClean(workDir, "config", "user.email", "test@test.com")
runGitClean(workDir, "config", "user.name", "Test User")
pageName := "Wiki测试"
fileName := encodePageName(pageName)
os.WriteFile(filepath.Join(workDir, fileName), []byte("# Wiki测试\nHello"), 0644)
runGitClean(workDir, "add", ".")
runGitClean(workDir, "commit", "-m", "Add Chinese page")
runGitClean(workDir, "push", "origin", "master")
wikiRepoURLOverride = repo
defer func() { wikiRepoURLOverride = "" }()
if err := runWikiShortcut(t, "list", nil); err != nil {
t.Fatalf("list with special chars failed: %v", err)
}
}
func TestEncodeDecodePageName(t *testing.T) {
tests := []struct {
raw string
expected string
}{
{"Home", "Home.md"},
{"Getting Started", "Getting%20Started.md"},
{"API-Reference", "API-Reference.md"},
{"Wiki测试", "Wiki%E6%B5%8B%E8%AF%95.md"},
}
for _, tt := range tests {
encoded := encodePageName(tt.raw)
if encoded != tt.expected {
t.Errorf("encodePageName(%q) = %q, want %q", tt.raw, encoded, tt.expected)
}
decoded := decodePageName(encoded)
if decoded != tt.raw {
t.Errorf("decodePageName(%q) = %q, want %q", encoded, decoded, tt.raw)
}
}
}
func TestIsWikiFile(t *testing.T) {
tests := []struct {
name string
expected bool
}{
{"Home.md", true},
{"Guide.md", true},
{"API-Reference.md", true},
{"_Sidebar.md", false},
{"_Footer.md", false},
{"README.txt", false},
{"image.png", false},
}
for _, tt := range tests {
got := isWikiFile(tt.name)
if got != tt.expected {
t.Errorf("isWikiFile(%q) = %v, want %v", tt.name, got, tt.expected)
}
}
}
func TestWikiListHandlesEmptyRepo(t *testing.T) {
// Create a bare repo with no wiki pages
remoteDir, err := os.MkdirTemp("", "gitlink-wiki-empty-*")
if err != nil {
t.Fatalf("mkdir: %v", err)
}
defer os.RemoveAll(remoteDir)
runGitClean("", "init", "--bare", remoteDir)
// Clone and make an initial commit (so that clone works)
workDir, err := os.MkdirTemp("", "gitlink-wiki-work-*")
if err != nil {
t.Fatalf("mkdir work: %v", err)
}
defer os.RemoveAll(workDir)
runGitClean("", "clone", remoteDir, workDir)
runGitClean(workDir, "config", "user.email", "test@test.com")
runGitClean(workDir, "config", "user.name", "Test User")
// Add a .gitkeep or similar non-wiki file to make the repo non-empty
os.WriteFile(filepath.Join(workDir, ".gitkeep"), []byte(""), 0644)
runGitClean(workDir, "add", ".")
runGitClean(workDir, "commit", "-m", "init")
runGitClean(workDir, "push", "origin", "master")
os.RemoveAll(workDir)
wikiRepoURLOverride = remoteDir
defer func() { wikiRepoURLOverride = "" }()
if err := runWikiShortcut(t, "list", nil); err != nil {
t.Fatalf("list empty repo failed: %v", err)
}
}
func TestWikiDeleteMissingPageName(t *testing.T) {
_, cleanup := setupWikiRepo(t)
defer cleanup()
err := runWikiShortcut(t, "delete", nil)
if err == nil {
t.Fatal("expected error when --page-name is missing")
}
}

View File

@ -50,23 +50,34 @@ gitlink-cli pr +files --id <pull_request_id> --format json
| `files[].isDeleted` | boolean | 是否删除文件 |
| `files[].isRenamed` | boolean | 是否重命名 |
### 获取 PR Diff
### 获取 PR Diff`pr +version-diff`,替代已移除的 `pr +diff`
`pr +diff` 已移除(与 `file` 冗余)。先取补丁集版本,再看版本差异:
```bash
gitlink-cli pr +diff --id <pull_request_id> --format json
# 1) 取 patchset version_id
gitlink-cli pr +versions --id <pull_request_id> --format json
# 2) 看该版本的逐行 diff
gitlink-cli pr +version-diff --id <pull_request_id> --version-id <vid> [--file <path>] --format json
```
**返回字段说明:**
**`pr +versions` 返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `files_count` | int | 文件总数 |
| `total_addition` | int | 总新增行数 |
| `total_deletion` | int | 总删除行数 |
| `files[].sections[].lines[].leftIdx` | int | 原文件行号 |
| `files[].sections[].lines[].rightIdx` | int | 新文件行号 |
| `files[].sections[].lines[].type` | int | 1=未变, 2=新增, 3=删除, 4=统计信息 |
| `files[].sections[].lines[].content` | string | 行内容 |
| `data.versions[].id` | int | **patchset 版本 IDversion-diff 用这个)** |
**`pr +version-diff` 返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.file_nums` | int | 文件总数 |
| `data.files[].name` | string | 文件名 |
| `data.files[].addition` / `deletion` | int | 该文件增/删行数 |
| `data.files[].sections[].lines[].content` | string | 行内容(含 `@@ ...` hunk 头) |
| `data.files[].sections[].lines[].type` | int | **2=新增, 3=删除, 4=hunk 头(`@@`);据此筛出变更行** |
> ⚠️ `--file <path>` 过滤不稳定,建议整份取后客户端按 `files[].name` 筛。需读文件全文用 `file +get --ref <head分支> --path <文件>`
---
@ -100,6 +111,53 @@ gitlink-cli pr +list --state <open|merged|closed> --format json
---
## 评审与评论命令(代码审查写入)
### `pr +review` — 提交评审(首选)
```bash
gitlink-cli pr +review --id <pr_id> --status <common|approved|rejected> --content "<报告>" [--commit <sha>] [--dry-run] --format json
```
| 参数 | 说明 |
|------|------|
| `-i, --id` | PR 编号 |
| `-s, --status` | **评审状态:`common`(普通评论)/ `approved`(批准)/ `rejected`(拒绝,即 Request changes**,默认 `common` |
| `-c, --content` | 评审内容Markdown放整体审查报告 |
| `-m, --commit` | 可选,挂到具体 commit SHA |
| `--dry-run` | **预览不提交(审查必用)** |
> 状态映射:报告含 Critical → `rejected`;仅 Warning/Suggestion → `common`;无问题 → `approved`。**不要用 GitHub 风格的 `event:"COMMENT"`**GitLink 用 `--status`
### `pr +create-comment` — 行内评审评论
```bash
gitlink-cli pr +create-comment --id <pr_id> --path <文件路径> --line <行号> --body "<意见>" --format json
```
| 参数 | 说明 |
|------|------|
| `-i, --id` | PR 编号 |
| `-p, --path` | 文件路径 |
| `-l, --line` | **文件中的行号**(非 diff 补丁位置;注意 import/头注释偏移) |
| `-b, --body` | 评论内容 |
> 行号不确定时**不要发**——并入 `pr +review --content` 总报告,或改用 `pr +comment`
### `pr +comment` — 普通评论(不绑行)
```bash
gitlink-cli pr +comment --id <pr_id> --body "<评论>" --format json
```
> 以 journal 形式挂在 PR 下,不绑定文件行。是行内评论失败时的安全 fallback。
### `pr +reviews` — 列出已有评审(复审时用)
```bash
gitlink-cli pr +reviews --id <pr_id> [--status <common|approved|rejected>] --format json
```
## Issue 相关 API
### 获取 Issue 列表

View File

@ -1,7 +1,7 @@
---
name: gitlink-code-review
version: 1.0.0
description: "智能代码审查:获取 PR 变更、分析代码质量、自动生成 Review 评论与摘要报告。当用户需要审查 Pull Request、检查代码质量或生成审查报告时触发。"
version: 1.2.0
description: "当用户需要对 GitLink 上的 Pull Request 做代码审查时触发:获取 PR 变更/Diff、按严重程度输出结构化 Review 意见、并(经确认后)把评审评论提交到 PR。适用于收到 PR Review 请求、需要批量审查 PR、为 PR 自动生成审查报告等场景。"
metadata:
requires:
bins: ["gitlink-cli"]
@ -11,311 +11,201 @@ metadata:
# gitlink-code-review智能代码审查
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有写入/删除操作前,务必先确认用户意图。**
**CRITICAL — 所有写入/删除操作前,务必先确认用户意图;提交 Review 会通知 PR 相关人必须先出报告→用户确认→dry-run→再提交。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## 核心原则
1. **用 Shortcut不用 Raw API**:提交评审用 `pr +review`,行内评论用 `pr +create-comment`,普通评论用 `pr +comment`。不要用 GitHub 风格的 Raw API`event:"COMMENT"` / `position` 等字段 GitLink 不支持)。
2. **建议优先,确认后提交**:先把结构化审查报告交给用户确认,**禁止未经确认直接提交 Review/评论**。尤其禁止用 `--yes` 绕过确认,除非用户明确说「直接提交/不用确认」。
3. **严重程度分级驱动结论**:有 Critical → 评审状态用 `rejected`Request changes仅 Suggestion/Positive → `common`;无问题 → `approved`
4. **安全红线零容忍**硬编码密钥、SQL/命令注入、XSS、路径遍历、不安全反序列化必须标 Critical。
## 工作流概览
本 Skill 提供一套完整的 AI 驱动代码审查工作流,覆盖从获取 PR 变更到生成审查报告的全过程。不需要额外的 CLI Shortcuts——现有 `gitlink-cli` 命令 + AI Agent 的分析能力即可完成。
| 阶段 | 操作 | AI Agent 角色 |
|------|------|--------------|
| ① 获取上下文 | 拉取 PR 详情、变更文件、Diff | 执行 CLI 命令采集数据 |
| ② 分析代码 | 检查每个文件的变更 | 逐文件审查,标记问题 |
| ③ 结构化反馈 | 按严重程度分级输出审查意见 | 生成分级 Review 评论 |
| ④ 提交评论 | 发表 Review 到 PR | 通过 API 提交 |
| ⑤ 生成报告 | 输出审查摘要 | 生成 Markdown 摘要 |
| 阶段 | 操作 | 命令 |
|------|------|------|
| ① 获取上下文 | PR 详情 / 变更文件 / Diff | `pr +view` / `pr +files` / `pr +diff` |
| ② 逐文件分析 | 按语言检查项审查 | AI 分析) |
| ③ 出审查报告 | 按严重程度分级,**等用户确认** | (无写入) |
| ④ dry-run 预览 | 用户确认后,预览评审 | `pr +review --dry-run` |
| ⑤ 提交评审 | 用户再次确认后提交 | `pr +review`(去 `--dry-run` |
| ⑥ 行内评论(可选) | 针对具体行追加 | `pr +create-comment --path --line` |
---
## 详细工作流
### 工作流 1PR 代码审查
**场景**:收到 PR Review 请求后,进行完整代码审查。
#### Step 1获取 PR 上下文
### Step 1获取 PR 上下文
```bash
# 获取 PR 详情
# PR 详情
gitlink-cli pr +view --id <pr_id> --format json
# 获取变更文件列表
# 变更文件列表(含增删行数、是否新建)
gitlink-cli pr +files --id <pr_id> --format json
# 获取 Diff 内容(含变更行号和代码上下文)
gitlink-cli pr +diff --id <pr_id> --format json
# Diff 内容先取补丁集版本再看版本差异pr +diff 已移除)
gitlink-cli pr +versions --id <pr_id> --format json # 拿 version_id
gitlink-cli pr +version-diff --id <pr_id> --version-id <vid> --format json # 逐行 diff
```
#### Step 2逐文件分析
> `--id` 是 PR 编号web URL 中的编号。Diff 在 `files[].sections[].lines[]``type`2=新增、3=删除、4=hunk 头(`@@ ...`);按文件分组逐文件分析,大型 PR 分段处理。
>
> ⚠️ **实测2026-06-21**`pr +diff` 已被移除(与 `file` 冗余),由 `pr +version-diff` 替代。`--file <path>` 过滤不稳定,建议整份取 diff 后客户端按文件名筛。需要读某文件全文时用 `file +get --ref <head分支> --path <文件>`
对每个变更文件,根据文件类型执行针对性检查:
### Step 2逐文件分析
**Python 文件检查项:**
- 语法与导入:未使用的 import、循环导入、wildcard import
- 代码规范PEP 8 风格偏离、过长行(>88 chars、命名规范
- 安全硬编码密钥、SQL 注入风险、`eval()`/`exec()` 使用
- 性能不必要的循环、缺少缓存、N+1 查询
- 错误处理:裸 `except`、吞异常、缺少 finally
对每个变更文件,按语言执行针对性检查:
**JavaScript/TypeScript 文件检查项:**
- 安全:`innerHTML` 直接赋值、`eval()` 使用
- 类型安全:`any` 滥用、缺失类型定义
- 性能:不必要的 re-render、大对象深拷贝
- 异步:未处理的 Promise、缺少 error boundary
- 依赖:已废弃 API 使用
**Python**:未用/循环 importPEP8 偏离与过长行硬编码密钥、SQL 注入、`eval()`/`exec()`;裸 `except`、吞异常N+1 查询。
**JS/TS**`innerHTML` 直赋、`eval()``any` 滥用;未处理 Promise废弃 API。
**Go**:未检查的 error return、panic 滥用goroutine 泄漏、缺 sync未关闭 file/conn导出标识符缺注释。
**通用**:硬编码配置/密钥/URL边界条件缺失圈复杂度过高魔法数字DRY 违反;注释过时;测试覆盖不足。
**Go 文件检查项:**
- 错误处理:未检查的 error return、panic 滥用
- 并发goroutine 泄漏、缺少 sync 保护
- 资源管理:未关闭的 file/conn、defer 使用
- 命名:导出标识符缺少注释、变量 shadowing
### Step 3出审查报告建议不写入
**通用检查项:**
- 硬编码的配置值、密钥、URL
- 缺少或错误的边界条件检查
- 过于复杂的函数(圈复杂度高)
- 魔法数字(未命名的常量)
- 重复代码DRY 违反)
- 缺少或过时的注释
- 测试覆盖不足
#### Step 3生成结构化审查结果
按以下 Severity 分级输出:
按严重程度分级输出,**此阶段不执行任何写入**
```markdown
## PR #<id> 代码审查报告
### 🔴 Critical必须修改
- <问题描述><文件>:<行号>
- <问题><文件>:<行号>
> <修改建议>
### 🟡 Warning建议修改
- <问题描述><文件>:<行号>
> <修改建议>
### 🟡 Warning建议修改 / 🔵 Suggestion可选优化 / ✅ Positive值得肯定
- ...
### 🔵 Suggestion可选优化
- <问题描述><文件>:<行号>
> <修改建议>
### ✅ Positive值得肯定
- <做得好的地方>
### 总体结论
<评审状态建议rejected / common / approved是否阻塞合并>
```
#### Step 4提交 Review 评论
**等待用户确认**:用户没说「确认/提交」前停在 Step 3。**禁止用 `--yes` 自行推进。**
### Step 4dry-run 预览(用户确认报告后)
根据报告的严重程度选择评审状态dry-run 预览(不实际提交):
| 报告含 Critical | 评审状态 | 含义 |
|-----------------|----------|------|
| 是 | `rejected` | Request changes阻塞合并 |
| 否,仅 Warning/Suggestion | `common` | 普通评审评论 |
| 无问题 | `approved` | 批准合并 |
```bash
# 方式 1提交整体 Review
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{
"body": "## 审查结果\n\n### 🔴 Critical\n...\n\n### 🟡 Warning\n...\n\n总体评价...",
"event": "COMMENT"
}'
# 方式 2在特定行添加内联评论逐条提交
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{
"body": "这里存在安全风险:用户输入未经转义直接拼接到 SQL 查询中,存在注入风险。建议使用参数化查询。",
"event": "COMMENT",
"commit_id": "<commit_sha>",
"path": "src/query.py",
"position": 42
}'
gitlink-cli pr +review --id <pr_id> \
--status <common|approved|rejected> \
--content "<把审查报告 Markdown 作为 content>" \
--dry-run --format json
```
> **注意:** `event` 参数支持 `COMMENT`(普通评论)和 `APPROVE`(批准)。对于需要修改的问题,使用 `COMMENT`
> `--content` 放整体审查报告Markdown。`--commit <sha>` 可选,把评审挂到具体 commit。核对 dry-run 输出无误。
#### Step 5生成审查摘要
### Step 5提交评审用户确认 dry-run 后)
审查完成后,输出 Markdown 摘要供用户查阅
去掉 `--dry-run` 正式提交:
```markdown
## 📋 审查摘要 — PR #<id> <title>
```bash
gitlink-cli pr +review --id <pr_id> \
--status <common|approved|rejected> \
--content "<审查报告>" \
--format json
```
| 指标 | 数据 |
|------|------|
| 审查文件数 | <n> |
| 变更行数 | +<add> / -<del> |
| Critical 问题 | <n> |
| Warning | <n> |
| Suggestion | <n> |
### Step 6行内评论可选针对具体行
### 主要发现
1. **[Critical]** <最严重的问题>
2. **[Warning]** <次要问题>
3. **[Suggestion]** <优化建议>
需要把某条意见精准挂在某一行时,用 `pr +create-comment`
### 总体评价
<整体评估代码质量审查通过建议>
```bash
gitlink-cli pr +create-comment --id <pr_id> \
--path <文件路径> --line <行号> \
--body "<针对该行的意见>" --format json
```
---
*由 gitlink-code-review Skill 自动生成*
> ⚠️ `--line` 是**文件中的行号**。Diff 输出给的可能是补丁内位置,需换算为文件真实行号(注意文件头部 import/license 偏移)。**若行号不确定,不要盲目发**——把该意见并入 Step 5 的 `--content` 总报告,或改用 `pr +comment`(普通评论,不挂行)。
>
> ⚠️ **实测2026-06-21**`pr +create-comment --line` 会返回 `ok:true`,但返回的 `line_code``null`**评论未真正锚定到该行**(仅挂在文件级)。因此优先用 `pr +review --content` 提交总报告;行内评论不可靠时改用 `pr +comment`
```bash
# 普通评论(挂在 PR 下,不绑定行;行号不确定时的安全 fallback
gitlink-cli pr +comment --id <pr_id> --body "<评论>" --format json
```
---
### 工作流 2仓库代码健康度扫描
## 评审状态与严重程度的映射
**场景**:对仓库整体代码质量进行评估,不依赖 PR。
| 报告内容 | `--status` | 合并建议 |
|---------|-----------|---------|
| 含任何 Critical | `rejected` | 阻塞,修复后复审 |
| 仅 Warning/Suggestion | `common` | 可合并,建议跟进 |
| 全部 Positive / 无问题 | `approved` | 可合并 |
```bash
# 1. 获取仓库信息
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
## 安全红线(必须标 Critical
# 2. 获取仓库文件列表(遍历关键目录)
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master'
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=tests&ref=master'
# 3. 获取关键文件内容
gitlink-cli api GET /:owner/:repo/raw/master/README.md
gitlink-cli api GET /:owner/:repo/raw/master/.gitignore
gitlink-cli api GET /:owner/:repo/raw/master/.eslintrc.js # 或类似配置
gitlink-cli api GET /:owner/:repo/raw/master/package.json # 或 go.mod, Cargo.toml
# 4. 获取语言统计和贡献者
gitlink-cli api GET /:owner/:repo/languages
gitlink-cli api GET /:owner/:repo/contributors
```
**健康度检查清单:**
| 检查项 | 标准 | 评分依据 |
|--------|------|----------|
| 文档完整性 | 有 README、CONTRIBUTING、CHANGELOG | 文件是否存在、内容质量 |
| 许可证 | 有 LICENSE 文件 | 是否存在、是否合规 |
| CI 配置 | 有 CI 配置(.github/workflows, Jenkinsfile 等) | 文件是否存在 |
| 代码规范 | 有 linter 配置 | eslint/prettier/ruff/pylint 等 |
| 测试覆盖 | 有 test 目录或测试文件 | 测试文件比例 |
| 依赖管理 | 依赖文件完整且无已知漏洞 | package-lock/go.sum/poetry.lock |
| Issue 健康度 | Issue 有分类标签、响应及时 | 通过 Issue 列表分析 |
**输出格式:**
```markdown
## 🏥 仓库健康度报告 — <owner>/<repo>
### 总体评分:<⭐x/5>
| 维度 | 状态 | 评分 | 建议 |
|------|:----:|:----:|------|
| 📖 文档 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
| 📜 许可证 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
| 🔧 CI/CD | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
| 🎨 代码规范 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
| 🧪 测试覆盖 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
| 📦 依赖安全 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
| 🐛 Issue 管理 | ✅/⚠️/❌ | ☆☆☆☆☆ | <建议> |
### 关键发现
1. <最需要改进的问题>
2. <次要问题>
3. <做得好的方面>
### 改进路线图
- **紧急(本周):** ...
- **短期(本月):** ...
- **长期(本季度):** ...
```
---
### 工作流 3批量 Issue Triage + 自动分配
**场景**:对新 Issue 进行自动分类、标签分配和责任人推荐。
```bash
# 1. 获取未标记的 Issue
gitlink-cli issue +list --state open --format json
# 2. 逐个分析 Issue 内容
gitlink-cli issue +view --id <issue_id> --format json
# 3. 根据内容智能分类
# 分析标题和描述后,通过 Raw API 打标签
gitlink-cli api POST /:owner/:repo/issues/:id --body '{
"issue_tag_ids": [<tag_id>],
"done_ratio": 0,
"subject": "<原始标题>",
"description": "<原始描述>"
}'
```
**分类规则参考:**
| Issue 关键词 | 推荐标签 | 优先级 |
|-------------|----------|:------:|
| bug, 错误, 失败, crash, 崩溃 | bug | 🔴 High |
| feature, 新增, 建议, 希望 | enhancement | 🔵 Low |
| 安全, 漏洞, 权限, 泄露 | security | 🔴 High |
| 性能, 慢, 卡顿, 优化 | performance | 🟡 Medium |
| 文档, README, 注释 | documentation | 🔵 Low |
| question, 如何, 怎么, 请问 | question | 🟡 Medium |
| 测试, test, 覆盖率 | testing | 🔵 Low |
---
## Raw API 参考
代码审查相关的 GitLink API 端点:
```bash
# 获取 PR 详情
gitlink-cli api GET /:owner/:repo/pulls/:id --format json
# 获取 PR 变更文件列表
gitlink-cli api GET /:owner/:repo/pulls/:id/files --format json
# 获取 PR Diff
gitlink-cli api GET /:owner/:repo/pulls/:id/diff --format json
# 提交 PR Review
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}'
# 获取仓库文件列表
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>'
# 获取仓库语言统计
gitlink-cli api GET /:owner/:repo/languages --format json
# 获取贡献者列表
gitlink-cli api GET /:owner/:repo/contributors --format json
# 获取仓库动态
gitlink-cli api GET /:owner/:repo/activity --format json
```
- 硬编码的密钥 / Token / 密码 / 数据库连接串
- SQL / NoSQL 注入(用户输入直接拼接进查询)
- 命令注入shell 命令拼接用户输入)
- 路径遍历(用户输入直接用于文件路径)
- XSS未转义的用户输入直接渲染`innerHTML`
- 不安全的反序列化
## 代码审查最佳实践
### 审查原则
1. **先大局后细节**:先理解 PR 目的与整体变更范围,再逐文件审查。
2. **关注行为而非风格**:风格问题交给 linter/formatter。
3. **提供可操作的建议**:不只指出问题,给出具体修改方案。
4. **肯定好的代码**:清晰命名、完善测试、良好设计给予正面反馈。
5. **控制评论量**:最严重的 35 个问题比 20 个小问题更有价值。
1. **先大局后细节**:先理解 PR 的目的和整体变更范围,再逐文件审查
2. **关注行为,而非风格**自动化工具linter/formatter能处理的风格问题优先交给工具
3. **提供可操作的建议**:不只是指出问题,要给出具体的修改方案
4. **肯定好的代码**:发现好的设计、清晰的命名、完善的测试时给予正面反馈
5. **控制评论量**:避免信息过载——最严重的 3-5 个问题比 20 个小问题更有价值
## 命令速查
### 安全红线
```bash
# 获取上下文
gitlink-cli pr +view --id <pr_id> --format json
gitlink-cli pr +files --id <pr_id> --format json
gitlink-cli pr +versions --id <pr_id> --format json # 拿 version_id
gitlink-cli pr +version-diff --id <pr_id> --version-id <vid> --format json # 逐行 diff替代已移除的 pr +diff
以下问题必须标记为 **Critical**,不得忽略:
# 提交评审(首选)
gitlink-cli pr +review --id <pr_id> --status <common|approved|rejected> --content "<报告>" [--commit <sha>] [--dry-run]
- 硬编码的密钥 / Token / 密码
- SQL / NoSQL 注入漏洞
- 命令注入shell 命令拼接)
- 路径遍历(用户输入直接用于文件路径)
- 不安全的反序列化
- XSS未转义的用户输入直接渲染
# 行内评论(绑定文件+行;行号不确定时勿用)
gitlink-cli pr +create-comment --id <pr_id> --path <path> --line <n> --body "<意见>"
### 输出规范
# 普通评论(不绑行;安全 fallback
gitlink-cli pr +comment --id <pr_id> --body "<评论>"
- 始终使用 `--format json` 获取结构化数据
- 审查报告输出为 **Markdown 格式**,便于直接粘贴到 PR 评论
- 涉及文件/行号时使用精准引用,方便定位
- 批量操作前使用 `--dry-run` 预检
# 查看已有评审(复审时用)
gitlink-cli pr +reviews --id <pr_id> --format json
```
## Raw API 参考
评审相关操作**优先用上述 Shortcut**。Shortcut 未覆盖时才用 Raw API且字段需符合 GitLink**不是** GitHub 的 `event:"COMMENT"`/`position`
```bash
# 列出已有评审
gitlink-cli api GET /:owner/:repo/pulls/:id/reviews --format json
```
> 字段说明详见 [`REFERENCE.md`](REFERENCE.md)。
## 红线与常见错误
- ❌ **用 GitHub 风格 Raw API 提交评审**`event:"COMMENT"`、`position`、`commit_id`+`path`——GitLink 用 `pr +review --status``pr +create-comment --path --line`
- ❌ **未经用户确认就提交 Review/评论**——必须 报告→确认→dry-run→提交。
- ❌ **用 `--yes` 绕过确认门**——除非用户明确要求「直接提交」。
- ❌ **行号不确定却发 `+create-comment --line`**——会挂错行或被拒;改并入总报告或用 `+comment`
- ❌ **有 Critical 却用 `--status common/approved`**——Critical 必须配 `rejected`
- ❌ **用 `gh` 操作 GitLink**——只用 `gitlink-cli`
- 所有命令加 `--format json` 便于解析;输出为 `{ok, data, meta}` envelope。
## 注意事项
- PR Review 提交后会通知所有关注该 PR 的参与者,评论内容请保持专业
- `pr +diff` 输出可能很大(大型 PRAgent 应分段处理
- API 的 PR files 和 diff 接口有频率限制,避免短时间内重复请求
- 对于 draft PR草稿应提示用户先将其标记为 Ready for Review
- Review/评论提交后会通知 PR 所有相关人,内容请专业、可操作。
- 大型 PR 的 diff 可能非常大,按文件分段处理,避免一次性塞满上下文。
- `pr +diff` / `+files` 接口有频率限制,避免短时间内重复请求。
- 对 draft PR提示用户先标记为 Ready for Review 再审查。
- 审查范围限于 PR 本身代码审查仓库整体健康度、Issue 分拣等场景见各自专用 Skill不在本 Skill 范围。

View File

@ -113,17 +113,50 @@ gitlink-cli pr +diff --id 42 --format json
- 有类型注解,代码可读性好
```
### Step 5提交 Review
### Step 5dry-run 预览(用户确认报告后)
报告含 2 个 Critical评审状态取 `rejected`Request changes。先 dry-run 预览,**不实际提交**
```bash
# 提交整体 Review 评论
gitlink-cli api POST /Gitlink/forgeplus/pulls/42/reviews --body '{
"body": "## PR #42 代码审查报告\n\n### 🔴 Critical\n\n1. **JWT Secret 硬编码**`src/config.py:15`\n JWT_SECRET 硬编码在源码中。建议使用 `os.getenv(\"JWT_SECRET\")`。\n\n2. **SQL 注入风险**`src/auth/login.py:42`\n 直接拼接用户输入到 SQL 查询。建议使用参数化查询。\n\n### 🟡 Warning\n\n1. **密码明文存储** — 建议使用 bcrypt 哈希处理。\n\n### 总体评价\n\n代码整体结构清晰测试覆盖良好。建议修复 Critical 问题后合并。",
"event": "COMMENT"
}'
gitlink-cli pr +review --id 42 \
--status rejected \
--content "## PR #42 代码审查报告
### 🔴 Critical
1. **JWT Secret 硬编码** — src/config.py:15。建议 os.getenv(\"JWT_SECRET\")。
2. **SQL 注入风险** — src/auth/login.py:42。建议参数化查询。
### 🟡 Warning
1. **密码明文存储** — 建议使用 bcrypt 哈希处理。
### 总体评价
代码整体结构清晰,测试覆盖良好。建议修复 Critical 问题后合并。" \
--dry-run --format json
```
### Step 6输出审查摘要
→ 核对预览无误,请用户二次确认。
### Step 6提交评审用户确认 dry-run 后)
去掉 `--dry-run` 正式提交:
```bash
gitlink-cli pr +review --id 42 --status rejected --content "<同上审查报告>" --format json
```
### Step 7行内评论可选针对具体行
把 Critical 意见精准挂到对应行(行号取文件真实行号,注意 import/头注释偏移;不确定则并入上面的总报告):
```bash
gitlink-cli pr +create-comment --id 42 --path src/config.py --line 15 \
--body "Critical: JWT_SECRET 硬编码,存在泄露风险。改用 os.getenv(\"JWT_SECRET\")。" --format json
gitlink-cli pr +create-comment --id 42 --path src/auth/login.py --line 42 \
--body "Critical: SQL 注入风险,用户输入直接拼接。改用参数化查询。" --format json
```
### Step 8输出审查摘要
```markdown
## 📋 审查摘要 — PR #42 feat: add user authentication module
@ -159,6 +192,12 @@ gitlink-cli pr +files --id <id> --format json
# 获取 Diff
gitlink-cli pr +diff --id <id> --format json
# 提交 Review
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}'
# 提交评审首选Critical→rejected / 仅建议→common / 无问题→approved
gitlink-cli pr +review --id <id> --status <common|approved|rejected> --content "<审查报告>" [--dry-run]
# 行内评论(绑定文件+行;行号不确定时勿用,改用 +comment 或并入 --content
gitlink-cli pr +create-comment --id <id> --path <path> --line <n> --body "<意见>"
# 普通评论(不绑行)
gitlink-cli pr +comment --id <id> --body "<评论>"
```

View File

@ -0,0 +1,167 @@
# gitlink-issue-triage API 参考
> 分拣相关的 gitlink-cli 命令返回字段说明。先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解 envelope `{ok, data, meta}` 与全局参数。
## 前置ID 解析命令
分拣写入参数(`--label`/`--assignee`/`--priority`)都吃 **ID**,必须先解析。
### `label +list` — 标签name → id
```bash
gitlink-cli label +list --format json
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.issue_tags[].id` | int | **标签 ID写入时传这个** |
| `data.issue_tags[].name` | string | 标签名称GitLink 默认为中文:缺陷/功能/疑问/文档/任务…) |
| `data.issue_tags[].color` | string | 颜色hex |
| `data.issue_tags[].description` | string | 标签说明 |
### `label +assigners` — 可指派人login → id
```bash
gitlink-cli label +assigners --format json
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.assigners[].id` | int | **用户 ID`--assignee` 传这个)** |
| `data.assigners[].login` | string | 登录名 |
| `data.assigners[].name` | string | 显示名/角色(如「后端组」) |
### `label +priorities` — 优先级name → id
```bash
gitlink-cli label +priorities --format json
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.priorities[].id` | int | 优先级 ID`--priority` 传这个) |
| `data.priorities[].name` | string | 名称(如 高/正常/低) |
### `label +create` — 补建缺失标签(确认后)
```bash
gitlink-cli label +create --name <name> --color <hex_without_#> --format json
```
| 参数 | 说明 |
|------|------|
| `-n, --name` | 标签名称(必填) |
| `-c, --color` | 颜色 hex不带 #,如 `ff0000`),可选 |
---
## 分拣主体命令
### `issue +list` — 列出 Issue
```bash
gitlink-cli issue +list --state open --format json
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.issues[].number` | int | **Issue 编号web URL 中的编号,写入用这个)** |
| `data.issues[].id` | int | Issue 内部 ID |
| `data.issues[].name` / `subject` | string | 标题 |
| `data.issues[].tags` | array | 已打标签(**为空 = 未分拣,需处理** |
| `data.issues[].assigners` | array | 已指派人 |
| `data.issues[].priority` | object | 优先级 `{id, name}` |
| `data.issues[].author` | object | 作者 `{login, name}` |
> ⚠️ `--state` 仅影响统计计数,返回列表可能含所有状态。**幂等筛选**:保留 `tags` 为空的 Issue跳过已打标签的。
### `issue +view` — 查看详情(语义分析用)
```bash
gitlink-cli issue +view --number <n> --format json
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.issue.number` | int | Issue 编号 |
| `data.issue.subject` | string | 标题 |
| `data.issue.description` | string | **描述(语义分析的主要输入)** |
| `data.issue.status_id` | int | 状态 ID |
| `data.issue.tags` | array | 已打标签(`[{id,name,color}]`**为空 = 未分拣** |
| `data.issue.priority` | object | 优先级 |
> 写入参数用 `--number`web 编号),不是内部 `id`
### `issue +batch-update` — 批量更新(同标签批量场景)
```bash
gitlink-cli issue +batch-update \
--numbers <a,b,c> \
--label <单个id> \
[--assignee <id>] [--priority <id>] \
[--dry-run] --format json
```
| 参数 | 说明 |
|------|------|
| `-n, --numbers` | 逗号分隔的 Issue 编号 |
| `-l, --label` | 标签 **ID**;⚠️ **集合式**——把传入的标签集合打到 `--numbers` 里**每一个** Issue**非按位置对应** |
| `-a, --assignee` | 指派人 **ID** |
| `-m, --milestone` / `-p, --priority` | 里程碑 / 优先级 ID |
| `-s, --state` | open / closed / 数字 status_id |
| `--dry-run` | **预览不写入** |
| `--from` | 从 CSV 读编号(大批量场景) |
> ⚠️ **实测**`--label a,b,c` 会把 {a,b,c} 全部打到这批每个 Issue集合式。因此**仅用于多个 Issue 共用同一(组)标签**;各 Issue 标签不同时改用下方 Raw API。`--dry-run` 输出含 `updates.issue_tag_ids`(扁平数组,印证集合式)。
### `issue +update` — 单个 Issue 更新
```bash
gitlink-cli issue +update --number <n> --assignee <id> --format json
```
| 参数 | 说明 |
|------|------|
| `-n, --number` | Issue 编号 |
| `-l, --label` | ⚠️ **实测不可用**CLI 发单数 `issue_tag_id` 被平台忽略,标签打不上。打标签改用 Raw API `issue_tag_ids` |
| `-a, --assignee` | 指派人 **ID** |
| `-t, --title` / `-b, --body` | 新标题/描述(更新会自动保留原 subject/description不会清空 |
| `-s, --state` / `-m, --milestone` / `-p, --priority` | 状态/里程碑/优先级 |
### `issue +comment` — 添加引导评论
```bash
gitlink-cli issue +comment --number <n> --body "<评论内容>" --format json
```
| 参数 | 说明 |
|------|------|
| `-n, --number` | Issue 编号 |
| `-b, --body` | 评论内容(支持 Markdown |
---
## Raw API 兜底
Shortcut 未覆盖时用 Raw API。**Issue 更新需先 GET 拿到当前 `subject`/`description` 再带上提交**,否则可能清空描述(见 gitlink-shared 已知坑)。
```bash
# 打标签issue_tag_ids 为标签 ID 数组)
gitlink-cli api PATCH /:owner/:repo/issues/:number --body '{
"issue_tag_ids": [<tag_id>, ...],
"subject": "<原标题>",
"description": "<原描述>"
}'
```
| 字段 | 说明 |
|------|------|
| `issue_tag_ids` | 标签 **ID 数组**(不是名称) |
| `subject` / `description` | 必须回带原值,防止清空 |
## 数据获取最佳实践
1. **始终 `--format json`** 便于解析。
2. 写入参数一律传 **ID**:标签/指派人/优先级都先解析为 ID。
3. **幂等**`issue +list` 后客户端筛 `labels` 为空的,避免重复分拣。
4. 批量前先 `--dry-run` 预览,核对 `--numbers``--label`/`--assignee` 位置对应。

View File

@ -0,0 +1,217 @@
---
name: gitlink-issue-triage
version: 1.2.0
description: "当用户需要批量处理新提交的 GitLink Issue自动分类、打标签、分配责任人、添加引导评论时触发。适用于 Issue 积压无人分流、新 Issue 缺少分类标签、需要按类别指派维护者等场景。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli issue --help"
---
# gitlink-issue-triageIssue 自动分拣)
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有写入/删除操作前务必先确认用户意图分拣属于批量写入必须先出方案→用户确认→dry-run 预览→再执行。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
## 核心原则
1. **语义分类为主,关键词表为辅**:你是 AI Agent价值在于读懂 Issue 意图(如「登录后白屏、控制台报 500」即使没有「bug」字眼也应判为 Bug。下方关键词表只是兜底/加速,不要退化成正则匹配。
2. **建议优先,确认后写入**:先输出「分拣方案表」给用户确认,**禁止未经确认直接打标签/改指派人**。尤其禁止用 `--yes` 绕过确认,除非用户明确说「直接应用/不用确认」。
3. **幂等**:只处理「尚未分拣」的 Issue无标签或仅有默认标签避免对已分拣 Issue 重复操作。
4. **打标签用 Raw API批量仅限同标签**:实测 `issue +update --label` 不可用、`+batch-update --label` 是集合式(整组标签打到每个 Issue故各 Issue 不同标签时用 Raw API `issue_tag_ids`(详见 Step 5仅当多 Issue 共用同一标签时才用 `+batch-update`
## 工作流概览
| 阶段 | 操作 | 命令 |
|------|------|------|
| ① 解析 ID必做前置 | 取仓库标签/可指派人/优先级,建立 名称→ID 映射 | `label +list` / `label +assigners` / `label +priorities` |
| ② 取未分拣 Issue | 拉取 open Issue客户端筛掉已打标签的 | `issue +list` |
| ③ 语义分析 | 逐个读标题+描述,判类别/标签/责任人/评论 | `issue +view` |
| ④ 出方案 | 输出「分拣方案表」,等用户确认 | (无写入) |
| ⑤ 打标签 | 用户确认后,逐 Issue 用 Raw API 打标签(不同标签);同标签可批量 | `api PATCH .../issues/:n` / `+batch-update` |
| ⑥ 引导评论 | 为每个 Issue 添加分类引导评论 | `issue +comment` |
---
## 详细工作流
### Step 1解析 ID**必做前置,切勿跳过**
GitLink 的标签、指派人、优先级参数都吃 **ID 而非名称**。先拉取并建立映射:
```bash
# 标签name → id返回在 data.issue_tags[];标签名因仓库而异,如某仓库为 缺陷/功能/疑问/文档…)
gitlink-cli label +list --format json
# 可指派人login → id如 alice→101
gitlink-cli label +assigners --format json
# 优先级name → id数值因仓库而异如某仓库为 低→1/正常→2/高→3/紧急→4以实际输出为准
gitlink-cli label +priorities --format json
```
> 没有 ID 映射就无法正确执行 Step ⑤⑥。若目标标签不存在,见 Step 4「补建标签」。
### Step 2取未分拣 Issue幂等筛选
```bash
# 拉取所有 open Issue
gitlink-cli issue +list --state open --format json
```
筛选规则(客户端过滤):**保留尚未打标签的 Issue**`tags` 字段为空),跳过已有分类标签的,避免重复分拣。
### Step 3语义分析
对每个待分拣 Issue
```bash
gitlink-cli issue +view --number <issue_number> --format json
```
读取 `subject` + `description`,按下方「分类规则参考」判断类别、推荐标签、推荐责任人角色、引导评论要点。
### Step 4出「分拣方案表」建议不写入
输出一张表给用户确认,**此阶段不执行任何写入**
```markdown
| # | 标题 | 判定类别 | 标签(id) | 责任人(id) | 评论文案要点 |
|---|------|---------|----------|-----------|-------------|
| 12 | 登录后白屏 500 | Bug | bug(1) | bob(102) | 请补浏览器/复现步骤/后端日志 |
| 13 | 希望支持暗黑模式 | Feature | enhancement(2) | alice(101) | 请确认范围:全局/页面级、是否跟随系统 |
```
**补建标签(可选)**:若方案需要的标签在 `label +list` 中不存在(如缺 `bug`),不要静默跳过,也**不要擅自创建**——在方案表中标注「标签缺失」,待用户确认后用 `gitlink-cli label +create --name bug` 创建,再继续。
**等待用户确认**:用户没说「确认/执行」前,停在 Step 4。**禁止用 `--yes` 自行推进。**
### Step 5打标签用户确认方案后
> ⚠️ **实测要点2026-06-15 端到端验证)**
> - `issue +update --label <id>` **不可用**——CLI 发送单数 `issue_tag_id`,平台忽略,标签打不上。
> - `issue +batch-update --label a,b,c` 是**集合式**:把 `{a,b,c}` 打到 `--numbers` 里**每一个** Issue**不是**按位置对应。仅适合「多个 Issue 共用同一(组)标签」。
> - 可用的打标签方式是 Raw API 的 `issue_tag_ids`(复数数组)。
**情形 A各 Issue 标签不同(最常见)→ 逐 Issue 用 Raw API已验证**
```bash
gitlink-cli api PATCH /:owner/:repo/issues/<number> --body '{
"issue_tag_ids": [<标签id>],
"subject": "<原标题>",
"description": "<原描述>"
}'
```
`issue_tag_ids` 是数组,**整体替换**该 Issue 的标签集;务必回带 `subject`/`description`,否则描述会被清空(见 gitlink-shared 已知坑)。
**情形 B多个 Issue 共用同一标签 → 批量(先 dry-run 再执行):**
```bash
# dry-run 预览(同一标签打到这批所有 Issue
gitlink-cli issue +batch-update --numbers <a,b,c> --label <单个id> --dry-run --format json
# 核对后去掉 --dry-run 正式执行
```
**指派人**`issue +update --number <n> --assignee <user_id>``user_id` 来自 `label +assigners`)。若 `label +assigners` 为空(如个人仓库无可指派成员),**留空并在方案表注明**,不要乱指派。
### Step 6引导评论
为每个 Issue 添加分类引导评论(语气专业,包含分类理由 + 需要补充的信息):
```bash
gitlink-cli issue +comment --number 12 --body "已自动分拣为 **Bug**,转交 @bob
为加快定位,请补充:运行环境、复现步骤、浏览器控制台与后端日志。维护者会尽快处理。"
```
---
## 分类规则参考
### 类别判定(语义优先,关键词兜底)
| 类别 | 语义信号 | 关键词兜底 | 建议标签 | 责任人角色 |
|------|---------|-----------|----------|-----------|
| Bug | 运行时报错、崩溃、行为异常、阻断主流程 | 错误/失败/异常/crash/报错/500/白屏 | bug | 后端或前端(按错误来源) |
| 功能需求 | 希望新增能力、改进现有行为 | 建议/希望/需要/feature/支持 | enhancement | PM / 对应模块开发 |
| 文档 | 文档缺失/错误/拼写 | 文档/README/拼写 | documentation | 文档负责人 |
| 问题咨询 | 询问用法、求助 | 请问/怎么/如何/help/question | question | 社区支持 / 文档 |
| 性能 | 慢、卡顿、资源占用 | 慢/卡顿/性能/优化/performance | performance | 核心开发者 |
> 上表「建议标签」是语义类别名,**实际标签名以 `label +list` 为准**——GitLink 默认标签是中文(缺陷/功能/疑问/文档/任务…),需先把类别映射到仓库实际标签的 ID 再打。
### 角色 → 默认责任人映射(需用 `label +assigners` 的实际 ID 替换)
| 角色 | 何时指派 |
|------|---------|
| 后端 | 服务端错误、API、数据库相关 |
| 前端 | UI、样式、浏览器端错误 |
| PM | 需求范围、优先级决策 |
| 文档 | 文档/问答类 |
| 核心开发者 | 性能、架构、安全 |
> 实际指派时,把「角色」替换为 `label +assigners` 中对应的 **user_id**。若角色无对应成员,**留空并在方案表注明**,不要乱指派。
---
## 命令速查
```bash
# 前置:解析 ID
gitlink-cli label +list --format json # 标签 name→id
gitlink-cli label +assigners --format json # 可指派人 login→id
gitlink-cli label +priorities --format json # 优先级 name→id
gitlink-cli label +create --name <name> --color <hex> # 补建缺失标签(确认后)
# 分拣主体
gitlink-cli issue +list --state open --format json # 取未分拣(客户端筛 tags 为空的)
gitlink-cli issue +view --number <n> --format json # 读详情做语义分析(标签字段为 tags
# 打标签(已验证可用):
# - 各 Issue 不同标签 → 逐个 Raw APIissue_tag_ids 整体替换)
gitlink-cli api PATCH /:owner/:repo/issues/<n> --body '{"issue_tag_ids":[<id>],"subject":"<>","description":"<>"}'
# - 多 Issue 共用同一标签 → 批量(集合式,先 --dry-run
gitlink-cli issue +batch-update --numbers <a,b,c> --label <单个id> --dry-run
gitlink-cli issue +comment --number <n> --body "<引导评论>" # 引导评论
gitlink-cli issue +update --number <n> --assignee <user_id> # 指派人user_id 来自 label +assigners
```
> ⚠️ `issue +update --label``+batch-update --label a,b,c`(多标签)均不可靠,详见红线。
---
## Raw API 参考
打标签的**主要可用方式**是 Raw APIShortcut 的 `--label` 不可靠,见红线)。务必回带 `subject`/`description` 防清空:
```bash
# 打标签issue_tag_ids 为标签 ID 数组,整体替换该 Issue 标签集)
gitlink-cli api PATCH /:owner/:repo/issues/:number --body '{
"issue_tag_ids": [<tag_id>, ...],
"subject": "<原标题>",
"description": "<原描述>"
}'
```
> 字段说明详见 [`REFERENCE.md`](REFERENCE.md)。
---
## 红线与常见错误
- ❌ **未经用户确认就打标签/指派**——必须方案表→确认→执行。
- ❌ **用 `--yes` 绕过确认门**——除非用户明确要求「直接应用」。
- ❌ **用 `issue +update --label` 打标签**——实测 CLI 发单数 `issue_tag_id` 被平台忽略,标签打不上;改用 Raw API `issue_tag_ids`Step 5
- ❌ **用 `+batch-update --label a,b,c` 给不同 Issue 打不同标签**——它是集合式,会把 {a,b,c} 全打到每个 Issue仅用于多 Issue 共用同一标签。
- ❌ **把标签名称当 ID 传**——必须先用 `label +list` 解析为 ID返回在 `data.issue_tags[]`,标签名以实际为准)。
- ❌ **重复分拣已分类 Issue**——Step 2 客户端筛掉 `tags` 非空的。
- ❌ **用 `gh` 操作 GitLink**——只用 `gitlink-cli`
- 所有命令加 `--format json` 便于解析;输出为 `{ok, data, meta}` envelope。
## 注意事项
- 分拣是批量写入,评论会通知 Issue 相关人,内容请专业、可操作。
- `issue +list``--state` 仅影响统计计数,列表可能含全部状态,需客户端二次过滤。
- 若 Issue 描述信息不足以判定类别,在方案表标注「信息不足,建议先发评论索取复现信息」,不要强行分类。

View File

@ -0,0 +1,188 @@
# Issue 批量自动分拣完整工作流示例
**场景**:仓库 `z2_cc/gitlink-cli` 积压了一批未分类的 open Issue需要批量分流自动判定类别、打标签、指派责任人、添加引导评论。
> 本示例遵循 SKILL.md 的核心原则:**语义分类为主、建议优先确认后写入、幂等、批量为主**。所有写入操作都经过「方案→确认→dry-run→执行」四步。
## 前置条件
- `gitlink-cli` 已安装并登录(`gitlink-cli auth status` 正常)
- 对目标仓库有写入权限
- 已阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md)
---
## Step 1解析 ID建立 名称→ID 映射)
```bash
gitlink-cli label +list --format json
gitlink-cli label +assigners --format json
gitlink-cli label +priorities --format json
```
**`label +list` 输出示例:**
```json
{
"ok": true,
"data": { "labels": [
{"id": 1, "name": "bug"},
{"id": 2, "name": "enhancement"},
{"id": 3, "name": "documentation"},
{"id": 4, "name": "question"},
{"id": 5, "name": "performance"}
]}
}
```
**`label +assigners` 输出示例:**
```json
{
"ok": true,
"data": { "assigners": [
{"id": 101, "login": "alice", "name": "前端组"},
{"id": 102, "login": "bob", "name": "后端组"},
{"id": 103, "login": "carol", "name": "文档组"}
]}
}
```
→ 建立映射:标签 `bug→1, enhancement→2, question→4`;指派人 `alice→101, bob→102, carol→103`
## Step 2取未分拣 Issue客户端筛掉已打标签的
```bash
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open --format json
```
筛出**尚未打标签**的 3 个(已分类的跳过,保证幂等):
| number | subject | labels |
|--------|---------|--------|
| 12 | 登录后页面白屏,控制台报 500 | (空) |
| 13 | 希望能支持暗黑模式 | (空) |
| 14 | 请问怎么配置 webhook | (空) |
## Step 3语义分析逐个读详情
```bash
gitlink-cli issue +view --owner z2_cc --repo gitlink-cli --number 12 --format json
```
```json
{
"ok": true,
"data": { "issue": {
"number": 12,
"subject": "登录后页面白屏,控制台报 500",
"description": "输入账号密码点登录后页面直接白屏,浏览器控制台显示 500 Internal Server Error。Chrome 120 / macOS。"
}}
}
```
**语义判定(注意 #12 没有「bug」字眼靠语义识别**
- **#12**500 + 阻断登录主流程 → **Bug**500 是服务端响应 → 后端 bob
- **#13**:「希望支持暗黑模式」→ **功能需求 enhancement**UI/样式 → 前端 alice
- **#14**:「请问怎么配置 webhook」→ **问题咨询 question**;答疑 → 文档 carol
## Step 4出「分拣方案表」建议不写入等用户确认
```
分拣方案(共 3 个 Issue请确认后我将 dry-run 预览):
| # | 标题 | 类别 | 标签(id) | 责任人(id) |
|-----|----------------------|----------|-----------------|-------------|
| 12 | 登录后白屏 500 | Bug | bug(1) | bob(102) |
| 13 | 希望支持暗黑模式 | Feature | enhancement(2) | alice(101) |
| 14 | 请问怎么配置 webhook | Question | question(4) | carol(103) |
引导评论将分别请求:#12 复现步骤/后端日志;#13 主题范围确认;#14 直接答复+文档化。
确认执行吗?(回复「确认」我进入 dry-run 预览;如需调整请指出)
```
> ⏸️ **停在此处等用户确认。禁止用 `--yes` 自行推进。**
## Step 5打标签用户确认方案后
> ⚠️ 各 Issue 标签不同,**用 Raw API 逐个打**(实测 `issue +update --label` 不可用、`+batch-update --label` 是集合式,见 SKILL.md 红线)。务必回带 `subject`/`description` 防清空。
```bash
# #12 → bug(1)
gitlink-cli api PATCH /:owner/:repo/issues/12 --body '{
"issue_tag_ids": [1], "subject": "登录后页面白屏,控制台报 500",
"description": "输入账号密码点登录后页面直接白屏,控制台显示 500 Internal Server Error。Chrome 120/macOS。"
}'
# #13 → enhancement(2)
gitlink-cli api PATCH /:owner/:repo/issues/13 --body '{
"issue_tag_ids": [2], "subject": "希望能支持暗黑模式", "description": "希望加暗黑主题切换。"
}'
# #14 → question(4)
gitlink-cli api PATCH /:owner/:repo/issues/14 --body '{
"issue_tag_ids": [4], "subject": "请问怎么配置 webhook", "description": "想把推送事件通知到群机器人。"
}'
```
> 多个 Issue **共用同一标签**时,可改用批量:`issue +batch-update --numbers <a,b,c> --label <单个id> --dry-run` → 去 `--dry-run`
> 指派人:`issue +update --number <n> --assignee <user_id>``user_id` 来自 `label +assigners`;无可指派人则留空)。
**验证打标结果:**
```bash
gitlink-cli issue +view --number 12 --format json # data.issue.tags 应为 [{"id":1,"name":"bug"}]
```
## Step 6引导评论逐个添加
```bash
# #12 Bug
gitlink-cli issue +comment --owner z2_cc --repo gitlink-cli --number 12 \
--body "已自动分拣为 **Bug**,转交 @bob。为加快定位,请补充:运行环境、稳定复现步骤、浏览器控制台与后端对应时间点的报错日志。"
# #13 Feature
gitlink-cli issue +comment --owner z2_cc --repo gitlink-cli --number 13 \
--body "已自动分拣为 **enhancement**,转交 @alice。落地前请确认范围:① 全局暗黑主题还是特定页面;② 是否跟随系统 prefers-color-scheme③ 配色基准。"
# #14 Question
gitlink-cli issue +comment --owner z2_cc --repo gitlink-cli --number 14 \
--body "已自动分拣为 **question**,转交 @carol。快速回答Webhook 在「仓库设置 → Webhooks」新增填回调 URL、选触发事件GitLink 会向该 URL 发 POST。完整字段说明将补充到 docs/。"
```
---
## 命令速览
```bash
# 1. 解析 ID
gitlink-cli label +list --format json
gitlink-cli label +assigners --format json
# 2. 取未分拣 Issue客户端筛无标签的
gitlink-cli issue +list --state open --format json
# 3. 语义分析
gitlink-cli issue +view --number <n> --format json
# 4. 出方案表 → 用户确认(不写入)
# 5. 打标签:各 Issue 不同标签 → 逐个 Raw APIissue_tag_ids 整体替换)
gitlink-cli api PATCH /:owner/:repo/issues/<n> --body '{"issue_tag_ids":[<id>],"subject":"<>","description":"<>"}'
# 多 Issue 共用同一标签 → 批量(先 --dry-run
gitlink-cli issue +batch-update --numbers <a,b,c> --label <单个id> --dry-run
# 6. 引导评论
gitlink-cli issue +comment --number <n> --body "<评论>"
```
## 退化场景:单个 Issue
只需分拣一个 Issue 时:
```bash
# 打标签Raw API+update --label 实测不可用)
gitlink-cli api PATCH /:owner/:repo/issues/<n> --body '{"issue_tag_ids":[<id>],"subject":"<原标题>","description":"<原描述>"}'
# 指派人(可选)
gitlink-cli issue +update --number <n> --assignee <user_id>
# 引导评论
gitlink-cli issue +comment --number <n> --body "<评论>"
```
同样遵循「方案→确认→执行」,不要直接 `--yes`

View File

@ -0,0 +1,137 @@
---
name: gitlink-newcomer-guide
version: 1.0.0
description: "新人引导:为 good-first-issue 自动添加引导评论,降低新贡献者参与门槛。当项目收到新贡献者或标记了 good-first-issue 时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli issue --help"
---
# gitlink-newcomer-guide新人引导
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
## 说明
本 Skill 帮助项目维护者为新贡献者创建友好的入门体验。自动识别标记为 good-first-issue 的 Issue并为其添加结构化的引导评论降低新贡献者的参与门槛。
## 依赖的 Shortcuts
| Shortcut | 用途 |
|----------|------|
| `issue +list` | 获取标记了 good-first-issue 的 Issue |
| `issue +view` | 查看 Issue 详细内容 |
| `issue +comment` | 添加引导评论 |
| `label +list` | 查看可用标签 |
| `repo +info` | 获取仓库基本信息 |
## 工作流程
```
1. issue +list → 获取所有打开的 Issue
2. 筛选含 good-first-issue 标签的 Issue
3. issue +view --number {id} → 查看 Issue 详情
4. 分析 Issue 内容 → 判断适合的引导信息
5. issue +comment --body "..." → 添加引导评论
6. 可选issue +update → 分配 mentor
```
## 引导评论模板
根据 Issue 类型选择合适的引导评论:
### Bug 类 Issue
```markdown
你好!欢迎参与贡献 🎉
这个 Issue 被标记为 **good first issue**,非常适合作为你的第一个贡献。
## 如何开始
1. 在评论区留言"我想认领这个 Issue",我们会把你添加到 Assignee
2. 查看 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解开发环境搭建
3. Fork 本仓库并创建你的功能分支
4. 修复问题后提交 Pull Request
## 需要帮助?
- 如果对问题描述有疑问,可以在下方留言
- 在开发过程中遇到任何问题,也欢迎随时提问
祝你编码愉快!
```
### 功能需求类 Issue
```markdown
你好!欢迎参与贡献 🎉
这个 Issue 被标记为 **good first issue**,适合作为你了解本项目的起点。
## 实现思路
1. 建议先阅读相关模块的源代码,了解现有实现
2. 可以参考已有的类似功能实现
3. 记得添加对应的单元测试
## 提交流程
1. Fork 仓库并创建分支
2. 实现功能并编写测试
3. 确保 `go test ./...` 全部通过
4. 提交 Pull Request描述你的改动
如有任何问题,随时留言!
```
### 文档类 Issue
```markdown
你好!欢迎参与贡献 🎉
感谢你对改进文档的兴趣!
## 文档贡献指南
1. 文档位于项目中的 `docs/` 目录下
2. 使用 Markdown 格式编写
3. 修改后请预览效果,确保格式正确
4. 提交 Pull Request描述你的改动
## 注意事项
- 保持文档风格一致
- 中英文之间保留空格
- 代码块请标注语言类型
期待你的贡献!
```
## 使用示例
```bash
# 1. 查找所有标记了 good-first-issue 的 Issue
gitlink-cli issue +list --owner myuser --repo myrepo --state open --format json
# 2. 查看某个 Issue 的详细内容(判断类型)
gitlink-cli issue +view --owner myuser --repo myrepo --number 10 --format json
# 3. 查看仓库可用标签
gitlink-cli label +list --owner myuser --repo myrepo
# 4. 添加引导评论
gitlink-cli issue +comment --owner myuser --repo myrepo --number 10 --body "你好!欢迎参与贡献..."
# 5. 可选:将 Issue 标记为已分配
gitlink-cli issue +update --owner myuser --repo myrepo --number 10
```
## 注意事项
- 添加引导评论前,先检查该 Issue 是否已有引导评论(避免重复)。
- 如果 Issue 已经被 Assignee 认领,不需要再添加引导评论。
- 不要修改 good-first-issue 标签,由项目维护者管理。
- 引导评论建议使用温和、鼓励的语气。

View File

@ -0,0 +1,71 @@
# 新人引导完整工作流示例
**场景**:项目标记了一个 good-first-issue需要为新贡献者添加引导评论帮助 TA 迈出第一步。
## 前置条件
- `gitlink-cli` 已安装并登录
- 对目标仓库有评论权限
## 工作流步骤
### Step 1查找 good-first-issue
```bash
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open --format json
```
查看 Issue 列表中哪些标记了适合新人的任务。
### Step 2查看 Issue 详情
```bash
gitlink-cli issue +view --owner z2_cc --repo gitlink-cli --number 10 --format json
```
分析 Issue 内容:
- 是否是 Bug 修复?→ 使用 Bug 模板
- 是否是文档改进?→ 使用文档模板
- 是否是功能需求?→ 使用功能模板
### Step 3添加引导评论
```bash
gitlink-cli issue +comment --owner z2_cc --repo gitlink-cli --number 10 --body "你好!欢迎参与贡献 🎉
这个 Issue 被标记为 **good first issue**,非常适合作为你的第一个贡献。
### 如何开始
1. 在评论区留言"我想认领",我们会把你添加到 Assignee
2. Fork 本仓库并创建你的功能分支
3. 修改代码后提交 Pull Request
### 需要帮助?
- 如果对问题描述有疑问,可以在下方留言
- 开发过程中遇到任何问题,也欢迎随时提问
祝你编码愉快!"
```
### Step 4确认引导已生效
```bash
gitlink-cli issue +view --owner z2_cc --repo gitlink-cli --number 10 --format json
```
检查 `comment_journals_count` 是否增加了。
---
## 完整命令速览
```bash
# 1. 查找 good-first-issue
gitlink-cli issue +list --state open
# 2. 查看详情
gitlink-cli issue +view --number <id>
# 3. 添加引导评论
gitlink-cli issue +comment --number <id> --body "<引导内容>"
```

View File

@ -0,0 +1,136 @@
---
name: gitlink-project-health
version: 1.0.0
description: "项目健康度报告:统计 Issue 响应时间、PR 合并效率、贡献者活跃度,生成项目健康度分析报告。当用户需要了解项目整体运行状况时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli --help"
---
# gitlink-project-health项目健康度报告
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
## 说明
本 Skill 组合多个 gitlink-cli 命令,从不同维度采集项目数据,生成结构化的项目健康度报告。
## 数据来源
| Shortcut | 采集的数据 |
|----------|-----------|
| `issue +list` | Issue 总数、打开/关闭数量、平均响应时间 |
| `pr +list` | PR 总数、打开/合并/关闭数量、平均合并时间 |
| `repo +info` | 仓库基本信息、fork 数、watch 数 |
| `commit +list` | 近期提交频率、活跃开发者数 |
| `label +list` | 标签使用情况 |
| `milestone +list` | 里程碑完成进度 |
## 报告维度
### 1. Issue 健康度
```bash
# 所有 Issue 统计
gitlink-cli issue +list --owner myuser --repo myrepo --state all --format json
# 已关闭的 Issue用于统计平均解决时间
gitlink-cli issue +list --owner myuser --repo myrepo --state closed --limit 30
# 待处理的 Issue
gitlink-cli issue +list --owner myuser --repo myrepo --state open
```
### 2. PR 健康度
```bash
# 所有 PR 统计
gitlink-cli pr +list --owner myuser --repo myrepo --state all --format json
# 待合并的 PR
gitlink-cli pr +list --owner myuser --repo myrepo --state open
# 已合并的 PR统计平均合并时间
gitlink-cli pr +list --owner myuser --repo myrepo --state merged --limit 30
```
### 3. 仓库基本信息
```bash
# 仓库概况
gitlink-cli repo +info --owner myuser --repo myrepo --format json
```
### 4. 里程碑进度
```bash
# 查看里程碑
gitlink-cli milestone +list --owner myuser --repo myrepo
```
### 5. 提交活跃度
```bash
# 最近提交
gitlink-cli commit +list --owner myuser --repo myrepo --limit 20
```
## 报告模板
收集完数据后,按以下模板生成报告:
```markdown
# 项目健康度报告
## 基本信息
- 项目:{repo_name}
- 所有者:{owner}
- Stars{stars} | Forks{forks}
## Issue 状况
- 总 Issue 数:{total_issues}
- 打开:{open_issues}(占比 {open_percent}%
- 已关闭:{closed_issues}(占比 {closed_percent}%
## PR 状况
- 总 PR 数:{total_prs}
- 打开:{open_prs}
- 已合并:{merged_prs}
## 活跃度
- 近期提交:{recent_commits} 次
- 活跃开发者:{active_devs} 人
```
## 使用示例
```bash
# 完整报告生成流程
# 步骤1获取仓库信息
gitlink-cli repo +info --owner myuser --repo myrepo --format json
# 步骤2获取 Issue 统计
gitlink-cli issue +list --owner myuser --repo myrepo --state open
gitlink-cli issue +list --owner myuser --repo myrepo --state all --format json
# 步骤3获取 PR 统计
gitlink-cli pr +list --owner myuser --repo myrepo --state open
gitlink-cli pr +list --owner myuser --repo myrepo --state merged --limit 20
# 步骤4获取提交活跃度
gitlink-cli commit +list --owner myuser --repo myrepo --limit 30
# 步骤5获取里程碑进度
gitlink-cli milestone +list --owner myuser --repo myrepo
# 步骤6汇总数据生成报告
```
## 注意事项
- 使用 `--format json` 获取结构化数据,便于 Agent 解析。
- 指定 `--limit` 控制数据量,避免输出过大。
- 不同项目的数据量级不同,可根据实际情况调整采样数量。
- 报告中的"健康状况"建议使用颜色标识(绿色=健康,黄色=一般,红色=需关注)。

View File

@ -0,0 +1,92 @@
# 项目健康度报告生成工作流示例
**场景**:项目维护者需要生成一份项目健康度周报,了解 Issue 处理情况、PR 合并效率和整体活跃度。
## 前置条件
- `gitlink-cli` 已安装并登录
## 工作流步骤
### Step 1获取项目基本信息
```bash
gitlink-cli repo +info --owner z2_cc --repo gitlink-cli --format json
```
### Step 2统计 Issue 数据
```bash
# 所有 Issue
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state all --format json
# 打开的 Issue
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open
# 已关闭的 Issue最近 30 条)
gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state closed --limit 30
```
### Step 3统计 PR 数据
```bash
# 所有 PR
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state all --format json
# 待合并的 PR
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state open
# 已合并的 PR
gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state merged --limit 30
```
### Step 4查看近期提交活跃度
```bash
gitlink-cli commit +list --owner z2_cc --repo gitlink-cli --limit 20
```
### Step 5查看里程碑进度
```bash
gitlink-cli milestone +list --owner z2_cc --repo gitlink-cli
```
### Step 6生成报告
汇总以上数据,生成结构化报告:
```markdown
## 项目健康度周报
### Issue 状况
- 总 Issue 数12打开 3 / 关闭 9
- 本周新增2
- 本周解决3
### PR 状况
- 总 PR 数5打开 1 / 已合并 4
- 本周新增1
- 本周合并2
### 活跃度
- 近 20 次提交涉及3 位贡献者
- 最近提交时间2 小时前
### 里程碑
- 进行中v2.0(完成 60%
🟢 总体评价:项目健康
```
---
## 完整命令速览
```bash
gitlink-cli repo +info
gitlink-cli issue +list --state all --format json
gitlink-cli pr +list --state all --format json
gitlink-cli commit +list --limit 20
gitlink-cli milestone +list
```

View File

@ -109,6 +109,9 @@ gitlink-cli auth login
| PR 合并需要 `do` 参数 | `pr +merge` 需传 `do` 字段指定合并方式merge/rebase/squash | `pr +merge` 已内置处理 |
| PR 列表 state 过滤 | `--state` 参数仅影响统计计数,返回列表可能包含所有状态 | 需通过 `pull_request_status` 字段客户端过滤0=open, 1=merged, 2=closed |
| PR 创建需要代码差异 | 分支内容必须与目标分支不同,否则拒绝创建 | 需要先在分支上有实际提交 |
| **PR Diff 命令已变更** | `pr +diff` 已移除(与 `file` 命令冗余)。取 PR 代码差异用 `pr +versions --id <pr>``version_id`,再 `pr +version-diff --id <pr> --version-id <vid>`diff 在 `files[].sections[].lines[]``type` 2=新增/3=删除/4=hunk 头) | `pr +version-diff` 替代原 `pr +diff``--file` 过滤不稳,建议整份取后客户端筛 |
| **Issue 打标签需用 Raw API** | `issue +update --label <id>` 不可用CLI 发单数 `issue_tag_id`,被忽略);`issue +batch-update --label a,b,c` 是**集合式**(整组标签打到每个 Issue非按位置对应 | 打标签用 Raw API `PATCH /:owner/:repo/issues/:n --body '{"issue_tag_ids":[<id>],"subject":"<原>","description":"<原>"}'``label +list` 返回 `data.issue_tags[]``issue +view` 标签字段为 `tags` |
| **PR 行内评论锚定失效** | `pr +create-comment --path --line --body` 返回 `ok``line_code=null`,评论未真正绑定到行(仅文件级) | 评审意见优先放 `pr +review --status <common\|approved\|rejected> --content`;单条补充用 `pr +comment`(不绑行) |
## 文件操作 API

View File

@ -0,0 +1,57 @@
---
name: gitlink-snippet
version: 1.0.0
description: "代码片段管理:在仓库的 snippets/ 目录下管理代码片段,支持列出、查看、创建、删除。当用户需要管理或共享代码片段时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli snippet --help"
---
# gitlink-snippet代码片段管理
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## 说明
代码片段存放在项目仓库的 `snippets/` 目录下,每个片段是一个独立的文件。
读操作list/view通过 Git 克隆实现无需认证写操作create/delete通过文件 REST API 实现,需要 Token。
## Shortcuts
| Shortcut | 说明 | 需要认证 |
|----------|------|----------|
| `snippet +list` | 列出代码片段 | 否(公开项目) |
| `snippet +view` | 查看代码片段内容 | 否(公开项目) |
| `snippet +create` | 创建代码片段 | 是 |
| `snippet +delete` | 删除代码片段 | 是 |
## 使用示例
```bash
# 列出代码片段
gitlink-cli snippet +list --owner myuser --repo myrepo
# 查看代码片段
gitlink-cli snippet +view --owner myuser --repo myrepo --name "hello.py"
# 创建代码片段(需要认证)
gitlink-cli snippet +create --owner myuser --repo myrepo --name "hello.py" --content 'print("Hello World!")' --language python --message "添加Python示例"
# 删除代码片段(确认后再执行)
gitlink-cli snippet +delete --owner myuser --repo myrepo --name "old.py"
```
## 注意事项
- 片段名带扩展名(如 `.py`、`.go`)会按原样存储;不带扩展名会自动追加 `.md`
- `--language` 参数用于代码块语法高亮,不影响文件名。
- 执行写入/删除前务必确认用户意图。
## 相关链接
- [gitlink-repo](../gitlink-repo/SKILL.md) — 仓库管理

View File

@ -21,6 +21,8 @@ metadata:
| `webhook +delete` | 删除 webhook |
| `webhook +test` | 触发 webhook 测试投递 |
| `webhook +tasks` | 查看 webhook 投递任务历史 |
| `webhook +failed` | 查看投递失败的记录 |
| `webhook +task-view` | 查看单次投递的详细内容 |
## 使用示例
@ -30,4 +32,6 @@ gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
--url https://example.com/hook --events push,create
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +failed --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +task-view --owner Gitlink --repo forgeplus --id 68 --task-id 4775684
```

View File

@ -0,0 +1,58 @@
---
name: gitlink-wiki
version: 1.0.0
description: "Wiki 管理:基于 Git 仓库操作,支持 Wiki 页面的列出、查看、创建、更新、删除。当用户需要管理 GitLink 项目 Wiki 时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli wiki --help"
---
# gitlink-wikiWiki 管理)
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## 说明
GitLink 的 Wiki 是一个独立的 Git 仓库(`{owner}/{repo}.wiki.git`),每个 Wiki 页面是一个 `.md` 文件。
读操作list/view无需认证写操作create/update/delete需要 Personal Access Token。
## Shortcuts
| Shortcut | 说明 | 需要认证 |
|----------|------|----------|
| `wiki +list` | 列出 Wiki 页面 | 否(公开项目) |
| `wiki +view` | 查看 Wiki 页面内容 | 否(公开项目) |
| `wiki +create` | 创建 Wiki 页面 | 是 |
| `wiki +update` | 更新 Wiki 页面 | 是 |
| `wiki +delete` | 删除 Wiki 页面 | 是 |
## 使用示例
```bash
# 列出 Wiki 页面
gitlink-cli wiki +list --owner myuser --repo myrepo
# 查看 Wiki 页面内容
gitlink-cli wiki +view --owner myuser --repo myrepo --page-name "Home"
# 创建 Wiki 页面(需要认证)
gitlink-cli wiki +create --owner myuser --repo myrepo --page-name "guide" --title "使用指南" --content "这是使用指南" --message "创建新页面"
# 更新 Wiki 页面
gitlink-cli wiki +update --owner myuser --repo myrepo --page-name "guide" --title "更新标题"
# 删除 Wiki 页面(确认后再执行)
gitlink-cli wiki +delete --owner myuser --repo myrepo --page-name "guide"
```
## 注意事项
- 更改页面名或删除页面后,自动生成的 _Sidebar.md 不会立即更新,等待 GitLink 网页端重建。
- 中文页面名会进行 URL 编码(如 `Wiki测试``Wiki%E6%B5%8B%E8%AF%95.md`)。
- `--content``--file` 不能同时使用。
- 如果不在仓库目录内运行,需要 `--owner``--repo` 参数。

3
snippets/hello.py Normal file
View File

@ -0,0 +1,3 @@
```python
print(Hello World!)
```