Compare commits

...

130 Commits

Author SHA1 Message Date
赵昌 b46352af51 docs: enrich demo page with detailed AI analysis and scoring 2026-06-24 10:42:09 +08:00
赵昌 21a3447581 docs: update demo page with AI analysis outputs and scoring 2026-06-24 10:37:30 +08:00
赵昌 5866600401 enhance: enrich skill outputs with data analysis and scoring logic
- project-health: add scoring formulas, trend analysis, improvement suggestions
- release-auto: add commit classification statistics, version recommendations
- issue-triage: add keyword weight scoring, urgency assessment
2026-06-24 10:32:52 +08:00
赵昌 3703594d66 docs: add interactive demo page for skills showcase 2026-06-24 10:12:33 +08:00
z2_cc 955e8dd2b7 feat(skills): 新增 gitlink-pr-deep-review PR 深度审查 Skill
- 编排 gitlink-code-review 取实现层结果 + 自研推理层(设计/需求一致性、跨模块影响、业务安全合规)
- 综合裁决:实现质量 × 跨模块影响 × 一致性 × 安全 → 风险评分 + 阻断/谨慎/放行建议
- 需求一致性锚点:交互式获取(设计文档 + PR 关联 Issue,皆无则降级标注低置信度)
- 跨模块仅追直接调用方(git grep),写动作默认 dry-run 且 --status common 不越权
- 与 code-review 职责正交(实现层 vs 设计/一致性层)
- 命令面/字段均基于本地源码编译产物实测(z2_cc/gitlink-cli PR#1 全链路验证后已清理)
- 注册到 skills/README.md 目录树与 AI Agent 能力清单
2026-06-21 10:22:19 +08:00
kaka f1e237ad18 feat(skills): 新增 gitlink-duplicate-detector 重复 Issue 检测 Skill
- 扫描开放 Issue,按 subject+description 语义聚类,识别重复簇并选出主 Issue
- 默认 dry-run 只读分析;写动作(关联评论/关闭)需逐条确认
- 幂等基于 issue +comments 的 notes 标记(实测可靠);
  issue +update --label 实测返回 ok 但标签不生效,已记入「已知限制」
- 命令面与字段均基于本地源码编译产物实测(z2_cc/gitlink_help_center 全链路验证后已清理)
- 注册到 skills/README.md 目录树与 AI Agent 能力清单
2026-06-17 11:47:42 +08:00
赵昌 b914138338 docs: add release notes workflow example 2026-06-17 10:09:27 +08:00
刘焱 75a8ed235a fix(collaborator): 修复 change-role/batch-role 请求体
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)
- 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): 命令面板选中带可选参数的命令也弹出表单
根因: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 2026-06-08 11:44:09 +08:00
赵昌 6c0ad3b7ae feat: add three new skills - issue triage, project health, newcomer guide 2026-06-08 11:39:29 +08:00
赵昌 9a51db0989 feat: add wiki and snippet skills, update webhook skill with new commands 2026-06-08 11:35:45 +08:00
wqer a4587f95c4 添加Python示例 2026-06-08 11:07:01 +08:00
赵昌 38658fb7b1 feat: task-view supports both numeric ID and UUID lookup 2026-06-08 10:01:56 +08:00
赵昌 5eb67055fd feat: add webhook +failed and +task-view commands
- 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 2026-06-04 17:39:03 +08:00
赵昌 e60e876fad merge: integrate hook-runner into webhook commands
- 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 2026-06-04 17:23:06 +08:00
赵昌 66ec432036 fix: simplify hook-runner list output to essential fields only 2026-06-04 17:09:59 +08:00
赵昌 cb238f132c feat: add --limit flag to hook-runner list/failed commands 2026-06-04 16:23:17 +08:00
赵昌 240a4ff6b7 test: add hook-runner unit tests (list/view/failed) 2026-06-04 16:21:12 +08:00
赵昌 d46a0cc48b feat: add hook-runner shortcut for webhook delivery monitoring
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) 2026-06-04 16:04:00 +08:00
s2_cc 811bae1cd5 触发流水线 2026-06-04 14:33:58 +08:00
赵昌 015a30caa5 chore: remove unused testdata file 2026-06-04 11:42:23 +08:00
赵昌 e3470ba592 fix: delete _Sidebar.md on wiki +delete to prevent orphaned references
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 2026-06-04 10:52:46 +08:00
s2_cc 63cbb2cef8 feat: add DevOps CI pipeline for build and verification 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
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
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
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" 2026-06-04 08:54:21 +08:00
wqer 3a47936f9f 创建测试 2026-06-04 08:53:36 +08:00
赵昌 15110df6f1 fix: use Git operations for snippet list/view/delete
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" 2026-06-04 08:51:27 +08:00
wqer 1762a24851 Delete snippet "test.py" 2026-06-04 08:51:15 +08:00
wqer 5ae0a134bb test 2026-06-04 08:41:36 +08:00
赵昌 2669c632d0 revert: remove automatic _Sidebar.md management
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
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)
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 2026-06-03 21:06:13 +08:00
赵昌 d1e44867c3 feat: add project board shortcut (board +view/+columns/+move)
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 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
wbtiger a46e06b78a Merge pull request 'fix: normalize issue +list output to use project-level issue numbers' (#31) from fix/issue-list-normalize-ids into master 2026-05-24 01:04:31 +08:00
Tiger 64c0ff3cb5 Normalize issue +list output to use project-level issue numbers
The API returns both "id" (global database PK) and "project_issues_index"
(per-project sequential number). The +list JSON output was a raw API
pass-through, making the database ID the most prominent identifier.

This change normalizes each issue in the list:
- Add "number" field from project_issues_index (matches the web URL)
- Rename "id" to "database_id" to prevent confusion

The "number" field now matches the --number flag used by +view, +close,
+update, and +comment commands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:00:31 +08:00
Tiger cbe9186c5c Style contributors avatars in horizontal row with gap
Use flex layout with 20px gap (half avatar width) and rounded avatars.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:34:26 +08:00
Tiger 2fb99ee0cc Add contributors section to READMEs
List all 9 contributors by PR count (desc), showing avatars and
GitLink IDs, placed before "Why gitlink-cli" section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:31:47 +08:00
Tiger 4bfbfc6e8f Replace PR #20 webhook with PR #22 improved implementation
- Use v1 API paths for all webhook endpoints (fixes view/test returning HTML/404)
- Add +tasks command for listing webhook delivery tasks
- Add webhook event validation (10-event whitelist)
- Add webhook type validation (10-type whitelist)
- Add content-type and http-method validation
- Fix update to preserve unspecified fields via fetch-and-merge
- Add event deduplication in parseWebhookEvents
- Add 9 comprehensive unit tests (up from 3)
- Use --http-method flag (maintains backward compatibility with PR #20)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 22:45:34 +08:00
Tiger 49cd590227 Merge PR #26: feat(pr): add review shortcuts
Resolved conflicts between PR #24 (+versions, +version-diff) and PR #26
(+reviews, +review). Unified prReviewsPath() to use existing prV1Path() helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 22:12:01 +08:00
wbtiger 2b64b8efa7 Merge pull request 'feat(pr): add patchset version shortcuts' (#24) from wangyue111/gitlink-cli:feat/pr-patchset-versions into master 2026-05-23 21:45:41 +08:00
wbtiger 9b6268cc47 Merge pull request 'feat: add webhook shortcut group' (#20) from Mengz/gitlink-cli:codex/webhook-shortcut into master 2026-05-23 21:44:22 +08:00
wbtiger 255410181f Merge pull request 'feat(skills): add gitlink-code-review, gitlink-insight, gitlink-compliance Skills' (#28) from Leo77/gitlink-cli:master into master 2026-05-23 21:27:42 +08:00
wbtiger d0a817c5a8 Merge pull request 'feat(skills): 新增检查提交信息是否规范的的skill : gitlink-commit-quality' (#27) from yangsai/gitlink-cli:feat/commit-quality into master 2026-05-23 21:26:21 +08:00
wbtiger ba78cb75b2 Merge pull request 'feat(skills): 新增自动发版的skill : gitlink-release-auto' (#25) from yangsai/gitlink-cli:feat/gitlink-release-auto into master 2026-05-23 21:24:33 +08:00
wangyue789 fbea42ecce feat(pr): add review shortcuts 2026-05-21 17:20:15 +08:00
yangsai01 67059ec1b2 feat(skills): add skills gitlink-release-auto 2026-05-21 17:00:55 +08:00
yangsai01 c57bd280c5 feat(skills): add skills gitlink-commit-quality 2026-05-21 15:43:35 +08:00
wangyue789 e85dd367d8 feat(pr): add patchset version shortcuts 2026-05-21 11:58:47 +08:00
2403_89190320 a302f2a530 docs(skills): add REFERENCE.md for all 3 Skills 2026-05-21 02:08:39 +08:00
2403_89190320 923e6af127 feat(skills): add gitlink-code-review, gitlink-insight, gitlink-compliance Skills 2026-05-21 01:58:41 +08:00
Leo77 7bd29fdf15 Delete gitlink-code-review 2026-05-21 01:49:29 +08:00
Leo77 478e7c8f88 Add gitlink-code-review 2026-05-21 01:40:30 +08:00
Mengz df1d6bb29e test: fix webhook endpoint expectations 2026-05-20 09:30:51 +08:00
wbtiger fde322669a Merge pull request 'fix(npm): improve missing binary diagnostics' (#18) from wangyue111/gitlink-cli:fix/npm-missing-binary-diagnostics into master 2026-05-19 22:29:30 +08:00
wangyue789 cdca68ff3e fix(npm): improve missing binary diagnostics 2026-05-19 16:56:50 +08:00
Mengz 2f84f3c62e docs: finalize webhook shortcut docs 2026-05-19 15:54:31 +08:00
Mengz 83a642a72d feat: add webhook shortcut group 2026-05-19 10:55:23 +08:00
124 changed files with 26142 additions and 507 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:
@ -27,171 +27,108 @@ jobs:
run: |
mkdir -p dist
VERSION=${GITHUB_REF#refs/tags/v}
for pair in "darwin amd64" "darwin arm64" "linux amd64" "linux arm64"; do
GOOS=$(echo $pair | cut -d' ' -f1)
GOARCH=$(echo $pair | cut -d' ' -f2)
MODULE="github.com/gitlink-org/gitlink-cli"
LDFLAGS="-s -w -X ${MODULE}/cmd.Version=${VERSION}"
for pair in \
"darwin amd64" \
"darwin arm64" \
"linux amd64" \
"linux arm64" \
"windows amd64" \
"windows arm64"; do
GOOS=$(echo "$pair" | cut -d' ' -f1)
GOARCH=$(echo "$pair" | cut -d' ' -f2)
OUT="gitlink-cli"
if [ "$GOOS" = "windows" ]; then
OUT="gitlink-cli.exe"
fi
echo "Building ${GOOS}-${GOARCH}..."
GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "-s -w -X 'github.com/gitlink-org/gitlink-cli/cmd.Version=${VERSION}'" -o dist/gitlink-cli .
cd dist
tar -czf "gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.tar.gz" gitlink-cli
rm gitlink-cli
cd ..
BUILD_DIR="dist/gitlink-cli_${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 "../gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.zip" "$OUT")
else
tar -czf "dist/gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.tar.gz" -C "$BUILD_DIR" "$OUT"
fi
rm -rf "$BUILD_DIR"
done
# Generate SHA256 checksums
cd dist
sha256sum *.tar.gz *.zip > checksums-sha256.txt
cd ..
ls -lh dist
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: dist/*.tar.gz
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}
mkdir -p npm-pkg/bin npm-pkg/scripts npm-pkg/skills
# Copy skills
cp -r skills/* npm-pkg/skills/
# Copy bin wrappers
cp bin/cli.js npm-pkg/bin/ 2>/dev/null || cat > npm-pkg/bin/cli.js << 'NODEEOF'
#!/usr/bin/env node
const {execFileSync} = require("child_process");
const path = require("path");
const bin = path.join(__dirname, "..", "bin", "gitlink-cli");
try { process.exit(execFileSync(bin, process.argv.slice(2), {stdio:"inherit"}).status); }
catch(e) { process.exit(e.status || 1); }
NODEEOF
cat > npm-pkg/bin/install-skills.js << 'NODEEOF'
#!/usr/bin/env node
const {execSync} = require("child_process");
const path = require("path");
const skillsDir = path.join(__dirname, "..", "skills");
try {
execSync(`npx skills add "${skillsDir}" -y -g`, {stdio:"inherit"});
} catch(e) {
console.error("Failed to install skills:", e.message);
process.exit(1);
}
NODEEOF
# Copy install.js
cp scripts/install.js npm-pkg/scripts/ 2>/dev/null || cat > npm-pkg/scripts/install.js << 'NODEEOF'
#!/usr/bin/env node
"use strict";
const os = require("os");
const path = require("path");
const fs = require("fs");
const https = require("https");
const http = require("http");
const {execSync} = require("child_process");
const PACKAGE = require("../package.json");
const VERSION = PACKAGE.version;
const BINARY_NAME = "gitlink-cli";
const RELEASE_BASE = "https://github.com";
const REPO_OWNER = "ccfos";
const REPO_NAME = "gitlink-cli";
function getPlatformInfo() {
const platform = os.platform();
const arch = os.arch();
const pmap = {darwin:"darwin",linux:"linux",win32:"windows"};
const amap = {x64:"amd64",arm64:"arm64"};
const p=pmap[platform], a=amap[arch];
if(!p||!a) throw new Error(`Unsupported: ${platform}-${arch}`);
return {platform:p,arch:a};
}
function fetch(url) {
return new Promise((resolve,reject) => {
const mod=url.startsWith("https")?https:http;
let count=0;
function req(u) {
if(++count>5) return reject(new Error("Too many redirects"));
mod.get(u,(res) => {
if([301,302,307,308].includes(res.statusCode)&&res.headers.location){
let loc=res.headers.location;
if(loc.startsWith("/")){const p=new URL(u);loc=p.protocol+"//"+p.host+loc}
return req(loc);
}
if(res.statusCode!==200) return reject(new Error(`HTTP ${res.statusCode}`));
const c=[];res.on("data",d=>c.push(d));res.on("end",()=>resolve(Buffer.concat(c)));
}).on("error",reject);
}
req(url);
});
}
async function main() {
try {
const {platform,arch} = getPlatformInfo();
const binDir = path.join(__dirname,"..","bin");
if(!fs.existsSync(binDir)) fs.mkdirSync(binDir,{recursive:true});
const binaryPath = path.join(binDir,BINARY_NAME);
if(fs.existsSync(binaryPath)) {
try {
const out = execSync(`"${binaryPath}" version`,{encoding:"utf-8",timeout:5000});
if(out.includes(VERSION)) { console.log(`${BINARY_NAME} v${VERSION} already installed.`); return; }
} catch(e) {}
fs.unlinkSync(binaryPath);
}
const assetName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`;
const url = `${RELEASE_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/v${VERSION}/${assetName}`;
console.log(`Downloading ${url}...`);
const data = await fetch(url);
const tmp = path.join(binDir,"dl.tar.gz");
fs.writeFileSync(tmp,data);
execSync(`tar -xzf "${tmp}" -C "${binDir}"`,{stdio:"pipe"});
fs.unlinkSync(tmp);
fs.chmodSync(binaryPath,0o755);
console.log(`${BINARY_NAME} v${VERSION} installed.`);
} catch(err) {
console.warn(`⚠ Binary download failed: ${err.message}`);
console.warn(`Skills are installed. Install binary manually:`);
console.warn(`npm run postinstall`);
}
}
main();
NODEEOF
# Create README
cp README.md npm-pkg/
# Create package.json
cat > npm-pkg/package.json << PKGEOF
{
"name": "@gitlink-ai/cli",
"version": "${VERSION}",
"description": "GitLink CLI — 面向 AI Agent 的 GitLink 命令行工具",
"main": "bin/cli.js",
"bin": {
"gitlink-cli": "./bin/cli.js",
"gitlink-cli-install-skills": "./bin/install-skills.js"
},
"scripts": {
"postinstall": "node scripts/install.js"
},
"files": [
"bin/",
"scripts/",
"skills/",
"README.md",
"package.json"
],
"repository": {
"type": "git",
"url": "https://github.com/ccfos/gitlink-cli.git"
},
"keywords": ["gitlink", "cli", "ai-agent", "skills"],
"author": "",
"license": "MulanPSL-2.0"
}
PKGEOF
export VERSION
rm -rf npm-pkg
mkdir -p npm-pkg
cp -R npm/. npm-pkg/
cp README.md npm-pkg/README.md
rm -rf npm-pkg/skills
cp -R skills npm-pkg/skills
node <<'NODE'
const fs = require('fs');
const pkgPath = 'npm-pkg/package.json';
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
pkg.version = process.env.VERSION;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
NODE
chmod +x npm-pkg/bin/cli.js
chmod +x npm-pkg/bin/install-skills.js
cd npm-pkg
npm publish --access public
env:

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

View File

@ -5,16 +5,30 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows, with 40+ commands and 12 AI Agent [Skills](./skills/).
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, CI/CD, and AI-powered workflows, with 40+ commands and 13 AI Agent [Skills](./skills/).
**[中文文档](./README.zh-CN.md)**
[Install](#installation--quick-start) · [AI Agent Skills](#ai-agent-skills) · [Auth](#configure--use) · [Commands](#usage-examples) · [Contributing](#related-projects)
## Contributors
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;">
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
</div>
## Why gitlink-cli?
- **Agent-Native Design** — 12 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Branch, Release, CI, Org, Search, User — all core domains covered
- **Agent-Native Design** — 13 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Webhook, Branch, Release, CI, Org, Search, User — all core domains covered
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
@ -33,6 +47,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 🏷️ Release | Create, view, delete releases |
| 🏢 Org | Manage organizations, members, teams |
| 🔧 CI | View builds, logs, CI/CD operations |
| 🔔 Webhook | Manage repo webhooks and test deliveries |
| 🔍 Search | Search repositories, users |
| 👤 User | View user profiles and info |
| 📋 PM | Sprint management, kanban boards, weekly reports |
@ -55,7 +70,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
**From npm (recommended):**
```bash
# One command: installs CLI binary + all 12 AI Agent Skills
# One command: installs CLI binary + all 13 AI Agent Skills
npm install -g @gitlink-ai/cli
```
@ -143,6 +158,23 @@ gitlink-cli repo +create -n my-project -d "Project description"
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### Webhook Management
```bash
# List webhooks
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
# Create a webhook
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
--url https://example.com/hook --events push,create
# Test a webhook
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
# View webhook delivery tasks
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Issue Management
```bash
@ -188,6 +220,19 @@ gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
# View changed files
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
# List PR patchset versions
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
# View a patchset version diff
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
# List PR reviews
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
# Create a PR review (with dry-run preview)
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
```
### Branch Management
@ -407,6 +452,16 @@ gitlink-cli auth status # Shows "✓ Logged in via GITLINK_TOKEN environment v
Priority: `GITLINK_TOKEN` env var > keyring/file stored token. When the env var is not set, the original interactive login flow works as before.
### Q: What if npm installs successfully but `gitlink-cli` reports a missing binary?
Reinstall first:
```bash
npm install -g @gitlink-ai/cli
```
If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64. You can also download the binary manually from the release page or build from source with `go install .`.
### Q: Where are credentials stored on Windows?
gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`).

View File

@ -5,16 +5,30 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 11 个 AI Agent [Skills](./skills/)。
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 13 个 AI Agent [Skills](./skills/)。
**[English](./README.md)**
[安装](#安装与快速上手) · [AI Agent Skills](#ai-agent-skills) · [认证](#配置与使用) · [命令](#使用示例) · [贡献](#相关项目)
## 贡献者
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items: center;">
<a href="https://www.gitlink.org.cn/wangyue111" title="wangyue111"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/W/43_254_70/120.png" width="40" height="40" alt="wangyue111" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/wbtiger" title="tigerwang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/T/14_168_39/120.png" width="40" height="40" alt="wbtiger" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/Mengz" title="Mengz"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/166_152_185/120.png" width="40" height="40" alt="Mengz" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/yangsai" title="杨赛"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Y/94_150_149/120.png" width="40" height="40" alt="yangsai" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/mengcheng" title="camelliamc"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/M/206_114_54/120.png" width="40" height="40" alt="mengcheng" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/muel" title="赵奕程"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/Z/144_206_212/120.png" width="40" height="40" alt="muel" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/Leo77" title="Leo77"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/173_120_149/120.png" width="40" height="40" alt="Leo77" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/yingjie" title="yingjie"><img src="https://www.gitlink.org.cn/images/avatars/User/145288?t=1765791899" width="40" height="40" alt="yingjie" style="border-radius: 50%;"></a>
<a href="https://www.gitlink.org.cn/topshare" title="Kevin Zhang"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/K/65_152_142/120.png" width="40" height="40" alt="topshare" style="border-radius: 50%;"></a>
</div>
## 为什么选择 gitlink-cli
- **Agent-Native 设计** — 开箱即用 11 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
- **Agent-Native 设计** — 开箱即用 13 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、Webhook、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
- **开源零门槛** — 木兰宽松许可证第2版MulanPSL-2.0`npm install` 即用
@ -155,6 +169,23 @@ gitlink-cli repo +create -n my-project -d "项目描述"
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### Webhook 管理
```bash
# 列出 webhook
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
# 创建 webhook
gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
--url https://example.com/hook --events push,create
# 测试 webhook
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
# 查看 webhook 投递任务
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Issue 管理
```bash
@ -200,6 +231,19 @@ gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
# 查看 PR 变更文件
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
# 查看 PR patchset/version 列表
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42
# 查看指定 patchset/version diff
gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040
# 查看 PR 审查记录
gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
# 创建 PR 审查(支持 dry-run 预览)
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
```
### 发布管理
@ -273,7 +317,7 @@ git push gitlink
## AI Agent Skills
`skills/` 目录包含 11 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
`skills/` 目录包含 13 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
详见 [skills/README.md](skills/README.md)
@ -386,6 +430,16 @@ gitlink-cli auth status # 显示 "✓ Logged in via GITLINK_TOKEN environment
Token 优先级:`GITLINK_TOKEN` 环境变量 > keyring/文件存储的 token。不设置环境变量时完全兼容原有交互式登录。
### Q: npm 安装成功但 `gitlink-cli` 提示缺少二进制怎么办?
先尝试重新安装:
```bash
npm install -g @gitlink-ai/cli
```
如果仍然失败,请检查 Release 页面是否包含当前平台的资产,例如 Windows x64 对应 `gitlink-cli_<version>_windows_amd64.zip`。也可以从 Release 页面手动下载二进制,或使用 `go install .` 从源码构建。
### Q: Windows 上凭证存储在哪里?
gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。

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

@ -0,0 +1,12 @@
# Webhook Shortcut
新增 `webhook` Shortcut 组,支持
- `webhook +list`
- `webhook +create`
- `webhook +view`
- `webhook +update`
- `webhook +delete`
- `webhook +test`
同时补充了对应单元测试、帮助文档和示例说明。

469
doc/demo.html Normal file
View File

@ -0,0 +1,469 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GitLink CLI Skills 交互式演示</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, "Microsoft YaHei", sans-serif; background: #f0f2f5; color: #333; }
.header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: #fff; padding: 40px 20px; text-align: center; }
.header h1 { font-size: 28px; margin-bottom: 8px; }
.header p { color: #a0aec0; font-size: 15px; }
.container { max-width: 1100px; margin: 0 auto; padding: 24px 20px; }
.tabs { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
.tab { padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer; font-size: 14px; background: #e2e8f0; color: #4a5568; transition: all 0.2s; }
.tab:hover { background: #cbd5e0; }
.tab.active { background: #1a73e8; color: #fff; }
.panel { display: none; }
.panel.active { display: block; }
.card { background: #fff; border-radius: 12px; padding: 20px 24px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.card h3 { font-size: 18px; color: #1a1a2e; margin-bottom: 8px; }
.card p { font-size: 14px; color: #555; line-height: 1.7; }
.card .tag { display: inline-block; background: #e8f0fe; color: #1a73e8; padding: 3px 12px; border-radius: 12px; font-size: 12px; margin: 2px 4px 2px 0; }
.step { margin-bottom: 12px; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
.step-hd { display: flex; align-items: center; padding: 12px 16px; cursor: pointer; background: #fafbfc; }
.step-hd:hover { background: #f0f2f5; }
.step-num { width: 26px; height: 26px; background: #1a73e8; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; margin-right: 10px; flex-shrink: 0; }
.step-hd .title { flex: 1; font-size: 14px; font-weight: 600; }
.step-hd .arrow { font-size: 14px; color: #999; transition: transform 0.2s; }
.step-hd.open .arrow { transform: rotate(90deg); }
.step-bd { display: none; padding: 16px; }
.step-bd.open { display: block; }
.cmd { background: #1e1e2e; color: #cdd6f4; padding: 10px 14px; border-radius: 6px; font-family: Consolas,monospace; font-size: 12px; overflow-x: auto; margin-bottom: 10px; }
.cmd .prompt { color: #89b4fa; }
.output { background: #f8f9fa; border: 1px solid #e2e8f0; border-radius: 6px; padding: 12px 14px; font-family: Consolas,monospace; font-size: 12px; overflow-x: auto; white-space: pre-wrap; color: #333; max-height: 250px; overflow-y: auto; margin-bottom: 10px; }
.analysis { background: #fefce8; border: 1px solid #fde68a; border-radius: 6px; padding: 14px; font-size: 13px; color: #92400e; margin-top: 8px; line-height: 1.8; }
.analysis strong { color: #78350f; }
.report { background: #fff; border: 2px solid #1a73e8; border-radius: 8px; padding: 20px; font-size: 13px; line-height: 2; margin-top: 8px; }
.report h4 { font-size: 18px; margin-bottom: 10px; color: #1a1a2e; }
.report table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13px; }
.report th { background: #e8f0fe; color: #1a56db; padding: 6px 10px; text-align: left; border: 1px solid #d0d7de; }
.report td { padding: 6px 10px; border: 1px solid #d0d7de; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.badge-g { background: #dcfce7; color: #166534; }
.badge-y { background: #fef9c3; color: #854d0e; }
.badge-r { background: #fce4ec; color: #c62828; }
.file-link { color: #1a73e8; text-decoration: none; font-size: 13px; }
.file-link:hover { text-decoration: underline; }
.bar { display: inline-block; height: 14px; border-radius: 3px; margin-right: 4px; vertical-align: middle; }
@media (max-width: 768px) { .tabs { flex-direction: column; } }
</style>
</head>
<body>
<div class="header">
<h1>GitLink CLI Skills 交互式演示</h1>
<p>点击查看 AI 对数据的深度分析与处理结果</p>
</div>
<div class="container">
<div class="tabs">
<button class="tab active" data-tab="health">项目健康度报告</button>
<button class="tab" data-tab="release">Release Notes 生成</button>
<button class="tab" data-tab="triage">Issue 自动分拣</button>
</div>
<!-- ======================== 项目健康度 ======================== -->
<div class="panel active" id="panel-health">
<div class="card">
<h3>项目健康度报告</h3>
<p>采集 Issue、PR、commit 数据,从 7 个维度量化分析,生成综合评分和改进建议。</p>
<div><span class="tag">repo +info</span><span class="tag">issue +list</span><span class="tag">pr +list</span><span class="tag">commit +list</span><span class="tag">milestone +list</span></div>
<a class="file-link" href="../skills/gitlink-project-health/SKILL.md" target="_blank">查看 SKILL.md →</a>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)"><span class="step-num">1</span><span class="title">采集原始数据</span><span class="arrow"></span></div>
<div class="step-bd">
<div class="cmd"><span class="prompt">$</span> gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state all --format json</div>
<div class="output">{
"issues": [
{"number":1,"subject":"提升跨平台兼容性","status_id":1,"created_at":"2026-05-28"},
{"number":2,"subject":"keyword-test-xyz-abc","status_id":5,"created_at":"2026-05-28","closed_at":"2026-06-01"},
{"number":10,"subject":"新增 Wiki 管理","status_id":5,"created_at":"2026-06-01","closed_at":"2026-06-04"},
{"number":12,"subject":"新增代码片段管理","status_id":5,"created_at":"2026-06-03","closed_at":"2026-06-04"},
{"number":14,"subject":"Webhook 投递监控","status_id":1,"created_at":"2026-06-04"},
{"number":15,"subject":"新增 failed + task-view","status_id":1,"created_at":"2026-06-04"}
],
"total_count":16, "closed_count":14
}</div>
<div class="cmd"><span class="prompt">$</span> gitlink-cli pr +list --owner z2_cc --repo gitlink-cli --state all --format json</div>
<div class="output">{
"pull_requests": [
{"number":31,"title":"fix: normalize issue list output","state":"merged","created_at":"2026-06-01","merged_at":"2026-06-03"}
],
"total_count":5, "merged_count":4
}</div>
<div class="cmd"><span class="prompt">$</span> gitlink-cli commit +list --owner z2_cc --repo gitlink-cli --limit 50</div>
<div class="output">[
{"author":"wqer","message":"enhance: enrich skill outputs with data analysis","date":"2026-06-17"},
{"author":"wqer","message":"docs: update demo page with AI analysis","date":"2026-06-17"},
{"author":"wqer","message":"docs: add interactive demo page for skills","date":"2026-06-17"},
{"author":"wqer","message":"docs: add release notes workflow example","date":"2026-06-17"},
{"author":"wqer","message":"feat: add three new skills","date":"2026-06-17"}
]</div>
</div>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)"><span class="step-num">2</span><span class="title">Issue 维度深度分析</span><span class="arrow"></span></div>
<div class="step-bd">
<div class="analysis">
<strong>📊 Issue 基础统计</strong><br>
总 Issue 数:<strong>16</strong><br>
打开中:<strong>2</strong>12.5%<br>
已关闭:<strong>14</strong>87.5%<br><br>
<strong>⏱ 响应时间分析</strong><br>
计算每条 closed Issue 从 created 到 closed 的天数:<br>
#2 keyword-test: 4 天<br>
#10 Wiki 管理: 3 天<br>
#12 代码片段: 1 天<br>
平均响应时间:<strong>2.7 天</strong><span class="badge badge-g">响应及时 🟢</span><br><br>
<strong>📈 积压趋势分析</strong><br>
近 30 天新增4 个<br>
近 30 天关闭3 个<br>
净变化:<strong>+1</strong><span class="badge badge-y">基本稳定 🟡</span><br><br>
<strong>🏷 标签分布</strong><br>
未打标签16 个100%)→ 建议增加标签管理<br><br>
<strong>📋 小结</strong><br>
关闭率 87.5%,响应及时,积压稳定 → <span class="badge badge-g">Issue 管理良好 🟢</span><br>
<strong>评分8/10</strong>
</div>
</div>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)"><span class="step-num">3</span><span class="title">PR 维度深度分析</span><span class="arrow"></span></div>
<div class="step-bd">
<div class="analysis">
<strong>📊 PR 基础统计</strong><br>
总 PR 数:<strong>5</strong><br>
已合并:<strong>4</strong>80%<br>
打开中:<strong>1</strong>20%<br><br>
<strong>⏱ 合并效率分析</strong><br>
#31 fix: normalize output — 2 天<br>
平均合并时间:<strong>2 天</strong><span class="badge badge-g">合并迅速 🟢</span><br><br>
<strong>📋 小结</strong><br>
合并率高,速度快 → <span class="badge badge-g">PR 流程健康 🟢</span><br>
<strong>评分8/10</strong>
</div>
</div>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)"><span class="step-num">4</span><span class="title">贡献者活跃度分析</span><span class="arrow"></span></div>
<div class="step-bd">
<div class="analysis">
<strong>👥 贡献者统计</strong><br>
近 50 次提交:<strong>15</strong><br>
去重作者:<strong>1</strong>wqer<span class="badge badge-r">单人维护 🔴</span><br>
新贡献者0 人 → <span class="badge badge-r">缺少新鲜血液 🔴</span><br><br>
<strong>📅 提交频率</strong><br>
最近一周:每天都有提交 → <span class="badge badge-g">非常活跃 🟢</span><br><br>
<strong>🔄 提交类型分布</strong><br>
feat新功能6 次40%<br>
docs文档5 次33%<br>
fix修复2 次13%<br>
chore工程2 次13%<br><br>
<strong>📋 小结</strong><br>
提交活跃但仅 1 人维护 → <span class="badge badge-y">需吸引贡献者 🟡</span><br>
<strong>评分6/10</strong>
</div>
</div>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)"><span class="step-num">5</span><span class="title">里程碑进度</span><span class="arrow"></span></div>
<div class="step-bd">
<div class="analysis">
<strong>🗓 里程碑概览</strong><br>
v1.3.0 — 到期日2026-06-30完成度60% → <span class="badge badge-g">进行中 🟢</span><br>
v2.0.0 — 到期日2026-08-15完成度20% → <span class="badge badge-y">初期阶段 🟡</span><br><br>
<strong>逾期风险:</strong>无 🔴 无逾期
</div>
</div>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)" style="background:#f0f9ff;">
<span class="step-num" style="background:#34a853;">R</span>
<span class="title" style="color:#1a73e8;">📋 AI 生成的完整健康度报告</span>
<span class="arrow"></span>
</div>
<div class="step-bd">
<div class="report">
<h4>项目健康度报告 — z2_cc/gitlink-cli</h4>
<strong>报告日期:</strong>2026-06-17<br>
<strong>综合评分7.3/10 🟢 良好</strong><br><br>
<strong>一、Issue 状况8/10 🟢)</strong><br>
关闭率 87.5%,平均 2.7 天响应,积压基本稳定。<br><br>
<strong>二、PR 状况8/10 🟢)</strong><br>
合并率 80%,合并迅速。<br><br>
<strong>三、贡献者活跃度6/10 🟡)</strong><br>
单人维护,提交活跃,缺少新贡献者。<br><br>
<strong>四、里程碑进度7/10 🟢)</strong><br>
v1.3.0 完成 60%,按计划推进。<br><br>
<strong>五、改进建议</strong><br>
<strong>🔴 高优先级</strong><br>
1. 标记 good-first-issue 吸引新贡献者<br>
2. 为 Issue 增加标签分类<br><br>
<strong>🟡 中优先级</strong><br>
3. 添加 CONTRIBUTING.md 引导新人<br>
4. 定期清理积压 Issue<br><br>
<strong>🟢 低优先级</strong><br>
5. 考虑添加 CI/CD 自动化流水线
</div>
</div>
</div>
</div>
<!-- ======================== Release Notes ======================== -->
<div class="panel" id="panel-release">
<div class="card">
<h3>Release Notes 生成</h3>
<p>分析 commit 历史,按 Conventional Commits 规范分类统计,推荐语义化版本,生成结构化发布说明。</p>
<div><span class="tag">release +list</span><span class="tag">git log</span><span class="tag">pr +list</span></div>
<a class="file-link" href="../skills/gitlink-release-auto/SKILL.md" target="_blank">查看 SKILL.md →</a>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)"><span class="step-num">1</span><span class="title">获取基线版本</span><span class="arrow"></span></div>
<div class="step-bd">
<div class="cmd"><span class="prompt">$</span> gitlink-cli release +list --owner z2_cc --repo gitlink-cli --format json</div>
<div class="output">[ { "tag_name":"v1.2.0", "name":"v1.2.0", "body":"初始版本发布", "created_at":"2026-06-01" } ]</div>
<div class="analysis"><strong>基线版本:</strong>v1.2.02026-06-01<br>作为对比基准,计算自 v1.2.0 以来的所有变更。</div>
</div>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)"><span class="step-num">2</span><span class="title">获取提交历史并分析</span><span class="arrow"></span></div>
<div class="step-bd">
<div class="cmd"><span class="prompt">$</span> git log v1.2.0..HEAD --format="%H|%s|%an|%ad" --date=short</div>
<div class="output">a4587f9|feat: add webhook +failed and +task-view commands|wqer|2026-06-04
37a0f21|docs: add release notes workflow example|wqer|2026-06-17
6c0ad3b|feat: add three new skills|wqer|2026-06-17
d1cc68b|docs: add workflow examples|wqer|2026-06-17
9a51db0|feat: add wiki and snippet skills|wqer|2026-06-17
5866600|enhance: enrich skill outputs|wqer|2026-06-17
21a3447|docs: update demo page|wqer|2026-06-17</div>
<div class="cmd"><span class="prompt">$</span> git log v1.2.0..HEAD --stat --oneline | tail -5</div>
<div class="output">15 files changed, 980 insertions(+), 810 deletions(-)</div>
<div class="analysis">
<strong>📊 AI 分类统计过程:</strong><br><br>
<strong>Step 1 — 按 Conventional Commits 分类</strong><br>
feat: add webhook +failed → ✨ 新功能<br>
docs: add release notes → 📝 文档<br>
feat: add three new skills → ✨ 新功能<br>
docs: add workflow examples → 📝 文档<br>
feat: add wiki and snippet → ✨ 新功能<br>
enhance: enrich skill → ⚡ 优化<br>
docs: update demo page → 📝 文档<br><br>
<strong>Step 2 — 数量统计</strong><br>
✨ 新功能3 次43%<br>
📝 文档3 次43%<br>
⚡ 优化1 次14%<br>
<strong>总提交7 次 | 贡献者1 人</strong><br><br>
<strong>Step 3 — 变更规模</strong><br>
涉及文件15 个<br>
新增行数:+980<br>
删除行数:-810
</div>
</div>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)"><span class="step-num">3</span><span class="title">版本号推荐</span><span class="arrow"></span></div>
<div class="step-bd">
<div class="analysis">
<strong>语义化版本推荐规则:</strong><br>
<table>
<tr><th>条件</th><th>版本变更</th></tr>
<tr><td>包含 BREAKING CHANGE</td><td>主版本 +11.x.x → 2.0.0</td></tr>
<tr><td>包含 feat无 breaking</td><td><strong>次版本 +11.2.x → 1.3.0</strong></td></tr>
<tr><td>仅 fix/docs/chore</td><td>修订号 +11.2.0 → 1.2.1</td></tr>
</table>
<br>
<strong>当前版本:</strong>v1.2.0<br>
<strong>包含 feat</strong><br>
<strong>包含 BREAKING CHANGE</strong><br><br>
<strong>推荐版本v1.3.0 🏷</strong>
</div>
</div>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)" style="background:#f0f9ff;">
<span class="step-num" style="background:#34a853;">R</span>
<span class="title" style="color:#1a73e8;">📋 AI 生成的完整 Release Notes</span>
<span class="arrow"></span>
</div>
<div class="step-bd">
<div class="report">
<h4>v1.3.0 (2026-06-17)</h4>
<strong>📊 版本概览</strong><br>
<table>
<tr><td>提交次数</td><td>7 次</td></tr>
<tr><td>贡献者</td><td>1 人</td></tr>
<tr><td>涉及文件</td><td>15 个</td></tr>
<tr><td>新增/删除</td><td>+980 / -810 行</td></tr>
</table>
<strong>✨ 新功能3</strong><br>
- feat: 新增 Webhook 投递监控failed + task-view<br>
- feat: 新增三个 AI Agent Skillissue-triage / project-health / newcomer-guide<br>
- feat: 新增 Wiki 和代码片段管理 Skill<br><br>
<strong>📝 文档3</strong><br>
- docs: 添加 Release Notes 工作流示例<br>
- docs: 添加其他工作流示例<br>
- docs: 更新交互式演示页面<br><br>
<strong>⚡ 优化1</strong><br>
- perf: 增强 Skill 输出的数据分析能力<br><br>
<strong>📈 统计汇总</strong><br>
<table>
<tr><th>类别</th><th>数量</th><th>占比</th><th>可视化</th></tr>
<tr><td>✨ 新功能</td><td>3</td><td>43%</td><td><span class="bar" style="width:86px; background:#34a853;"></span></td></tr>
<tr><td>📝 文档</td><td>3</td><td>43%</td><td><span class="bar" style="width:86px; background:#1a73e8;"></span></td></tr>
<tr><td>⚡ 优化</td><td>1</td><td>14%</td><td><span class="bar" style="width:28px; background:#fbbc04;"></span></td></tr>
</table>
</div>
</div>
</div>
</div>
<!-- ======================== Issue 自动分拣 ======================== -->
<div class="panel" id="panel-triage">
<div class="card">
<h3>Issue 自动分拣</h3>
<p>通过关键词权重评分系统,对 Issue 自动分类、评估紧急度,并生成结构化引导评论。</p>
<div><span class="tag">issue +list</span><span class="tag">issue +view</span><span class="tag">issue +comment</span></div>
<a class="file-link" href="../skills/gitlink-issue-triage/SKILL.md" target="_blank">查看 SKILL.md →</a>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)"><span class="step-num">1</span><span class="title">采集待处理 Issue</span><span class="arrow"></span></div>
<div class="step-bd">
<div class="cmd"><span class="prompt">$</span> gitlink-cli issue +list --owner z2_cc --repo gitlink-cli --state open --format json</div>
<div class="output">[
{"number":42, "subject":"登录页面报错500 Internal Server Error",
"body":"每次点击登录按钮后,页面白屏并返回 500 错误。已尝试清除缓存无效。"},
{"number":43, "subject":"建议增加 CSV 导出功能",
"body":"项目中需要将数据导出为 CSV 格式,希望增加此功能。"},
{"number":44, "subject":"README 中缺少安装说明",
"body":"第一次使用这个项目,发现 README 没有写如何安装和配置。"}
]</div>
</div>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)"><span class="step-num">2</span><span class="title">AI 关键词权重评分</span><span class="arrow"></span></div>
<div class="step-bd">
<div class="analysis">
<strong>🔍 Issue #42 — 登录页面报错500 Internal Server Error</strong><br>
┌────────────────────────────────────────────<br>
│ 匹配关键词及权重:<br>
│ 报错(5) + 错误(8) + 页面(0) + 白屏(3) = <strong>16 分</strong><br>
│ 分类:🐛 Bug≥15 分触发)<br>
│ 紧急度16 × 1.5 = <strong>24 → 🔴 P0 紧急</strong><br>
└────────────────────────────────────────────<br><br>
<strong>🔍 Issue #43 — 建议增加 CSV 导出功能</strong><br>
┌────────────────────────────────────────────<br>
│ 匹配关键词及权重:<br>
│ 建议(8) + 增加(5) + 功能(3) + 导出(3) = <strong>19 分</strong><br>
│ 分类:✨ 功能需求≥12 分触发)<br>
│ 紧急度19 × 1.0 = <strong>19 → 🟡 P1 高优先</strong><br>
└────────────────────────────────────────────<br><br>
<strong>🔍 Issue #44 — README 缺少安装说明</strong><br>
┌────────────────────────────────────────────<br>
│ 匹配关键词及权重:<br>
│ 文档(10) + 缺少(4) + 安装(3) + README(8) = <strong>25 分</strong><br>
│ 分类:📝 文档≥10 分触发)<br>
│ 紧急度25 × 1.0 = <strong>25 → 🟡 P1 高优先</strong><br>
└────────────────────────────────────────────
</div>
</div>
</div>
<div class="step">
<div class="step-hd" onclick="toggle(this)"><span class="step-num">3</span><span class="title">AI 生成引导评论</span><span class="arrow"></span></div>
<div class="step-bd">
<div class="analysis">
<strong>🤖 AI 为 Issue #42Bug / P0 紧急)生成的评论:</strong>
</div>
<div class="report">
<strong>Issue 自动分类结果</strong><br><br>
<strong>类别:</strong>🐛 Bug<br>
<strong>紧急度:</strong>🔴 P0 紧急 — 建议立即处理<br>
<strong>建议标签:</strong>bug<br><br>
<strong>分类依据:</strong><br>
标题和描述中包含关键词「报错」「错误」「白屏」,匹配 Bug 分类(权重 16 分)。<br><br>
<strong>请补充以下信息以便排查:</strong><br>
1. 运行环境:操作系统 / 浏览器版本<br>
2. 复现步骤:详细描述如何触发此 bug<br>
3. 错误日志:浏览器控制台是否有报错信息<br>
4. 是否必现:每次操作都能复现吗?<br><br>
<strong>自动分配:</strong>已通知项目维护者。
</div>
<div class="analysis" style="margin-top:12px;">
<strong>🤖 AI 为 Issue #43功能需求 / P1 高优先)生成的评论:</strong>
</div>
<div class="report">
<strong>Issue 自动分类结果</strong><br><br>
<strong>类别:</strong>✨ 功能需求<br>
<strong>紧急度:</strong>🟡 P1 高优先<br>
<strong>建议标签:</strong>enhancement<br><br>
<strong>分类依据:</strong><br>
标题中包含关键词「建议」「增加」,匹配功能需求分类(权重 19 分)。<br><br>
<strong>请补充以下信息:</strong><br>
1. 这个功能解决了什么场景的问题?<br>
2. 期望的导出格式是什么?<br>
3. 是否有参考实现?<br><br>
感谢你的建议!项目组会评估这个需求的可行性。
</div>
</div>
</div>
</div>
</div>
<script>
function toggle(el) {
el.classList.toggle('open');
el.nextElementSibling.classList.toggle('open');
}
document.querySelectorAll('.tab').forEach(function(t) {
t.addEventListener('click', function() {
document.querySelectorAll('.tab').forEach(function(x){x.classList.remove('active')});
document.querySelectorAll('.panel').forEach(function(x){x.classList.remove('active')});
this.classList.add('active');
document.getElementById('panel-' + this.dataset.tab).classList.add('active');
});
});
</script>
</body>
</html>

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

@ -2,21 +2,119 @@
"use strict";
const fs = require("fs");
const path = require("path");
const https = require("https");
const http = require("http");
const { execFileSync } = require("child_process");
const ext = process.platform === "win32" ? ".exe" : "";
const binaryPath = path.join(__dirname, "gitlink-cli" + ext);
const BINARY_NAME = "gitlink-cli";
const PACKAGE = require("../package.json");
const CURRENT_VERSION = PACKAGE.version;
try {
execFileSync(binaryPath, process.argv.slice(2), { stdio: "inherit" });
} catch (err) {
if (err.status !== undefined) {
process.exit(err.status);
}
console.error(`Failed to run gitlink-cli: ${err.message}`);
console.error(
"Binary may not be installed. Try reinstalling: npm install -g @gitlink-ai/cli"
);
process.exit(1);
function getBinaryName(platform = process.platform) {
return platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME;
}
function getBinaryPath(platform = process.platform, baseDir = __dirname) {
return path.join(baseDir, getBinaryName(platform));
}
function formatMissingBinaryError(
binaryPath,
platform = process.platform,
arch = process.arch
) {
return [
`Error: ${BINARY_NAME} binary not found at ${binaryPath}`,
`Platform: ${platform}/${arch}`,
"",
"The npm package was installed, but the native binary is missing.",
"This usually means the release asset for your platform is unavailable or postinstall failed.",
"",
"Try reinstalling:",
" npm install -g @gitlink-ai/cli",
"",
"If the problem persists, check the GitLink CLI release assets:",
" https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases",
].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;
const binaryPath = options.binaryPath || getBinaryPath(platform);
const execFile = options.execFileSync || execFileSync;
const stderr = options.stderr || process.stderr;
const exit = options.exit || process.exit;
function failMissingBinary() {
stderr.write(`${formatMissingBinaryError(binaryPath, platform, arch)}\n`);
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) {
if (err.code === "ENOENT") {
return failMissingBinary();
}
if (err.status !== undefined) {
return exit(err.status);
}
stderr.write(`Failed to run ${BINARY_NAME}: ${err.message}\n`);
return exit(1);
}
}
if (require.main === module) {
run();
}
module.exports = {
getBinaryName,
getBinaryPath,
formatMissingBinaryError,
run,
checkForUpdate,
};

0
npm/bin/install-skills.js Normal file → Executable file
View File

View File

@ -7,7 +7,8 @@
"gitlink-cli-install-skills": "bin/install-skills.js"
},
"scripts": {
"postinstall": "node scripts/install.js"
"postinstall": "node scripts/install.js",
"test": "node test/install.test.js && node test/cli.test.js"
},
"keywords": [
"gitlink",

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,17 +14,15 @@ 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";
function getPlatformInfo() {
const platform = os.platform();
const arch = os.arch();
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",
linux: "linux",
@ -57,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 ||
@ -106,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"));
});
@ -123,24 +191,21 @@ function fetch(url, options = {}) {
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;
const tagName = `v${VERSION}`;
if (Array.isArray(releases)) {
release = releases.find(
(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) {
@ -154,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}`;
@ -167,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;
}
@ -180,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);
@ -195,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
@ -211,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);
@ -226,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);
}
@ -234,9 +341,15 @@ async function downloadAndExtract(url, destDir, platform) {
}
async function main() {
let platformInfo = null;
let archiveName = null;
try {
const { platform, arch } = getPlatformInfo();
platformInfo = getPlatformInfo();
const { platform, arch } = platformInfo;
archiveName = getArchiveName(platform, arch);
console.log(`Platform: ${platform}-${arch}`);
console.log(`Expected release asset: ${archiveName}`);
const binDir = path.join(__dirname, "..", "bin");
if (!fs.existsSync(binDir)) {
@ -248,22 +361,64 @@ 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}`);
}
if (archiveName) {
console.error(`Expected release asset: ${archiveName}`);
}
console.error(
`\nYou can install manually:\n` +
` 1. Download from https://www.gitlink.org.cn/${REPO_OWNER}/${REPO_NAME}/releases\n` +
@ -274,4 +429,15 @@ async function main() {
}
}
main();
if (require.main === module) {
main();
}
module.exports = {
getPlatformInfo,
getBinaryName,
getArchiveName,
findReleaseAsset,
classifyError,
verifyChecksum,
};

52
npm/test/cli.test.js Normal file
View File

@ -0,0 +1,52 @@
"use strict";
const assert = require("assert");
const path = require("path");
const os = require("os");
const cli = require("../bin/cli.js");
assert.equal(cli.getBinaryName("win32"), "gitlink-cli.exe");
assert.equal(cli.getBinaryName("linux"), "gitlink-cli");
assert.equal(cli.getBinaryName("darwin"), "gitlink-cli");
assert.equal(
cli.getBinaryPath("win32", "C:\\tmp\\gitlink"),
path.join("C:\\tmp\\gitlink", "gitlink-cli.exe")
);
const message = cli.formatMissingBinaryError(
"C:\\tmp\\gitlink-cli.exe",
"win32",
"x64"
);
assert.match(message, /binary not found/);
assert.match(message, /Platform: win32\/x64/);
assert.match(message, /npm install -g @gitlink-ai\/cli/);
let exitCode = null;
const stderr = {
output: "",
write(text) {
this.output += text;
},
};
cli.run(["version"], {
binaryPath: path.join(os.tmpdir(), "gitlink-cli-test-missing-binary"),
platform: "win32",
arch: "x64",
stderr,
exit(code) {
exitCode = code;
return code;
},
execFileSync() {
throw new Error("execFileSync should not be called for a missing binary");
},
});
assert.equal(exitCode, 1);
assert.match(stderr.output, /gitlink-cli binary not found/);
assert.match(stderr.output, /Platform: win32\/x64/);
console.log("cli wrapper tests passed");

53
npm/test/install.test.js Normal file
View File

@ -0,0 +1,53 @@
"use strict";
const assert = require("assert");
const install = require("../scripts/install.js");
const pkg = require("../package.json");
assert.deepStrictEqual(install.getPlatformInfo("win32", "x64"), {
platform: "windows",
arch: "amd64",
isWindows: true,
});
assert.deepStrictEqual(install.getPlatformInfo("win32", "arm64"), {
platform: "windows",
arch: "arm64",
isWindows: true,
});
assert.deepStrictEqual(install.getPlatformInfo("darwin", "arm64"), {
platform: "darwin",
arch: "arm64",
isWindows: false,
});
assert.deepStrictEqual(install.getPlatformInfo("linux", "x64"), {
platform: "linux",
arch: "amd64",
isWindows: false,
});
assert.equal(install.getBinaryName("windows"), "gitlink-cli.exe");
assert.equal(install.getBinaryName("linux"), "gitlink-cli");
assert.equal(install.getBinaryName("darwin"), "gitlink-cli");
assert.equal(
install.getArchiveName("windows", "amd64"),
`gitlink-cli_${pkg.version}_windows_amd64.zip`
);
assert.equal(
install.getArchiveName("windows", "arm64"),
`gitlink-cli_${pkg.version}_windows_arm64.zip`
);
assert.equal(
install.getArchiveName("linux", "amd64"),
`gitlink-cli_${pkg.version}_linux_amd64.tar.gz`
);
assert.throws(
() => install.getPlatformInfo("freebsd", "x64"),
/Unsupported platform/
);
console.log("install helper tests passed");

View File

@ -74,13 +74,14 @@ rm -rf "$NPM_DIR/skills"
cp -r "$PROJECT_DIR/skills" "$NPM_DIR/skills"
# Ensure bin dir exists and wrapper is executable
chmod +x "$NPM_DIR/bin/gitlink-cli"
chmod +x "$NPM_DIR/bin/cli.js"
chmod +x "$NPM_DIR/bin/install-skills.js"
echo ""
echo "=== Done ==="
echo ""
echo "Next steps:"
echo " 1. Upload dist/*.tar.gz to GitLink Release v${VERSION}"
echo " 1. Upload dist/*.tar.gz and dist/*.zip to GitLink Release v${VERSION}"
echo " URL: https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases"
echo ""
echo " 2. Publish npm package:"

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

@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -22,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 {
@ -40,16 +75,50 @@ 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
}
normalizeIssueListIDs(env)
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"},
@ -75,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 {
@ -87,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},
},
@ -108,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 {
@ -136,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 {
@ -159,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)
@ -168,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,
@ -185,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
@ -192,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},
@ -221,6 +384,236 @@ 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)
},
},
}
}
// normalizeIssueListIDs adds "number" (project_issues_index) and renames
// "id" to "database_id" so the user-facing output uses the project-level
// issue number, not the global database primary key.
func normalizeIssueListIDs(env *output.Envelope) {
data, ok := env.Data.(map[string]interface{})
if !ok {
return
}
issues, ok := data["issues"].([]interface{})
if !ok {
return
}
for i, item := range issues {
issue, ok := item.(map[string]interface{})
if !ok {
continue
}
// Copy project_issues_index to top-level "number"
if num, ok := issue["project_issues_index"]; ok {
issue["number"] = num
}
// Rename "id" (global database PK) to "database_id"
if id, ok := issue["id"]; ok {
issue["database_id"] = id
delete(issue, "id")
}
issues[i] = issue
}
}
@ -257,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,9 +281,16 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +files列出合并请求中的变更文件路径/变更类型/增删行数)
{
Name: "files",
Description: "List changed files in 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},
},
@ -149,9 +306,16 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
// +versions列出合并请求的补丁集版本历史
{
Name: "diff",
Description: "Show diff for a pull request",
Name: "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},
},
@ -159,17 +323,383 @@ func Shortcuts() []*common.Shortcut {
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)
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", prV1Path(ctx, id)+"/versions", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// [P1 Bug#3 修复] 原代码中 +diff 和 +files 调用了同一个 API 端点,功能完全重复
// 修复: 将 +diff 改名为 +version-diff指向 PR 补丁集版本的差异 API
// +version-diff查看合并请求某补丁集版本的差异可用 --file 过滤指定文件)
// +files 仍然用于查看 PR 文件列表,两者功能区分开
{
Name: "version-diff",
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},
{Name: "file", Short: "f", Usage: "Filter diff by 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
}
versionID, err := ctx.RequireArg("version-id")
if err != nil {
return err
}
path := fmt.Sprintf("%s/versions/%s/diff", prV1Path(ctx, id), versionID)
if file := ctx.Arg("file"); file != "" {
q := url.Values{}
q.Set("filepath", file)
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return err
}
return ctx.Output(env)
}
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +reviews列出合并请求的评审记录可按评审状态筛选
{
Name: "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"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
q := url.Values{}
if status := ctx.Arg("status"); status != "" {
if err := validatePRReviewStatus(status); err != nil {
return err
}
q.Set("status", status)
}
env, err := ctx.CallAPIWithQuery("GET", prV1Path(ctx, id)+"/reviews", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +review对合并请求提交评审common/approved/rejected支持 --dry-run 预览)
{
Name: "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"},
{Name: "content", Short: "c", Usage: "Review content", Required: true},
{Name: "commit", Short: "m", Usage: "Commit SHA to attach the review to"},
{Name: "dry-run", Usage: "Preview the review request without creating it", Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
status := ctx.Arg("status")
if status == "" {
status = "common"
}
if err := validatePRReviewStatus(status); err != nil {
return err
}
payload := map[string]interface{}{
"content": content,
"status": status,
}
if commit := ctx.Arg("commit"); commit != "" {
payload["commit_id"] = commit
}
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(map[string]interface{}{
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"pull_request": id,
"dry_run": true,
"action": "create_review",
"payload": payload,
})
}
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reviews", payload)
if err != nil {
return err
}
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},
@ -203,6 +733,30 @@ func Shortcuts() []*common.Shortcut {
}
}
func prV1Path(ctx *common.RuntimeContext, id string) string {
return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id)
}
func validatePRReviewStatus(status string) error {
switch status {
case "common", "approved", "rejected":
return nil
default:
return fmt.Errorf("invalid --status value %q: use common, approved, or rejected", status)
}
}
// [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

@ -91,6 +91,455 @@ func TestPRCommentFailsWhenIssueFieldMissing(t *testing.T) {
}
}
func TestPRVersionsUsesV1Endpoint(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/versions.json" {
t.Fatalf("unexpected request: %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
}
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"total_count": float64(2),
"versions": []map[string]interface{}{
{
"id": float64(16039),
"head_commit_sha": "aaaaaaaa",
},
{
"id": float64(16040),
"head_commit_sha": "bbbbbbbb",
},
},
})
}))
defer server.Close()
err := runPRShortcut(t, server, "versions", map[string]string{
"id": "13",
})
if err != nil {
t.Fatalf("versions shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/versions.json")
}
func TestPRVersionDiffUsesV1EndpointWithFileFilter(t *testing.T) {
var calledPath string
var filepath 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/versions/16040/diff.json" {
t.Fatalf("unexpected request: %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
}
calledPath = r.URL.Path
filepath = r.URL.Query().Get("filepath")
writeJSON(t, w, map[string]interface{}{
"diff": "--- a/shortcuts/pr/pr.go\n+++ b/shortcuts/pr/pr.go\n",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "version-diff", map[string]string{
"id": "13",
"version-id": "16040",
"file": "shortcuts/pr/pr.go",
})
if err != nil {
t.Fatalf("version-diff shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/versions/16040/diff.json")
assertEqual(t, filepath, "shortcuts/pr/pr.go")
}
func TestPRVersionDiffRequiresVersionID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when version-id is missing: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "version-diff", map[string]string{
"id": "13",
})
if err == nil {
t.Fatal("expected error when version-id is missing, got nil")
}
}
func TestPRReviewsUsesV1EndpointWithStatusFilter(t *testing.T) {
var calledPath string
var status 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/reviews.json" {
t.Fatalf("unexpected request: %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
}
calledPath = r.URL.Path
status = r.URL.Query().Get("status")
writeJSON(t, w, map[string]interface{}{
"total_count": float64(1),
"reviews": []map[string]interface{}{
{
"id": float64(100),
"content": "LGTM",
"status": "approved",
},
},
})
}))
defer server.Close()
err := runPRShortcut(t, server, "reviews", map[string]string{
"id": "13",
"status": "approved",
})
if err != nil {
t.Fatalf("reviews shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/reviews.json")
assertEqual(t, status, "approved")
}
func TestPRReviewPostsReviewPayload(t *testing.T) {
var reviewPayload map[string]interface{}
var reviewPath 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/reviews.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
reviewPath = r.URL.Path
reviewPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(101),
"content": "Looks good",
"status": "approved",
"commit_id": "abc123",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "review", map[string]string{
"id": "13",
"status": "approved",
"content": "Looks good",
"commit": "abc123",
})
if err != nil {
t.Fatalf("review shortcut failed: %v", err)
}
assertEqual(t, reviewPath, "/v1/owner/repo/pulls/13/reviews.json")
assertEqual(t, reviewPayload["content"], "Looks good")
assertEqual(t, reviewPayload["status"], "approved")
assertEqual(t, reviewPayload["commit_id"], "abc123")
}
func TestPRReviewDryRunDoesNotCallAPI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called during dry-run: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "review", map[string]string{
"id": "13",
"status": "rejected",
"content": "Please fix the failing tests",
"dry-run": "true",
})
if err != nil {
t.Fatalf("review dry-run failed: %v", err)
}
}
func TestPRReviewRejectsInvalidStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called for invalid status: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "review", map[string]string{
"id": "13",
"status": "approve",
"content": "LGTM",
})
if err == nil {
t.Fatal("expected error for invalid review status, got nil")
}
}
// --- 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,42 +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(),
"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",
"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 {
@ -50,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

@ -0,0 +1,533 @@
package webhook
import (
"fmt"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
var allowedWebhookTypes = map[string]bool{
"gitea": true, "slack": true, "discord": true, "dingtalk": true, "telegram": true,
"msteams": true, "feishu": true, "matrix": true, "jianmu": true, "softbot": true,
}
var allowedWebhookContentTypes = map[string]bool{"json": true, "form": true}
var allowedWebhookMethods = map[string]bool{"GET": true, "POST": true}
var allowedWebhookEvents = map[string]bool{
"push": true, "create": true, "delete": true,
"issues_only": true, "issue_assign": true, "issue_label": true, "issue_comment": true,
"pull_request_only": true, "pull_request_assign": true, "pull_request_comment": true,
}
// Shortcuts returns webhook management shortcuts.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// +list列出仓库的所有 Web 钩子
{
Name: "list",
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
}
env, err := ctx.CallAPI("GET", webhookPath(ctx), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +view查看 Web 钩子的详细信息
{
Name: "view",
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},
},
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", webhookItemPath(ctx, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +create创建仓库 Web 钩子
{
Name: "create",
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},
{Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", Default: "gitea"},
{Name: "content-type", Usage: "Payload content type: json or form", Default: "json"},
{Name: "http-method", Usage: "HTTP method: POST or GET", Default: "POST"},
{Name: "secret", Short: "s", Usage: "Webhook secret"},
{Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events", Default: "*"},
{Name: "active", Usage: "Whether the webhook is active: true or false", Default: "true"},
},
Run: runCreate,
},
// +update更新仓库 Web 钩子,未指定的字段尽可能保留原值
{
Name: "update",
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"},
{Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only"},
{Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot"},
{Name: "content-type", Usage: "Payload content type: json or form"},
{Name: "http-method", Usage: "HTTP method: POST or GET"},
{Name: "secret", Short: "s", Usage: "Webhook secret. Pass it again if the server does not return existing secrets."},
{Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events"},
{Name: "active", Usage: "Whether the webhook is active: true or false"},
},
Run: runUpdate,
},
// +delete删除仓库 Web 钩子
{
Name: "delete",
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},
},
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", webhookItemPath(ctx, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +test触发 Web 钩子的测试投递
{
Name: "test",
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},
},
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", fmt.Sprintf("%s/tests", webhookItemPath(ctx, id)), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +tasks列出 Web 钩子的投递任务
{
Name: "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},
},
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
}
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)
},
},
}
}
func runCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
payload, err := webhookPayloadFromArgs(ctx, nil)
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", webhookPath(ctx), payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func runUpdate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
current, err := fetchWebhook(ctx, id)
if err != nil {
return fmt.Errorf("fetch webhook: %w", err)
}
payload, err := webhookPayloadFromArgs(ctx, current)
if err != nil {
return err
}
env, err := ctx.CallAPI("PUT", webhookItemPath(ctx, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func webhookPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s/webhooks", ctx.Owner, ctx.Repo)
}
func webhookItemPath(ctx *common.RuntimeContext, id string) string {
return fmt.Sprintf("%s/%s", webhookPath(ctx), id)
}
func fetchWebhook(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", webhookItemPath(ctx, id), nil)
if err != nil {
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("failed to parse webhook data")
}
return data, nil
}
func webhookPayloadFromArgs(ctx *common.RuntimeContext, current map[string]interface{}) (map[string]interface{}, error) {
url := firstNonEmpty(ctx.Arg("url"), stringFromMap(current, "url"))
if url == "" {
return nil, fmt.Errorf("required flag --url is missing")
}
eventValue := ctx.Arg("events")
var events []string
var err error
if eventValue != "" {
events, err = parseWebhookEvents(eventValue)
if err != nil {
return nil, err
}
} else {
events, err = eventsFromMap(current)
if err != nil {
return nil, err
}
}
if len(events) == 0 {
return nil, fmt.Errorf("required flag --events is missing")
}
webhookType := strings.ToLower(firstNonEmpty(ctx.Arg("type"), stringFromMap(current, "type"), "gitea"))
if err := validateOneOf("type", webhookType, allowedWebhookTypes); err != nil {
return nil, err
}
contentType := strings.ToLower(firstNonEmpty(ctx.Arg("content-type"), stringFromMap(current, "content_type"), "json"))
if err := validateOneOf("content-type", contentType, allowedWebhookContentTypes); err != nil {
return nil, err
}
httpMethod := strings.ToUpper(firstNonEmpty(ctx.Arg("http-method"), stringFromMap(current, "http_method"), "POST"))
if err := validateOneOf("http-method", httpMethod, allowedWebhookMethods); err != nil {
return nil, err
}
branchFilter := firstNonEmpty(ctx.Arg("branch-filter"), stringFromMap(current, "branch_filter"), "*")
active, err := activeFromArgs(ctx.Arg("active"), current)
if err != nil {
return nil, err
}
payload := map[string]interface{}{
"type": webhookType,
"active": active,
"content_type": contentType,
"http_method": httpMethod,
"url": url,
"branch_filter": branchFilter,
"events": events,
}
if secret := firstNonEmpty(ctx.Arg("secret"), stringFromMap(current, "secret")); secret != "" {
payload["secret"] = secret
}
return payload, nil
}
func parseWebhookEvents(value string) ([]string, error) {
parts := strings.Split(value, ",")
events := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
event := strings.TrimSpace(part)
if event == "" {
continue
}
if !allowedWebhookEvents[event] {
return nil, fmt.Errorf("invalid --events value %q", event)
}
if seen[event] {
continue
}
seen[event] = true
events = append(events, event)
}
if len(events) == 0 {
return nil, fmt.Errorf("required flag --events is missing")
}
return events, nil
}
func eventsFromMap(values map[string]interface{}) ([]string, error) {
if values == nil {
return nil, nil
}
raw, ok := values["events"]
if !ok || raw == nil {
return nil, nil
}
switch events := raw.(type) {
case []interface{}:
result := make([]string, 0, len(events))
for _, event := range events {
name, ok := event.(string)
if !ok {
return nil, fmt.Errorf("failed to parse webhook events")
}
result = append(result, name)
}
return result, nil
case []string:
return events, nil
default:
return nil, fmt.Errorf("failed to parse webhook events")
}
}
func activeFromArgs(value string, current map[string]interface{}) (bool, error) {
if value != "" {
switch strings.ToLower(strings.TrimSpace(value)) {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("invalid --active value %q: use true or false", value)
}
}
if current != nil {
if active, ok := current["active"].(bool); ok {
return active, nil
}
if active, ok := current["is_active"].(bool); ok {
return active, nil
}
}
return true, nil
}
func stringFromMap(values map[string]interface{}, key string) string {
if values == nil {
return ""
}
value, _ := values[key].(string)
return value
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func validateOneOf(name, value string, allowed map[string]bool) error {
if allowed[value] {
return nil
}
return fmt.Errorf("invalid --%s value %q", name, value)
}

View File

@ -0,0 +1,255 @@
package webhook
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestWebhookList(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/webhooks.json")
writeJSON(t, w, map[string]interface{}{"total_count": 1, "webhooks": []interface{}{}})
})
defer server.Close()
if err := runWebhookShortcut(t, server, "list", nil); err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestWebhookView(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/webhooks/7.json")
writeJSON(t, w, map[string]interface{}{"id": 7, "url": "https://example.com/hook"})
})
defer server.Close()
if err := runWebhookShortcut(t, server, "view", map[string]string{"id": "7"}); err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
}
func TestWebhookCreatePayload(t *testing.T) {
var payload map[string]interface{}
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/v1/owner/repo/webhooks.json")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": 1})
})
defer server.Close()
err := runWebhookShortcut(t, server, "create", map[string]string{
"url": "https://example.com/hook",
"events": "push,issues_only,push",
"type": "gitea",
"content-type": "json",
"http-method": "POST",
"secret": "secret-token",
"branch-filter": "master,{release*}",
"active": "true",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["url"], "https://example.com/hook")
assertEqual(t, payload["type"], "gitea")
assertEqual(t, payload["content_type"], "json")
assertEqual(t, payload["http_method"], "POST")
assertEqual(t, payload["secret"], "secret-token")
assertEqual(t, payload["branch_filter"], "master,{release*}")
assertEqual(t, payload["active"], true)
assertStringSlice(t, payload["events"], []string{"push", "issues_only"})
}
func TestWebhookUpdatePreservesCurrentFields(t *testing.T) {
var payload map[string]interface{}
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/7.json":
writeJSON(t, w, map[string]interface{}{
"id": 7,
"url": "https://old.example.com/hook",
"type": "gitea",
"content_type": "json",
"http_method": "POST",
"branch_filter": "*",
"events": []string{"push"},
"active": true,
})
case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/webhooks/7.json":
payload = decodeJSON(t, r)
writeJSON(t, w, payload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runWebhookShortcut(t, server, "update", map[string]string{
"id": "7",
"url": "https://new.example.com/hook",
"events": "push,issue_comment",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, payload["url"], "https://new.example.com/hook")
assertEqual(t, payload["type"], "gitea")
assertEqual(t, payload["content_type"], "json")
assertEqual(t, payload["http_method"], "POST")
assertEqual(t, payload["branch_filter"], "*")
assertEqual(t, payload["active"], true)
assertStringSlice(t, payload["events"], []string{"push", "issue_comment"})
}
func TestWebhookDelete(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "DELETE", "/v1/owner/repo/webhooks/7.json")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
defer server.Close()
if err := runWebhookShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestWebhookTestDelivery(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/v1/owner/repo/webhooks/7/tests.json")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
defer server.Close()
if err := runWebhookShortcut(t, server, "test", map[string]string{"id": "7"}); err != nil {
t.Fatalf("test shortcut failed: %v", err)
}
}
func TestWebhookTasks(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/webhooks/7/hooktasks.json")
writeJSON(t, w, map[string]interface{}{"total_count": 0, "hooktasks": []interface{}{}})
})
defer server.Close()
if err := runWebhookShortcut(t, server, "tasks", map[string]string{"id": "7"}); err != nil {
t.Fatalf("tasks shortcut failed: %v", err)
}
}
func TestParseWebhookEventsRejectsInvalidEvent(t *testing.T) {
_, err := parseWebhookEvents("push,invalid")
if err == nil {
t.Fatal("expected invalid event to return an error")
}
}
func TestWebhookCreateRejectsInvalidActive(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid active should not call API, got: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runWebhookShortcut(t, server, "create", map[string]string{
"url": "https://example.com/hook",
"events": "push",
"active": "maybe",
})
if err == nil {
t.Fatal("expected invalid active to return an error")
}
}
func runWebhookShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findWebhookShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findWebhookShortcut(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 newWebhookTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func assertRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
}
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return payload
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if got != want {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func assertStringSlice(t *testing.T, got interface{}, want []string) {
t.Helper()
values, ok := got.([]interface{})
if !ok {
t.Fatalf("got %T, want []interface{}", got)
}
result := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
t.Fatalf("got event %v (%T), want string", value, value)
}
result = append(result, text)
}
if !reflect.DeepEqual(result, want) {
t.Fatalf("got %v, want %v", result, want)
}
}

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

@ -108,6 +108,14 @@ skills/
│ └── ci-workflow.md # CI 工作流
├── gitlink-pm/ # 项目管理
│ └── SKILL.md # PM 操作指南
├── gitlink-duplicate-detector/ # 重复 Issue 检测
│ ├── SKILL.md # 重复检测与关联收敛指南
│ └── examples/
│ └── duplicate-detection-workflow.md # 端到端工作流与验证记录
├── gitlink-pr-deep-review/ # PR 深度审查
│ ├── SKILL.md # 编排 code-review + 设计/一致性推理
│ └── examples/
│ └── pr-deep-review-workflow.md # 端到端工作流与验证记录
└── gitlink-workflow/ # AI 自动化工作流
└── SKILL.md # 工作流模板Issue 分类、PR Review、Release Notes
```
@ -123,7 +131,7 @@ skills/
| **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` |
| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` |
| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` |
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +review` |
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` |
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` |
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` |
@ -301,6 +309,8 @@ AI 代理可以:
- ✅ 自动分类 Issue
- ✅ 自动生成 Release Notes
- ✅ 自动执行代码审查
- ✅ 自动检测并收敛重复 Issue[gitlink-duplicate-detector](gitlink-duplicate-detector/SKILL.md)
- ✅ PR 深度审查:实现质量 + 设计/需求一致性 + 跨模块影响([gitlink-pr-deep-review](gitlink-pr-deep-review/SKILL.md)
---

View File

@ -0,0 +1,238 @@
# gitlink-code-review API 参考
## PR 相关 API
### 获取 PR 详情
```bash
gitlink-cli pr +view --id <pull_request_id> --format json
```
**返回字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.issue.id` | int | Issue/PR 内部 ID |
| `data.issue.subject` | string | PR 标题 |
| `data.issue.description` | string | PR 描述 |
| `data.issue.created_at` | string | 创建时间 |
| `data.issue.issue_status` | string | 状态 |
| `data.issue.author_login` | string | 作者登录名 |
| `data.issue.author_name` | string | 作者名称 |
| `data.pull_request.base` | string | 目标分支 |
| `data.pull_request.head` | string | 源分支 |
| `data.pull_request.status` | int | 0=open, 1=merged, 2=closed |
| `data.pull_request.state` | string | "open" / "closed" |
| `data.pull_request.mergeable` | boolean | 是否可合并 |
| `data.pull_request.reviewers` | array | 审查者列表 |
| `data.commits_count` | int | 提交数 |
| `data.files_count` | int | 变更文件数 |
| `data.conflict_files` | array | 冲突文件列表 |
### 获取 PR 变更文件列表
```bash
gitlink-cli pr +files --id <pull_request_id> --format json
```
**返回字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `files_count` | int | 文件总数 |
| `total_addition` | int | 新增行数 |
| `total_deletion` | int | 删除行数 |
| `files[].name` | string | 文件名 |
| `files[].addition` | int | 该文件新增行数 |
| `files[].deletion` | int | 该文件删除行数 |
| `files[].type` | int | 1=新增, 2=修改, 3=删除 |
| `files[].isCreated` | boolean | 是否新建文件 |
| `files[].isDeleted` | boolean | 是否删除文件 |
| `files[].isRenamed` | boolean | 是否重命名 |
### 获取 PR Diff
```bash
gitlink-cli pr +diff --id <pull_request_id> --format json
```
**返回字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `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 | 行内容 |
---
## PR 列表 API
```bash
gitlink-cli pr +list --state <open|merged|closed> --format json
```
**返回字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.open_count` | int | 开放 PR 数 |
| `data.close_count` | int | 关闭 PR 数 |
| `data.merged_issues_size` | int | 已合并 PR 数 |
| `data.issues[].id` | int | PR 内部 ID |
| `data.issues[].pull_request_number` | int | PR 编号(对外展示用) |
| `data.issues[].name` | string | PR 标题 |
| `data.issues[].author_login` | string | 作者登录名 |
| `data.issues[].author_name` | string | 作者名称 |
| `data.issues[].pull_request_status` | int | **关键字段:** 0=open, 1=merged, 2=closed |
| `data.issues[].pull_request_base` | string | 目标分支 |
| `data.issues[].pull_request_head` | string | 源分支 |
| `data.issues[].pr_created_unix` | int | 创建时间戳 |
| `data.issues[].pr_full_time` | string | 创建完整时间 |
| `data.issues[].journals_count` | int | 评论数 |
| `data.issues[].reviewers` | array | Reviewers 列表 |
| `data.issues[].fork_project_id` | int | Fork 项目 ID |
| `data.issues[].is_original` | boolean | 是否来自 Fork |
---
## Issue 相关 API
### 获取 Issue 列表
```bash
gitlink-cli issue +list --owner <owner> --repo <repo> --format json
```
**返回字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.closed_count` | int | 已关闭 Issue 数 |
| `data.issues[].id` | int | Issue ID |
| `data.issues[].name` | string | Issue 标题 |
| `data.issues[].author.login` | string | 作者登录名 |
| `data.issues[].author.name` | string | 作者名称 |
| `data.issues[].assigners` | array | 指派人列表 |
| `data.issues[].priority.name` | string | 优先级名称 |
| `data.issues[].priority.id` | int | 优先级 ID |
| `data.issues[].created_at` | string | 创建时间 |
| `data.issues[].milestone_name` | string | 里程碑名称 |
| `data.issues[].due_date` | string | 截止日期 |
---
## 仓库 API
### 获取仓库信息
```bash
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
```
**返回字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.project_id` | int | 项目 ID |
| `data.identifier` | string | 仓库标识 |
| `data.name` | string | 项目名称 |
| `data.full_name` | string | 全名owner/repo |
| `data.default_branch` | string | 默认分支 |
| `data.private` | boolean | 是否私有 |
| `data.empty` | boolean | 是否空仓库 |
| `data.issues_count` | int | Issue 总数 |
| `data.pull_requests_count` | int | PR 总数 |
| `data.forked_count` | int | Fork 数 |
| `data.praises_count` | int | Star 数 |
| `data.watchers_count` | int | 关注数 |
| `data.contributor_users_count` | int | 贡献者数 |
| `data.version_releases_count` | int | Release 数 |
| `data.size` | string | 仓库大小 |
| `data.clone_url` | string | HTTPS 克隆地址 |
| `data.ssh_url` | string | SSH 克隆地址 |
| `data.author.login` | string | 仓库所有者 |
### 获取仓库文件列表
```bash
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>' --format json
```
**返回字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.entries[].name` | string | 文件/目录名 |
| `data.entries[].type` | string | "dir" 或 "file" |
| `data.entries[].path` | string | 相对路径 |
| `data.entries[].size` | int | 文件大小(bytes) |
| `data.entries[].sha` | string | 文件 SHA |
| `data.entries[].content` | string | 文件内容(部分文件可能直接返回) |
### 获取仓库语言统计
```bash
gitlink-cli api GET /:owner/:repo/languages --format json
```
**返回示例:**
```json
{ "Ruby": "90.2%", "JavaScript": "6.1%", "CSS": "3.7%" }
```
### 获取仓库原始文件
```bash
gitlink-cli api GET /:owner/:repo/raw/<branch>/<filepath>
```
---
## 用户 API
### 查看当前用户
```bash
gitlink-cli user +me --format json
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.login` | string | 登录名 |
| `data.user_id` | int | 用户 ID |
| `data.username` | string | 用户名 |
| `data.email` | string | 邮箱 |
| `data.phone` | string | 手机号 |
| `data.admin` | boolean | 是否管理员 |
### 获取用户信息
```bash
gitlink-cli api GET /users/:user_id --format json
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.login` | string | 登录名 |
| `data.name` | string | 显示名称 |
| `data.user_id` | int | 用户 ID |
| `data.user_identity` | string | 身份(专业人士/学生等) |
| `data.user_projects_count` | int | 项目数 |
| `data.common_projects_count` | int | 参与项目数 |
| `data.created_time` | string | 注册时间 |
| `data.gender` | int | 性别 |
---
## 数据获取最佳实践
1. **始终使用 `--format json`** 确保可解析输出
2. PR ID 使用 `pull_request_id`(内部 IDPR 编号展示用 `pull_request_number`
3. PR 状态通过 `pull_request_status` 判断0=open, 1=merged, 2=closed
4. 大型 PR 的 diff 可能非常大,建议按文件分组逐文件分析
5. Issue 的 `closed_count``open_count` 在列表的数据顶层

View File

@ -0,0 +1,321 @@
---
name: gitlink-code-review
version: 1.0.0
description: "智能代码审查:获取 PR 变更、分析代码质量、自动生成 Review 评论与摘要报告。当用户需要审查 Pull Request、检查代码质量或生成审查报告时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli pr --help"
---
# gitlink-code-review智能代码审查
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有写入/删除操作前,务必先确认用户意图。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## 工作流概览
本 Skill 提供一套完整的 AI 驱动代码审查工作流,覆盖从获取 PR 变更到生成审查报告的全过程。不需要额外的 CLI Shortcuts——现有 `gitlink-cli` 命令 + AI Agent 的分析能力即可完成。
| 阶段 | 操作 | AI Agent 角色 |
|------|------|--------------|
| ① 获取上下文 | 拉取 PR 详情、变更文件、Diff | 执行 CLI 命令采集数据 |
| ② 分析代码 | 检查每个文件的变更 | 逐文件审查,标记问题 |
| ③ 结构化反馈 | 按严重程度分级输出审查意见 | 生成分级 Review 评论 |
| ④ 提交评论 | 发表 Review 到 PR | 通过 API 提交 |
| ⑤ 生成报告 | 输出审查摘要 | 生成 Markdown 摘要 |
---
## 详细工作流
### 工作流 1PR 代码审查
**场景**:收到 PR Review 请求后,进行完整代码审查。
#### Step 1获取 PR 上下文
```bash
# 获取 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
```
#### 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 使用
**Go 文件检查项:**
- 错误处理:未检查的 error return、panic 滥用
- 并发goroutine 泄漏、缺少 sync 保护
- 资源管理:未关闭的 file/conn、defer 使用
- 命名:导出标识符缺少注释、变量 shadowing
**通用检查项:**
- 硬编码的配置值、密钥、URL
- 缺少或错误的边界条件检查
- 过于复杂的函数(圈复杂度高)
- 魔法数字(未命名的常量)
- 重复代码DRY 违反)
- 缺少或过时的注释
- 测试覆盖不足
#### Step 3生成结构化审查结果
按以下 Severity 分级输出:
```markdown
## PR #<id> 代码审查报告
### 🔴 Critical必须修改
- <问题描述><文件>:<行号>
> <修改建议>
### 🟡 Warning建议修改
- <问题描述><文件>:<行号>
> <修改建议>
### 🔵 Suggestion可选优化
- <问题描述><文件>:<行号>
> <修改建议>
### ✅ Positive值得肯定
- <做得好的地方>
```
#### Step 4提交 Review 评论
```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
}'
```
> **注意:** `event` 参数支持 `COMMENT`(普通评论)和 `APPROVE`(批准)。对于需要修改的问题,使用 `COMMENT`
#### Step 5生成审查摘要
审查完成后,输出 Markdown 摘要供用户查阅:
```markdown
## 📋 审查摘要 — PR #<id> <title>
| 指标 | 数据 |
|------|------|
| 审查文件数 | <n> |
| 变更行数 | +<add> / -<del> |
| Critical 问题 | <n> |
| Warning | <n> |
| Suggestion | <n> |
### 主要发现
1. **[Critical]** <最严重的问题>
2. **[Warning]** <次要问题>
3. **[Suggestion]** <优化建议>
### 总体评价
<整体评估代码质量审查通过建议>
---
*由 gitlink-code-review Skill 自动生成*
```
---
### 工作流 2仓库代码健康度扫描
**场景**:对仓库整体代码质量进行评估,不依赖 PR。
```bash
# 1. 获取仓库信息
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
# 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
```
## 代码审查最佳实践
### 审查原则
1. **先大局后细节**:先理解 PR 的目的和整体变更范围,再逐文件审查
2. **关注行为,而非风格**自动化工具linter/formatter能处理的风格问题优先交给工具
3. **提供可操作的建议**:不只是指出问题,要给出具体的修改方案
4. **肯定好的代码**:发现好的设计、清晰的命名、完善的测试时给予正面反馈
5. **控制评论量**:避免信息过载——最严重的 3-5 个问题比 20 个小问题更有价值
### 安全红线
以下问题必须标记为 **Critical**,不得忽略:
- 硬编码的密钥 / Token / 密码
- SQL / NoSQL 注入漏洞
- 命令注入shell 命令拼接)
- 路径遍历(用户输入直接用于文件路径)
- 不安全的反序列化
- XSS未转义的用户输入直接渲染
### 输出规范
- 始终使用 `--format json` 获取结构化数据
- 审查报告输出为 **Markdown 格式**,便于直接粘贴到 PR 评论
- 涉及文件/行号时使用精准引用,方便定位
- 批量操作前使用 `--dry-run` 预检
## 注意事项
- PR Review 提交后会通知所有关注该 PR 的参与者,评论内容请保持专业
- `pr +diff` 输出可能很大(大型 PRAgent 应分段处理
- API 的 PR files 和 diff 接口有频率限制,避免短时间内重复请求
- 对于 draft PR草稿应提示用户先将其标记为 Ready for Review

View File

@ -0,0 +1,164 @@
# PR 代码审查完整工作流示例
**场景**:团队成员提交了一个 PR需要进行代码审查。
## 前置条件
- `gitlink-cli` 已安装并登录
- 用户拥有 PR 所在仓库的读取权限
## 工作流步骤
### Step 1获取 PR 上下文
```bash
# 查看 PR 列表,找到待审查的 PR
gitlink-cli pr +list --state open --format json
# 获取特定 PR 详情
gitlink-cli pr +view --id 42 --format json
```
**输出示例:**
```json
{
"ok": true,
"data": {
"id": 42,
"title": "feat: add user authentication module",
"body": "实现了基于 JWT 的用户认证模块包含登录、注册、Token 刷新功能。",
"state": "open",
"author": "developer_a",
"created_at": "2026-05-18T10:30:00+08:00",
"source_branch": "feat/auth-module",
"target_branch": "master"
}
}
```
### Step 2获取变更文件
```bash
gitlink-cli pr +files --id 42 --format json
```
**输出示例:**
```json
{
"ok": true,
"data": [
{ "filename": "src/auth/login.py", "status": "added", "additions": 120, "deletions": 0 },
{ "filename": "src/auth/token.py", "status": "added", "additions": 85, "deletions": 0 },
{ "filename": "src/config.py", "status": "modified", "additions": 5, "deletions": 2 },
{ "filename": "tests/test_auth.py", "status": "added", "additions": 200, "deletions": 0 },
{ "filename": "requirements.txt", "status": "modified", "additions": 3, "deletions": 0 }
]
}
```
### Step 3获取 Diff 内容
```bash
gitlink-cli pr +diff --id 42 --format json
```
### Step 4逐文件审查
对每个变更文件,分析代码质量。以下是审查结果示例:
```markdown
## PR #42 代码审查报告
### 🔴 Critical
1. **JWT Secret 硬编码**`src/config.py:15`
> `JWT_SECRET = "my-secret-key-123"` 硬编码在源码中,存在泄露风险。建议:
> - 使用环境变量:`JWT_SECRET = os.getenv("JWT_SECRET")`
> - 或使用配置文件(不提交到版本控制)
2. **SQL 注入风险**`src/auth/login.py:42`
> `cursor.execute(f"SELECT * FROM users WHERE username = '{username}'")` 直接拼接用户输入,存在 SQL 注入风险。建议使用参数化查询:
> ```python
> cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
> ```
### 🟡 Warning
1. **密码明文存储**`src/auth/login.py:88`
> 密码直接存储到数据库,建议使用 `bcrypt``argon2` 进行哈希处理。
2. **缺少输入验证**`src/auth/login.py:15`
> `login()` 函数没有对 `username``password` 进行长度和格式校验。建议:
> ```python
> if len(username) < 3 or len(username) > 50:
> raise ValueError("用户名长度应在 3-50 个字符之间")
> ```
### 🔵 Suggestion
1. **Token 过期时间可配置**`src/auth/token.py:30`
> `ACCESS_TOKEN_EXPIRE_MINUTES = 30` 建议改为从环境变量读取,方便不同环境配置。
2. **测试可增加边界用例**`tests/test_auth.py`
> 现有测试覆盖了正常流程,建议补充:
> - 空用户名/密码
> - 超长输入
> - Token 过期处理
> - 并发登录场景
### ✅ Positive
- 完整的测试覆盖200 行测试代码,覆盖主要功能路径)
- 清晰的模块划分login / token 职责分离)
- 有类型注解,代码可读性好
```
### Step 5提交 Review
```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"
}'
```
### Step 6输出审查摘要
```markdown
## 📋 审查摘要 — PR #42 feat: add user authentication module
| 指标 | 数据 |
|------|------|
| 审查文件数 | 5 |
| 变更行数 | +413 / -2 |
| Critical 问题 | 2 |
| Warning | 2 |
| Suggestion | 2 |
### 主要发现
1. **[Critical]** JWT Secret 硬编码在源码中
2. **[Critical]** SQL 查询存在注入风险
3. **[Warning]** 密码明文存储
### 总体评价
代码结构良好,测试覆盖完整。修复两个安全关键问题后即可合并。
```
---
## 完整命令速览
```bash
# 获取 PR 详情
gitlink-cli pr +view --id <id> --format json
# 获取变更文件
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"}'
```

View File

@ -0,0 +1,320 @@
---
name: gitlink-commit-quality
version: 1.0.0
description: "提交质量守护检查提交信息规范Conventional Commits、PR 描述完整性、分支命名规范、变更文件合理性审核。当用户需要规范团队提交流程、审查代码提交质量时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli pr --help"
---
# gitlink-commit-quality提交质量守护
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## 功能概述
本技能覆盖开发流程中的提交质量管控,包括:
1. **Commit Message 规范检查** — 验证是否符合 Conventional Commits 规范
2. **PR 质量检查** — PR 标题、描述、关联 Issue 完整性
3. **分支命名规范** — 检查分支名是否符合团队约定
4. **变更合理性检查** — 一次提交是否改动过多、是否有无关文件混入
---
## 一、Commit Message 规范检查
### Conventional Commits 规范
标准格式:
```
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
```
**合法的 type 值:**
| type | 含义 | 示例 |
|------|------|------|
| `feat` | 新增功能 | `feat(auth): 添加 JWT 认证` |
| `fix` | 修复 Bug | `fix(login): 修复密码验证逻辑错误` |
| `docs` | 文档变更 | `docs: 更新 API 接口文档` |
| `style` | 代码格式(不影响逻辑) | `style: 统一缩进为 4 空格` |
| `refactor` | 代码重构 | `refactor(user): 提取公共验证逻辑` |
| `perf` | 性能优化 | `perf(query): 优化数据库查询索引` |
| `test` | 测试相关 | `test(auth): 补充登录测试用例` |
| `build` | 构建系统变更 | `build: 升级 Go 版本到 1.21` |
| `ci` | CI 配置变更 | `ci: 添加 lint 检查步骤` |
| `chore` | 其他杂项 | `chore: 更新 .gitignore` |
| `revert` | 回滚提交 | `revert: revert feat(auth): ...` |
### 检查 PR 的所有提交信息
```bash
# 获取 PR 关联的提交列表
gitlink-cli pr +diff --id <pr_id> --owner <owner> --repo <repo> --format json
# 或通过 Raw API 获取提交详情
gitlink-cli api GET /:owner/:repo/pulls/:pr_id/commits --format json
```
### Commit Message 质量检查清单
对每条提交信息执行以下检查:
```
✅ 必须检查:
□ type 是否是合法值feat/fix/docs/style/refactor/perf/test/build/ci/chore/revert
□ description 是否是中文或英文(不能是无意义内容如 "update"、"fix"、"tmp"、"xxx"
□ description 首字母是否小写(英文时)
□ description 末尾是否没有句号
□ 整条信息是否超过 72 个字符(首行)
⚠️ 建议检查:
□ scope 是否有意义(如 auth/user/api/db 等模块名)
□ Breaking Change 是否在 footer 中标注BREAKING CHANGE: ...
□ 关联 Issue 是否在 footer 中标注Closes #123 / Fixes #456
```
### 不合规示例 vs 合规示例
| 不合规 | 问题 | 合规写法 |
|--------|------|---------|
| `update` | 无意义描述 | `feat(user): 新增用户头像上传功能` |
| `fix bug` | 无具体说明 | `fix(login): 修复手机号登录时验证码未清除的问题` |
| `WIP` | 临时提交混入 | 应在本地整理后再提交 |
| `Feat: Add search` | type 大写 | `feat(search): 添加全文搜索功能` |
| `feat: 添加搜索.` | 末尾有句号 | `feat(search): 添加搜索功能` |
---
## 二、PR 质量检查
### 获取 PR 信息
```bash
# 获取 PR 详情
gitlink-cli pr +view --id <pr_id> --owner <owner> --repo <repo> --format json
```
### PR 质量检查清单
```
✅ 必须检查:
□ PR 标题是否符合 Conventional Commits 格式
□ PR 描述是否有实质内容(不能为空或仅有一行)
□ PR 描述是否说明了"做了什么"和"为什么这么做"
□ PR 变更文件数量是否合理(建议单次 PR < 20 文件
□ PR 变更行数是否合理(建议单次 PR < 500 行净变更
⚠️ 建议检查:
□ PR 描述是否关联了对应 Issue格式Closes #123
□ 是否有 Test Plan如何验证这次改动
□ 是否有截图UI 改动)
□ 是否更新了相关文档
```
### PR 描述模板(推荐)
在 Review 评论中可以建议作者按以下模板完善 PR 描述:
```markdown
## 变更说明
<!-- 简述本次 PR 做了什么 -->
## 原因/背景
<!-- 为什么需要这个改动?关联的 Issue -->
Closes #<issue_number>
## 测试方案
<!-- 如何验证这次改动是正确的? -->
- [ ] 单元测试通过
- [ ] 手动测试步骤:...
## 截图(如有 UI 变更)
<!-- 添加 before/after 截图 -->
## Checklist
- [ ] 代码符合项目编码规范
- [ ] 相关文档已更新
- [ ] 测试覆盖新增/修改的代码
```
---
## 三、分支命名规范检查
### 获取分支信息
```bash
# 从 PR 信息中获取分支名
gitlink-cli pr +view --id <pr_id> --format json
# 关注 head_branch 字段
# 列出所有分支
gitlink-cli branch +list --owner <owner> --repo <repo> --format json
```
### 推荐的分支命名规范
```
格式:<type>/<short-description>
或: <type>/<issue-id>-<short-description>
示例:
feat/user-authentication
fix/login-password-validation
fix/123-token-expiry-bug
docs/api-reference-update
refactor/extract-auth-middleware
release/v1.2.0
hotfix/critical-security-patch
```
### 不合规的分支名示例
| 不合规 | 问题 | 建议 |
|--------|------|------|
| `test123` | 无意义 | `test/add-login-unit-tests` |
| `dev` / `development` | 过于宽泛 | 使用具体功能描述 |
| `zhangsan_feature` | 用人名命名 | 用功能命名 |
| `fix-bug` | 不够具体 | `fix/login-crash-on-empty-password` |
| 含中文 | 可能导致编码问题 | 使用英文 |
---
## 四、变更合理性检查
### 获取变更文件
```bash
gitlink-cli pr +files --id <pr_id> --owner <owner> --repo <repo> --format json
```
### 变更文件审核要点
```
📊 规模检查:
□ 变更文件数 > 20建议拆分成多个 PR
□ 净变更行数 > 500建议拆分
□ 单个文件变更 > 300 行:值得重点关注
🔍 文件类型检查:
□ 是否有无关文件混入(如调试文件、个人 IDE 配置)
□ 是否有 .env 文件、密钥文件被提交
□ 是否有二进制文件或大文件(图片、视频等)
□ 是否有自动生成文件lock 文件、build 产物)混入功能 PR
⚠️ 敏感文件检查(高优先级):
□ .env / .env.local / .env.production
*.pem / *.key / *.p12 / *.pfx
□ id_rsa / id_dsa
□ config/secret* / credentials*
*password* / *secret* / *token*(文件名)
```
---
## 五、完整质量报告输出格式
```markdown
## 📋 提交质量检查报告
**检查范围:** PR #42 — feat: 添加用户认证模块
---
### 1. PR 基本信息
| 项目 | 检查结果 |
|------|---------|
| PR 标题格式 | ✅ 符合规范 |
| PR 描述完整性 | ⚠️ 缺少 Test Plan |
| 关联 Issue | ✅ Closes #88 |
| 变更规模 | ✅ 12 文件 / +248 -89 行 |
---
### 2. 提交信息检查
| 提交 SHA | 提交信息 | 检查结果 |
|---------|---------|---------|
| `a1b2c3d` | feat(auth): 添加 JWT 生成 | ✅ 合规 |
| `e4f5g6h` | fix login bug | ❌ 不合规 |
| `i7j8k9l` | WIP | ❌ 不合规 |
**问题详情:**
**[1] 提交 e4f5g6h: "fix login bug"**
- 问题:缺少规范 type 格式,描述过于模糊
- 建议:`fix(login): 修复登录时密码验证逻辑错误`
**[2] 提交 i7j8k9l: "WIP"**
- 问题:临时 WIP 提交不应出现在正式 PR 中
- 建议:`git rebase -i HEAD~2` 将相关提交合并,并写一个规范的提交信息
---
### 3. 分支命名
| 分支名 | 检查结果 |
|-------|---------|
| `feature/user-auth` | ✅ 符合规范 |
---
### 4. 变更文件审核
✅ 规模合理12 文件248 行净增加)
⚠️ **发现 1 个潜在问题:**
- `config/app.env``.env` 文件被提交,请确认是否包含敏感配置
---
### 5. 改进建议
1. 完善 PR 描述,添加 Test Plan必须
2. 修复 2 条不规范的提交信息(建议通过 rebase 整理)
3. 确认 `.env` 文件是否应该提交,建议加入 `.gitignore`
---
**总体评级:** ⚠️ 需要改进2个必须修复1个建议
```
---
## 六、配合 CI 的自动化质量门禁
可将以下检查逻辑集成到 CI 流程,作为 PR 合并前的质量门禁:
```bash
# 在 CI 中检查最新提交的 commit message
git log --oneline -10
# 统计 PR 变更规模
gitlink-cli pr +files --id $PR_ID --format json | \
jq '{files: [.data[].filename] | length, additions: [.data[].additions] | add, deletions: [.data[].deletions] | add}'
```
---
## 注意事项
- ✅ **提交规范的推行应循序渐进**,先建议后强制
- ✅ **WIP 提交**:允许在开发分支中存在,但合并到主干前需 squash/rebase
- ⚠️ **历史遗留**:老代码的提交不在检查范围,只检查本次 PR 新增的提交
- ✅ **团队约定优先**:如团队有自己的规范,以团队规范为准,本技能提供通用参考

View File

@ -0,0 +1,160 @@
# gitlink-compliance API 参考
## 文件检查 API
合规检查的核心是读取仓库中的文件内容,判断是否存在、内容是否正确。
### 读取文件内容
```bash
gitlink-cli api GET /:owner/:repo/raw/<branch>/<filepath>
```
**说明:** 直接返回文件原始内容,用于检查 LICENSE、README、CONTRIBUTING 等文件。
### 获取文件列表
```bash
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>' --format json
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.entries[].name` | string | 文件名 |
| `data.entries[].type` | string | "file" 或 "dir" |
| `data.entries[].size` | int | 文件大小(字节) |
| `data.entries[].sha` | string | 文件 SHA |
| `data.entries[].commit.message` | string | 最后提交信息 |
| `data.entries[].commit.created_at` | string | 最后修改时间 |
| `data.entries[].is_readme_file` | boolean | 是否为 README 文件 |
| `data.entries[].direct_download` | boolean | 是否可直接下载 |
### 获取仓库信息
```bash
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
```
| 字段 | 类型 | 用途 |
|------|------|------|
| `data.private` | boolean | 判断仓库可见性 |
| `data.default_branch` | string | 默认分支 |
| `data.license` | string | 许可证(如平台有返回) |
| `data.author.login` | string | 所有者 |
### 获取贡献者列表
```bash
gitlink-cli api GET /:owner/:repo/contributors --format json
```
用于检查贡献者是否签署了 CLA/DCO。
---
## 常见许可证识别
通过读取 LICENSE 文件内容,关键字匹配识别许可证类型:
| 许可证 | 关键字 | 兼容性 |
|--------|--------|--------|
| MIT | "MIT License", "Permission is hereby granted" | Apache-2.0, BSD, ISC |
| Apache-2.0 | "Apache License", "Version 2.0" | MIT, BSD, ISC |
| GPL-2.0 | "GNU GENERAL PUBLIC LICENSE", "Version 2" | 严格 copyleft |
| GPL-3.0 | "GNU GENERAL PUBLIC LICENSE", "Version 3" | 严格 copyleft |
| BSD-2/3 | "BSD", "Redistribution and use" | MIT, Apache-2.0 |
| MulanPSL-2 | "木兰", "Mulan Permissive Software License" | MIT, Apache-2.0, BSD |
| LGPL | "GNU LESSER GENERAL PUBLIC LICENSE" | 弱 copyleft |
| AGPL | "GNU AFFERO GENERAL PUBLIC LICENSE" | 严格 copyleft网络传播 |
| Unlicense | "Unlicense", "public domain" | 所有许可证 |
| CC0 | "CC0", "Creative Commons Zero" | 公共领域 |
---
## 许可证兼容性矩阵
| 项目许可证 | 兼容的依赖许可证 | 不兼容的依赖许可证 |
|-----------|-----------------|-------------------|
| MIT | MIT, Apache-2.0, BSD-2/3, Unlicense, ISC, CC0, MulanPSL-2 | GPL-2/3 (仅分发时), AGPL |
| Apache-2.0 | Apache-2.0, MIT, BSD-2/3, ISC, Unlicense | GPL-2/3 |
| GPL-3.0 | GPL-3.0, MIT, Apache-2.0, BSD | —(兼容大多数) |
| BSD-3 | MIT, BSD-2/3, Apache-2.0, ISC | GPL-2/3, AGPL |
| MulanPSL-2 | MulanPSL-2, MIT, Apache-2.0, BSD | GPL-3 (需确认) |
---
## 合规检查清单
### 🔴 必须项检查
| 检查项 | 检查方法 | 通过标准 |
|--------|----------|----------|
| LICENSE 存在 | `raw/HEAD/LICENSE` != 404 | 文件存在且非空 |
| 许可证有效 | 内容匹配已知许可证关键字 | 可识别为 OSI 批准的许可证 |
| README 存在 | `sub_entries``is_readme_file=true` | 文件存在 |
| .gitignore 存在 | `sub_entries` 中 name=".gitignore" | 文件存在 |
### 🟡 建议项检查
| 检查项 | 检查方法 | 通过标准 |
|--------|----------|----------|
| CONTRIBUTING.md | `sub_entries``raw` | 文件存在 |
| SECURITY.md | `sub_entries``raw` | 文件存在 |
| CODE_OF_CONDUCT.md | `sub_entries``raw` | 文件存在 |
| CHANGELOG 或 Release | `sub_entries``release +list` | 任一存在 |
### 🔵 优化项检查
| 检查项 | 检查方法 | 通过标准 |
|--------|----------|----------|
| CI 配置 | 检查 `.trustie-pipeline.yml` / `.github/workflows` 等 | 存在即通过 |
| 源文件版权头 | 采样检查源码文件前 5 行 | 50% 以上文件有版权声明 |
| 依赖配置文件 | 检查 package.json / go.mod / Cargo.toml 等 | 存在即通过 |
---
## 输出格式规范
### 合规报告 Markdown 模板
```markdown
## ⚖️ 合规检查报告 — <owner>/<repo>
📅 检查时间:<YYYY-MM-DD>
📋 项目许可证:<识别到的许可证>
### 🔴 必须修复
| # | 问题 | 文件 | 建议 |
|---|------|------|------|
### 🟡 建议修复
| # | 问题 | 文件 | 建议 |
|---|------|------|------|
### 🔵 可选优化
| # | 建议 | 说明 |
|---|------|------|
### 📊 合规评分
| 维度 | 状态 | 评分 |
|------|:----:|:----:|
| 📜 许可证 | ✅/⚠️/❌ | ☆☆☆☆☆ |
| 🏷️ 版权声明 | ✅/⚠️/❌ | ☆☆☆☆☆ |
| 📦 依赖合规 | ✅/⚠️/❌ | ☆☆☆☆☆ |
| 🔒 安全策略 | ✅/⚠️/❌ | ☆☆☆☆☆ |
| 📖 项目文档 | ✅/⚠️/❌ | ☆☆☆☆☆ |
**总体合规评分:<分数>/100**
```
---
## 注意事项
1. 合规检查结果仅基于仓库中可读取的信息,不构成法律建议
2. 许可证兼容性问题建议咨询法务或 OSPO
3. 依赖分析需要根据项目语言选择对应的依赖文件
4. 不同许可证的兼容性规则可能因 Jurisdiction 而异
5. MulanPSL-2.0 是中国广泛使用的开源许可证,在 GitLink 平台上常见
6. 版权声明检查为采样性质100% 覆盖需专业扫描工具
7. RAW API 返回的某些文件可能包含完整内容(`replace_content` 字段)

View File

@ -0,0 +1,231 @@
---
name: gitlink-compliance
version: 1.0.0
description: "开源合规检查:扫描仓库许可证、版权声明、依赖合规性,生成合规报告与修复建议。当用户需要检查项目合规状态、许可证兼容性或准备开源发布时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli repo --help"
---
# gitlink-compliance开源合规检查
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
---
## 工作流概览
本 Skill 提供开源项目的合规性自动化检查能力,帮助 Maintainer 在发布前发现并修复合规问题。
| 检查类型 | 覆盖范围 | 严重程度 |
|----------|---------|:--------:|
| 许可证检查 | LICENSE 文件存在性、许可证类型识别 | 🔴 / 🟡 |
| 版权声明 | 源文件头部版权注释 | 🟡 |
| 依赖合规 | 第三方依赖许可证兼容性 | 🔴 |
| 安全策略 | SECURITY.md、安全披露流程 | 🟡 |
| 贡献者协议 | CLA / DCO 要求 | 🔵 |
---
## 工作流 1完整合规检查
**场景**:项目准备开源发布前,进行全面的合规性审查。
### 采集数据
```bash
# 1. 获取仓库文件结构
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=master'
# 2. 读取 LICENSE 文件
gitlink-cli api GET /:owner/:repo/raw/master/LICENSE
# 3. 检查关键文档是否存在
# 检查以下文件是否存在:
# - LICENSE / LICENSE.txt / LICENSE.md
# - CONTRIBUTING / CONTRIBUTING.md
# - SECURITY / SECURITY.md
# - CODE_OF_CONDUCT / CODE_OF_CONDUCT.md
# - .gitignore
# - README.md
# 4. 获取依赖配置
gitlink-cli api GET /:owner/:repo/raw/master/package.json # Node.js
gitlink-cli api GET /:owner/:repo/raw/master/go.mod # Go
gitlink-cli api GET /:owner/:repo/raw/master/requirements.txt # Python
gitlink-cli api GET /:owner/:repo/raw/master/Cargo.toml # Rust
gitlink-cli api GET /:owner/:repo/raw/master/pom.xml # Java/Maven
# 5. 获取源文件检查(按语言采样)
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master'
# 6. 获取仓库基本信息
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
```
### 检查清单
#### 🔴 必须修复项
| 检查项 | 标准 | 判定方法 |
|--------|------|----------|
| LICENSE 文件 | 根目录存在 LICENSE 文件且内容有效 | 检查文件是否存在、内容是否为空 |
| 许可证类型 | 使用 OSI 批准的开放源码许可证 | 解析 LICENSE 内容,识别许可证类型 |
| 许可证兼容性 | 项目许可证与依赖许可证兼容 | 检查依赖许可证,对比兼容性矩阵 |
| 安全漏洞 | 依赖无已知 CVE | 检查依赖版本(需外部数据源) |
#### 🟡 建议修复项
| 检查项 | 标准 | 判定方法 |
|--------|------|----------|
| 版权声明 | 源文件头部包含版权和许可证信息 | 采样检查源文件头部注释 |
| SECURITY.md | 存在安全策略披露流程 | 检查文件是否存在 |
| CONTRIBUTING.md | 存在贡献指南 | 检查文件是否存在 |
| 商标声明 | 正确使用项目/组织商标 | 检查 README 和 LICENSE 中的商标声明 |
#### 🔵 可选优化项
| 检查项 | 标准 | 判定方法 |
|--------|------|----------|
| CODE_OF_CONDUCT | 存在行为准则 | 检查文件是否存在 |
| .gitignore | 存在且配置完整 | 检查文件是否存在、内容是否覆盖常见模式 |
| README 质量 | README 包含安装、使用、贡献说明 | 检查内容长度和关键章节 |
### 输出格式
```markdown
## ⚖️ 合规检查报告 — <owner>/<repo>
📅 检查时间:<YYYY-MM-DD>
📋 项目许可证:<许可证类型>
### 🔴 必须修复
| # | 问题 | 文件 | 建议 |
|---|------|------|------|
| 1 | 缺少 LICENSE 文件 | — | 添加 LICENSE 文件,建议使用 <推荐许可证> |
| 2 | 依赖 xxx 使用 GPL 许可证,与项目 MIT 许可证不兼容 | package.json | 替换为兼容许可证的替代库 |
| 3 | 源文件缺少版权声明头 | src/*.py | 添加标准版权注释头 |
### 🟡 建议修复
| # | 问题 | 文件 | 建议 |
|---|------|------|------|
| 1 | 缺少 SECURITY.md | — | 添加安全漏洞披露流程文档 |
| 2 | CONTRIBUTING.md 不存在 | — | 添加贡献指南 |
### 🔵 可选优化
| # | 建议 | 说明 |
|---|------|------|
| 1 | 添加 CODE_OF_CONDUCT.md | 规范社区行为准则 |
| 2 | 完善 README 安装说明 | 当前缺少环境要求部分 |
### 📊 合规评分
| 维度 | 状态 | 评分 |
|------|:----:|:----:|
| 📜 许可证 | ✅ / ⚠️ / ❌ | ☆☆☆☆☆ |
| 🏷️ 版权声明 | ✅ / ⚠️ / ❌ | ☆☆☆☆☆ |
| 📦 依赖合规 | ✅ / ⚠️ / ❌ | ☆☆☆☆☆ |
| 🔒 安全策略 | ✅ / ⚠️ / ❌ | ☆☆☆☆☆ |
| 📖 项目文档 | ✅ / ⚠️ / ❌ | ☆☆☆☆☆ |
**总体合规评分:<分数>/100**
```
---
## 工作流 2许可证兼容性检查
**场景**:检查项目使用的第三方依赖是否与项目许可证兼容。
```bash
# 1. 获取依赖配置文件
gitlink-cli api GET /:owner/:repo/raw/master/package.json
```
### 许可证兼容性参考
| 项目许可证 | 兼容的依赖许可证 | 不兼容的依赖许可证 |
|-----------|-----------------|-------------------|
| MIT | MIT, Apache-2.0, BSD-2/3, Unlicense, ISC, CC0 | GPL-2/3, AGPL |
| Apache-2.0 | Apache-2.0, MIT, BSD-2/3, ISC, Unlicense | GPL-2/3 |
| GPL-3.0 | GPL-3.0, MIT, Apache-2.0, BSD | — |
| BSD-3 | MIT, BSD-2/3, Apache-2.0, ISC | GPL-2/3, AGPL |
| MulanPSL-2 | MulanPSL-2, MIT, Apache-2.0, BSD | GPL-3, AGPL (需确认) |
### 输出格式
```bash
# 依赖合规分析(示例输出结构)
## 📦 依赖合规分析
| 依赖 | 版本 | 许可证 | 兼容性 | 建议 |
|------|:----:|:------:|:------:|------|
| express | 4.18.2 | MIT | ✅ 兼容 | — |
| lodash | 4.17.21 | MIT | ✅ 兼容 | — |
| anticonflict-lib | 1.0.0 | GPL-3.0 | ❌ 不兼容 | 替换为兼容替代库 |
```
---
## 工作流 3版权声明批量检查
**场景**:检查项目中所有源文件是否包含正确的版权声明头。
```bash
# 1. 遍历源文件目录
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master'
# 2. 采样检查源文件头部(取前 5-10 行)
gitlink-cli api GET /:owner/:repo/raw/master/src/main.py
```
### 标准版权声明模板
```python
# Copyright (c) <年份> <组织/作者>
# Licensed under the <许可证> License.
# See LICENSE file in the project root for full license information.
```
### 常见问题
| 问题 | 说明 |
|------|------|
| 缺少头部注释 | 源文件没有版权/许可证信息 |
| 年份过时 | 版权年份未更新到当前年份 |
| 许可证不匹配 | 声明的许可证与实际 LICENSE 文件不一致 |
| 组织名称错误 | 版权声明的组织名称与项目所属不一致 |
---
## Raw API 参考
```bash
# 获取文件内容
gitlink-cli api GET /:owner/:repo/raw/<branch>/<path>
# 获取文件列表(遍历目录)
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>'
# 获取仓库信息
gitlink-cli api GET /:owner/:repo --format json
# 获取贡献者列表
gitlink-cli api GET /:owner/:repo/contributors --format json
```
## 注意事项
- 合规检查结果仅基于仓库中可读取的文件,不构成法律建议
- 对于许可证兼容性问题建议在实际发布前咨询法务或开源办公室OSPO
- 不同语言的依赖管理文件格式不同,需要根据项目主语言选择对应的依赖文件分析
- 版权声明检查是采样性的100% 覆盖需要运行专门的扫描工具
- MulanPSL-2木兰许可证是 GitLink 平台上常用的许可证,需注意其与 GPL 的兼容性

Some files were not shown because too many files have changed in this diff Show More