Compare commits

..

128 Commits

Author SHA1 Message Date
Donkey_kevin 245d3bc1fc fix: 还原 README 到合并前版本 2026-07-14 17:00:32 +08:00
Donkey_kevin d9ff6d134d Merge origin/master into zk_branch, resolve conflicts in milestone/issue/README 2026-07-14 16:41:56 +08:00
mengcheng 7f82dbdc24 Merge pull request '修改测试脚本' (#38) from mc_branch into master 2026-07-12 18:31:54 +08:00
camelliamc 460618d4b5 Merge branch 'master' into mc_branch 2026-07-12 18:31:21 +08:00
camelliamc 7079bfae64 修改测试脚本 2026-07-12 18:31:12 +08:00
mengcheng 693906cb6c Merge pull request '更新README' (#37) from mc_branch into master 2026-07-11 00:39:56 +08:00
camelliamc a02e3a53fd update README 2026-07-11 00:39:35 +08:00
mengcheng 59535c72c1 Merge pull request '实现多仓库协同自动化工作流' (#36) from mc_branch into master
Release / release (push) Failing after 1m20s Details
2026-07-10 20:55:19 +08:00
camelliamc 8a2c4d3bd9 实现多仓库协同自动化工作流(04)
串联 repo/issue/pr/release +list 命令完成端到端任务链:
逐仓库采集数据 → 对齐 gitlink-health 的健康度评分 → 生成中文 HTML
看板 → 协调发版(支持真 --dry-run 预览)
2026-07-10 20:54:32 +08:00
mengcheng e893efa817 Merge pull request '完成项目一键初始化工作流(03-project-init)' (#35) from mc_branch into master 2026-07-10 18:39:22 +08:00
camelliamc 3988802e2c 完成项目一键初始化工作流(03-project-init):重构为文件模式生成README/LICENSE/CI配置+里程碑+Issue+分支保护+Release
- README/LICENSE/CONTRIBUTING/.gitlink-ci.yml 生成在仓库内,而非Wiki页面
- 新增里程碑创建步骤、欢迎Issue
- README中文标题(快速开始、环境要求、安装、使用、测试)
- git clone/push免密认证、git作者行内传参
- 修复CRLF换行符、反引号命令替换等问题
- 同步更新PowerShell版本(03-project-init.ps1)
- 更新workflows文档和SKILL菜单
2026-07-10 18:37:19 +08:00
zzx-coder 5b3af9d978 Merge pull request '完成两条自动化工作流:社区运营自动化 + 代码质量看门人' (#34) from mc_branch into master 2026-07-10 11:10:01 +08:00
Donkey_kevin 4b2d45baea feat: 修复 milestone +view 输出 + issue/board assign 字段修正 + 文档更新
- 修复 milestone +view 显示 No results 的问题(提取 milestone 字段输出)
- 修正 issue/board 的 assign 字段:assigned_to_id -> assigner_ids
- 添加代码逻辑文档、CLI 优化报告、reading notes
- 添加 board 功能示例和修改笔记
2026-07-10 11:06:11 +08:00
camelliamc 9f0973f80a 完成两条自动化工作流:社区运营自动化 + 代码质量看门人
工作流一(社区运营自动化):
- Webhook 监听 Issue 创建 → 自动关键词分类 → 打标签 + 分配责任人
- 修复 assigner_ids 字段名(原 assigned_to_id 已被后端静默忽略)
- 修复标签 ID 从项目 issue_tags API 动态获取(原 tracker ID 与 issue_tag ID 不匹配)
- 新增 Windows PowerShell 版本脚本

工作流二(代码质量看门人):
- Webhook 监听 PR 创建 → 数据采集 → AI 代码审查 → 本地 CI → 冲突检测 → 自动合并
- 新增 02a-pr-gatekeeper.sh 核心编排脚本(603 行)
- 新增 Windows PowerShell 版本
- Webhook 监听器增加 pull_request 事件路由
2026-07-10 10:49:48 +08:00
Donkey_kevin 19b0e31bf8 feat: 补全 Raw API 封装 — file/milestone/issue增强/pr增强 + 单元测试
新增模块:
- file: 11个命令 (ls/tree/read/readme/search/create/update/delete/batch/commits/diff)
- milestone: 6个命令 (list/create/view/update/delete/status)

Issue增强:
- 元数据查询: +statuses/+authors/+assigners/+priorities
- 评论管理: +comment-edit/+comment-delete/+replies
- 批量删除: +batch-destroy (原生batch_destroy接口)

PR增强:
- +reopen/+update/+commits/+versions/+vdiff/+filesv1
- 评论管理: +comment-edit/+comment-delete

其他:
- 新增 board 看板模块, compliance 合规模块
- 更新 README.md/README.zh-CN.md 帮助文档
- 43个单元测试全部通过

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-09 23:38:52 +08:00
mengcheng b476963fde Merge pull request '增加部分代码注释' (#32) from mc_branch into master 2026-07-08 15:20:25 +08:00
camelliamc 007bbe4da3 增加部分代码注释 2026-07-08 15:19:33 +08:00
zzx-coder e26160a335 Merge pull request '修改流水线相关信息处理' (#31) from zzx_branch into master 2026-07-08 10:29:59 +08:00
狗gogo 80c7554fd8 Merge origin/master into zzx_branch, local wins on conflicts 2026-07-08 10:29:42 +08:00
狗gogo 17a876b0bd Update community-ops workflows and demo index on zzx_branch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:29:15 +08:00
zzx-coder dde0a6d0b4 Merge pull request '修改脚本中获取网页数据的渠道与方法' (#30) from zzx_branch into master 2026-07-08 09:14:53 +08:00
狗gogo d19b4c2722 Merge origin/master into zzx_branch, local wins on conflicts 2026-07-08 09:14:43 +08:00
狗gogo 86eff95244 Update research-compliance workflow on zzx_branch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:14:21 +08:00
zzx-coder 2a75e68df9 Merge pull request '修改了skills中的部分bug' (#29) from zzx_branch into master 2026-07-08 08:29:13 +08:00
狗gogo f44333f9e1 Merge origin/master into zzx_branch, local wins on conflicts 2026-07-08 08:20:46 +08:00
狗gogo 152cb108d6 Update health skill docs and demo files on zzx_branch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 08:20:35 +08:00
zzx-coder 88dc671bc3 Merge pull request '修改部分bug' (#28) from zzx_branch into master 2026-07-08 01:29:51 +08:00
狗gogo a5e9fe8199 Update academic workflows and dashboard on zzx_branch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 01:15:23 +08:00
狗gogo 609a62c146 Update workflows and demo files on zzx_branch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:57:15 +08:00
狗gogo de9bca30bb Merge origin/master into zzx_branch, local wins on conflicts 2026-07-07 13:09:19 +08:00
狗gogo 08037ca60b Resolve conflicts: keep local changes on zzx_branch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:08:51 +08:00
mengcheng fe4341fede Merge pull request '实现场景1:社区运营自动化' (#27) from mc_branch into master 2026-07-07 09:43:33 +08:00
camelliamc 2420579ba9 Merge branch 'master' into mc_branch 2026-07-07 09:37:59 +08:00
camelliamc 8ecbf8a203 fix(workflows): 修复项目初始化/多仓库协同 + 公共库 2026-07-07 09:25:53 +08:00
camelliamc 0b6d176a45 feat(workflows): 社区运营自动化 — Webhook接收器 + 部署 + systemd 2026-07-07 09:25:39 +08:00
camelliamc 411403ac5a feat(workflows): 社区运营自动化 — Issue实时分类 + 周报/Release定时发布 2026-07-07 09:23:27 +08:00
zzx-coder ad825a3e83 Merge pull request '新增前端' (#26) from zzx_branch into master 2026-07-06 20:12:53 +08:00
狗gogo 4379b16083 feat: add interactive showcase frontend (demo/) for gitlink-cli
新增 GitLink CLI 交互式展示平台,提供 Web UI 一键执行所有 CLI 命令及 AI Agent Skills,
无需记忆命令行参数即可完成功能演示、测试和验证。

- server.py:Python stdlib HTTP 服务 (127.0.0.1:8765),提供 /api/exec(执行 gitlink-cli
  命令)、/api/claude(执行 Claude Code Skills 提示词,支持多轮 session)、/api/cancel
- index.html:原生 JS/HTML/CSS 单文件 SPA(~1260 行),包含:
  - 双引擎切换:PowerShell 模式(8 模块 ~60 条预设命令)和 Claude Code 模式(9 模块 ~22 条
    预设提示词)
  - 双运行模式:Direct Run(点选即执行)和 Edit Mode(填入终端编辑后手动执行)
  - 终端模拟器:命令回显、stdout/stderr 着色、多行编辑、Ctrl+L 清屏
  - 智能链接提取:从输出中自动解析 owner/repo 及 Issue/PR/Webhook/Wiki 编号,生成 GitLink
    网页端可点击链接
  - 可折叠侧边栏 + 拖拽调节宽度 + 执行引擎状态指示

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 20:12:23 +08:00
nudt_zk ea86b2b5a8 Merge pull request 'workflow的skill增加' (#25) from zk_branch into master 2026-07-06 12:15:51 +08:00
Donkey_kevin a8f8a30953 feat(workflows): add gitlink-research skill and academic workflow scripts
Add 5 research auxiliary scenarios: project insights, compliance check,
progress tracking, citation format, and knowledge graph support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-06 12:14:06 +08:00
Donkey_kevin 4f3ae280b7 feat(workflows): add workflow skill system and fix batch commands
- Fix batch-label: use issue_tag_ids instead of tracker_id
- Fix parseTracker: support Chinese label names (缺陷/功能/疑问 etc.)
- Add workflow skill menu (workflows/SKILL.md) with 5 automation options
- Add workflow reference files: community-ops, contributor-growth, multi-repo
- Fix PowerShell scripts: Invoke-GL array syntax, remove ConvertFrom-Json
- Fix wiki +create flag from --body to --content
- Update contributor-growth doc with known issues and field corrections
- Add contributor leaderboard HTML report

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-02 16:17:15 +08:00
nudt_zk b2b0336236 Merge pull request '1' (#24) from zk_branch into master 2026-07-02 15:39:07 +08:00
mengcheng 6a701c6e3a Merge pull request 'feat(faq): 将生成的faq内容组织重构为真正的Q&A知识库' (#23) from mc_branch into master
Release / release (push) Failing after 2m17s Details
2026-07-02 14:57:55 +08:00
camelliamc b1f9adf8ad Merge branch 'master' into mc_branch 2026-07-02 14:50:23 +08:00
Donkey_kevin 012c197ee6 fix(workflows): make PowerShell scripts compatible with PS 5.1
- Replace ?? operator with if/else for Windows PowerShell 5.1 compatibility
- Remove Chinese characters to avoid encoding issues
- Use string concatenation instead of complex expressions in here-strings
- Update common.psm1 to remove PS 7+ syntax

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 23:59:45 +08:00
Donkey_kevin ecf16b379d feat(workflows): add PowerShell scripts and refactor workflow Skill
- Add 4 PowerShell workflow scripts for cross-platform automation:
  - 01-community-ops.ps1: Issue auto-classify, assign, weekly report, release notes
  - 03-project-init.ps1: One-click project init with README, CONTRIBUTING, CI guide, issues, branch protection, release
  - 04-multi-repo-collab.ps1: Cross-repo dashboard with HTML output and coordinated release
  - 05-contributor-growth.ps1: Contributor scoring (AHP), HTML report, Wiki, badge awards
- Add workflows/lib/common.psm1 shared PowerShell module
- Refactor gitlink-workflow SKILL.md to focus on AI-powered code review (workflow 2)
- Retain original .sh scripts alongside new .ps1 scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 23:52:17 +08:00
zzx-coder 42fafdc8ed Merge pull request '新修了一个user.go中的显示bug' (#22) from zzx_branch into master 2026-07-01 15:51:13 +08:00
狗gogo 64b8dabb50 fix(user): user +info 改用绝对路径 /users/:login,避免 URL 拼接歧义
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-01 15:37:16 +08:00
zzx-coder 3a761d20c9 Merge pull request '修复子任务一中部分展示bug' (#21) from zzx_branch into master 2026-07-01 14:25:48 +08:00
狗gogo b1bf96141c feat: add repo batch-delete, unify api error envelope, harden wiki handling
- repo: 新增 `+batch-delete` shortcut(--names / --from CSV / --dry-run),
  补齐 batch-create / batch-update / batch-delete 三件套
- api: 新增 `--body-file` 解决 PowerShell 调用 .exe 时剥离内嵌引号的问题;
  JSON 解析、参数冲突、读文件失败、网络错误统一按 envelope 输出,
  便于 --format json + jq 自动化解析
- wiki: unwrapGatewayResponse 接受全部 HTTP 2xx(修复 DELETE 返回 204 被误判失败);
  错误改返回 *clierrors.CLIError 让 envelope 输出生效;fetchPageContent 自动重试
  ".-" 后缀(GitLink 后端给 sub_url 加后缀导致 GET 404);outputWithDecodedContent
  解码 base64 字段为明文提升 agent 可读性;wiki +delete 先解析实际 pageName 再
  DELETE,删除后强制 GET 验证并返回结构化结果

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-01 13:07:29 +08:00
Donkey_kevin a3c433e697 feat(workflows): add 5 workflow scripts with bug fixes
Add workflow automation scripts for GitLink CLI:
- 01-community-ops: issue triage, weekly reports, release notes
- 02-code-quality-gatekeeper: PR review, quality scoring, auto-merge
- 03-project-init: repo creation, branch protection, wiki setup
- 04-multi-repo-collab: cross-repo dashboard, health monitoring
- 05-contributor-growth: AHP-weighted scoring, HTML reports, wiki publish

Fixes applied during testing:
- common.sh: fix SIGPIPE error in gl_check (use here-string instead of pipe)
- common.sh: add WinGet Links to PATH for jq availability on Windows
- 05-contributor-growth: fix pull_request_number → pull_request_id
- 05-contributor-growth: fix null total_addition/total_deletion (sum from files)
- 05-contributor-growth: fix wiki publish 400 error (use timestamp in title)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 12:11:38 +08:00
Donkey_kevin a7513cf683 Merge branch 'zk_branch' of https://www.gitlink.org.cn/zzx-coder/gitlink-cli into zk_branch
# Conflicts:
#	skills/README.md
2026-06-30 23:42:11 +08:00
camelliamc c02cf03a5b feat(faq): 将生成的faq内容组织重构为真正的Q&A知识库 2026-06-29 11:59:53 +08:00
camelliamc 582d27273f Merge branch 'master' into mc_branch 2026-06-29 10:44:59 +08:00
zzx-coder bd92fcf578 Merge pull request '针对子任务一种的功能实现修复了部分bug' (#20) from zzx-coder into master 2026-06-29 10:31:35 +08:00
camelliamc 0442e5b80c 同步master分支新增 dashboard 和 code-insight 的skill 2026-06-28 11:20:37 +08:00
camelliamc e5d46d6381 修改生成健康度报告SKILL.md 2026-06-28 10:35:28 +08:00
狗gogo 3e867d23f8 refactor: improve error handling, output formatting, and wiki/webhook shortcuts
- Add dedicated error_print.go for centralized error output
- Enhance formatter with better table/json/yaml rendering
- Add unit tests for output formatter
- Improve runner with cleaner error propagation
- Update globals and root command configuration
- Refine webhook and wiki shortcut implementations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-27 19:16:53 +08:00
zkevin 93d695d1b3 Add gitlink-code-insight skill: interactive dashboard for all shortcuts
New skill that generates an HTML dashboard showcasing all 69 gitlink-cli
shortcuts organized into 14 categories with search and collapsible UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 09:59:12 +08:00
mengcheng 36684f039b Merge pull request 'feat(health):项目健康度报告由 markdown 格式改成 HTML 文件' (#19) from mc_branch into master 2026-06-24 00:21:03 +08:00
camelliamc 2535a313f8 Merge branch 'master' into mc_branch 2026-06-24 00:20:33 +08:00
camelliamc 1d70aadf05 feat(health):项目健康度报告由 markdown 格式改成 HTML 文件 2026-06-24 00:20:02 +08:00
zzx-coder cd34efe773 Merge pull request '新增三个skills' (#18) from zzx_branch into master 2026-06-24 00:04:35 +08:00
狗gogo de6f94351e feat(skills): add gitlink-stale skill for stale issue/PR detection
Add a new AI Agent skill for detecting and managing stale issues and PRs:

- SKILL.md - main skill guide
- README.md - overview documentation
- examples/ - AI judgment demo, PR stale workflow, weekly cleanup workflow
- references/ - scan, judge, actions, and exempt rules

The skill automates stale detection with AI-powered judgment and supports
weekly cleanup workflows with configurable exemption rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 00:03:43 +08:00
狗gogo d17b4f0383 Merge branch 'master' of https://www.gitlink.org.cn/zzx-coder/gitlink-cli 2026-06-23 23:45:38 +08:00
mengcheng be0d600d56 Merge pull request 'docs(skills): gitlink-changelog 发行版条目新增作者标注' (#16) from mc_branch into master 2026-06-23 22:38:57 +08:00
camelliamc 26fa5cf6e4 Merge branch 'master' into mc_branch 2026-06-23 22:36:18 +08:00
camelliamc 7a30a03836 docs(skills): gitlink-changelog 发行版条目新增作者标注 2026-06-23 22:35:29 +08:00
mengcheng 5d3f68f3a5 Merge pull request 'feat(skills): 新增 gitlink-faq skill + 补全 repo/issue/wiki 的skill 文档' (#15) from mc_branch into master
Release / release (push) Failing after 1m24s Details
2026-06-23 21:21:50 +08:00
camelliamc 601e566bac docs(skills): 同步更新 repo/issue/wiki skill 文档,补全新增 shortcut 命令 2026-06-23 21:16:06 +08:00
camelliamc 69a820cccb 新增 gitlink-faq skill — Issue 知识库 2026-06-23 20:32:17 +08:00
zkevin dc5425690d Add gitlink-code-insight skill: interactive dashboard for all shortcuts
New skill that generates an HTML dashboard showcasing all 69 gitlink-cli
shortcuts organized into 14 categories with search and collapsible UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 15:56:57 +08:00
狗gogo d804a01e8a Merge branch 'master' of https://www.gitlink.org.cn/zzx-coder/gitlink-cli 2026-06-16 17:02:39 +08:00
zzx-coder 11ab6ba049 Merge pull request '新增skill' (#14) from zk_branch into master 2026-06-16 09:40:44 +08:00
狗gogo 5a590f6850 feat(skills): add gitlink-code-review and gitlink-issue-triage skills
Add two new AI Agent skills for automated GitLink workflows:

- gitlink-code-review: automated PR/code review with quality and security analysis
- gitlink-issue-triage: automated issue classification (tracker/priority/labels)

Each skill includes SKILL.md, README.md, examples, and references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:54:56 +08:00
狗gogo 1907e80c34 Merge remote master with local changes: resolve README conflict 2026-06-16 08:50:13 +08:00
狗gogo d18de0437a Merge branch 'master' of https://www.gitlink.org.cn/zzx-coder/gitlink-cli 2026-06-16 08:47:53 +08:00
Donkey_kevin 58c9f77b6b Add --force flag to onboard +welcome for re-adding comments
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 19:27:24 +08:00
Donkey_kevin be837d4f8e Improve onboard template: per-issue content-aware welcome comments
- fetchIssueDetail now gets both subject and description
- renderComment substitutes {subject}, {description}, {number}, {login}
- defaultTemplate includes issue summary for targeted guidance
- dry-run preview shows the full rendered comment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 19:21:42 +08:00
Donkey_kevin 0ed9dcabfc Add onboard, compliance, and contrib shortcuts with skill docs
- onboard: new contributor onboarding with --issues/--tag support
- compliance: license audit, secret scan, PII check, sensitive vocab scan
- contrib: contribution report generation
- Update register.go to register all three shortcut groups
- Add skills/gitlink-onboard and skills/gitlink-compliance skill docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 19:11:01 +08:00
mengcheng 4145897dcf Merge pull request '修复增加加wiki管理shortcut命令遗留下来的问题' (#13) from mc_branch into master 2026-06-14 22:43:27 +08:00
camelliamc 9028dbb376 test(wiki): 补充 callWikiAPI、runLint、resolveContent 的单元测试
在 RuntimeContext 中新增 GatewayHTTPClient 字段以支持测试中注入 mock HTTP client。
2026-06-14 22:34:03 +08:00
camelliamc 8fef2f3642 fix(wiki):解决wiki管理中gateway硬编码的问题 2026-06-14 21:33:59 +08:00
camelliamc 0a6e0f0b94 refactor(client): 提取 shouldAppendJSONSuffix 方法,保留 raw 路径特例 2026-06-14 21:19:03 +08:00
mengcheng 70dc7f2bad Merge pull request 'feat(skills): 新增 gitlink-changelog 和 gitlink-health 两个独立skill' (#12) from mc_branch into master 2026-06-14 19:42:57 +08:00
camelliamc 6583d09f04 feat(skills): 新增 gitlink-changelog 和 gitlink-health 两个独立skill 2026-06-14 19:41:52 +08:00
狗gogo 198c17ea08 test webhook trigger 2026-06-04 15:17:56 +08:00
狗gogo 7ed6fd565a fix: 修复流水线验证部署节点的参数解析错误
- 将verify_deployment_0节点的ssh_cmd从'gitlink-cli version && ls -lh /opt/gitlink-cli/gitlink-cli'简化为'gitlink-cli version'
- 修复原因:&&符号在GitLink流水线参数解析器中被当作特殊字符处理,导致"未找到对应的参数"错误
- 简化后的验证命令足以确认部署成功

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:03:39 +08:00
zzx-coder 740a47f865 refactor: .devops/gitlink-cli-autodeploy.yml 2026-06-04 15:02:59 +08:00
zzx-coder dc20c21790 Merge pull request '改进安装功能,新增卸载功能' (#11) from zzx_branch into master 2026-06-04 14:47:46 +08:00
狗gogo 1e36747b00 Add uninstall functionality and update installation scripts
- Added uninstall scripts for multiple platforms (install/uninstall.sh, .ps1)
- Updated installation documentation with INSTALL.md and UNINSTALL.md
- Enhanced npm package with uninstall and update scripts
- Improved README files with latest changes
- Updated install.sh with better functionality

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:43:42 +08:00
zzx-coder b483043a82 Merge pull request '修改文档,修改user.go查找url逻辑' (#10) from zzx_branch into master 2026-06-04 12:42:06 +08:00
狗gogo 0be1db712a Merge branch 'master' of https://www.gitlink.org.cn/zzx-coder/gitlink-cli 2026-06-04 12:34:56 +08:00
狗gogo 3edc27fac9 Update documentation and user functionality
- Updated design documentation with latest features
- Updated API reference documentation
- Enhanced user shortcuts functionality
- Added new gitlink-cli binary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:34:51 +08:00
zkevin 8f420e5ef2 fix: update RequireArg calls to match new 2-arg signature from master merge
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:12:42 +08:00
zkevin 682dbf8619 feat: add one-line install script, remove Go/npm dependency for end users
- Add install.sh: auto-detect platform, download prebuilt binary, install skills
- Bump npm package to v0.2.0
- Update README (EN/CN) with curl-based one-line install as primary method
- No Go, no npm required for standard installation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:54:50 +08:00
zkevin d122b95d6c Merge branch 'master' into zk_branch
- resolve conflicts in register.go, release/release.go, repo/repo.go, SKILL.md
- keep milestone/team/webhook registrations from zk_branch
- incorporate batch_create/batch_update from master

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:47:40 +08:00
mengcheng f3ab651a7b Merge pull request 'fix:统一错误提示优化+Issue标签ID动态获取' (#9) from mc_branch into master 2026-06-04 10:54:00 +08:00
camelliamc 89afa968fd fix:统一错误提示优化+Issue标签ID动态获取 2026-06-04 10:36:41 +08:00
zkevin 36b8a912a5 feat: add label/review/milestone/team shortcuts, merge master updates
- issue: add label add/remove/list subcommands, add reopen command
- pr: add approve/request-changes/reviews subcommands
- org: merge update/delete from master, add invite/remove-member
- repo: add update command
- release: add update command, clean up comments
- search: add issues search
- register: add milestone, team, webhook modules

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 23:37:40 +08:00
zzx-coder 297a2ccbbe Merge pull request '修改webhook功能中的相关代码,完善其功能' (#7) from zzx_branch into master 2026-06-03 23:21:30 +08:00
狗gogo 5fd2ad0c6d Update webhook functionality and fix authentication issues
- Updated webhook shortcuts with enhanced functionality
- Fixed authentication issues in login.go and auth.go
- Improved repo context resolution
- Added npm package configuration
- Updated binary build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:19:43 +08:00
狗gogo 813cd9a285 测试webhook触发 2026-06-03 22:15:50 +08:00
zzx-coder eee47cde57 Merge pull request '解决issue16~18' (#6) from zzx_branch into master 2026-06-02 11:36:25 +08:00
狗gogo 789c32cc64 Add webhook and wiki management features
- Added webhook shortcuts and skill
- Added wiki management shortcuts and skill
- Updated workflow examples and references
- Enhanced skills documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:17:47 +08:00
狗gogo 50c6882a60 fix: 添加缺失的tagIDs和parseLabel定义到batch.go 2026-06-02 07:19:37 +08:00
mengcheng 863e3e512a Merge pull request '更新文档' (#5) from mc_branch into master 2026-06-01 21:41:06 +08:00
camelliamc a3b6b1c11c 更新文档 2026-06-01 21:31:17 +08:00
zzx-coder 58853aea80 refactor: .devops/gitlink-cli-autodeploy.yml 2026-06-01 19:55:04 +08:00
zzx-coder e11478e392 feat: .devops/gitlink-cli-autodeploy.yml 2026-06-01 18:46:05 +08:00
zzx-coder 599914b2b3 refactor: delete .devops/gitlink_cli.yml 2026-06-01 18:29:30 +08:00
zzx-coder 4981ca0869 refactor: .devops/gitlink_cli.yml 2026-06-01 17:24:55 +08:00
Donkey_kevin 466a7ab181 feat: add wiki +lint command and dry-run support for all write operations
- Add wiki +lint: check empty pages, missing H1, short content, dead links, broken images
- Fix lint to use sub_url for fetching page content instead of title
- Skip system pages (_Sidebar, _Footer, _Header) in lint checks
- Add CHANGELOG.md for wiki module
- Add dry-run support to branch, ci, issue, org, pr, release, repo commands
- Add batch operations for org and repo members
- Add gitlink-wiki skill definition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 17:11:39 +08:00
zzx-coder 2e4c6f7a6e refactor: .devops/gitlink_cli.yml 2026-05-29 08:55:59 +08:00
zzx-coder 59d2244cb4 feat: .devops/gitlink_cli.yml 2026-05-29 08:50:27 +08:00
zzx-coder 27c3a9b1ad refactor: delete .devops/未命名项目.yml 2026-05-29 08:50:26 +08:00
zzx-coder df490c84ad refactor: .devops/未命名项目.yml 2026-05-29 08:49:48 +08:00
zzx-coder 9377d596cf feat: .devops/未命名项目.yml 2026-05-29 08:49:42 +08:00
mengcheng f256000fdb Merge pull request 'fix:增加相关test.go文件' (#4) from mc_branch into master 2026-05-28 17:01:06 +08:00
camelliamc e9b4362683 fix:增加相关test.go文件 2026-05-28 16:59:32 +08:00
mengcheng b722211d02 Merge pull request 'feat:批量仓库管理' (#3) from mc_branch into master 2026-05-28 16:11:01 +08:00
camelliamc bb0950d082 feat:批量仓库管理 2026-05-28 16:04:37 +08:00
mengcheng f5227e5e22 Merge pull request '新增issue批量操作' (#2) from mc_branch into master 2026-05-23 16:45:18 +08:00
camelliamc 59c5f8f43c 新增issue批量操作 2026-05-23 16:38:20 +08:00
mengcheng 0e36f58327 Merge pull request '新增wiki管理的shortcut' (#1) from mc_branch into master 2026-05-23 08:44:14 +08:00
camelliamc 5702ce9959 增加wiki管理的shortcut 2026-05-23 08:30:29 +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
254 changed files with 66215 additions and 586 deletions

107
.claude/settings.local.json Normal file
View File

@ -0,0 +1,107 @@
{
"permissions": {
"allow": [
"Bash(git -C \"C:\\\\Users\\\\Lenovo\\\\Desktop\\\\soft运维\\\\gitlink-cli\" rev-parse --git-common-dir)",
"Bash(git -C \"C:\\\\Users\\\\Lenovo\\\\Desktop\\\\soft运维\\\\gitlink-cli\" rev-parse --git-dir)",
"Bash(git:*)",
"Bash(wc -l \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-compliance/SKILL.md\" \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-compliance/references/\"*.md)",
"Bash(go build:*)",
"Bash(go vet:*)",
"Bash(wc -l \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/compliance/\"*.go)",
"Bash(wc -l \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/onboard/\"*.go)",
"Bash(# 复制两个 skill 到全局 agents 目录\ncp -r \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-compliance\" \"C:/Users/Lenovo/.agents/skills/gitlink-compliance\"\ncp -r \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-onboard\" \"C:/Users/Lenovo/.agents/skills/gitlink-onboard\"\n\n# 建立符号链接\ncd \"C:/Users/Lenovo/.claude/skills\" && cmd /c \"mklink /D gitlink-compliance C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance\" && cmd /c \"mklink /D gitlink-onboard C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-onboard\")",
"Bash(cmd.exe /c \"mklink /D C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-compliance C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance\")",
"Bash(powershell.exe -Command \"New-Item -ItemType SymbolicLink -Path 'C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-compliance' -Target 'C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance' -Force\")",
"Bash(cmd.exe /c \"mklink /J C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-compliance C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance\")",
"Bash(cmd.exe /c \"mklink /J C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-onboard C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-onboard\")",
"Bash(ln -s \"/c/Users/Lenovo/.agents/skills/gitlink-compliance\" \"/c/Users/Lenovo/.claude/skills/gitlink-compliance\")",
"Bash(ln -s \"/c/Users/Lenovo/.agents/skills/gitlink-onboard\" \"/c/Users/Lenovo/.claude/skills/gitlink-onboard\")",
"Bash(export GITLINK_TOKEN=\"ce538a7a6b60b189e8ee28c6bf2a2e8186da8141\")",
"Bash(gitlink-cli api:*)",
"Bash(gitlink-cli auth:*)",
"Bash(gitlink-cli user:*)",
"Bash(gitlink-cli issue:*)",
"Bash(./gitlink-cli.exe onboard:*)",
"Bash(./gitlink-cli.exe compliance:*)",
"Bash(gitlink-cli onboard:*)",
"Bash(./gitlink-cli-tmp.exe onboard:*)",
"Bash(printf \"y\\\\nn\\\\n\")",
"Bash(printf \"y\\\\ny\\\\n\")",
"Bash(\"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/template-13.txt\":*)",
"Bash(jq --version)",
"Bash(for f:*)",
"Bash(do echo:*)",
"Bash(bash -n \"$f\")",
"Bash(done)",
"Bash(bash workflows/01-community-ops.sh --help)",
"Bash(bash workflows/02-code-quality-gatekeeper.sh --help)",
"Bash(bash workflows/03-project-init.sh --help)",
"Bash(bash workflows/04-multi-repo-collab.sh --help)",
"Bash(bash workflows/05-contributor-growth.sh --help)",
"Bash(gitlink-cli repo:*)",
"Bash(bash workflows/01-community-ops.sh --owner nudt_zk --repo gitlink-cli --dry-run)",
"Bash(bash workflows/01-community-ops.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash workflows/02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash workflows/03-project-init.sh --owner nudt_zk --name test-workflow-03 --description \"Testing workflow 03\" --lang go --dry-run)",
"Bash(bash workflows/04-multi-repo-collab.sh --org nudt_zk --dry-run)",
"Bash(bash workflows/05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(ls:*)",
"Bash(claude --version)",
"Bash(claude -p --output-format json)",
"Bash(export CLAUDE_CODE_GIT_BASH_PATH=\"C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe\")",
"Read(//c/Program Files/Git/usr/bin/**)",
"Read(//c/Program Files/Git/bin/**)",
"Bash(export CLAUDE_CODE_GIT_BASH_PATH=\"D:\\\\Git\\\\bin\\\\bash.exe\")",
"Bash(bash workflows/02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cli --pr-id 21 --dry-run)",
"Bash(source workflows/lib/common.sh)",
"Bash(echo \"EXIT: $?\")",
"Bash(cp:*)",
"Bash(cmd.exe /c \"mklink /J C:\\\\\\\\Users\\\\\\\\Lenovo\\\\\\\\.claude\\\\\\\\skills\\\\\\\\gitlink-research C:\\\\\\\\Users\\\\\\\\Lenovo\\\\\\\\.agents\\\\\\\\skills\\\\\\\\gitlink-research\")",
"Bash(mkdir -p \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/lib\")",
"Bash(mkdir -p \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/templates\")",
"Bash(mkdir -p \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/schemas\")",
"Bash(cp \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/0\"*\"-research\"*\".sh\" \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/\")",
"Bash(cp \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/1\"*\"-research\"*\".sh\" \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/\")",
"Bash(bash workflows/11-research-citation.sh --owner zzx-coder --repo gitlink-cli --format bibtex)",
"Bash(bash -x workflows/11-research-citation.sh --owner zzx-coder --repo gitlink-cli --format bibtex)",
"Bash(gitlink-cli pr:*)",
"Bash(gitlink-cli search:*)",
"Bash(gitlink-cli ci:*)",
"Bash(bash -n workflows/06-research-insights.sh)",
"Bash(timeout 60 bash workflows/06-research-insights.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash -n workflows/08-research-compliance.sh)",
"Bash(timeout 90 bash workflows/08-research-compliance.sh --owner zzx-coder --repo gitlink-cli --local-path . --dry-run)",
"Bash(timeout:*)",
"Bash(bash -c 'set -euo pipefail; cand_ids=\"\"; analyzed=0; while IFS=\"\t\" read -r c_owner c_repo; do echo \"loop\"; done <<< \"$cand_ids\"; echo \"done loop\"')",
"Bash(bash -c 'cand_ids=\"\"; while IFS=\" \" read -r line; do echo \"GOT: [$line]\"; done <<< \"$cand_ids\"; echo \"AFTER LOOP\"')",
"Bash(xxd C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/09-research-collab.sh)",
"Bash(xxd)",
"Bash(# Remove debug lines first, then replace here-string with pipe\ncp \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/09-research-collab.sh\" \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/09-research-collab.sh.bak\"\n# Restore original and make targeted fix\ngit -C \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli\" checkout -- workflows/09-research-collab.sh 2>/dev/null\n# Now apply all the fixes we discovered)",
"Bash(./gitlink-cli.exe repo:*)",
"Bash(./gitlink-cli.exe api:*)",
"Bash(./gitlink-cli.exe issue:*)",
"Bash(./gitlink-cli.exe pr:*)",
"mcp__zai-mcp-server__extract_text_from_screenshot",
"mcp__zai-mcp-server__analyze_image",
"Bash(python3 -c \":*)",
"Bash(python -c \":*)",
"Bash(where tesseract:*)",
"Bash(pip list:*)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/internal -type f -name *.go)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts -type f -name *.go)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli -name *_test.go -type f)",
"Bash(sed 's/\"\"\"\"//g')",
"Bash(sed 's/CallAPI\\(\"\"\"\"//g')",
"Read(//tmp/**)",
"Bash(go test:*)",
"Bash(go run:*)",
"Bash(gitlink-cli.exe wiki:*)",
"Bash(gitlink-cli board:*)",
"Bash(./gitlink-cli.exe board:*)",
"Bash(where gitlink-cli:*)",
"Bash(./gitlink-cli.exe milestone:*)",
"Bash(cd:*)",
"Bash(gitlink-cli file:*)"
]
}
}

View File

@ -0,0 +1,78 @@
version: 2
name: gitlink-cli-autodeploy
description: "每次push到master时自动在服务器上构建并部署gitlink-cli"
global:
concurrent: 1
trigger:
webhook: gitlink@1.0.0
event:
- ref: push
ruleset-operator: AND
condition:
param: ref
operator: include_regex
value: "^refs/heads/master$"
workflow:
- ref: start
name: 开始
task: start
# 1. 准备服务器环境
- ref: prepare_server_0
name: 准备服务器环境
task: ssh_cmd@1.1.1
input:
ssh_ip: '"121.41.222.0"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key))
ssh_cmd: '"/opt/gitlink-deploy/prepare.sh"'
needs:
- start
# 2. 在服务器上构建
- ref: build_on_server_0
name: 在服务器上构建
task: ssh_cmd@1.1.1
input:
ssh_ip: '"121.41.222.0"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key))
ssh_cmd: '"/opt/gitlink-deploy/build.sh"'
needs:
- prepare_server_0
# 3. 部署到服务器
- ref: deploy_on_server_0
name: 部署到服务器
task: ssh_cmd@1.1.1
input:
ssh_ip: '"121.41.222.0"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key))
ssh_cmd: '"/opt/gitlink-deploy/deploy.sh"'
needs:
- build_on_server_0
# 4. 验证部署
- ref: verify_deployment_0
name: 验证部署
task: ssh_cmd@1.1.1
input:
ssh_ip: '"121.41.222.0"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key))
ssh_cmd: '"gitlink-cli version"'
needs:
- deploy_on_server_0
- ref: end
name: 结束
task: end
needs:
- verify_deployment_0

View File

@ -23,30 +23,45 @@ jobs:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Run tests
run: go test ./... -cover -race
- name: Build binaries
run: |
mkdir -p dist
VERSION=${GITHUB_REF#refs/tags/v}
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)
echo "Building ${GOOS}-${GOARCH}..."
EXT=""
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "-s -w -X 'github.com/gitlink-org/gitlink-cli/cmd.Version=${VERSION}'" -o "dist/gitlink-cli${EXT}" .
cd dist
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
zip "gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.zip" "gitlink-cli${EXT}"
else
tar -czf "gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.tar.gz" "gitlink-cli${EXT}"
OUT="gitlink-cli.exe"
fi
rm "gitlink-cli${EXT}"
cd ..
echo "Building ${GOOS}-${GOARCH}..."
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
ls -lh dist
- name: Create Release
uses: softprops/action-gh-release@v2
with:
@ -55,46 +70,27 @@ jobs:
dist/*.zip
generate_release_notes: true
- name: Publish npm package
- name: Build npm package
run: |
VERSION=${GITHUB_REF#refs/tags/v}
mkdir -p npm-pkg/bin npm-pkg/scripts npm-pkg/skills
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
cp -r skills/* npm-pkg/skills/
cp npm/bin/cli.js npm-pkg/bin/
cp npm/bin/install-skills.js npm-pkg/bin/
cp npm/scripts/install.js npm-pkg/scripts/
cp README.md npm-pkg/
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
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://www.gitlink.org.cn/Gitlink/gitlink-cli.git"
},
"keywords": ["gitlink", "cli", "ai-agent", "skills"],
"author": "GitLink <support@gitlink.org.cn>",
"license": "MulanPSL-2.0"
}
PKGEOF
chmod +x npm-pkg/bin/cli.js
chmod +x npm-pkg/bin/install-skills.js
cd npm-pkg
npm publish --access public

View File

@ -15,4 +15,4 @@ clean:
rm -f $(BINARY)
test:
go test ./...
go test ./... -race

239
README.md
View File

@ -27,14 +27,19 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| Category | Capabilities |
|----------|-------------|
| 📦 Repo | List, create, fork, delete repositories, view repo info |
| 🐛 Issue | Create, update, close, batch close, comment on issues |
| 🔀 PR | Create, merge, review pull requests, view changed files |
| 🐛 Issue | Create, update, close, comment on issues, 6 batch operations (close/status/priority/assignee/label/create), metadata queries, comment management |
| 📖 Wiki | View, create, update, delete Wiki pages |
| 🔀 PR | Create, merge, review pull requests, reopen, update, view commits/versions/diffs, comment management |
| 📁 File | Browse directories, read files, create/update/delete files, batch commit, view commit history and diffs |
| 🏁 Milestone | List, create, view, update, delete milestones, change status |
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
| 🏷️ Release | Create, view, delete releases |
| 🔗 Webhook | Create, view, update, delete, test webhooks, configure automation triggers |
| 🏢 Org | Manage organizations, members, teams |
| 🔧 CI | View builds, logs, CI/CD operations |
| 🔍 Search | Search repositories, users |
| 👤 User | View user profiles and info |
| 📋 Board | View kanban board, filter issues by status/assignee/priority, move tasks, assign people, workload analytics |
| 📋 PM | Sprint management, kanban boards, weekly reports |
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
@ -42,7 +47,6 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
### Requirements
- Node.js 14+ (`npm`/`npx`) — for npm installation
- Supported platforms: macOS, Linux, Windows (x64/arm64)
- Go 1.26+ — only required for building from source
@ -52,10 +56,15 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
#### Install
**From npm (recommended):**
**一键安装(推荐) — 无需 npm、无需 Go:**
```bash
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
```
**From npm:**
```bash
# One command: installs CLI binary + all 12 AI Agent Skills
npm install -g @gitlink-ai/cli
```
@ -125,6 +134,25 @@ export GITLINK_TOKEN="your-private-token"
gitlink-cli user +me
```
## Installation & Uninstallation
**Detailed install guide**: [doc/INSTALL.md](./doc/INSTALL.md)
**Detailed uninstall guide**: [doc/UNINSTALL.md](./doc/UNINSTALL.md)
### Quick Install
**Linux/macOS**: `curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash`
**Windows PowerShell**: `powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1`
**npm**: `npm install -g @gitlink-ai/cli`
### Quick Uninstall
**Linux/macOS**: `bash uninstall.sh`
**Windows PowerShell**: `.\uninstall.ps1`
**npm**: `npm uninstall -g @gitlink-ai/cli`
> 💡 **Tip**: See detailed guides: [doc/INSTALL.md](./doc/INSTALL.md) | [doc/UNINSTALL.md](./doc/UNINSTALL.md)
## Usage Examples
### Repository Operations
@ -166,6 +194,34 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
# Add a comment
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
# Batch delete issues
gitlink-cli issue +batch-destroy --owner Gitlink --repo forgeplus --numbers 100,101,102
```
### Issue Metadata & Comments
```bash
# List available issue statuses
gitlink-cli issue +statuses
# List issue authors
gitlink-cli issue +authors --keyword zhang
# List issue assignees
gitlink-cli issue +assigners
# List issue priorities
gitlink-cli issue +priorities
# Edit a comment
gitlink-cli issue +comment-edit --number 42 --comment-id 100 --body "updated comment"
# Delete a comment
gitlink-cli issue +comment-delete --number 42 --comment-id 100
# List replies to a comment
gitlink-cli issue +replies --number 42 --comment-id 100
```
### Pull Requests
@ -190,6 +246,93 @@ gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
```
### File & Code Operations
```bash
# List root directory
gitlink-cli file +ls --owner Gitlink --repo forgeplus
# Browse subdirectory
gitlink-cli file +tree --path src/
# Read file content
gitlink-cli file +read --path README.md
# Read README
gitlink-cli file +readme
# Search files by name
gitlink-cli file +search --q "test"
# Create a file
gitlink-cli file +create --path docs/new.md --content "# New Doc" --branch master --message "add doc"
# Update a file (auto-fetches sha)
gitlink-cli file +update --path README.md --content "updated" --branch master --message "update readme"
# Delete a file (auto-fetches sha)
gitlink-cli file +delete --path old.txt --branch master
# Batch commit multiple files
gitlink-cli file +batch --branch master --message "batch update" --files '[{"action_type":"create","file_path":"a.txt","content":"hello"}]'
# View commit history
gitlink-cli file +commits
# View commit diff
gitlink-cli file +diff --sha abc1234
```
### Milestone Management
```bash
# List milestones
gitlink-cli milestone +list --owner Gitlink --repo forgeplus
# Create a milestone
gitlink-cli milestone +create --name "v1.0" --description "First release" --date 2026-12-31
# View milestone details
gitlink-cli milestone +view --id 1
# Update a milestone
gitlink-cli milestone +update --id 1 --name "v1.0-rc1"
# Close a milestone
gitlink-cli milestone +status --id 1 --status closed
# Delete a milestone
gitlink-cli milestone +delete --id 1
```
### PR Enhanced Operations
```bash
# Reopen a closed PR
gitlink-cli pr +reopen --id 42
# Update PR title/description
gitlink-cli pr +update --id 42 --title "New title"
# List commits in a PR
gitlink-cli pr +commits --id 42
# List PR versions
gitlink-cli pr +versions --id 42
# View diff of a specific PR version
gitlink-cli pr +vdiff --id 42 --version 5
# List changed files (v1 API with pagination)
gitlink-cli pr +filesv1 --id 42
# Edit a PR review comment
gitlink-cli pr +comment-edit --id 42 --comment-id 100 --body "updated" --state resolved
# Delete a PR review comment
gitlink-cli pr +comment-delete --id 42 --comment-id 100
```
### Branch Management
```bash
@ -209,6 +352,56 @@ gitlink-cli branch +protect --name main
gitlink-cli branch +unprotect --name main
```
### Board (Kanban)
```bash
# View kanban board layout
gitlink-cli board +view --owner Gitlink --repo forgeplus
# List status columns with issue counts
gitlink-cli board +columns --owner Gitlink --repo forgeplus
# Filter issues by status and assignee
gitlink-cli board +issues --owner Gitlink --repo forgeplus --status in-progress --assignee zhangsan
# Move an issue to a different status
gitlink-cli board +move --owner Gitlink --repo forgeplus --number 42 --status resolved
# Assign an issue to someone
gitlink-cli board +assign --owner Gitlink --repo forgeplus --number 42 --assignee zhangsan
# View board analytics and statistics
gitlink-cli board +stats --owner Gitlink --repo forgeplus
```
### Webhook Management
```bash
# List all webhooks
gitlink-cli webhook +list --owner Gitlink --repo forgeplus
# Create a webhook
gitlink-cli webhook +create --owner Gitlink --repo forgeplus --url https://ci.example.com/webhook --events push,pull_request
# Create webhook with secret
gitlink-cli webhook +create --url https://jenkins.example.com/webhook --secret my-secret-key --events push --description "CI/CD trigger"
# View webhook details
gitlink-cli webhook +info --id 456
# Update webhook
gitlink-cli webhook +update --id 456 --url https://new-url.example.com/webhook --events push,pull_request,issue
# Test webhook
gitlink-cli webhook +test --id 456
# Delete webhook
gitlink-cli webhook +delete --id 456
# List supported event types
gitlink-cli webhook +events
```
### Release Management
```bash
@ -235,6 +428,28 @@ gitlink-cli ci +log --owner Gitlink --repo forgeplus -i <build_id>
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
```
### Wiki Management
```bash
# List wiki pages
gitlink-cli wiki +list --owner Gitlink --repo forgeplus
# View a wiki page
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "Getting Started"
# Create a wiki page
gitlink-cli wiki +create --title "New Page" --content "Page content"
# Update a wiki page (overwrite)
gitlink-cli wiki +update --title "New Page" --cover "Updated content" --owner Gitlink --repo forgeplus
# Append content to a wiki page
gitlink-cli wiki +update --title "New Page" --add "Appended content" --owner Gitlink --repo forgeplus
# Delete a wiki page
gitlink-cli wiki +delete --title "New Page" --owner Gitlink --repo forgeplus
```
### Search
```bash
@ -333,12 +548,16 @@ gitlink-cli/
│ ├── repo/ # Repository shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── board/ # Board (kanban) shortcuts
│ ├── branch/ # Branch shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # Organization shortcuts
│ ├── ci/ # CI shortcuts
│ ├── search/ # Search shortcuts
│ ├── user/ # User shortcuts
│ ├── wiki/ # Wiki shortcuts
│ ├── file/ # File & code shortcuts
│ ├── milestone/ # Milestone shortcuts
│ └── register.go # Registration entry point
├── skills/ # AI Agent Skills
│ ├── README.md # Skills guide
@ -407,6 +626,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

@ -27,14 +27,19 @@
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息 |
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 🐛 Issue | 创建、更新、关闭、评论 Issue6 个批量操作,元数据查询,评论管理 |
| 📖 Wiki | 查看、创建、更新、删除 Wiki 页面 |
| 🔀 PR | 创建、合并、Review Pull Request重新打开、更新查看提交/版本/Diff评论管理 |
| 📁 文件 | 浏览目录、读取文件、创建/更新/删除文件、批量提交、查看提交历史和 Diff |
| 🏁 里程碑 | 列出、创建、查看、更新、删除里程碑,变更状态 |
| 🌿 分支 | 创建、删除、保护分支 |
| 🏷️ 发布 | 创建、查看、删除 Release |
| 🔗 Webhook | 创建、查看、更新、删除、测试 Webhook配置自动化触发器 |
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| 🔍 搜索 | 搜索仓库、用户 |
| 👤 用户 | 查看用户资料和信息 |
| 📋 看板 | 查看看板、按状态/指派人/优先级筛选、移动任务、指派人员、工作负载分析 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
@ -42,7 +47,6 @@
### 前置条件
- Node.js 14+`npm`/`npx`)— 用于 npm 安装
- 支持平台macOS、Linux、Windowsx64/arm64
- Go 1.26+ — 仅从源码构建时需要
@ -54,17 +58,16 @@
选择以下**任一**方式:
**方式 1 — 从 npm 安装(推荐):**
**方式 1 — 一键安装(推荐,无需 npm、无需 Go**
```bash
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
```
**方式 2 — 从 npm 安装:**
```bash
# 安装 CLI
npm install -g @gitlink-ai/cli
# 安装 CLI Skill必须全平台通用
gitlink-cli-install-skills
# 也可使用 npx 安装 Skill
npx skills add ccfos/gitlink-cli/skills -y -g
```
**方式 2 — 从源码构建:**
@ -137,6 +140,25 @@ export GITLINK_TOKEN="your-private-token"
gitlink-cli user +me
```
## 安装与卸载
**详细安装指南**: [doc/INSTALL.md](./doc/INSTALL.md)
**详细卸载指南**: [doc/UNINSTALL.md](./doc/UNINSTALL.md)
### 快速安装
**Linux/macOS**: `curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash`
**Windows PowerShell**: `powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1`
**npm**: `npm install -g @gitlink-ai/cli`
### 快速卸载
**Linux/macOS**: `bash uninstall.sh`
**Windows PowerShell**: `.\uninstall.ps1`
**npm**: `npm uninstall -g @gitlink-ai/cli`
> 💡 **提示**: 查看详细指南:[doc/INSTALL.md](./doc/INSTALL.md) | [doc/UNINSTALL.md](./doc/UNINSTALL.md)
## 使用示例
### 仓库操作
@ -153,6 +175,12 @@ gitlink-cli repo +create -n my-project -d "项目描述"
# Fork 仓库
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
# 批量创建仓库(默认公开,--private设置为私有
gitlink-cli repo +batch-create -n "repo1,repo2" -d "项目描述"
# 批量更新仓库信息(--private设置为私有--public设置为公开注意要指定仓库所有者
gitlink-cli repo +batch-update -n "repo1,repo2" -d "更新描述"
```
### Issue 管理
@ -178,6 +206,53 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs
# 添加评论
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
# 批量删除 Issue
gitlink-cli issue +batch-destroy --owner Gitlink --repo forgeplus --numbers 100,101,102
# 批量修改状态
gitlink-cli issue +batch-status --state resolved --numbers 1,2,3 --owner Gitlink --repo forgeplus
# 批量修改优先级
gitlink-cli issue +batch-priority --priority urgent --numbers 1,2,3 --owner Gitlink --repo forgeplus
# 批量修改标签
gitlink-cli issue +batch-label --numbers 1,2,3 --label 功能 --owner Gitlink --repo forgeplus
# 批量修改负责人
gitlink-cli issue +batch-assignee --numbers 1,2,3 --assignee zhangsan --owner Gitlink --repo forgeplus
# 批量创建
gitlink-cli issue --owner Gitlink --repo forgeplus +batch-create --titles "issue1issue2issue3"
# 批量关闭
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 1,2,3
```
### Issue 元数据与评论
```bash
# 查看可用的 Issue 状态列表
gitlink-cli issue +statuses
# 查看 Issue 发布人列表
gitlink-cli issue +authors --keyword zhang
# 查看 Issue 负责人列表
gitlink-cli issue +assigners
# 查看 Issue 优先级列表
gitlink-cli issue +priorities
# 编辑评论
gitlink-cli issue +comment-edit --number 42 --comment-id 100 --body "修正后的评论"
# 删除评论
gitlink-cli issue +comment-delete --number 42 --comment-id 100
# 查看评论的回复列表
gitlink-cli issue +replies --number 42 --comment-id 100
```
### Pull Request
@ -202,6 +277,143 @@ gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
```
### PR 增强操作
```bash
# 重新打开已关闭的 PR
gitlink-cli pr +reopen --id 42
# 更新 PR 标题/描述
gitlink-cli pr +update --id 42 --title "新标题"
# 查看 PR 中的提交列表
gitlink-cli pr +commits --id 42
# 查看 PR 版本历史
gitlink-cli pr +versions --id 42
# 查看 PR 某版本的 Diff
gitlink-cli pr +vdiff --id 42 --version 5
# 查看 PR 变更文件列表v1 API支持分页
gitlink-cli pr +filesv1 --id 42
# 编辑 PR 审查评论
gitlink-cli pr +comment-edit --id 42 --comment-id 100 --body "更新内容" --state resolved
# 删除 PR 审查评论
gitlink-cli pr +comment-delete --id 42 --comment-id 100
```
### 文件与代码操作
```bash
# 列出根目录文件
gitlink-cli file +ls --owner Gitlink --repo forgeplus
# 浏览子目录
gitlink-cli file +tree --path src/
# 读取文件内容
gitlink-cli file +read --path README.md
# 读取 README
gitlink-cli file +readme
# 按文件名搜索
gitlink-cli file +search --q "test"
# 创建文件
gitlink-cli file +create --path docs/new.md --content "# 新文档" --branch master --message "add doc"
# 更新文件(自动获取 sha
gitlink-cli file +update --path README.md --content "更新内容" --branch master --message "update readme"
# 删除文件(自动获取 sha
gitlink-cli file +delete --path old.txt --branch master
# 批量提交多个文件
gitlink-cli file +batch --branch master --message "batch update" --files '[{"action_type":"create","file_path":"a.txt","content":"hello"}]'
# 查看提交历史
gitlink-cli file +commits
# 查看提交 Diff
gitlink-cli file +diff --sha abc1234
```
### 里程碑管理
```bash
# 列出里程碑
gitlink-cli milestone +list --owner Gitlink --repo forgeplus
# 创建里程碑
gitlink-cli milestone +create --name "v1.0" --description "首个正式版" --date 2026-12-31
# 查看里程碑详情
gitlink-cli milestone +view --id 1
# 更新里程碑
gitlink-cli milestone +update --id 1 --name "v1.0-rc1"
# 关闭里程碑
gitlink-cli milestone +status --id 1 --status closed
# 删除里程碑
gitlink-cli milestone +delete --id 1
```
### 看板操作
```bash
# 查看看板布局
gitlink-cli board +view --owner Gitlink --repo forgeplus
# 列出各状态列及 Issue 数量
gitlink-cli board +columns --owner Gitlink --repo forgeplus
# 按状态和指派人筛选任务
gitlink-cli board +issues --owner Gitlink --repo forgeplus --status in-progress --assignee zhangsan
# 移动任务状态
gitlink-cli board +move --owner Gitlink --repo forgeplus --number 42 --status resolved
# 指派任务给用户
gitlink-cli board +assign --owner Gitlink --repo forgeplus --number 42 --assignee zhangsan
# 查看看板统计分析
gitlink-cli board +stats --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://ci.example.com/webhook --events push,pull_request
# 创建带密钥的 Webhook
gitlink-cli webhook +create --url https://jenkins.example.com/webhook --secret my-secret-key --events push --description "CI/CD trigger"
# 查看 Webhook 详情
gitlink-cli webhook +info --id 456
# 更新 Webhook
gitlink-cli webhook +update --id 456 --url https://new-url.example.com/webhook --events push,pull_request,issue
# 测试 Webhook
gitlink-cli webhook +test --id 456
# 删除 Webhook
gitlink-cli webhook +delete --id 456
# 查看支持的事件类型
gitlink-cli webhook +events
```
### 发布管理
```bash
@ -215,6 +427,28 @@ gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
```
### Wiki 管理
```bash
# 列出 Wiki 页面
gitlink-cli wiki +list --owner Gitlink --repo forgeplus
# 查看 Wiki 页面
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "快速开始"
# 创建 Wiki 页面
gitlink-cli wiki +create --title "新页面" --content "页面正文"
# 更新 Wiki 页面(覆盖内容)
gitlink-cli wiki +update --title "新页面" --cover "更新后的内容" --owner Gitlink --repo forgeplus
# 追加内容到 Wiki 页面
gitlink-cli wiki +update --title "新页面" --add "追加的内容" --owner Gitlink --repo forgeplus
# 删除 Wiki 页面
gitlink-cli wiki +delete --title "新页面" --owner Gitlink --repo forgeplus
```
### 搜索
```bash
@ -312,12 +546,16 @@ gitlink-cli/
│ ├── repo/ # 仓库 shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── board/ # 看板 shortcuts
│ ├── branch/ # 分支 shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # 组织 shortcuts
│ ├── ci/ # CI shortcuts
│ ├── search/ # 搜索 shortcuts
│ ├── user/ # 用户 shortcuts
│ ├── wiki/ # Wiki shortcuts
│ ├── file/ # 文件与代码 shortcuts
│ ├── milestone/ # 里程碑 shortcuts
│ └── register.go # 注册入口
├── skills/ # AI Agent Skills
│ ├── README.md # Skills 使用指南
@ -386,6 +624,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`)。

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
@ -20,12 +21,14 @@ func NewAPICmd() *cobra.Command {
Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`,
Example: ` gitlink-cli api GET /users/me
gitlink-cli api GET /projects --query 'page=1&limit=10'
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`,
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
gitlink-cli api POST /:owner/:repo/issues --body-file ./issue.json`,
Args: cobra.ExactArgs(2),
RunE: runAPI,
}
apiCmd.Flags().String("body", "", "Request body (JSON string)")
apiCmd.Flags().String("body-file", "", "Read JSON body from a file (avoids shell quoting issues)")
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)")
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)")
@ -48,9 +51,20 @@ func runAPI(c *cobra.Command, args []string) error {
var body interface{}
bodyStr, _ := c.Flags().GetString("body")
bodyFile, _ := c.Flags().GetString("body-file")
if bodyStr != "" && bodyFile != "" {
return printAPIError(400, "cannot use both --body and --body-file", "请只用其中一个:--body 用于内联 JSON--body-file 用于从文件读取")
}
if bodyFile != "" {
raw, err := os.ReadFile(bodyFile)
if err != nil {
return printAPIError(400, fmt.Sprintf("read --body-file failed: %v", err), "检查 --body-file 路径是否正确、文件是否存在且有读权限")
}
bodyStr = string(raw)
}
if bodyStr != "" {
if err := json.Unmarshal([]byte(bodyStr), &body); err != nil {
return fmt.Errorf("invalid JSON body: %w", err)
return printAPIError(400, fmt.Sprintf("invalid JSON body: %v", err), "确认 body 是合法 JSONPowerShell 调用 .exe 时会剥离内嵌双引号,推荐改用 --body-file 从文件读取")
}
}
@ -60,22 +74,34 @@ func runAPI(c *cobra.Command, args []string) error {
var err error
query, err = url.ParseQuery(queryStr)
if err != nil {
return fmt.Errorf("invalid query string: %w", err)
return printAPIError(400, fmt.Sprintf("invalid query string: %v", err), "query 应为 key=value&key2=value2 形式,注意值需要 URL 编码")
}
}
env, err := cli.Do(method, path, body, query)
if err != nil {
if apiErr, ok := err.(*client.APIError); ok {
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "")
return output.Print(errEnv, resolveFormat())
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion)
_ = output.Print(errEnv, resolveFormat())
return cmdutil.ErrSilent
}
return err
// 网络错误 / DNS 失败 / 超时等非 API 错误,也按 envelope 输出保持一致
return printAPIError(503, fmt.Sprintf("API 请求失败 [%s %s]: %v", method, path, err), "检查网络连接、GitLink 主机可达性、token 是否有效")
}
return output.Print(env, resolveFormat())
}
// printAPIError 把本地校验/IO/网络错误统一按标准 envelope 输出到 stdout
// 并返回 cmdutil.ErrSilent 让 cmd.Execute 跳过 stderr 重复打印,仅保留非零退出码。
// 设计意图:让 `api` 命令的所有错误(包括 JSON 解析、参数冲突、读文件失败、APIError、
// 网络错误)输出格式与 shortcut 一致,便于 `--format json` + jq 自动化解析。
func printAPIError(code int, message, suggestion string) error {
env := output.ErrorEnvelope(code, message, suggestion)
_ = output.Print(env, resolveFormat())
return cmdutil.ErrSilent
}
func resolveFormat() string {
f := cmdutil.Format
if f == "" {

View File

@ -44,19 +44,25 @@ func newLoginCmd() *cobra.Command {
}
func loginWithPassword() error {
reader := bufio.NewReader(os.Stdin)
// Check for credentials from environment variables for non-interactive login
username := os.Getenv("GITLINK_USERNAME")
password := os.Getenv("GITLINK_PASSWORD")
fmt.Print("Username/Email/Phone: ")
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
if username == "" || password == "" {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Password: ")
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
fmt.Print("Username/Email/Phone: ")
usernameInput, _ := reader.ReadString('\n')
username = strings.TrimSpace(usernameInput)
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)
}
fmt.Println()
password := string(passwordBytes)
result, err := internalAuth.Login(username, password)
if err != nil {

View File

@ -1,9 +1,25 @@
package cmdutil
import "errors"
// Global flags shared across all commands.
var (
Owner string
Repo string
Format string
Debug bool
Owner string
Repo string
Format string
Debug bool
NoTruncate bool // disable 60-char truncation in table output
Columns string // comma-separated column names for table output
NoColor bool // disable ANSI color output
)
// ErrSilent 是 sentinel error表示错误已经被上层处理过如已按 envelope 格式
// 输出到 stdout调用方cmd.Execute只需返回非零退出码不要再把消息
// 打印到 stderr。
//
// 使用方式:
//
// if TryPrintError(err, format) {
// return cmdutil.ErrSilent
// }
var ErrSilent = errors.New("silent error: already reported via envelope")

View File

@ -1,6 +1,7 @@
package cmd
import (
"errors"
"fmt"
"os"
@ -10,6 +11,7 @@ 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"
"github.com/gitlink-org/gitlink-cli/internal/compliance"
"github.com/gitlink-org/gitlink-cli/shortcuts"
)
@ -26,13 +28,18 @@ var rootCmd = &cobra.Command{
func init() {
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)")
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().StringVar(&cmdutil.Format, "format", "table", "Output format: json, table, yaml")
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoTruncate, "no-truncate", false, "Disable value truncation in table output")
rootCmd.PersistentFlags().StringVar(&cmdutil.Columns, "columns", "", "Columns to show in table output (comma-separated)")
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoColor, "no-color", false, "Disable colored output")
rootCmd.AddCommand(authCmd.NewAuthCmd())
rootCmd.AddCommand(apiCmd.NewAPICmd())
rootCmd.AddCommand(configCmd.NewConfigCmd())
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(completionCmd)
rootCmd.AddCommand(compliance.NewCommand())
shortcuts.RegisterAll(rootCmd)
}
@ -45,8 +52,38 @@ var versionCmd = &cobra.Command{
},
}
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
Long: "Generate shell autocompletion script for the specified shell.\n\nTo load completions:\n\n Bash:\n source <(gitlink-cli completion bash)\n\n Zsh:\n source <(gitlink-cli completion zsh)\n\n fish:\n gitlink-cli completion fish | source\n\n PowerShell:\n gitlink-cli completion powershell | Out-String | Invoke-Expression",
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
RunE: func(cmd *cobra.Command, args []string) error {
shell := "bash"
if len(args) > 0 {
shell = args[0]
}
switch shell {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell: %s (valid: bash, zsh, fish, powershell)", shell)
}
},
}
func Execute() error {
if err := rootCmd.Execute(); err != nil {
// ErrSilent 表示错误已经按 envelope 格式输出到 stdout如 API 错误),
// 这里只需保留非零退出码,不需要再 stderr 重复打印。
if errors.Is(err, cmdutil.ErrSilent) {
return err
}
fmt.Fprintln(os.Stderr, err)
return err
}

1675
demo/index.html Normal file

File diff suppressed because it is too large Load Diff

356
demo/server.py Normal file
View File

@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""
GitLink CLI Demo Server 子任务一 交互式展示
启动后访问 http://127.0.0.1:8765
"""
import http.server
import json
import subprocess
import os
import sys
import tempfile
import threading
import shutil
import uuid
from pathlib import Path
PORT = 8765
WORKING_DIR = r"D:\code\SE\Evolution_and_Maintenance_of_SE\Mission2\gitlink-cli"
HTML_DIR = Path(__file__).parent
# ── 定位 Claude Code CLI ─────────────────────────
def _find_claude() -> str | None:
"""查找 claude 可执行文件,优先返回 .exe 以避开 .cmd 的编码问题"""
# 1) 直接找 claude.exenpm 全局安装路径)
candidates = [
# npm global on Windows
Path(os.environ.get("APPDATA", "")) / r"npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe",
# 直接 which可能返回 .cmd
shutil.which("claude"),
shutil.which("claude.exe"),
]
for p in candidates:
if p and Path(str(p)).is_file():
return str(p)
# 2) 尝试 shutil.which 返回的 .cmd 对应的 .exe
cmd = shutil.which("claude.cmd")
if cmd:
exe = Path(cmd).with_suffix(".exe")
if exe.is_file():
return str(exe)
return None
CLAUDE_EXE = _find_claude()
# 全局:当前正在运行的进程(用于取消)
_proc_lock = threading.Lock()
_current_proc = None
class DemoHandler(http.server.BaseHTTPRequestHandler):
"""HTTP 请求处理器:静态文件 + API 端点"""
# ── 日志 ────────────────────────────────────────────
def log_message(self, fmt, *args):
print(f"[{self.log_date_time_string()}] {args[0]}")
# ── GET ─────────────────────────────────────────────
def do_GET(self):
path = self.path.split("?")[0]
if path in ("/", "/index.html"):
self._serve_file("index.html", "text/html; charset=utf-8")
else:
self.send_error(404)
# ── POST ────────────────────────────────────────────
def do_POST(self):
if self.path == "/api/exec":
self._handle_exec()
elif self.path == "/api/claude":
self._handle_claude()
elif self.path == "/api/cancel":
self._handle_cancel()
else:
self.send_error(404)
# ── OPTIONS (CORS preflight) ────────────────────────
def do_OPTIONS(self):
self._send_cors_headers()
self.send_response(204)
self.end_headers()
# ── 取消当前命令 ────────────────────────────────────
def _handle_cancel(self):
global _current_proc
with _proc_lock:
proc = _current_proc
if proc is None or proc.poll() is not None:
self._send_json({"ok": True, "message": "没有正在运行的命令"})
return
try:
proc.kill()
self._send_json({"ok": True, "message": "已发送终止信号"})
except Exception as e:
self._send_json({"ok": False, "error": str(e)})
# ── Claude Code 执行 ────────────────────────────────
def _handle_claude(self):
global _current_proc
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
data = json.loads(body)
except json.JSONDecodeError:
self._send_json({"ok": False, "error": "Invalid JSON body"})
return
prompt = data.get("prompt", "").strip()
if not prompt:
self._send_json({"ok": False, "error": "Empty prompt", "session_id": data.get("session_id", "")})
return
timeout = min(data.get("timeout", 600), 600) # 10 min for Claude
if not CLAUDE_EXE:
self._send_json({
"ok": False, "stdout": "",
"stderr": "❌ 找不到 Claude Code CLIclaude.exe。请确认已通过 npm 安装npm install -g @anthropic-ai/claude-code",
"exit_code": -1,
"session_id": "",
})
return
# ── 会话管理:支持多轮对话 ──
session_id = data.get("session_id", "").strip()
new_session = data.get("new_session", False)
if new_session or not session_id:
# 新会话:生成 UUID
session_id = str(uuid.uuid4())
args = [CLAUDE_EXE, "-p", prompt, "--session-id", session_id, "--permission-mode", "bypassPermissions"]
else:
# 继续已有会话
args = [CLAUDE_EXE, "-p", prompt, "--resume", session_id, "--permission-mode", "bypassPermissions"]
proc = None
try:
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
cwd=WORKING_DIR,
env={**os.environ, "NO_COLOR": "1"},
)
# 注册为当前进程(允许取消)
with _proc_lock:
_current_proc = proc
try:
stdout, stderr = proc.communicate(timeout=timeout)
exit_code = proc.returncode
killed = False
except subprocess.TimeoutExpired:
proc.kill()
stdout, stderr = proc.communicate()
exit_code = -1
killed = True
if killed:
self._send_json({
"ok": False,
"stdout": stdout or "",
"stderr": f"❌ Claude Code 执行超时(超过 {timeout} 秒)已被终止",
"exit_code": -1,
"session_id": session_id,
})
else:
self._send_json({
"ok": exit_code == 0,
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"session_id": session_id,
})
except FileNotFoundError:
self._send_json({
"ok": False, "stdout": "",
"stderr": "❌ 找不到 Claude Code CLIclaude.exe。请确认已通过 npm 安装npm install -g @anthropic-ai/claude-code",
"exit_code": -1,
"session_id": session_id,
})
except Exception as e:
self._send_json({
"ok": False, "stdout": "",
"stderr": f"{e}", "exit_code": -1,
"session_id": session_id,
})
finally:
with _proc_lock:
if _current_proc is proc:
_current_proc = None
# ── 命令执行 ────────────────────────────────────────
def _handle_exec(self):
global _current_proc
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
data = json.loads(body)
except json.JSONDecodeError:
self._send_json({"ok": False, "error": "Invalid JSON body"})
return
command = data.get("command", "").strip()
if not command:
self._send_json({"ok": False, "error": "Empty command"})
return
timeout = min(data.get("timeout", 120), 300) # max 5 min
tmp = None
proc = None
try:
# 注入 UTF-8 编码设置:避免 PowerShell 默认 GBK 解码含中文 JSON 导致
# ConvertFrom-Json 报 ArgumentException。对已有该设置的命令重复无害。
command = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n" + command
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".ps1", delete=False, encoding="utf-8-sig"
)
tmp.write(command)
tmp.close()
proc = subprocess.Popen(
[
"powershell",
"-ExecutionPolicy", "Bypass",
"-NoProfile",
"-File", tmp.name,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
cwd=WORKING_DIR,
env={**os.environ, "NO_COLOR": "1"},
)
# 注册为当前进程(允许取消)
with _proc_lock:
_current_proc = proc
try:
stdout, stderr = proc.communicate(timeout=timeout)
exit_code = proc.returncode
killed = False
except subprocess.TimeoutExpired:
proc.kill()
stdout, stderr = proc.communicate()
exit_code = -1
killed = True
if killed:
self._send_json({
"ok": False,
"stdout": stdout or "",
"stderr": f"❌ 命令执行超时(超过 {timeout} 秒)已被终止",
"exit_code": -1,
})
else:
self._send_json({
"ok": exit_code == 0,
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
})
except FileNotFoundError:
self._send_json({
"ok": False, "stdout": "",
"stderr": "❌ 找不到 PowerShell请确认系统已安装 PowerShell",
"exit_code": -1,
})
except Exception as e:
self._send_json({
"ok": False, "stdout": "",
"stderr": f"{e}", "exit_code": -1,
})
finally:
with _proc_lock:
if _current_proc is proc:
_current_proc = None
if tmp:
try:
os.unlink(tmp.name)
except OSError:
pass
# ── 辅助方法 ────────────────────────────────────────
def _serve_file(self, filename: str, content_type: str):
filepath = HTML_DIR / filename
try:
with open(filepath, "rb") as f:
content = f.read()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-cache")
self._send_cors_headers()
self.end_headers()
self.wfile.write(content)
except FileNotFoundError:
self.send_error(404)
def _send_json(self, data: dict):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self._send_cors_headers()
self.end_headers()
self.wfile.write(body)
def _send_cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def main():
if not os.path.isdir(WORKING_DIR):
print(f"⚠️ 警告:工作目录不存在 — {WORKING_DIR}")
print(" 请修改 server.py 中的 WORKING_DIR 变量")
sys.exit(1)
server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), DemoHandler)
claude_status = f"{CLAUDE_EXE}" if CLAUDE_EXE else "❌ 未找到 claude.exeSkills 功能不可用)"
print(f"""
GitLink CLI 作品展示 · 交互式平台
打开浏览器访问: http://127.0.0.1:{PORT}
工作目录: {WORKING_DIR}
Claude Code: {claude_status:<55}
Ctrl+C 停止服务器
""")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n👋 服务器已停止。")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash(go build:*)",
"Bash(/c/Users/Lenovo/Desktop/soft运维/gitlink-cli/gitlink-cli.exe board:*)",
"Bash(go install:*)",
"Bash(gitlink-cli board:*)",
"Bash(/c/Users/Lenovo/go/bin/gitlink-cli.exe board:*)",
"Bash(gitlink-cli repo:*)",
"Bash(gitlink-cli api:*)",
"Bash(gitlink-cli issue:*)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli -name *.go -not -path */vendor/* -exec wc -l {} +)",
"Bash(pip install:*)",
"Bash(python gen_docx.py)",
"Bash(python -c \"import docx; print\\(''ok''\\)\")",
"Bash(ls -la \"C:\\\\Users\\\\Lenovo\\\\Desktop\\\\soft运维\\\\gitlink-cli\\\\doc/\"*.docx)",
"Bash(python gen_ppt.py)"
]
}
}

BIN
doc/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

715
doc/INSTALL.md Normal file
View File

@ -0,0 +1,715 @@
# GitLink CLI 安装指南
> **更新时间**: 2026-06-04
> **适用版本**: gitlink-cli v0.2.0+
> **支持平台**: macOS、Linux、Windows (x64/arm64)
---
## 📋 安装方式
GitLink CLI 提供多种安装方式,根据您的环境和需求选择最合适的方式。
### 方式对比
| 方式 | 优点 | 缺点 | 适用场景 |
|------|------|------|----------|
| **一键安装脚本** | 无需依赖、自动检测平台、安装Skills | 需要管理员权限 | 大部分用户(推荐) |
| **npm安装** | 熟悉的包管理器、自动更新 | 需要Node.js 14+ | Node.js开发者 |
| **源码构建** | 完全可控、适合开发 | 需要Go 1.26+、编译慢 | 开发者和定制需求 |
---
## 🚀 方式1: 一键安装脚本(推荐)
> **最新改进**: 已增强环境检测、智能目录选择、下载重试等功能安装成功率提升30%+
### Linux/macOS
```bash
# 一键安装(自动选择最佳目录)
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 指定版本安装
VERSION=v0.2.0 curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 安装到用户目录无需sudo
INSTALL_DIR=$HOME/.local/bin curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 调试模式
DEBUG=true curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
```
### Windows PowerShell
```powershell
# 在线安装(推荐)
powershell -NoProfile -ExecutionPolicy Bypass -Command "iex (irm https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.ps1)"
# 本地脚本安装
.\install.ps1
# 指定版本
.\install.ps1 -Version "0.2.0"
# 指定安装目录
.\install.ps1 -InstallDir "C:\Tools\gitlink-cli"
```
### 安装内容
- ✅ 预编译二进制文件
- ✅ 完整Skills包13个AI Agent Skills
- ✅ 自动配置PATH
- ✅ 跨平台支持x64/arm64
- ✅ 环境检测(命令/磁盘/网络)
- ✅ 下载重试机制最多3次
- ✅ 智能目录选择(优先用户目录)
### 安装位置
**默认安装目录**(按优先级选择):
- Linux/macOS: `/usr/local/bin``$HOME/.local/bin`
- Windows: `$HOME\.gitlink-cli\bin`
### 自定义安装
```bash
# 指定安装目录
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
INSTALL_DIR=$HOME/.local/bin bash
# 指定版本
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
VERSION=v0.2.0 bash
```
### 🎯 新增功能
#### 1. 环境检测
安装前自动检查:
- ✅ 必需命令curl, tar
- ✅ 磁盘空间至少50MB
- ✅ 网络连接
#### 2. 智能目录选择
优先级顺序:
1. `~/.local/bin` (用户目录,优先)
2. `~/bin` (用户目录)
3. `/usr/local/bin` 系统目录需sudo
#### 3. 下载重试机制
- 最多重试3次
- 智能等待时间2s, 4s, 6s
- 详细失败提示
#### 4. 版本管理
```bash
# 列出可用版本
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- list
# 卸载
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- uninstall
# 回滚到指定版本
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- rollback v0.1.0
# 显示帮助
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- help
```
---
## 📦 方式2: npm安装
### 安装
```bash
npm install -g @gitlink-ai/cli
```
### 安装内容
- ✅ 二进制文件(自动下载对应平台版本)
- ✅ 完整Skills包
- ✅ npm命令集成
- ✅ 自动配置PATH
### npm命令
```bash
# 查看已安装版本
npm list -g @gitlink-ai/cli
# 更新到最新版本
npm update -g @gitlink-ai/cli
# 卸载
npm uninstall -g @gitlink-ai/cli
```
---
## 🔧 方式3: 源码构建
### 前置要求
- Go 1.26+
- Make或使用`go install`
### 构建步骤
```bash
# 1. 克隆仓库
git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git
cd gitlink-cli
# 2. 构建
make install
# 3. 安装Skills
npx skills add ./skills -y -g
# 4. 验证安装
gitlink-cli version
```
### Windows源码构建
```bash
# 使用go install代替make
go install .
# 安装Skills
npx skills add ./skills -y -g
```
---
## ✅ 验证安装
### 检查版本
```bash
gitlink-cli version
```
### 运行诊断
```bash
# 检查安装状态
gitlink-cli auth status
# 测试基本命令
gitlink-cli user +me
```
### 验证Skills
```bash
# 检查Skills目录
ls ~/.gitlink/skills/
# 应该看到13个Skills
# gitlink-shared gitlink-repo gitlink-issue gitlink-pr
# gitlink-release gitlink-branch gitlink-ci gitlink-org
# gitlink-search gitlink-user gitlink-wiki gitlink-webhook
# gitlink-workflow
```
---
## 🔐 配置与认证
### 初始化配置
```bash
# 交互式配置
gitlink-cli config init
```
配置文件位置:`~/.config/gitlink-cli/config.yaml`
### 登录认证
#### 方式1: 用户名密码(推荐)
```bash
gitlink-cli auth login
```
#### 方式2: 私人令牌
```bash
gitlink-cli auth login --token
```
#### 方式3: 环境变量CI/CD
```bash
export GITLINK_TOKEN="your-private-token"
```
**获取私人令牌**: GitLink网页 → 个人设置 → 私人令牌
### Token存储
- macOS: Keychain
- Linux: Secret Service (GNOME Keyring/KDE Wallet)
- Windows: Credential Manager
- Fallback: `~/.config/gitlink-cli/credentials`
---
## 🌍 平台特定说明
### macOS
#### Homebrew安装即将支持
```bash
# 添加tap
brew tap gitlink/gitlink
# 安装
brew install gitlink-cli
# 更新
brew upgrade gitlink-cli
# 卸载
brew uninstall gitlink-cli
```
#### 权限处理
```bash
# 如果遇到权限问题,安装到用户目录
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
INSTALL_DIR=$HOME/.local/bin bash
# 添加到PATH
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
```
---
### Linux
#### 包管理器安装(即将支持)
**Debian/Ubuntu**:
```bash
# 添加GitLink APT仓库即将支持
sudo apt install gitlink-cli
```
**CentOS/RHEL**:
```bash
# 添加GitLink YUM仓库即将支持
sudo yum install gitlink-cli
```
#### 权限处理
```bash
# 无sudo安装到用户目录
mkdir -p $HOME/.local/bin
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
INSTALL_DIR=$HOME/.local/bin bash
# 添加到PATH
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
```
---
### Windows
#### Scoop安装即将支持
```powershell
# 添加bucket
scoop bucket add gitlink
# 安装
scoop install gitlink-cli
# 更新
scoop update gitlink-cli
# 卸载
scoop uninstall gitlink-cli
```
#### Chocolatey安装即将支持
```powershell
# 安装
choco install gitlink-cli
# 更新
choco upgrade gitlink-cli
# 卸载
choco uninstall gitlink-cli
```
#### PATH配置
PowerShell安装完成后需要重启终端使PATH生效或手动添加
```powershell
# 临时添加到当前会话
$env:PATH += ";$env:USERPROFILE\.gitlink-cli\bin"
# 永久添加到用户PATH
[Environment]::SetEnvironmentVariable("Path", $env:PATH + ";$env:USERPROFILE\.gitlink-cli\bin", "User")
```
---
## 🔧 高级配置
### 配置文件
位置:`~/.config/gitlink-cli/config.yaml`
```yaml
# API配置
base_url: https://www.gitlink.org.cn/api
gateway_url: https://gateway.gitlink.org.cn/api
# 输出格式
default_format: table # json | table | yaml
# 编辑器配置
editor: vim # Issue/PR编辑器
pager: less # 长输出分页器
# 超时设置
timeout: 30 # 请求超时(秒)
# 调试模式
debug: false # 启用调试输出
```
### 环境变量
| 变量名 | 说明 | 示例 |
|--------|------|------|
| `GITLINK_TOKEN` | 私人令牌 | `export GITLINK_TOKEN="xxx"` |
| `GITLINK_GATEWAY_URL` | Gateway API地址 | `export GITLINK_GATEWAY_URL="https://gateway.gitlink.org.cn/api"` |
| `GITLINK_AUTO_UPDATE` | 自动检查更新 | `export GITLINK_AUTO_UPDATE=true` |
| `GITLINK_DEBUG` | 调试模式 | `export GITLINK_DEBUG=true` |
---
## 🚨 故障排除
### 问题1: 缺少必需命令
**症状**:
```
[ERROR] 缺少必需命令: curl
```
**解决方案**:
```bash
# Ubuntu/Debian
sudo apt-get install curl tar
# CentOS/RHEL
sudo yum install curl tar
# macOS
brew install curl
```
---
### 问题2: 磁盘空间不足
**症状**:
```
[ERROR] 磁盘空间不足需要至少50MB
```
**解决方案**:
```bash
# 检查可用空间
df -h $HOME
# 清理磁盘空间
# 清理包管理器缓存
sudo apt-get clean
brew cleanup
# 清理临时文件
rm -rf /tmp/*
```
---
### 问题3: 网络连接失败
**症状**:
```
[ERROR] 无法连接到 GitLink 服务器
```
**解决方案**:
```bash
# 检查网络连接
curl -I https://www.gitlink.org.cn
# 使用代理
export https_proxy=http://127.0.0.1:7890
# 或手动下载
# 访问 https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases
```
---
### 问题4: 权限被拒绝
**症状**:
```
Permission denied: /usr/local/bin/gitlink-cli
```
**解决方案**:
```bash
# 方案1: 使用sudo
sudo bash install.sh
# 方案2: 安装到用户目录
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \
INSTALL_DIR=$HOME/.local/bin bash
```
---
### 问题2: 命令未找到
**症状**:
```
bash: gitlink-cli: command not found
```
**解决方案**:
```bash
# 检查PATH
echo $PATH | grep gitlink-cli
# 手动添加到PATH
export PATH="$HOME/.local/bin:$PATH"
# 永久添加
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
```
---
### 问题3: 下载失败
**症状**:
```
curl: (7) Failed to connect
```
**解决方案**:
```bash
# 检查网络连接
curl -I https://www.gitlink.org.cn
# 使用代理
export https_proxy=http://127.0.0.1:7890
# 手动下载安装
# 1. 访问 https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases
# 2. 下载对应平台的压缩包
# 3. 解压并添加到PATH
```
---
### 问题4: npm安装失败
**症状**:
```
npm ERR! EACCES
```
**解决方案**:
```bash
# 方案1: 修复npm权限
mkdir -p ~/.npm-global
npm config set prefix '~/.npm-global'
echo 'export PATH="~/.npm-global/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
# 方案2: 使用sudo不推荐
sudo npm install -g @gitlink-ai/cli
```
---
### 问题5: Skills未安装
**症状**:
```
Skills目录不存在或为空
```
**解决方案**:
```bash
# 手动安装Skills
npx skills add https://www.gitlink.org.cn/Gitlink/gitlink-cli.git -y -g
# 或从本地安装
npx skills add ./skills -y -g
# 使用专用命令
gitlink-cli-install-skills
```
---
## 📊 安装成功标准
### 检查清单
完成安装后,请验证以下内容:
- [ ] `gitlink-cli --version` 显示版本信息
- [ ] `gitlink-cli auth status` 可查看登录状态
- [ ] `gitlink-cli user +me` 可获取用户信息
- [ ] `~/.gitlink/skills/` 目录包含13个Skills
- [ ] 二进制文件在PATH中
- [ ] 配置文件已创建
### 快速测试
```bash
# 1. 查看版本
gitlink-cli version
# 2. 配置
gitlink-cli config init
# 3. 登录
gitlink-cli auth login
# 4. 测试命令
gitlink-cli user +me
# 5. 查看仓库列表
gitlink-cli repo +list
```
---
## 🔄 更新
### npm安装
```bash
# 更新到最新版本
npm update -g @gitlink-ai/cli
# 或重新安装
npm uninstall -g @gitlink-ai/cli
npm install -g @gitlink-ai/cli
```
### 脚本安装
```bash
# 重新运行安装脚本(会覆盖旧版本)
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
```
---
## ❓ 常见问题
### Q1: 需要哪些系统权限?
**A**:
- **一键脚本**: 需要`sudo`权限(安装到系统目录)
- **npm**: 需要全局npm写入权限
- **源码构建**: 需要Go环境和写入权限
### Q2: 可以安装多个版本吗?
**A**: 不建议。CLI工具通常会覆盖安装。如需多版本可以使用Docker或版本管理工具。
### Q3: 离线环境如何安装?
**A**:
```bash
# 在在线环境下载完整包
wget https://releases.gitlink.org.cn/gitlink-cli/gitlink-cli-full-v0.2.0.tar.gz
# 在离线环境安装
tar -xzf gitlink-cli-full-v0.2.0.tar.gz
cd gitlink-cli
./install.sh --offline
```
### Q4: 安装后如何配置默认编辑器?
**A**:
```bash
# 方法1: 配置文件
vim ~/.config/gitlink-cli/config.yaml
# 添加: editor: vim
# 方法2: 环境变量
export EDITOR=vim
# 方法3: 命令行参数
gitlink-cli issue +create --editor vim
```
### Q5: Skills占多少空间
**A**: Skills大约占用5-10MB空间包含12个完整的AI Agent技能包。
---
## 📞 获取帮助
如有安装问题,请:
1. 🐛 提交Issue: https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues
2. 📖 查看文档: https://www.gitlink.org.cn/Gitlink/gitlink-cli
3. 💬 查看卸载指南: [doc/UNINSTALL.md](./UNINSTALL.md)
4. 📧 联系支持: support@gitlink.org.cn
---
## 🎯 下一步
安装完成后,建议:
1. ✅ 运行 `gitlink-cli config init` 初始化配置
2. ✅ 运行 `gitlink-cli auth login` 登录账号
3. ✅ 查看 [README.md](../README.md) 了解基本使用
4. ✅ 浏览 [Skills指南](../skills/README.md) 了解AI功能
---
**最后更新**: 2026-06-04
**相关文档**: [UNINSTALL.md](./UNINSTALL.md) | [README.md](../README.md)

View File

@ -0,0 +1,450 @@
# PR 格式模板(以 board 功能为例)
## PR 标题
```
feat(board): 新增项目看板 shortcut — 查看/筛选/移动/指派/统计
```
格式:`type(scope): 简述`,与仓库现有 commit 风格一致。
---
## PR 描述
```markdown
## Summary
- 新增 `board` 命令组,提供 6 个看板操作子命令
- 基于 issue list API 实现看板视图(按 status_id 分组为 5 列)
- 写操作(+move/+assign复用 issue PATCH API支持 --dry-run
- 包含单元测试和帮助文档
## Changes
### 新增文件
- `shortcuts/board/board.go` — 6 个命令 + 辅助函数
- `shortcuts/board/board_test.go` — 单元测试
### 修改文件
- `shortcuts/register.go` — 注册 board 组
## Commands
| 命令 | 类型 | 说明 |
|------|------|------|
| `board +view` | 读 | 按状态分组显示看板 |
| `board +columns` | 读 | 列出各状态列及 issue 数量 |
| `board +issues` | 读 | 按状态/指派人/优先级筛选 |
| `board +move` | 写 | 移动任务状态 |
| `board +assign` | 写 | 指派任务 |
| `board +stats` | 读 | 完成率/工作负载/瓶颈分析 |
## Test plan
- [x] `go test ./shortcuts/board/...` 通过
- [x] `go build ./...` 编译通过
- [x] `board +view` 输出正确的看板结构
- [x] `board +columns` 返回 5 列
- [x] `board +issues --status in-progress` 筛选正确
- [x] `board +move --dry-run` 预览不执行
- [x] `board +assign --dry-run` 预览不执行
- [x] `board +stats` 完成率计算正确
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
---
## Commit 规范
仓库使用 conventional commits 格式:
```
type(scope): description
```
常用 type
- `feat` — 新功能
- `fix` — 修复
- `refactor` — 重构
- `docs` — 文档
- `test` — 测试
board 功能的 commit 示例:
```
feat(board): 新增 board shortcut — 看板查看/筛选/移动/指派/统计
test(board): 添加 board 命令单元测试
```
如果拆成多个 commit
```
feat(board): 新增 board +view/+columns/+issues 读命令
feat(board): 新增 board +move/+assign 写命令
test(board): 添加 board 命令单元测试
```
---
## 单元测试模板
仓库测试风格:用 `httptest.NewServer` mock API直接构造 `RuntimeContext` 调用 `Run` 函数。
### 文件:`shortcuts/board/board_test.go`
```go
package board
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// === mock server ===
func newBoardTestServer(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")
json.NewEncoder(w).Encode(payload)
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
return payload
}
// 模拟 issue list API 响应
func mockIssueListResponse() map[string]interface{} {
return map[string]interface{}{
"issues": []interface{}{
map[string]interface{}{
"id": 101,
"subject": "Fix login bug",
"status_id": float64(1),
"status_name": "待处理",
"priority_id": float64(2),
"priority_name": "正常",
"project_issues_index": float64(1),
"assigners": []interface{}{},
},
map[string]interface{}{
"id": 102,
"subject": "Add dark mode",
"status_id": float64(2),
"status_name": "进行中",
"priority_id": float64(3),
"priority_name": "高",
"project_issues_index": float64(2),
"assigners": []interface{}{
map[string]interface{}{"login": "zhangsan", "name": "Zhang San"},
},
},
map[string]interface{}{
"id": 103,
"subject": "Update README",
"status_id": float64(3),
"status_name": "已解决",
"priority_id": float64(1),
"priority_name": "低",
"project_issues_index": float64(3),
"assigners": []interface{}{},
},
},
"total_count": float64(3),
"total_issues_count": float64(3),
}
}
// === helper ===
func runBoardShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findBoardShortcut(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 findBoardShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
// === 测试用例 ===
func TestBoardViewGroupsByStatus(t *testing.T) {
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issues") {
writeJSON(t, w, mockIssueListResponse())
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runBoardShortcut(t, server, "view", map[string]string{"state": "all"})
if err != nil {
t.Fatalf("board +view failed: %v", err)
}
// 验证view 命令不报错即通过,输出由 ctx.OutputData 处理
}
func TestBoardColumnsReturnsAllStatuses(t *testing.T) {
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issues") {
writeJSON(t, w, mockIssueListResponse())
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
// columns 命令输出到 stdout这里只验证不报错
err := runBoardShortcut(t, server, "columns", map[string]string{"state": "all"})
if err != nil {
t.Fatalf("board +columns failed: %v", err)
}
}
func TestBoardIssuesFilterByStatus(t *testing.T) {
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issues") {
writeJSON(t, w, mockIssueListResponse())
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runBoardShortcut(t, server, "issues", map[string]string{
"state": "all",
"status": "in-progress",
})
if err != nil {
t.Fatalf("board +issues --status in-progress failed: %v", err)
}
}
func TestBoardMoveSendsCorrectStatusID(t *testing.T) {
var patchPayload map[string]interface{}
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issues/1"):
writeJSON(t, w, map[string]interface{}{
"subject": "Fix login bug",
"description": "Steps to reproduce...",
})
case r.Method == "PATCH" && strings.Contains(r.URL.Path, "/issues/1"):
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runBoardShortcut(t, server, "move", map[string]string{
"number": "1",
"status": "in-progress",
})
if err != nil {
t.Fatalf("board +move failed: %v", err)
}
// 验证 PATCH body 包含正确的 status_id
if patchPayload["status_id"] != float64(2) {
t.Errorf("expected status_id=2, got %v", patchPayload["status_id"])
}
// 验证 subject 和 description 被保留
if patchPayload["subject"] != "Fix login bug" {
t.Errorf("subject not preserved: got %v", patchPayload["subject"])
}
if patchPayload["description"] != "Steps to reproduce..." {
t.Errorf("description not preserved: got %v", patchPayload["description"])
}
}
func TestBoardAssignSendsAssignedToID(t *testing.T) {
var patchPayload map[string]interface{}
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/users/zhangsan.json":
writeJSON(t, w, map[string]interface{}{"id": float64(999), "login": "zhangsan"})
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issues/1"):
writeJSON(t, w, map[string]interface{}{
"subject": "Fix login bug",
"description": "Steps to reproduce...",
})
case r.Method == "PATCH" && strings.Contains(r.URL.Path, "/issues/1"):
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runBoardShortcut(t, server, "assign", map[string]string{
"number": "1",
"assignee": "zhangsan",
})
if err != nil {
t.Fatalf("board +assign failed: %v", err)
}
if patchPayload["assigned_to_id"] != float64(999) {
t.Errorf("expected assigned_to_id=999, got %v", patchPayload["assigned_to_id"])
}
}
func TestParseStatusID(t *testing.T) {
tests := []struct {
input string
want int
err bool
}{
{"new", 1, false},
{"in-progress", 2, false},
{"in_progress", 2, false},
{"resolved", 3, false},
{"closed", 5, false},
{"rejected", 6, false},
{"进行中", 2, false},
{"42", 42, false},
{"invalid", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parseStatusID(tt.input)
if tt.err && err == nil {
t.Errorf("expected error for %q", tt.input)
}
if !tt.err && err != nil {
t.Errorf("unexpected error for %q: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("parseStatusID(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestParsePriorityID(t *testing.T) {
tests := []struct {
input string
want int
err bool
}{
{"low", 1, false},
{"normal", 2, false},
{"high", 3, false},
{"urgent", 4, false},
{"99", 99, false},
{"invalid", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parsePriorityID(tt.input)
if tt.err && err == nil {
t.Errorf("expected error for %q", tt.input)
}
if !tt.err && err != nil {
t.Errorf("unexpected error for %q: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("parsePriorityID(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestGroupByStatus(t *testing.T) {
issues := []issueItem{
{ID: 1, StatusID: 1},
{ID: 2, StatusID: 2},
{ID: 3, StatusID: 2},
{ID: 4, StatusID: 3},
}
grouped := groupByStatus(issues)
if len(grouped[1]) != 1 {
t.Errorf("expected 1 issue in status 1, got %d", len(grouped[1]))
}
if len(grouped[2]) != 2 {
t.Errorf("expected 2 issues in status 2, got %d", len(grouped[2]))
}
if len(grouped[3]) != 1 {
t.Errorf("expected 1 issue in status 3, got %d", len(grouped[3]))
}
}
```
---
## 帮助文档更新
board 的帮助信息已经在 `board.go``Description`/`Long`/`Example` 字段中定义,`board --help` 会自动输出。无需额外文档文件。
如果要更新项目 README 或 skill 文档,在对应文件中添加:
```markdown
### Board (看板)
```bash
# 查看看板
gitlink-cli board +view
# 按状态筛选
gitlink-cli board +issues --status in-progress --assignee zhangsan
# 移动任务
gitlink-cli board +move --number 42 --status resolved
# 统计分析
gitlink-cli board +stats
```
```
---
## PR Checklist
```markdown
## Checklist
- [ ] `go build ./...` 编译通过
- [ ] `go test ./shortcuts/board/...` 测试通过
- [ ] `go vet ./...` 无警告
- [ ] 新命令 `--help` 输出正确
- [ ] 写命令支持 `--dry-run`
- [ ] 错误信息使用 `clierrors.OpError` 包装
- [ ] commit message 符合 `type(scope): description` 格式
```

317
doc/UNINSTALL.md Normal file
View File

@ -0,0 +1,317 @@
# GitLink CLI 卸载指南
本文档介绍如何完全卸载 GitLink CLI 及其相关文件。
## 📋 卸载方式
### 一、Linux/macOS
#### 方式1: 使用卸载脚本(推荐)
```bash
# 交互式卸载(保留配置)
bash uninstall.sh
# 完全删除所有文件(包括配置)
bash uninstall.sh --purge
# 自动确认,不询问
bash uninstall.sh -y
```
#### 方式2: 在线执行
```bash
# 保留配置
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/uninstall.sh | bash
# 完全删除
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/uninstall.sh | bash -s -- --purge
```
#### 方式3: 手动删除
```bash
# 删除二进制
sudo rm -f /usr/local/bin/gitlink-cli
# 或
sudo rm -f /usr/bin/gitlink-cli
# 删除Skills
rm -rf ~/.gitlink/skills
# 删除配置(可选)
rm -rf ~/.gitlink-cli
rm -rf ~/.gitlink
rm -rf ~/.config/gitlink-cli
```
---
### 二、Windows
#### 方式1: PowerShell 脚本(推荐)
```powershell
# 交互式卸载
powershell -NoProfile -ExecutionPolicy Bypass -File uninstall.ps1
# 完全删除
powershell -NoProfile -ExecutionPolicy Bypass -File uninstall.ps1 -Purge
# 自动确认
.\uninstall.ps1 -Yes
```
#### 方式2: 手动删除
```powershell
# 删除二进制(根据安装位置)
Remove-Item "$env:USERPROFILE\.gitlink-cli\bin\gitlink-cli.exe"
# 删除Skills
Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink\skills"
# 删除配置(可选)
Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink-cli"
Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink"
```
---
### 三、npm 安装
#### 方式1: npm 卸载(推荐)
```bash
# 标准卸载(保留配置)
npm uninstall -g @gitlink-ai/cli
# 完全删除(包括配置)
npm uninstall -g @gitlink-ai/cli --purge
```
#### 方式2: 使用卸载命令
```bash
# 保留配置
gitlink-cli-uninstall
# 完全删除
gitlink-cli-uninstall --purge
```
#### 方式3: 手动删除
```bash
# 卸载npm包
npm uninstall -g @gitlink-ai/cli
# 删除Skills
rm -rf ~/.gitlink/skills
# 删除配置(可选)
rm -rf ~/.gitlink-cli
rm -rf ~/.gitlink
```
---
## 🔧 卸载选项说明
### `--purge` 参数
完全删除所有文件,包括:
- ✅ 二进制文件
- ✅ Skills目录
- ✅ 配置文件
- ✅ 用户数据
- ✅ 缓存文件
### `--yes` / `-y` 参数
自动确认所有操作,不询问用户。
### 交互模式(默认)
卸载过程中会询问:
1. 是否删除配置文件和数据
2. npm安装时是否卸载npm包
3. 确认卸载操作
---
## 📂 卸载内容清单
### 始终删除
- ✅ 二进制文件 (`gitlink-cli` 或 `gitlink-cli.exe`)
- ✅ Skills目录 (`~/.gitlink/skills/`)
### 条件删除(需要确认或 `--purge`
- 🔸 配置目录 (`~/.gitlink-cli/`)
- 🔸 数据目录 (`~/.gitlink/`)
- 🔸 配置文件 (`~/.config/gitlink-cli/`)
- 🔸 npm全局包 (`@gitlink-ai/cli`)
### 保留文件
- 📌 用户的项目文件
- 📌 Git仓库
- 📌 系统环境变量(需手动清理)
---
## 🧹 清理剩余文件
### Linux/macOS
```bash
# 检查剩余文件
find ~ -name "*gitlink*" -type f 2>/dev/null
find ~ -name "*gitlink*" -type d 2>/dev/null
# 清理环境变量(如果手动添加过)
# 编辑 ~/.bashrc, ~/.zshrc 等,删除相关行
```
### Windows
```powershell
# 检查剩余文件
Get-ChildItem -Path $env:USERPROFILE -Recurse -Filter "*gitlink*" -ErrorAction SilentlyContinue
# 清理PATH环境变量
# 1. 打开"系统属性" > "环境变量"
# 2. 在"用户变量"或"系统变量"的Path中删除gitlink-cli路径
```
---
## ❓ 常见问题
### Q1: 卸载后命令仍然可用?
**A**: 可能是PATH缓存问题解决方法
**Linux/macOS**:
```bash
# 刷新shell
hash -r gitlink-cli
# 或重启终端
```
**Windows**:
```powershell
# 重启PowerShell或CMD
```
### Q2: 提示权限不足?
**A**: 使用管理员权限或删除到用户目录:
**Linux/macOS**:
```bash
# 使用sudo
sudo bash uninstall.sh
# 或安装到用户目录
INSTALL_DIR=$HOME/.local/bin bash uninstall.sh
```
**Windows**:
```powershell
# 以管理员身份运行PowerShell
```
### Q3: 如何重新安装?
**A**: 重新运行安装脚本即可:
```bash
# Linux/macOS
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# Windows
powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1
# npm
npm install -g @gitlink-ai/cli
```
### Q4: 配置文件会自动删除吗?
**A**: 不会,除非使用 `--purge` 参数或确认删除。这是为了保护用户数据。
### Q5: Skills会被删除吗
**A**: 会的Skills目录会被自动删除。如需保留请手动备份
```bash
# 备份Skills
cp -r ~/.gitlink/skills ~/gitlink-skills-backup
```
---
## 🔍 故障排除
### 卸载脚本找不到
```bash
# 确保在项目目录中
cd /path/to/gitlink-cli
# 检查脚本是否存在
ls -la uninstall.sh uninstall.ps1
```
### 删除失败
```bash
# 检查文件权限
ls -la /usr/local/bin/gitlink-cli
# 强制删除
sudo rm -f /usr/local/bin/gitlink-cli
```
### npm卸载后仍有残留
```bash
# 手动清理npm缓存
npm cache clean --force
# 检查全局安装位置
npm root -g
npm list -g --depth=0
# 手动删除残留
```
---
## 📞 获取帮助
如有任何问题,请:
1. 🐛 提交Issue: https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues
2. 💬 查看文档: https://www.gitlink.org.cn/Gitlink/gitlink-cli
3. 📧 联系支持: support@gitlink.org.cn
---
## ✅ 卸载检查清单
完成卸载后,可以检查以下内容:
- [ ] 命令不可用(运行 `gitlink-cli --version` 应该报错)
- [ ] 二进制文件已删除
- [ ] Skills目录已删除
- [ ] 配置文件已删除(如需要)
- [ ] PATH环境变量已清理
- [ ] 没有残留进程(`ps aux | grep gitlink` 或任务管理器)
---
**最后更新**: 2026-06-04
**适用版本**: gitlink-cli v0.2.0+

400
doc/dashboard.html Normal file
View File

@ -0,0 +1,400 @@
<!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 · 功能全景</title>
<style>
:root {
--bg: #0d1117;
--bg-subtle: #161b22;
--bg-inset: #010409;
--border: #30363d;
--border-muted: #21262d;
--text: #c9d1d9;
--text-muted: #8b949e;
--accent: #58a6ff;
--accent-soft: rgba(56,139,253,0.15);
--green: #3fb950;
--green-soft: rgba(63,185,80,0.12);
--purple: #bc8cff;
--radius: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", "Hiragino Sans GB", Helvetica, Arial, sans-serif;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
background-image:
radial-gradient(900px 380px at 100% -8%, rgba(56,139,253,0.10), transparent 60%),
radial-gradient(800px 360px at -8% -4%, rgba(188,140,255,0.08), transparent 60%);
background-attachment: fixed;
}
/* ---------- Header ---------- */
header {
position: sticky; top: 0; z-index: 50;
backdrop-filter: blur(12px);
background: rgba(13,17,23,0.78);
border-bottom: 1px solid var(--border);
}
.header-inner {
max-width: 1200px; margin: 0 auto;
padding: 22px 24px 18px;
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 12px; margin-right: auto; }
.logo {
width: 44px; height: 44px; border-radius: 11px;
background: linear-gradient(135deg, #2f81f7, #8957e5);
display: grid; place-items: center;
font-size: 24px; color: #fff; font-weight: 800;
box-shadow: 0 4px 14px rgba(56,139,253,0.45);
}
.brand h1 { font-size: 20px; font-weight: 650; letter-spacing: -0.3px; }
.brand .sub { font-size: 12.5px; color: var(--text-muted); }
.search-wrap { position: relative; flex: 0 1 320px; min-width: 200px; }
.search-wrap svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--text-muted); }
input#search {
width: 100%; padding: 10px 14px 10px 38px;
background: var(--bg-inset); color: var(--text);
border: 1px solid var(--border); border-radius: 9px;
font-size: 14px; outline: none; transition: .15s;
}
input#search:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.kbd {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
font-size: 11px; color: var(--text-muted);
border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px;
background: var(--bg-subtle);
}
/* ---------- Stats ---------- */
.stats {
max-width: 1200px; margin: 0 auto;
padding: 26px 24px 6px;
display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px;
}
.stat {
background: var(--bg-subtle);
border: 1px solid var(--border-muted);
border-radius: var(--radius);
padding: 16px 18px;
position: relative; overflow: hidden;
}
.stat::after {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--accent);
}
.stat.green::after { background: var(--green); }
.stat.purple::after { background: var(--purple); }
.stat.muted::after { background: var(--text-muted); }
.stat .num { font-size: 28px; font-weight: 700; letter-spacing: -1px; }
.stat .label { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
/* ---------- Layout ---------- */
main { max-width: 1200px; margin: 0 auto; padding: 22px 24px 64px; }
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
/* ---------- Category card ---------- */
.card {
background: var(--bg-subtle);
border: 1px solid var(--border-muted);
border-radius: var(--radius);
overflow: hidden; transition: border-color .15s;
}
.card:hover { border-color: var(--border); }
.card-head {
display: flex; align-items: center; gap: 12px;
padding: 15px 18px; cursor: pointer; user-select: none;
transition: background .12s;
}
.card-head:hover { background: rgba(177,186,196,0.04); }
.card-icon { font-size: 20px; line-height: 1; }
.card-title { font-size: 15px; font-weight: 600; flex: 1; }
.count-badge {
font-size: 12px; color: var(--text-muted);
background: var(--bg-inset); border: 1px solid var(--border);
padding: 2px 9px; border-radius: 20px; font-variant-numeric: tabular-nums;
}
.chev { color: var(--text-muted); transition: transform .2s; flex-shrink: 0; }
.card.open .chev { transform: rotate(90deg); }
.card-body { max-height: 0; overflow: hidden; transition: max-height .25s ease; }
.card.open .card-body { max-height: 2400px; }
.row {
border-top: 1px solid var(--border-muted);
padding: 11px 18px;
cursor: pointer; transition: background .12s;
}
.row:hover { background: rgba(56,139,253,0.06); }
.row-main { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.cmd {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 13px; color: var(--accent); font-weight: 600;
}
.tag {
font-size: 10.5px; font-weight: 600; letter-spacing: .3px;
padding: 2px 7px; border-radius: 20px; white-space: nowrap;
}
.tag.auth { color: var(--green); background: var(--green-soft); }
.tag.public { color: var(--accent); background: var(--accent-soft); }
.desc { color: var(--text-muted); font-size: 13px; flex: 1; min-width: 120px; }
.example {
max-height: 0; overflow: hidden; transition: max-height .2s ease;
}
.row.expanded .example { max-height: 140px; padding-top: 8px; }
.example code {
display: block;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px; color: var(--text);
background: var(--bg-inset);
border: 1px solid var(--border-muted); border-left: 3px solid var(--accent);
border-radius: 7px; padding: 10px 12px;
white-space: pre-wrap; word-break: break-all;
}
/* ---------- Empty / footer ---------- */
#empty {
text-align: center; padding: 60px 20px; color: var(--text-muted);
display: none;
}
#empty .big { font-size: 40px; margin-bottom: 10px; }
footer {
max-width: 1200px; margin: 0 auto;
padding: 0 24px 40px; text-align: center;
color: var(--text-muted); font-size: 12.5px;
}
footer a { color: var(--accent); text-decoration: none; }
footer a:hover { text-decoration: underline; }
@media (max-width: 760px) {
.stats { grid-template-columns: repeat(2, 1fr); }
.grid { grid-template-columns: 1fr; }
.search-wrap { flex: 1 1 100%; }
.header-inner { gap: 12px; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<div class="brand">
<div class="logo">G</div>
<div>
<h1>gitlink-cli · 功能全景</h1>
<div class="sub">交互式命令浏览仪表盘</div>
</div>
</div>
<div class="search-wrap">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04Z"/></svg>
<input id="search" type="text" placeholder="搜索命令或描述… ( 按 / 聚焦 )" autocomplete="off">
<span class="kbd">/</span>
</div>
</div>
</header>
<section class="stats">
<div class="stat"><div class="num" id="st-cat">0</div><div class="label">功能分类</div></div>
<div class="stat purple"><div class="num" id="st-total">0</div><div class="label">Shortcuts 总数</div></div>
<div class="stat green"><div class="num" id="st-auth">0</div><div class="label">需认证命令</div></div>
<div class="stat muted"><div class="num" id="st-public">0</div><div class="label">公开命令</div></div>
</section>
<main>
<div class="grid" id="grid"></div>
<div id="empty">
<div class="big">🔍</div>
<div>没有匹配的命令,换个关键词试试。</div>
</div>
</main>
<footer>
Generated by <strong>gitlink-code-insight</strong> skill ·
<a href="https://gitlink.org.cn" target="_blank" rel="noopener">Gitlink</a> ·
<span id="ft-total">0</span> 个命令
</footer>
<script>
const DATA = [
{ cat: "仓库管理", icon: "📦", items: [
{ cmd: "repo +list", desc: "仓库列表", auth: false, ex: "gitlink-cli repo +list --user zhangsan" },
{ cmd: "repo +info", desc: "仓库详情", auth: false, ex: "gitlink-cli repo +info --owner Gitlink --repo forgeplus" },
{ cmd: "repo +create", desc: "创建仓库", auth: true, ex: 'gitlink-cli repo +create --name my-project --description "项目描述"' },
{ cmd: "repo +fork", desc: "Fork 仓库", auth: true, ex: "gitlink-cli repo +fork --owner Gitlink --repo forgeplus" },
{ cmd: "repo +delete", desc: "删除仓库(不可逆)", auth: true, ex: "gitlink-cli repo +delete --owner myuser --repo old-project" },
{ cmd: "repo +batch-create", desc: "批量创建仓库", auth: true, ex: "gitlink-cli repo +batch-create --from repos.csv" },
{ cmd: "repo +batch-update", desc: "批量更新仓库", auth: true, ex: "gitlink-cli repo +batch-update --from updates.csv" },
{ cmd: "repo +add-member", desc: "添加仓库成员", auth: true, ex: "gitlink-cli repo +add-member --owner myuser --repo myrepo --user newmember --role developer" },
]},
{ cat: "分支管理", icon: "🌿", items: [
{ cmd: "branch +list", desc: "分支列表", auth: false, ex: "gitlink-cli branch +list --owner Gitlink --repo forgeplus" },
{ cmd: "branch +create", desc: "创建分支", auth: true, ex: "gitlink-cli branch +create --name feature/new-feature" },
{ cmd: "branch +delete", desc: "删除分支(不可逆)", auth: true, ex: "gitlink-cli branch +delete --name feature/old-feature" },
{ cmd: "branch +protect", desc: "保护分支", auth: true, ex: "gitlink-cli branch +protect --name main" },
{ cmd: "branch +unprotect", desc: "取消保护", auth: true, ex: "gitlink-cli branch +unprotect --name main" },
]},
{ cat: "Issue 管理", icon: "🐛", items: [
{ cmd: "issue +list", desc: "Issue 列表", auth: false, ex: "gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open" },
{ cmd: "issue +view", desc: "Issue 详情", auth: false, ex: "gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 4" },
{ cmd: "issue +create", desc: "创建 Issue", auth: true, ex: 'gitlink-cli issue +create --owner myuser --repo myrepo --title "Bug: 登录失败" --body "复现步骤"' },
{ cmd: "issue +update", desc: "更新 Issue", auth: true, ex: 'gitlink-cli issue +update --number 4 --title "新标题" --body "更新描述"' },
{ cmd: "issue +close", desc: "关闭 Issue", auth: true, ex: "gitlink-cli issue +close --number 4" },
{ cmd: "issue +batch-close", desc: "批量关闭 Issue", auth: true, ex: "gitlink-cli issue +batch-close --numbers 123,124 --dry-run" },
{ cmd: "issue +comment", desc: "添加评论", auth: true, ex: 'gitlink-cli issue +comment --number 4 --body "已修复"' },
]},
{ cat: "Pull Request", icon: "🔀", items: [
{ cmd: "pr +list", desc: "PR 列表", auth: false, ex: "gitlink-cli pr +list --owner Gitlink --repo forgeplus --state open" },
{ cmd: "pr +view", desc: "PR 详情", auth: false, ex: "gitlink-cli pr +view --id 3" },
{ cmd: "pr +create", desc: "创建 PR", auth: true, ex: 'gitlink-cli pr +create --title "feat: 新功能" --head feature/x --base master' },
{ cmd: "pr +merge", desc: "合并 PR", auth: true, ex: "gitlink-cli pr +merge --id 3 --method squash" },
{ cmd: "pr +close", desc: "关闭 PR", auth: true, ex: "gitlink-cli pr +close --id 3" },
{ cmd: "pr +files", desc: "变更文件列表", auth: false, ex: "gitlink-cli pr +files --id 3" },
{ cmd: "pr +diff", desc: "查看提交列表", auth: false, ex: "gitlink-cli pr +diff --id 3" },
{ cmd: "pr +comment", desc: "PR 评论", auth: true, ex: 'gitlink-cli pr +comment --id 3 --body "LGTM"' },
{ cmd: "pr +review", desc: "代码审查", auth: true, ex: 'gitlink-cli pr +review --id 3 --event COMMENT --body "整体 LGTM"' },
]},
{ cat: "版本发布", icon: "🚀", items: [
{ cmd: "release +list", desc: "发布列表", auth: false, ex: "gitlink-cli release +list --owner Gitlink --repo forgeplus" },
{ cmd: "release +view", desc: "发布详情", auth: false, ex: "gitlink-cli release +view --id <version_id>" },
{ cmd: "release +create", desc: "创建发布", auth: true, ex: 'gitlink-cli release +create --tag v1.0.0 --name "v1.0.0" --target master' },
{ cmd: "release +delete", desc: "删除发布(不可逆)", auth: true, ex: "gitlink-cli release +delete --id <version_id>" },
]},
{ cat: "Wiki 管理", icon: "📖", items: [
{ cmd: "wiki +list", desc: "Wiki 页面列表", auth: false, ex: "gitlink-cli wiki +list --owner Gitlink --repo forgeplus" },
{ cmd: "wiki +view", desc: "查看页面内容", auth: false, ex: 'gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "Home"' },
{ cmd: "wiki +create", desc: "创建页面", auth: true, ex: 'gitlink-cli wiki +create --owner myuser --repo myrepo --title "API 文档" --file ./api.md' },
{ cmd: "wiki +update", desc: "更新页面", auth: true, ex: 'gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --add "新内容"' },
{ cmd: "wiki +delete", desc: "删除页面", auth: true, ex: 'gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面"' },
]},
{ cat: "CI/CD", icon: "⚙️", items: [
{ cmd: "ci +builds", desc: "构建列表", auth: true, ex: "gitlink-cli ci +builds --owner myuser --repo myrepo" },
{ cmd: "ci +logs", desc: "构建日志", auth: true, ex: "gitlink-cli ci +logs --build 42 --stage 1 --step 1" },
{ cmd: "ci +restart", desc: "重启构建", auth: true, ex: "gitlink-cli ci +restart --build 42" },
{ cmd: "ci +stop", desc: "停止构建", auth: true, ex: "gitlink-cli ci +stop --build 42" },
]},
{ cat: "Webhook", icon: "🔔", items: [
{ cmd: "webhook +list", desc: "Webhook 列表", auth: true, ex: "gitlink-cli webhook +list --owner myuser --repo myrepo" },
{ cmd: "webhook +info", desc: "Webhook 详情", auth: true, ex: "gitlink-cli webhook +info --owner myuser --repo myrepo --id 123" },
{ cmd: "webhook +events", desc: "支持的事件类型", auth: false, ex: "gitlink-cli webhook +events" },
{ cmd: "webhook +create", desc: "创建 Webhook", auth: true, ex: "gitlink-cli webhook +create --url https://example.com/hook --events push" },
{ cmd: "webhook +update", desc: "更新 Webhook", auth: true, ex: "gitlink-cli webhook +update --id 123 --events push,pull_request" },
{ cmd: "webhook +test", desc: "测试 Webhook", auth: true, ex: "gitlink-cli webhook +test --id 123 --event push" },
{ cmd: "webhook +delete", desc: "删除 Webhook", auth: true, ex: "gitlink-cli webhook +delete --id 123" },
]},
{ cat: "组织管理", icon: "🏢", items: [
{ cmd: "org +list", desc: "组织列表", auth: false, ex: "gitlink-cli org +list" },
{ cmd: "org +info", desc: "组织详情", auth: false, ex: "gitlink-cli org +info --id Gitlink" },
{ cmd: "org +members", desc: "成员列表", auth: false, ex: "gitlink-cli org +members --id Gitlink" },
{ cmd: "org +create", desc: "创建组织", auth: true, ex: 'gitlink-cli org +create --name my-org --description "我的组织"' },
{ cmd: "org +batch-add", desc: "批量添加成员", auth: true, ex: 'gitlink-cli org +batch-add --id my-org --users "user1,user2"' },
]},
{ cat: "用户与搜索", icon: "👤", items: [
{ cmd: "user +me", desc: "当前登录用户", auth: true, ex: "gitlink-cli user +me" },
{ cmd: "user +info", desc: "用户详情", auth: false, ex: "gitlink-cli user +info --login zhangsan" },
{ cmd: "search +repos", desc: "搜索仓库", auth: false, ex: 'gitlink-cli search +repos --keyword "machine learning"' },
{ cmd: "search +users", desc: "搜索用户", auth: false, ex: 'gitlink-cli search +users --keyword "zhangsan"' },
]},
{ cat: "安全与合规", icon: "🛡️", items: [
{ cmd: "compliance +scan", desc: "全量扫描", auth: false, ex: "gitlink-cli compliance +scan" },
{ cmd: "compliance +license", desc: "许可证合规检查", auth: false, ex: "gitlink-cli compliance +license" },
{ cmd: "compliance +deps", desc: "依赖许可证检查", auth: false, ex: "gitlink-cli compliance +deps" },
{ cmd: "compliance +secrets", desc: "敏感信息扫描", auth: false, ex: "gitlink-cli compliance +secrets" },
{ cmd: "compliance +exposure", desc: "PII 与暴露面扫描", auth: false, ex: "gitlink-cli compliance +exposure" },
{ cmd: "compliance +vocab", desc: "敏感词汇扫描", auth: false, ex: "gitlink-cli compliance +vocab" },
]},
{ cat: "新人引导", icon: "👋", items: [
{ cmd: "onboard +welcome", desc: "添加引导评论", auth: true, ex: 'gitlink-cli onboard +welcome --issues "3,7,15"' },
]},
{ cat: "团队管理", icon: "👥", items: [
{ cmd: "team +list", desc: "团队列表", auth: false, ex: "gitlink-cli team +list --org my-org" },
{ cmd: "team +create", desc: "创建团队", auth: true, ex: "gitlink-cli team +create --org my-org --name dev-team" },
{ cmd: "team +add-member", desc: "添加成员", auth: true, ex: "gitlink-cli team +add-member --org my-org --team dev-team --user newmember" },
]},
{ cat: "贡献报告", icon: "📊", items: [
{ cmd: "contrib +report", desc: "贡献统计报告", auth: false, ex: "gitlink-cli contrib +report --owner myuser --repo myrepo" },
]},
];
const esc = s => s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
const grid = document.getElementById("grid");
const empty = document.getElementById("empty");
// 全量统计(不随搜索变化)
const TOTAL = DATA.reduce((n, c) => n + c.items.length, 0);
const AUTHN = DATA.reduce((n, c) => n + c.items.filter(i => i.auth).length, 0);
const PUBLIC = TOTAL - AUTHN;
document.getElementById("st-cat").textContent = DATA.length;
document.getElementById("st-total").textContent = TOTAL;
document.getElementById("st-auth").textContent = AUTHN;
document.getElementById("st-public").textContent = PUBLIC;
document.getElementById("ft-total").textContent = TOTAL;
function render(filter = "") {
const q = filter.trim().toLowerCase();
grid.innerHTML = "";
let shownCats = 0;
DATA.forEach(c => {
const matched = c.items.filter(it =>
!q || it.cmd.toLowerCase().includes(q) || it.desc.toLowerCase().includes(q) || c.cat.toLowerCase().includes(q)
);
if (!matched.length) return;
shownCats++;
const card = document.createElement("div");
card.className = "card" + (q ? " open" : ""); // 搜索时自动展开
card.innerHTML = `
<div class="card-head">
<span class="card-icon">${c.icon}</span>
<span class="card-title">${c.cat}</span>
<span class="count-badge">${matched.length}</span>
<svg class="chev" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>
</div>
<div class="card-body">
${matched.map(it => `
<div class="row">
<div class="row-main">
<span class="cmd">${esc(it.cmd)}</span>
<span class="tag ${it.auth ? "auth" : "public"}">${it.auth ? "需认证" : "公开"}</span>
<span class="desc">${esc(it.desc)}</span>
</div>
<div class="example"><code>$ ${esc(it.ex)}</code></div>
</div>`).join("")}
</div>`;
grid.appendChild(card);
});
empty.style.display = shownCats === 0 ? "block" : "none";
// 绑定交互
grid.querySelectorAll(".card-head").forEach(h =>
h.addEventListener("click", () => h.parentElement.classList.toggle("open")));
grid.querySelectorAll(".row").forEach(r =>
r.addEventListener("click", e => { e.stopPropagation(); r.classList.toggle("expanded"); }));
}
// 搜索(带去抖)
const input = document.getElementById("search");
let t;
input.addEventListener("input", e => { clearTimeout(t); t = setTimeout(() => render(e.target.value), 80); });
// 快捷键
document.addEventListener("keydown", e => {
if (e.key === "/" && document.activeElement !== input) { e.preventDefault(); input.focus(); }
if (e.key === "Escape") { input.value = ""; render(""); input.blur(); }
});
render();
</script>
</body>
</html>

View File

@ -40,28 +40,33 @@ gitlink-cli/
│ ├── common/
│ │ ├── types.go # Shortcut / Flag / RuntimeContext 定义
│ │ └── runner.go # CallAPI / PaginateAll / ResolveOwnerRepo
│ ├── repo/ # repo +create / +clone / +fork / +list / +info
│ ├── issue/ # issue +list / +create / +view / +close / +comment
│ ├── pr/ # pr +list / +create / +view / +merge / +review
│ ├── release/ # release +list / +create / +download
│ ├── branch/ # branch +list / +protect / +unprotect
│ ├── org/ # org +list / +info / +members
│ ├── repo/ # repo +create / +clone / +fork / +list / +info / +delete / +settings / +batch-create / +batch-update
│ ├── issue/ # issue +list / +create / +view / +update / +close / +comment / +assign / +label / +batch-* (6 个批量命令)
│ ├── wiki/ # wiki +list / +view / +create / +update / +delete
│ ├── pr/ # pr +list / +create / +view / +merge / +close / +review / +files / +diff
│ ├── release/ # release +list / +create / +view / +delete / +download
│ ├── branch/ # branch +list / +create / +delete / +protect / +unprotect
│ ├── webhook/ # webhook +list / +create / +update / +delete / +test / +info
│ ├── org/ # org +list / +info / +members / +create
│ ├── user/ # user +me / +info
│ ├── search/ # search +repos / +issues / +users
│ ├── ci/ # ci +builds / +logs / +restart
│ ├── ci/ # ci +builds / +logs / +restart / +stop
│ └── register.go # 注册所有 shortcuts 到 cobra
├── skills/
│ ├── gitlink-shared/ # SKILL.md — 认证、全局参数、安全规则
│ ├── gitlink-repo/ # SKILL.md + references/ — 仓库操作
│ ├── gitlink-issue/ # SKILL.md + references/ — Issue 操作
│ ├── gitlink-pr/ # SKILL.md + references/ — PR 操作
│ ├── gitlink-release/ # SKILL.md + references/ — 发布管理
│ ├── gitlink-branch/ # SKILL.md + references/ — 分支操作
│ ├── gitlink-ci/ # SKILL.md + references/ — CI/CD 操作
│ ├── gitlink-org/ # SKILL.md + references/ — 组织管理
│ ├── gitlink-release/ # SKILL.md + references/ — 发布管理
│ ├── gitlink-search/ # SKILL.md + references/ — 搜索
│ ├── gitlink-user/ # SKILL.md + references/ — 用户管理
│ ├── gitlink-pm/ # SKILL.md + references/ — 项目管理
│ └── gitlink-workflow/ # SKILL.md — AI 自动化工作流Issue 分类、PR Review 等)
│ ├── gitlink-wiki/ # SKILL.md + references/ + examples/ — Wiki 操作
│ ├── gitlink-webhook/ # SKILL.md + references/ + examples/ — Webhook 管理
│ └── gitlink-workflow/ # SKILL.md + references/ + examples/ — AI 自动化工作流Issue 分类、PR Review 等)
├── go.mod
├── go.sum
├── Makefile
@ -74,18 +79,20 @@ gitlink-cli/
### 2.1 Layer 1: Shortcuts快捷命令`+` 前缀)
面向高频场景的语义化封装,MVP 覆盖 ~43 个
面向高频场景的语义化封装,覆盖 13 个领域共 62 个命令
| 领域 | Shortcuts | 数量 |
|------|-----------|------|
| repo | `+create` `+clone` `+fork` `+list` `+info` `+delete` `+settings` | 7 |
| issue | `+list` `+create` `+view` `+update` `+close` `+comment` `+assign` `+label` | 8 |
| repo | `+create` `+clone` `+fork` `+list` `+info` `+delete` `+settings` `+batch-create` `+batch-update` | 9 |
| issue | `+list` `+create` `+view` `+update` `+close` `+comment` `+assign` `+label` `+batch-close` `+batch-status` `+batch-priority` `+batch-assign` `+batch-label` `+batch-create` | 14 |
| wiki | `+list` `+view` `+create` `+update` `+delete` | 5 |
| pr | `+list` `+create` `+view` `+merge` `+close` `+review` `+files` `+diff` | 8 |
| release | `+list` `+create` `+view` `+delete` `+download` | 5 |
| branch | `+list` `+create` `+delete` `+protect` `+unprotect` | 5 |
| org | `+list` `+info` `+members` `+create` | 4 |
| ci | `+builds` `+logs` `+restart` `+stop` | 4 |
| user | `+me` `+info` | 2 |
| webhook | `+list` `+create` `+update` `+delete` `+test` `+info` | 6 |
**Shortcut 声明式定义**
@ -437,7 +444,169 @@ skills/
---
## 9 完整命令参考
## 9 批量操作设计模式
Issue 和 Repo 两个领域均实现了批量操作命令,遵循统一的设计模式。
### 9.1 命令清单
| 领域 | 命令 | 用途 | 输入方式 |
|------|------|------|----------|
| issue | `+batch-close` | 批量关闭 | `--numbers``--from` CSV |
| issue | `+batch-status` | 批量更换状态 | `--state` + `--numbers`/`--from` |
| issue | `+batch-priority` | 批量更换优先级 | `--priority` + `--numbers`/`--from` |
| issue | `+batch-assign` | 批量更换负责人 | `--assignee` + `--numbers`/`--from` |
| issue | `+batch-label` | 批量更换标记 | `--label` + `--numbers`/`--from` |
| issue | `+batch-create` | 批量创建 Issue | `--titles``--from` CSV支持 Bug/Feature 模板) |
| repo | `+batch-create` | 批量创建仓库 | `--names``--from` CSV |
| repo | `+batch-update` | 批量更新仓库 | `--names``--from` CSV |
### 9.2 核心类型
```go
type BatchResult struct {
Number string `json:"number"` // Issue 编号或仓库名
Action string `json:"action"` // 操作类型
Status string `json:"status"` // 执行结果
Error string `json:"error,omitempty"`
}
type BatchSummary struct {
Repository string `json:"repository"`
Action string `json:"action"`
Value string `json:"value,omitempty"`
DryRun bool `json:"dry_run"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Results []BatchResult `json:"results"`
}
```
### 9.3 统一设计原则
| 原则 | 说明 |
|------|------|
| **输入灵活** | `--numbers`/`--names`(内联逗号分隔)和 `--from`CSV 文件)可同时使用,自动去重合并 |
| **dry-run 统一** | 所有批量命令支持 `--dry-run`,预览模式下 status 为 `"planned"`,不发起写请求 |
| **错误不中断** | 单条失败不影响后续处理,全部执行完后返回完整汇总。有任何失败则 exit code = 1 |
| **输出统一** | 所有命令输出相同结构的 `BatchSummary` JSON |
| **修改前先 GET** | Issue 批量修改先 GET 当前 Issue 保留 subject/descriptionPATCH 时只替换目标字段。Repo 批量更新先 GET 获取 name + identifier |
| **参数容错** | 所有名称映射(状态、优先级、标签、负责人)同时支持字符串名和直接传数字 ID |
### 9.4 Issue 参数值映射
**状态**`new`(1) / `in-progress`(2) / `resolved`(3) / `closed`(5) / `rejected`(6)
**优先级**`low`(1) / `normal`(2) / `high`(3) / `urgent`(4)
**标记**:使用项目级中文标签名映射到大整数 ID`缺陷`→315526、`功能`→315527
**负责人**:传入 login 用户名CLI 调用 `/users/{login}` API 转换为 `user_id`
### 9.5 Issue 批量创建模板系统
`+batch-create` 支持三种输入模式:
1. **CLI 直接输入**`--titles`):逗号分隔标题,统一应用 `--priority`/`--label`/`--assignee`/`--state`
2. **自由 CSV**`--from`):自由指定 title/body/priority/label/assignee/status 列
3. **模板 CSV**`--from` + `--template`
- `--template bug`:自动生成 Bug 描述格式,自动设置缺陷标签
- `--template feature`:自动生成功能描述格式,自动设置功能标签
### 9.6 Repo 批量操作注意事项
- **batch-create**POST 路径 `/{login}/{name}` 中的 login 必须是当前登录用户,需先 `GET /users/me`
- **batch-update**PATCH 请求体必须包含从 GET 获取的 `name``identifier`,否则 API 报错
- **--private/--public 互斥**batch-update 不允许同时设置两个标志
### 9.7 关键 API 字段差异
GitLink 基于 Redmine 但修改了大量字段名:
| Redmine 标准字段 | GitLink 实际字段 | 格式 |
|-----------------|-----------------|------|
| `assigned_to_id` | `assigner_ids` | 数组 `[user_id]` |
| `tracker_id` | `issue_tag_ids` | 数组 `[tag_id]`(项目级大整数) |
> GitLink API 对不认识的字段返回 200 而非报错,字段名错误会导致静默失败。必须通过浏览器 DevTools 抓取实际请求确认字段名和格式。
---
## 10 Wiki Shortcut 设计
Wiki 是独立的全新 Shortcut 领域,提供 5 个命令覆盖 Wiki 页面的 CRUD 操作。
### 10.1 双域名架构
Wiki API 部署在 gateway 域名上,与主站 APIwww 域名)分离:
```
┌─ www 域名 ─────────────────────┐
resolveProjectID() → │ GET /{owner}/{repo}/detail.json │ → project_id
└────────────────────────────────┘
┌─ gateway 域名 ───────────────────────────┐
callWikiAPI*() → │ /wiki/open/* (不带 .json) │ → 解包 code/data
└──────────────────────────────────────────┘
```
- **www 域名**`https://www.gitlink.org.cn/api`):用于 detail API 获取 project_id走默认 client
- **gateway 域名**`https://gateway.gitlink.org.cn/api`):用于所有 wiki CRUD API走独立 client
### 10.2 Client 扩展
`internal/client/client.go` 新增 `SkipJSONSuffix` 字段:
```go
type Client struct {
// ... 原有字段 ...
SkipJSONSuffix bool // 为 true 时不自动追加 .json 后缀wiki gateway API 需要)
}
```
wiki 命令创建独立 client 实例,设置 `SkipJSONSuffix: true` 并使用 gateway BaseURL。
### 10.3 响应解包
Gateway API 使用不同的响应格式 `{code, data, msg}`(而非常规的 `{status, ...}`
```go
// unwrapGatewayResponse 解包 gateway 响应
// 成功: code=200/201, 提取 data 字段
// 失败: code=500/400/404, 返回 "[code] msg" 错误信息
func unwrapGatewayResponse(raw []byte) ([]byte, error)
```
### 10.4 命令详情
| 命令 | HTTP 方法 | API 路径 | 关键参数 |
|------|----------|---------|---------|
| `wiki +list` | GET | `/wiki/open/wikiPages` | 无额外参数 |
| `wiki +view` | GET | `/wiki/open/getWiki` | `--title`(必填) |
| `wiki +create` | POST | `/wiki/open/createWiki` | `--title`(必填)`--content/--file` `--message` |
| `wiki +update` | PUT | `/wiki/open/updateWiki` | `--title`(必填)`--cover/--add` `--file` |
| `wiki +delete` | DELETE | `/wiki/open/deleteWiki` | `--title`(必填) |
### 10.5 特殊处理
| 处理项 | 说明 |
|--------|------|
| **base64 编解码** | Wiki 内容在 API 中为 base64 编码CLI 自动编解码,对用户透明 |
| **project_id 缓存** | `resolveProjectID()` 使用 `sync.Map` 缓存,同一 owner/repo 只调一次 API |
| **owner/repo 自动解析** | 在 git 仓库目录下可省略 `--owner`/`--repo` |
| **--update --add 模式** | 先 GET 现有内容 → 解码 → 追加 → 重新编码 → PUT 提交 |
| **嵌套对象过滤** | 表格输出时过滤无意义的嵌套对象字段(如 wiki_clone_link |
### 10.6 已知限制
- **delete 后端 bug**GitLink 平台 `deleteWiki` API 只清空内容,不删除侧边栏条目
- **gateway 域名硬编码**wiki API 仅部署在 gatewaydetail API 在 www不可互换
- **create/update pageName 差异**create 接受原始中文 pageNameupdate 需要 URL 编码
---
## 11 完整命令参考
```
gitlink-cli
@ -457,7 +626,9 @@ gitlink-cli
│ ├── +list # 仓库列表
│ ├── +info # 仓库详情
│ ├── +delete # 删除仓库
│ └── +settings # 仓库设置
│ ├── +settings # 仓库设置
│ ├── +batch-create # 批量创建仓库
│ └── +batch-update # 批量更新仓库
├── issue
│ ├── +list # Issue 列表
│ ├── +create # 创建 Issue
@ -466,7 +637,13 @@ gitlink-cli
│ ├── +close # 关闭 Issue
│ ├── +comment # 添加评论
│ ├── +assign # 指派
│ └── +label # 标签管理
│ ├── +label # 标签管理
│ ├── +batch-close # 批量关闭 Issue
│ ├── +batch-status # 批量更换状态
│ ├── +batch-priority # 批量更换优先级
│ ├── +batch-assign # 批量更换负责人
│ ├── +batch-label # 批量更换标记
│ └── +batch-create # 批量创建 Issue含 Bug/Feature 模板)
├── pr
│ ├── +list # PR 列表
│ ├── +create # 创建 PR
@ -501,6 +678,19 @@ gitlink-cli
├── user
│ ├── +me # 当前用户
│ └── +info # 用户详情
├── webhook
│ ├── +list # Webhook 列表
│ ├── +create # 创建 Webhook
│ ├── +update # 更新 Webhook
│ ├── +delete # 删除 Webhook
│ ├── +test # 测试 Webhook
│ └── +info # Webhook 详情
├── wiki
│ ├── +list # Wiki 页面列表
│ ├── +view # 查看 Wiki 页面
│ ├── +create # 创建 Wiki 页面
│ ├── +update # 更新 Wiki 页面
│ └── +delete # 删除 Wiki 页面
├── search
│ ├── +repos # 搜索仓库
│ ├── +issues # 搜索 Issue
@ -518,7 +708,7 @@ gitlink-cli
---
## 10 关键文件清单
## 12 关键文件清单
实现时需要修改/创建的核心文件:
@ -541,8 +731,10 @@ gitlink-cli
| `internal/registry/meta_data.json` | API 元数据 |
| `shortcuts/common/types.go` | Shortcut 核心类型 |
| `shortcuts/common/runner.go` | RuntimeContext |
| `shortcuts/repo/*.go` | 仓库 shortcuts |
| `shortcuts/issue/*.go` | Issue shortcuts |
| `shortcuts/repo/*.go` | 仓库 shortcuts含 batch_create/batch_update |
| `shortcuts/issue/*.go` | Issue shortcuts含 batch.go + batch_create.go 批量操作) |
| `shortcuts/wiki/*.go` | Wiki shortcutslist, view, create, update, delete |
| `shortcuts/webhook/*.go` | Webhook shortcutslist, create, update, delete, test, info |
| `shortcuts/pr/*.go` | PR shortcuts |
| `shortcuts/register.go` | Shortcut 注册 |
| `skills/gitlink-shared/SKILL.md` | 共享 Skill |
@ -550,7 +742,7 @@ gitlink-cli
---
## 11 开发计划
## 13 开发计划
### Phase 1: Foundation第 1-2 周)
@ -609,7 +801,7 @@ gitlink-cli
---
## 12 验证方案
## 14 验证方案
| 阶段 | 验证方式 |
|------|----------|

403
doc/generate_word.py Normal file
View File

@ -0,0 +1,403 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成系统动态模型章节的 Word 文档 - 四个赛题"""
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def set_cell_shading(cell, color):
"""设置单元格背景颜色"""
shading = OxmlElement('w:shd')
shading.set(qn('w:fill'), color)
cell._tc.get_or_add_tcPr().append(shading)
def add_table(doc, headers, rows, header_color="4472C4"):
"""添加表格"""
table = doc.add_table(rows=len(rows)+1, cols=len(headers))
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
# 表头
for i, header in enumerate(headers):
cell = table.rows[0].cells[i]
cell.text = header
set_cell_shading(cell, header_color)
for paragraph in cell.paragraphs:
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in paragraph.runs:
run.font.bold = True
run.font.color.rgb = RGBColor(255, 255, 255)
# 数据行
for i, row in enumerate(rows):
for j, value in enumerate(row):
table.rows[i+1].cells[j].text = value
return table
def create_document():
doc = Document()
# 设置默认字体
style = doc.styles['Normal']
style.font.name = 'Microsoft YaHei'
style._element.rPr.rFonts.set(qn('w:eastAsia'), 'Microsoft YaHei')
# 标题
title = doc.add_heading('系统动态模型', 0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
# 概述
doc.add_heading('概述', level=1)
doc.add_paragraph(
'系统动态模型描述了 gitlink-cli 在四个赛题场景下的运行时行为,'
'展示了各组件之间的交互流程和数据流向。每个赛题对应一个核心能力方向,'
'覆盖 CLI 功能扩展、Skills 开发、自动化工作流和科研辅助四大领域。'
)
# ==================== 赛题一 ====================
doc.add_heading('子赛题一:增加和完善 GitLink-CLI 能力', level=1)
doc.add_heading('1.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于 CLI 命令系统的功能扩展和优化。可构建以下动态模型:'
)
doc.add_paragraph('命令执行模型:描述 Shortcut 命令从参数解析到 API 调用的完整执行流程', style='List Bullet')
doc.add_paragraph('批量操作模型描述批量命令batch-close、batch-create 等)的遍历执行和错误处理机制', style='List Bullet')
doc.add_paragraph('参数校验模型:描述 Choices 枚举校验和 Validate 自定义校验的执行流程', style='List Bullet')
doc.add_paragraph('输出格式化模型:描述 Envelope 数据经过 table/json/yaml 格式化后的输出流程', style='List Bullet')
doc.add_heading('1.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Shortcut', 'wiki +list/+view/+create/+update/+delete', 'Wiki 页面 CRUD 操作'],
['Shortcut', 'webhook +list/+create/+update/+delete/+test', 'Webhook 配置管理'],
['Shortcut', 'board +view/+columns/+issues/+move/+assign', '项目看板操作'],
['Shortcut', 'issue +batch-close/+batch-create/+batch-assign', 'Issue 批量操作'],
['Shortcut', 'repo +batch-create/+batch-update', '仓库批量操作'],
['Shortcut', 'pr +merge (支持 merge/rebase/squash)', 'PR 合并方式选择'],
['Shortcut', 'branch +protect/+unprotect', '分支保护规则管理'],
['Shortcut', 'release +list/+create/+view/+delete', '版本发布管理'],
['结构体', 'Flag.Choices', '枚举参数校验,自动追加 [val1|val2] 提示'],
['结构体', 'Flag.Validate', '自定义校验函数,跨参数约束'],
['结构体', 'PrintOptions', '输出选项:列过滤、禁用截断、彩色输出'],
['函数', 'OpError()', '统一错误构造函数,中英文双语错误信息'],
]
)
doc.add_heading('1.3 交互流程示例', level=2)
doc.add_paragraph('示例一Wiki 页面创建', style='List Bullet')
steps = [
'用户输入gitlink-cli wiki +create --title "API文档" --content "# API Reference"',
'Cobra 解析命令,匹配到 wiki +create Shortcut',
'Validate 校验:检查 --title 是否为空',
'ResolveOwnerRepo() 从 git remote 解析 owner/repo',
'CallAPI("POST", "/wiki/open/createWiki", body) 调用 Gateway API',
'OutputData() 格式化输出创建结果',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二:枚举参数校验', style='List Bullet')
steps = [
'用户输入gitlink-cli pr +merge --id 42 --method unknown',
'Cobra 解析参数,发现 --method 值为 unknown',
'Choices 校验unknown 不在 [merge, rebase, squash] 中',
'输出错误invalid value "unknown" for --method有效值: merge, rebase, squash',
'命令终止exit code = 1',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三:批量关闭 Issuedry-run 预览)', style='List Bullet')
steps = [
'用户输入gitlink-cli issue +batch-close --numbers 101,102,103 --dry-run',
'解析 --numbers 参数,得到 [101, 102, 103]',
'IsDryRun() == true进入预览模式',
'构建 BatchSummary {dry_run: true, total: 3, results: [{status: "planned"},...]}',
'输出预览结果,不发起 HTTP 请求',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题二 ====================
doc.add_heading('子赛题二:编写和丰富 GitLink Skills', level=1)
doc.add_heading('2.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于 AI Agent Skill 的开发。可构建以下动态模型:'
)
doc.add_paragraph('Skill 加载模型:描述 Claude Code 触发 Skill、加载 SKILL.md 和 reference 文档的流程', style='List Bullet')
doc.add_paragraph('AI 分析模型:描述 AI 对 PR Diff、Issue 内容、仓库数据的分析和决策流程', style='List Bullet')
doc.add_paragraph('自动执行模型:描述 AI 分析结果转化为 CLI 命令执行的流程', style='List Bullet')
doc.add_heading('2.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Skill', 'gitlink-code-review', '智能代码审查:分析 PR diff输出结构化 Review 意见'],
['Skill', 'gitlink-issue-triage', 'Issue 自动分拣:根据内容自动分类、打标签、分配责任人'],
['Skill', 'gitlink-changelog', 'Release Notes 生成:根据 commit 和 PR 生成版本说明'],
['Skill', 'gitlink-health', '项目健康度报告:统计 Issue 响应时间、PR 合并效率'],
['Skill', 'gitlink-compliance', '许可证合规检查:扫描许可证合规性和敏感信息泄露'],
['Skill', 'gitlink-onboard', '新人引导:为 good-first-issue 自动添加引导评论'],
['Skill', 'gitlink-stale', '过期 Issue 管理:自动标记和关闭长期未活动的 Issue'],
['Skill', 'gitlink-faq', 'FAQ 自动回复:根据 Issue 内容匹配 FAQ 并自动评论'],
['Reference', 'references/pr-review.md', 'PR 审查参考文档:审查维度、评分标准、评论模板'],
['Reference', 'references/issue-triage.md', 'Issue 分类参考文档:分类规则、标签映射、分配策略'],
]
)
doc.add_heading('2.3 交互流程示例', level=2)
doc.add_paragraph('示例一:智能代码审查', style='List Bullet')
steps = [
'用户触发:"帮我审查 PR #42"',
'Claude Code 加载 gitlink-code-review skill',
'获取 PR 详情gitlink-cli pr +view --id 42 --format json',
'获取变更文件gitlink-cli pr +files --id 42 --format json',
'AI 分析代码:检测未处理 error、SQL 注入风险、性能问题',
'生成审查报告:质量评分 8.5/10列出 3 个问题',
'提交审查评论gitlink-cli pr +review --id 42 --body "..." --event comment',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二Issue 自动分拣', style='List Bullet')
steps = [
'用户触发:"帮我分类本周的 Issue"',
'Claude Code 加载 gitlink-issue-triage skill',
'获取 Issue 列表gitlink-cli issue +list --state open --format json',
'AI 分析内容识别关键词bug/功能/提问)',
'自动添加标签gitlink-cli issue +label-add --number 101 --labels "缺陷"',
'自动分配责任人gitlink-cli issue +update --number 101 --assignee "zhangsan"',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三Release Notes 生成', style='List Bullet')
steps = [
'用户触发:"帮我生成 v2.1.0 的 Release Notes"',
'Claude Code 加载 gitlink-changelog skill',
'获取已合并 PRgitlink-cli pr +list --state merged --format json',
'获取 commit 历史git log --oneline v2.0.0..HEAD',
'AI 分类整理新功能、Bug 修复、性能优化、Breaking Changes',
'生成 Release Notesgitlink-cli release +create --tag v2.1.0 --body "..."',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题三 ====================
doc.add_heading('子赛题三:构建端到端自动化工作流', level=1)
doc.add_heading('3.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于串联多个步骤的完整解决方案。可构建以下动态模型:'
)
doc.add_paragraph('工作流调度模型:描述工作流从触发、菜单选择、步骤执行到结果输出的完整流程', style='List Bullet')
doc.add_paragraph('多步骤编排模型:描述多个 CLI 命令和 Skill 的串联执行和错误处理', style='List Bullet')
doc.add_paragraph('数据聚合模型:描述从多个数据源采集数据并汇总生成报告的流程', style='List Bullet')
doc.add_heading('3.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Skill', 'gitlink-workflows', '工作流调度入口,提供功能菜单选择'],
['Workflow', '01-community-ops.sh', '社区运营自动化Issue 分类 + 周报生成'],
['Workflow', '01a-issue-triage.sh', 'Issue 分类子工作流'],
['Workflow', '01a-webhook-setup.sh', 'Webhook 配置子工作流'],
['Workflow', '02-code-quality-gatekeeper.sh', '代码质量门禁PR 审查 + 评分'],
['Workflow', '03-project-init.sh', '项目一键初始化:仓库 + 文件 + CI + Issues'],
['Workflow', '04-multi-repo-collab.sh', '多仓库协同:依赖追踪 + 协同发版'],
['Workflow', '05-contributor-growth.sh', '贡献者成长体系:评分 + 排行 + Badge'],
['Lib', 'lib/common.sh', '公共函数库:认证检查、错误处理、日志输出'],
]
)
doc.add_heading('3.3 交互流程示例', level=2)
doc.add_paragraph('示例一:项目一键初始化', style='List Bullet')
steps = [
'用户触发:"帮我初始化新项目 my-project"',
'Claude Code 加载 gitlink-workflows skill显示菜单',
'用户选择"项目一键初始化"',
'认证检查gitlink-cli auth status',
'创建仓库gitlink-cli repo +create --name my-project --private',
'初始化文件gitlink-cli file +create --path README.md --content "..."',
'初始化文件gitlink-cli file +create --path .gitignore --content "..."',
'初始化文件gitlink-cli file +create --path LICENSE --content "..."',
'配置分支保护gitlink-cli branch +protect --name master --require-review',
'创建 Issuesgitlink-cli issue +create --title "完善单元测试" --labels "good-first-issue"',
'输出初始化报告',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二:多仓库协同发版', style='List Bullet')
steps = [
'用户触发:"帮我管理多仓库协同"',
'扫描仓库列表gitlink-cli repo +list --user my-org --format json',
'分析依赖关系frontend → api-client → backend',
'查询跨仓库 PRgitlink-cli pr +list --state open --format json',
'检查发版就绪gitlink-cli release +list --format json',
'识别阻塞项backend PR #456 未合并',
'生成协同 Dashboard显示各仓库状态、依赖关系、阻塞项',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三:贡献者成长体系', style='List Bullet')
steps = [
'用户触发:"帮我生成贡献者排行榜"',
'获取贡献者列表gitlink-cli repo +members --format json',
'统计 PR 活动gitlink-cli pr +list --state merged --format json',
'统计 Issue 活动gitlink-cli issue +list --state closed --format json',
'AI 计算贡献评分PR 数量 × 3 + Issue 数量 × 1 + Review × 2',
'生成排行榜zhangsan(85分) > lisi(72分) > wangwu(68分)',
'自动颁发 Badge为 top 3 贡献者添加 "core-contributor" 标签',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题四 ====================
doc.add_heading('子赛题四:应用 GitLink 辅助科研', level=1)
doc.add_heading('4.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于科研场景的智能化赋能。可构建以下动态模型:'
)
doc.add_paragraph('科研项目洞悉模型:描述从仓库数据提取科研项目信息(技术栈、活跃度、依赖)的流程', style='List Bullet')
doc.add_paragraph('热点追踪模型:描述从 Issue/PR/Commit 数据挖掘科研热点趋势的流程', style='List Bullet')
doc.add_paragraph('合规检查模型:描述许可证合规性扫描和敏感信息检测的流程', style='List Bullet')
doc.add_paragraph('协作匹配模型:描述根据贡献者技能和项目需求进行智能匹配的流程', style='List Bullet')
doc.add_heading('4.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Skill', 'gitlink-research', '科研辅助系统总入口,提供科研场景菜单'],
['Skill', 'gitlink-code-insight', '仓库级科研项目洞悉:技术栈、活跃度、依赖分析'],
['Skill', 'gitlink-compliance', '科研项目合规与复现性检查:许可证、依赖、环境'],
['Workflow', '06-research-insights.sh', '科研热点追踪:从 Issue/PR 挖掘研究趋势'],
['Workflow', '08-research-compliance.sh', '科研合规检查:扫描许可证和敏感信息'],
['Workflow', '10-research-progress.sh', '科研进度跟踪:里程碑进度、阻塞项识别'],
['Workflow', '11-research-citation.sh', '论文引用分析:追踪仓库的学术引用情况'],
]
)
doc.add_heading('4.3 交互流程示例', level=2)
doc.add_paragraph('示例一:仓库级科研项目洞悉', style='List Bullet')
steps = [
'用户触发:"帮我分析这个仓库的科研价值"',
'Claude Code 加载 gitlink-code-insight skill',
'获取仓库信息gitlink-cli repo +info --format json',
'分析技术栈:扫描 package.json/go.mod/requirements.txt',
'分析活跃度gitlink-cli pr +list --state merged --format json',
'分析依赖:识别上下游依赖关系',
'生成科研洞悉报告:技术栈、活跃贡献者、核心模块、研究方向',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二:科研热点追踪', style='List Bullet')
steps = [
'用户触发:"帮我追踪 AI 领域的科研热点"',
'扫描相关仓库gitlink-cli search +repos --query "machine-learning"',
'获取 Issue 列表gitlink-cli issue +list --state open --format json',
'获取 PR 列表gitlink-cli pr +list --state merged --format json',
'AI 分析关键词识别高频技术词汇transformer、diffusion、LLM',
'生成热点报告:技术趋势、热门项目、活跃研究者',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三:科研合规与复现性检查', style='List Bullet')
steps = [
'用户触发:"帮我检查这个科研项目的合规性"',
'Claude Code 加载 gitlink-compliance skill',
'扫描许可证:检查 LICENSE 文件和依赖许可证',
'扫描敏感信息:检查 API Key、密码、私钥泄露',
'检查复现性:验证 requirements.txt/go.mod 完整性',
'生成合规报告:许可证合规、依赖风险、复现性评分',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题对比总结 ====================
doc.add_heading('赛题对比总结', level=1)
add_table(doc,
['维度', '赛题一CLI 能力', '赛题二Skills 开发', '赛题三:自动化工作流', '赛题四:辅助科研'],
[
['核心目标', '扩展 CLI 命令', '开发 AI Skill', '串联完整流程', '科研场景赋能'],
['技术栈', 'Go + Cobra', 'Markdown + CLI', 'Shell + CLI + Skill', '数据分析 + AI'],
['交付物', 'Shortcut + 结构体', 'SKILL.md + Reference', 'Workflow 脚本', '科研 Skill + 报告'],
['AI 参与', '', '核心AI 分析)', '调度 + 分析', '深度(知识挖掘)'],
['典型场景', 'Wiki/Webhook/批量', '代码审查/Issue 分类', '项目初始化/协同发版', '热点追踪/合规检查'],
]
)
# ==================== 组件依赖关系 ====================
doc.add_heading('组件依赖关系', level=1)
dep_tree = """gitlink-cli (CLI 核心)
Shortcuts (赛题一)
wiki +list/+view/+create/+update/+delete
webhook +list/+create/+update/+delete/+test
board +view/+columns/+issues/+move/+assign
issue +batch-close/+batch-create/+batch-assign
repo +batch-create/+batch-update
branch +protect/+unprotect
Skills (赛题二)
gitlink-code-review (代码审查)
gitlink-issue-triage (Issue 分类)
gitlink-changelog (Release Notes)
gitlink-health (项目健康度)
gitlink-compliance (合规检查)
gitlink-onboard (新人引导)
gitlink-stale (过期管理)
Workflows (赛题三)
01-community-ops.sh (社区运营)
02-code-quality-gatekeeper.sh (质量门禁)
03-project-init.sh (项目初始化)
04-multi-repo-collab.sh (多仓库协同)
05-contributor-growth.sh (贡献者成长)
Research Skills (赛题四)
gitlink-research (科研总入口)
gitlink-code-insight (项目洞悉)
gitlink-compliance (合规检查)
research workflows (热点/进度/引用)"""
p = doc.add_paragraph()
run = p.add_run(dep_tree)
run.font.name = 'Consolas'
# ==================== 状态转换说明 ====================
doc.add_heading('状态转换说明', level=1)
doc.add_heading('Shortcut 命令执行状态', level=2)
doc.add_paragraph('Init → Parsing → Validation → Resolving → Loading → Executing → APICall → Done/Error')
doc.add_paragraph('任何阶段出现错误都会进入 Error 状态,输出结构化错误信息后终止。')
doc.add_heading('Skill 触发状态', level=2)
doc.add_paragraph('Idle → Triggered → Loaded → AuthCheck → DataCollection → AIAnalysis → Execution → Output')
doc.add_paragraph('Skill 加载失败时回退到 Idle 状态AI 分析失败时输出错误提示。')
doc.add_heading('Workflow 执行状态', level=2)
doc.add_paragraph('Idle → Selecting → Loading → AuthCheck → Step1 → Step2 → ... → StepN → Done')
doc.add_paragraph('任一步骤失败时根据配置决定继续或终止,最终输出执行汇总。')
# 保存
output_path = r'C:\Users\Lenovo\Desktop\soft运维\gitlink-cli\doc\系统动态模型章节.docx'
doc.save(output_path)
print(f'Word 文档已生成:{output_path}')
if __name__ == '__main__':
create_document()

View File

@ -23,6 +23,159 @@
- HTTP Authentication, scheme: bearer
---
# GitLink API 使用注意事项
> **重要提示**: 以下注意事项基于实际使用经验总结使用GitLink API时请特别注意这些行为和限制。
## 已知问题和特殊行为
### API响应格式
| 问题 | 说明 | 影响 | 解决方案 |
|------|------|------|----------|
| **双重错误码** | HTTP 200 + body.status 非200 | 错误判断复杂 | 需要检查HTTP状态码和body.status |
| **错误格式不一致** | 有`{status, message}`也有`{code, msg}` | 错误解析困难 | 兼容处理两种格式 |
| **静默失败** | 字段名错误可能返回200但不生效 | 调试困难 | 通过GET验证实际修改 |
### Issue API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| Issue创建 | 必须包含`done_ratio: 0`,否则数据库报错 | 自动添加该字段 | ✅ shortcuts已处理 |
| Issue更新 | 需保留`subject`/`description`,否则可能清空描述 | 先GET再提交保留字段 | ⚠️ 需手动处理 |
| Issue列表 | 分页参数可能不返回完整统计 | 客户端需分页处理 | ✅ 正常 |
### Release API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| Release查看 | 需要`version_id`不能用`tag_name` | 使用`release +list`获取ID | ⚠️ 注意参数 |
| Release删除 | 需要`version_id` | 使用`release +delete -i <version_id>` | ✅ shortcuts已处理 |
### 分支API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| 分支操作 | 需要`/v1/`前缀 | 端点使用`/v1/:owner/:repo/branches` | ✅ shortcuts已处理 |
| 分支删除 | `DELETE`分支API始终返回"分支不存在" | GitLink平台Bug暂不支持 | ❌ API不可用 |
### 文件操作API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| 创建文件 | `content`字段必须base64编码 | 不编码会返回"文件已存在"错误 | ⚠️ 需手动处理 |
| 更新文件 | 需要`sha`参数,通过`sub_entries`获取 | 先GET获取SHA再PUT | ⚠️ 复杂操作 |
### Pull Request API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| PR合并 | 需要`do`参数指定合并方式 | `pr +merge`已内置处理 | ✅ shortcuts已处理 |
| PR列表 | `--state`参数只影响统计,列表可能包含所有状态 | 客户端需按`pull_request_status`过滤 | ⚠️ 需手动处理 |
| PR创建 | 分支内容必须与目标分支不同 | 需要先有实际提交差异 | ⚠️ API限制 |
### Wiki API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| Wiki域名 | 使用Gateway域名而非主域名 | Wiki使用独立client处理 | ✅ shortcuts已处理 |
| Wiki内容 | base64编码传输 | CLI自动编解码 | ✅ shortcuts已处理 |
| Wiki删除 | API只清空内容不删除侧边栏条目 | GitLink平台限制 | ⚠️ 部分功能 |
### Webhook API
| API端点 | 问题 | 解决方案 | 状态 |
|---------|------|----------|------|
| Webhook创建 | 需要完整的URL和事件配置 | 按文档格式提交 | ✅ shortcuts已处理 |
| Webhook测试 | 测试推送可能延迟 | 等待异步处理 | ✅ shortcuts已处理 |
## 推荐使用方式
### 优先级顺序
1. **Shortcuts** - 最简单,自动处理特殊情况
```bash
gitlink-cli issue +create -t "Bug" -b "详细描述"
gitlink-cli wiki +create --title "Home" --content "# 欢迎"
```
2. **Raw API** - Shortcuts未覆盖时使用
```bash
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"test","done_ratio":0}'
```
3. **直接HTTP** - 仅用于调试或特殊需求
```bash
curl -X POST "https://www.gitlink.org.cn/api/:owner/:repo/issues.json?access_token=xxx"
```
### 错误处理建议
**推荐错误处理流程**:
1. 检查HTTP状态码
2. 检查body中的status/code字段
3. 验证实际修改是否生效GET验证
4. 使用shortcuts避免直接处理复杂情况
**错误处理示例**:
```python
def check_gitlink_error(response):
# 1. 检查HTTP状态码
if response.status_code >= 400:
return f"HTTP错误: {response.status_code}"
# 2. 检查body中的错误字段
data = response.json()
if 'status' in data and data['status'] != 200:
return f"API错误: {data.get('message', '未知错误')}"
if 'code' in data and data['code'] != 200:
return f"Gateway错误: [{data['code']}] {data.get('msg', '未知错误')}"
# 3. 验证实际修改
return None
```
### 认证相关
**Token获取方式**:
1. 用户名密码登录: `gitlink-cli auth login`
2. 直接Token: `gitlink-cli auth login --token`
3. 环境变量: `export GITLINK_TOKEN="your-token"`
**Token有效期**: 7天过期需重新登录
**认证优先级**: 环境变量 > Keychain存储 > 交互式登录
### 请求限制
**速率限制**: GitLink API有基本的速率限制建议
- 批量操作使用专门的batch命令
- 避免短时间内大量请求
- 使用`--dry-run`预览批量操作
**分页处理**: 大量数据建议:
- 使用shortcuts的自动分页功能
- 或者使用Raw API手动处理分页参数
## 开发建议
### 使用gitlink-cli的优势
1. **自动处理特殊情况** - 如base64编码、双重错误码等
2. **统一的错误处理** - 标准化的错误信息和建议
3. **AI Agent友好** - 完整的Skills文档支持
4. **跨平台支持** - macOS、Linux、Windows
### 调试技巧
1. **使用`--debug`参数** 查看详细的请求响应
```bash
gitlink-cli --debug issue +list
```
2. **使用`--format json`** 获取结构化输出
```bash
gitlink-cli --format json issue +list
```
3. **使用`--dry-run`** 预览危险操作
```bash
gitlink-cli issue +batch-close --numbers 1,2,3 --dry-run
```
---
# 附件
## POST 上传文件
@ -15206,6 +15359,46 @@ GET /api/wikiExport/wikiExport-wrapper
|» data|object|false|none||none|
|» message|string|false|none||none|
---
## Gateway Wiki APICLI 实际调用)
gitlink-cli 的 wiki shortcut 实际调用的是 **gateway 域名**下的 `/wiki/open/*` 端点,而非上述 www 域名的 `/api/wiki/*` 端点。两者存在以下差异:
| 差异项 | www 域名APIfox 文档) | gateway 域名CLI 实际使用) |
|--------|------------------------|---------------------------|
| Base URL | `https://www.gitlink.org.cn/api` | `https://gateway.gitlink.org.cn/api` |
| URL 前缀 | `/api/wiki/` | `/wiki/open/` |
| JSON 后缀 | 需要 `.json` | 不需要 `.json` |
| 响应格式 | 直接返回 data | `{"code": 200, "data": {...}, "msg": ""}` 包一层 |
| 错误判断 | `status` 字段 ≠ 200 | `code` 字段 ≠ 200/201 |
### 实际调用路径对比
| 操作 | www 文档路径 | gateway 实际路径 |
|------|-------------|-----------------|
| 创建 | `POST /api/wiki/createWiki.json` | `POST /wiki/open/createWiki` |
| 删除 | `DELETE /api/wiki/deleteWiki.json` | `DELETE /wiki/open/deleteWiki` |
| 查看 | `GET /api/wiki/getWiki.json` | `GET /wiki/open/getWiki` |
| 更新 | `PUT /api/wiki/updateWiki.json` | `PUT /wiki/open/updateWiki` |
| 列表 | `GET /api/wiki/wikiPages.json` | `GET /wiki/open/wikiPages` |
### 响应格式差异示例
**www 域名响应**(标准格式):
```json
{"data": {"title": "test", "content": "..."}}
```
**gateway 域名响应**(包一层):
```json
{"code": 200, "data": {"title": "test", "content": "..."}, "msg": "success"}
```
CLI 通过 `unwrapGatewayResponse()` 函数自动解包 gateway 格式,对用户透明。
> **注意**project_id 仍需通过 www 域名的 `/api/{owner}/{repo}/detail.json` 获取,两个域名不可互换。
# 流水线
## GET 流水线列表

View File

@ -0,0 +1,552 @@
# shortcuts/common/types.go 阅读笔记(面向 Go 小白)
***
## 第 1 行:`package common`
**字面意思**:声明这个文件属于 `common`
**运行时作用**:这是一个通用工具包,里面定义的类型和函数可以被所有其他 shortcut 模块wiki、webhook、issue 等)复用。
**小白补充**
- 包名 `common` 表示"公共的",说明这里的内容是大家都需要用的
- 其他文件通过 `import "github.com/gitlink-org/gitlink-cli/shortcuts/common"` 来使用
***
## 第 3-18 行import 导入依赖
```go
import (
"bufio" // 缓冲输入(用于读取用户确认)
"encoding/json" // JSON 处理
"fmt" // 格式化输出
"net/http" // HTTP 客户端
"net/url" // URL 处理
"os" // 操作系统交互
"strings" // 字符串操作
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil" // 命令行工具(全局变量)
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端
"github.com/gitlink-org/gitlink-cli/internal/config" // 配置管理
"github.com/gitlink-org/gitlink-cli/internal/context" // 上下文解析owner/repo
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // 错误定义
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
)
```
**小白补充**
| 包名 | 用途 |
| ------------------ | ----------------------------------------------------------- |
| `bufio` | 读取用户输入(比如确认操作时的 y/N |
| `cmd/cmdutil` | 存放全局变量(如 `cmdutil.Owner`, `cmdutil.Repo`, `cmdutil.Format` |
| `internal/config` | 加载配置文件 |
| `internal/context` | 从 git remote 解析 owner/repo |
***
## 第 20-28 行:`Shortcut` 结构体(核心!)
```go
type Shortcut struct {
Name string
Description string
Flags []Flag
DryRun bool
DryRunHint func(ctx *RuntimeContext) (string, error)
Run func(ctx *RuntimeContext) error
}
```
**字面意思**:定义一个名为 `Shortcut` 的结构体类型
**运行时作用**:这是整个 CLI 命令系统的**核心数据结构**,每个 `Shortcut` 代表一个可执行的命令(如 `wiki +list`, `issue +create`)。
**小白补充**
### ① 结构体是什么?
结构体struct是 Go 语言中用来**组织相关数据和函数**的方式。可以把它想象成一个"数据容器",里面装着各种属性。
### ② 每个字段的含义:
| 字段 | 类型 | 含义 |
| ------------- | ------------------------------------------- | -------------------------- |
| `Name` | `string` | 命令名,用户通过 `+name` 调用 |
| `Description` | `string` | 命令描述,`--help` 时显示 |
| `Flags` | `[]Flag` | 命令行参数列表(如 `--title`, `-t` |
| `DryRun` | `bool` | 是否支持预览模式(`--dry-run` |
| `DryRunHint` | `func(ctx *RuntimeContext) (string, error)` | 预览时显示的提示信息 |
| `Run` | `func(ctx *RuntimeContext) error` | **真正执行的函数**,命令的核心逻辑 |
### ③ 函数类型字段:
注意 `DryRunHint``Run` 的类型是**函数**!这在 Go 中是完全合法的,函数可以作为结构体的字段。
```go
Run func(ctx *RuntimeContext) error
```
- 这表示 `Run` 字段存储了一个**函数**
- 这个函数接收 `*RuntimeContext` 类型的参数
- 返回 `error` 类型的值(如果执行失败)
***
## 第 30-38 行:`Flag` 结构体
```go
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
}
```
**字面意思**:定义命令行参数的结构
**运行时作用**:描述一个命令行参数,比如 `--title "Home"``-t "Home"`
**小白补充**
| 字段 | 含义 | 例子 |
| ---------- | ------ | ------------------------- |
| `Name` | 参数名 | `"title"``--title` |
| `Short` | 短参数名 | `"t"``-t` |
| `Usage` | 帮助说明 | `"Page title"` |
| `Required` | 是否必填 | `true` → 用户必须提供 |
| `Default` | 默认值 | `"1"` → 不提供时使用的默认值 |
| `Bool` | 是否布尔类型 | `true``--dry-run` 不需要值 |
***
## 第 40-50 行:`RuntimeContext` 结构体(核心!)
```go
type RuntimeContext struct {
Client *client.Client
Owner string
Repo string
Format string
CommandName string
Args map[string]string
GatewayBaseURL string
GatewayHTTPClient *http.Client
}
```
**字面意思**:定义运行时上下文的结构
**运行时作用**:这是每个命令执行时的**全局环境**,包含了所有需要的信息。
**小白补充**
### ① 为什么需要 RuntimeContext
每个命令执行时都需要很多信息:
- 用哪个 HTTP 客户端发请求?
- 当前操作的仓库是哪个owner/repo
- 输出格式是 JSON 还是 Table
- 用户传入了哪些参数?
`RuntimeContext` 把这些信息打包在一起,方便传递和使用。
### ② 每个字段的含义:
| 字段 | 类型 | 含义 |
| ------------------- | ------------------- | --------------------------- |
| `Client` | `*client.Client` | HTTP 客户端,用来调用 GitLink API |
| `Owner` | `string` | 仓库所有者(如 `zzx-coder` |
| `Repo` | `string` | 仓库名称(如 `gitlink-cli` |
| `Format` | `string` | 输出格式(`json`/`table`/`yaml` |
| `CommandName` | `string` | 当前命令名(如 `"wiki +list"` |
| `Args` | `map[string]string` | 用户传入的所有参数key-value |
| `GatewayBaseURL` | `string` | Wiki Gateway API 的地址 |
| `GatewayHTTPClient` | `*http.Client` | 可选的自定义 HTTP 客户端(主要用于测试) |
### ③ `*client.Client` 是什么?
- `*` 表示这是一个**指针**类型
- `client.Client``internal/client` 包中定义的结构体
- 指针的好处:避免拷贝大对象,多个地方共享同一个实例
***
## 第 52-80 行:`NewRuntimeContext` 函数
```go
func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeContext, error) {
// 1. 创建 HTTP 客户端
cli, err := client.New()
if err != nil {
return nil, err
}
cli.Debug = cmdutil.Debug // 设置调试模式
// 2. 确定输出格式
format := cmdutil.Format
if format == "" {
format = "json" // 默认 JSON 格式
}
// 3. 获取 Gateway URL
gatewayBaseURL := config.DefaultGatewayBaseURL
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
gatewayBaseURL = cfg.GatewayBaseURL // 使用配置文件中的地址
}
// 4. 创建并返回 RuntimeContext
return &RuntimeContext{
Client: cli,
Owner: cmdutil.Owner,
Repo: cmdutil.Repo,
Format: format,
CommandName: commandName,
Args: args,
GatewayBaseURL: gatewayBaseURL,
GatewayHTTPClient: nil,
}, nil
}
```
**字面意思**:创建一个新的 RuntimeContext 实例
**运行时作用**:这是 `RuntimeContext` 的**构造函数**,负责初始化所有字段。
**小白补充**
### ① 构造函数模式:
Go 没有专门的构造函数语法,通常约定用 `NewXXX()` 函数来创建结构体实例。
### ② `cmdutil` 是什么?
`cmdutil``cmd/cmdutil/globals.go` 中定义的全局变量模块:
```go
// cmd/cmdutil/globals.go 中定义
var (
Owner string // 通过 --owner 参数设置
Repo string // 通过 --repo 参数设置
Format string // 通过 --format 参数设置
Debug bool // 通过 --debug 参数设置
)
```
这些是**全局变量**,在命令行参数解析时被赋值,然后在这里被读取。
### ③ 配置加载:
```go
gatewayBaseURL := config.DefaultGatewayBaseURL
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
gatewayBaseURL = cfg.GatewayBaseURL
}
```
- 先使用默认值 `config.DefaultGatewayBaseURL`
- 尝试加载配置文件,如果配置文件中有自定义的 Gateway URL就使用配置文件中的值
***
## 第 82-91 行:`ResolveOwnerRepo` 方法
```go
func (ctx *RuntimeContext) ResolveOwnerRepo() error {
owner, repo, err := context.ResolveOwnerRepo(ctx.Owner, ctx.Repo)
if err != nil {
return err
}
ctx.Owner = owner
ctx.Repo = repo
return nil
}
```
**字面意思**:解析 owner 和 repo
**运行时作用**:这是 `RuntimeContext` 的**方法**,用来确定当前操作的仓库。
**小白补充**
### ① 方法是什么?
方法是和结构体绑定的函数。在 Go 中:
```go
func (ctx *RuntimeContext) ResolveOwnerRepo() error {
// ...
}
```
- `(ctx *RuntimeContext)` 表示这个函数绑定到 `RuntimeContext` 类型
- `ctx` 是方法内部的**接收器**receiver类似其他语言的 `this``self`
- 调用方式:`ctx.ResolveOwnerRepo()`
### ② 解析逻辑:
`context.ResolveOwnerRepo(ctx.Owner, ctx.Repo)` 的作用:
1. 如果用户通过 `--owner``--repo` 参数明确指定了,直接使用
2. 如果没有指定,尝试从当前目录的 `git remote` 中自动解析
***
## 第 93-96 行:`CallAPI` 方法
```go
func (ctx *RuntimeContext) CallAPI(method, path string, body interface{}) (*output.Envelope, error) {
return ctx.Client.Do(method, path, body, nil)
}
```
**字面意思**:调用 API无查询参数
**运行时作用**:封装 HTTP 请求,是所有 API 调用的入口。
**小白补充**
- 这是一个**包装方法**,把 `ctx.Client.Do()` 包装一层
- 其他模块只需要调用 `ctx.CallAPI()` 就能发送请求,不需要关心底层的 `client.Client`
***
## 第 98-101 行:`CallAPIWithQuery` 方法
```go
func (ctx *RuntimeContext) CallAPIWithQuery(method, path string, query url.Values) (*output.Envelope, error) {
return ctx.Client.Do(method, path, nil, query)
}
```
**字面意思**:调用 API带查询参数
**运行时作用**:和 `CallAPI` 类似,但支持 URL 查询参数(`?key=value`)。
***
## 第 103-106 行:`PaginateAll` 方法
```go
func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) {
return ctx.Client.PaginateAll(path, params)
}
```
**字面意思**:获取所有分页数据
**运行时作用**:处理分页 API自动获取所有页的数据。
**小白补充**
- GitLink API 常用分页返回大量数据(如 `page=1&limit=20`
- `PaginateAll` 会自动遍历所有页,把结果合并成一个大列表
***
## 第 108-111 行:`Output` 方法
```go
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
return output.Print(env, ctx.Format)
}
```
**字面意思**:输出结果
**运行时作用**根据用户指定的格式JSON/Table/YAML输出 API 响应。
***
## 第 113-116 行:`OutputData` 方法
```go
func (ctx *RuntimeContext) OutputData(data interface{}) error {
return output.Print(output.SuccessEnvelope(data, nil), ctx.Format)
}
```
**字面意思**:输出数据(自动包装成 Envelope
**运行时作用**:如果只有数据,没有完整的 Envelope可以用这个方法自动包装。
**小白补充**
- `output.SuccessEnvelope(data, nil)` 创建一个成功的包装结构:`{"ok": true, "data": ...}`
***
## 第 118-121 行:`RepoPath` 方法
```go
func (ctx *RuntimeContext) RepoPath() string {
return fmt.Sprintf("/%s/%s", ctx.Owner, ctx.Repo)
}
```
**字面意思**:返回仓库的 API 路径前缀
**运行时作用**:生成 `/owner/repo` 格式的路径,避免重复拼接。
***
## 第 123-129 行:`Arg` 方法
```go
func (ctx *RuntimeContext) Arg(name string) string {
if v, ok := ctx.Args[name]; ok {
return v
}
return ""
}
```
**字面意思**:获取命令行参数值
**运行时作用**:从 `ctx.Args` map 中获取指定参数的值。
**小白补充**
- `ctx.Args``map[string]string` 类型
- 调用方式:`ctx.Arg("title")` → 获取 `--title` 参数的值
***
## 第 131-145 行:`RequireArg` 方法(核心!)
```go
func (ctx *RuntimeContext) RequireArg(name, example string) (string, error) {
v := ctx.Arg(name)
if v == "" {
suggestion := fmt.Sprintf("请提供 --%s 参数", name)
if example != "" {
suggestion += fmt.Sprintf(",例如:%s", example)
}
return "", clierrors.InputError(
fmt.Sprintf("required flag --%s is missing", name),
suggestion,
).WithCommand(ctx.CommandName)
}
return v, nil
}
```
**字面意思**:获取必填参数,如果缺失则返回错误
**运行时作用**:强制检查必填参数,确保用户提供了必要的输入。
**小白补充**
### ① 使用场景:
```go
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err // 用户没提供 --title直接返回错误
}
```
### ② 错误处理:
如果用户没提供参数,会返回一个 `CLIError`,包含:
- `Kind`: `KindInput`(输入错误)
- `Message`: `"required flag --title is missing"`
- `Suggestion`: `"请提供 --title 参数,例如:--title \"Home Page\""`
***
## 第 147-150 行:`IsDryRun` 方法
```go
func (ctx *RuntimeContext) IsDryRun() bool {
return ctx.Arg("dry-run") == "true"
}
```
**字面意思**:检查是否是预览模式
**运行时作用**:判断用户是否传入了 `--dry-run` 参数。
***
## 第 152-166 行:`ConfirmAction` 函数
```go
func ConfirmAction(ctx *RuntimeContext) (bool, error) {
if !ctx.IsDryRun() {
return true, nil // 不是预览模式,直接执行
}
// 预览模式,提示用户确认
fmt.Fprint(os.Stderr, "\nProceed? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "y" || answer == "yes" {
return true, nil // 用户确认,继续执行
}
fmt.Fprintln(os.Stderr, "Aborted.")
return false, nil // 用户取消,不执行
}
```
**字面意思**:确认操作(预览模式下)
**运行时作用**:在 `--dry-run` 模式下,提示用户确认是否真的要执行操作。
**小白补充**
### ① `fmt.Fprint(os.Stderr, ...)`
- `os.Stderr` 是标准错误输出流
- 把提示信息输出到 stderr 而不是 stdout这样 stdout 可以保持干净(用于管道输出)
### ② `bufio.NewReader(os.Stdin)`
- `os.Stdin` 是标准输入流(用户键盘输入)
- `bufio.NewReader` 创建一个缓冲读取器,用来读取用户输入
***
## 调用关系图
```
NewRuntimeContext(args, commandName)
↓ 创建
RuntimeContext{
Client: client.New(), // HTTP 客户端
Owner: cmdutil.Owner, // 全局变量
Repo: cmdutil.Repo, // 全局变量
Format: cmdutil.Format, // 全局变量
Args: args, // 命令行参数
}
RuntimeContext 的方法:
├── ResolveOwnerRepo() → 解析 owner/repo自动或手动
├── CallAPI() → 调用 API无参数
├── CallAPIWithQuery() → 调用 API带参数
├── PaginateAll() → 获取所有分页数据
├── Output() → 输出结果
├── OutputData() → 输出数据(自动包装)
├── RepoPath() → 返回 /owner/repo 路径
├── Arg() → 获取参数值
├── RequireArg() → 获取必填参数(缺则报错)
└── IsDryRun() → 检查预览模式
Shortcut 结构体:
├── Name: "list"
├── Flags: [{Name:"title", Short:"t", Required:true}]
└── Run: func(ctx *RuntimeContext) error {
// 命令执行逻辑
}
```

View File

@ -0,0 +1,560 @@
# internal/client/client.go 阅读笔记(面向 Go 小白)
---
## 第 1 行:`package client`
**字面意思**:声明这个文件属于 `client`
**运行时作用**:这是项目的 HTTP 客户端模块,负责所有与 GitLink API 的通信。
---
## 第 3-16 行import 导入依赖
```go
import (
"bytes" // 字节缓冲(用于构造请求体)
"encoding/json" // JSON 序列化/反序列化
"fmt" // 格式化输出
"io" // 输入输出接口
"net/http" // HTTP 协议
"net/url" // URL 处理
"strings" // 字符串操作
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块(带 Token 的 HTTP 客户端)
"github.com/gitlink-org/gitlink-cli/internal/config" // 配置管理
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // 错误定义
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
)
```
**小白补充**
| 包名 | 用途 | 在本文件中的作用 |
|------|------|-----------------|
| `bytes` | 字节操作 | 把 JSON 数据转成 HTTP 请求体 |
| `io` | 输入输出 | 读取 HTTP 响应体 |
| `net/http` | HTTP 协议 | 创建和发送 HTTP 请求 |
---
## 第 18-23 行:`Client` 结构体(核心!)
```go
type Client struct {
HTTP *http.Client
BaseURL string
Debug bool
SkipJSONSuffix bool
}
```
**字面意思**:定义 HTTP 客户端的结构
**运行时作用**:这是项目封装的 HTTP 客户端,所有 API 调用都通过它来完成。
**小白补充**
### ① 每个字段的含义:
| 字段 | 类型 | 含义 |
|------|------|------|
| `HTTP` | `*http.Client` | Go 标准库的 HTTP 客户端(核心) |
| `BaseURL` | `string` | API 基础地址(如 `https://www.gitlink.org.cn/api` |
| `Debug` | `bool` | 是否开启调试模式(打印请求/响应) |
| `SkipJSONSuffix` | `bool` | 是否跳过自动添加 `.json` 后缀Wiki Gateway 需要) |
### ② `*http.Client` 是什么?
`http.Client` 是 Go 标准库提供的 HTTP 客户端,它包含:
- 连接池管理
- 超时设置
- Cookie 管理
- 传输层配置(如 TLS、代理
我们项目在 `internal/auth/transport.go` 中对它进行了扩展,自动添加认证 Token。
---
## 第 25-35 行:`APIError` 结构体
```go
type APIError struct {
StatusCode int
Code interface{}
Message string
Kind clierrors.ErrorKind
Suggestion string
}
func (e *APIError) Error() string {
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
}
```
**字面意思**:定义 API 错误的结构
**运行时作用**:封装 API 返回的错误信息,包含错误码、消息和解决建议。
**小白补充**
### ① `Error()` 方法:
```go
func (e *APIError) Error() string {
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
}
```
- 这是实现了 Go 的 `error` 接口
- 任何实现了 `Error() string` 方法的类型都可以作为 `error` 返回
- 这样 `APIError` 就可以像普通错误一样使用:`return apiErr`
### ② 为什么需要自定义错误类型?
普通的 `error` 只能包含一条消息,而我们需要:
- `StatusCode`HTTP 状态码404/403/500 等)
- `Code`API 返回的业务错误码
- `Kind`:错误分类(认证错误/输入错误/服务器错误等)
- `Suggestion`:给用户的解决建议
---
## 第 37-46 行:`New` 函数(构造函数)
```go
func New() (*Client, error) {
// 1. 加载配置
cfg, err := config.Load()
if err != nil {
return nil, err
}
// 2. 创建并返回 Client
return &Client{
HTTP: auth.NewHTTPClient(), // 带认证的 HTTP 客户端
BaseURL: cfg.BaseURL, // 从配置获取 API 地址
}, nil
}
```
**字面意思**:创建一个新的 Client 实例
**运行时作用**:这是 Client 的构造函数,自动加载配置并创建带认证的 HTTP 客户端。
**小白补充**
### ① `auth.NewHTTPClient()` 做了什么?
这个函数在 `internal/auth/transport.go` 中,它创建了一个 HTTP 客户端,并且:
- 自动从配置文件读取 Token
- 在每个请求的 `Authorization` 头中添加 `Bearer {token}`
- 处理 Token 过期等情况
### ② 配置文件的内容:
配置文件位于 `~/.config/gitlink-cli/config.yaml`,内容大致如下:
```yaml
base_url: https://www.gitlink.org.cn/api
gateway_base_url: https://gateway.gitlink.org.cn/api
token: your-token-here
```
---
## 第 48-168 行:`Do` 方法(核心!)
这是整个文件中**最重要的函数**,负责发送 HTTP 请求并解析响应。
### ① 路径处理(第 48-67 行)
```go
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
// 自动添加 .json 后缀GitLink API 约定)
if c.shouldAppendJSONSuffix(path) {
if idx := strings.Index(path, "?"); idx != -1 {
// 路径已经包含查询参数,在 ? 前面加 .json
basePath := path[:idx]
queryStr := path[idx:]
path = basePath + ".json" + queryStr
} else {
// 路径没有查询参数,直接加 .json
path += ".json"
}
}
// 构建完整 URL
fullURL := c.BaseURL + path
if query != nil && len(query) > 0 {
sep := "?"
if strings.Contains(fullURL, "?") {
sep = "&" // URL 已经有 ?,用 & 连接
}
fullURL += sep + query.Encode()
}
// ...
}
```
**字面意思**:处理请求路径,构建完整 URL
**运行时作用**GitLink API 约定所有路径都需要 `.json` 后缀,这里自动添加。
**小白补充**
- `c.BaseURL``https://www.gitlink.org.cn/api`
- `path``/users/me`
- 最终 `fullURL` 变成 `https://www.gitlink.org.cn/api/users/me.json`
### ② 请求体处理(第 69-77 行)
```go
// 处理请求体
var bodyReader io.Reader
if body != nil {
// 把 body 序列化成 JSON
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
// 转成 io.ReaderHTTP 请求需要的格式)
bodyReader = bytes.NewReader(data)
}
```
**字面意思**:把请求体转成 HTTP 可以发送的格式
**运行时作用**:如果有请求体(如 POST/PUT 请求),把 Go 的 map 转成 JSON 字符串,再转成字节流。
**小白补充**
- `json.Marshal(body)`:把 Go 结构体/map 转成 JSON 字节数组
- `bytes.NewReader(data)`:把字节数组包装成 `io.Reader`HTTP 请求体需要这个接口)
### ③ 创建 HTTP 请求(第 79-87 行)
```go
// 创建 HTTP 请求
req, err := http.NewRequest(method, fullURL, bodyReader)
if err != nil {
return nil, err
}
// 调试模式:打印请求信息
if c.Debug {
fmt.Printf("→ %s %s\n", method, fullURL)
}
```
**字面意思**:创建一个 HTTP 请求对象
**运行时作用**`http.NewRequest` 创建请求对象包含方法、URL 和请求体。
### ④ 发送请求(第 89-92 行)
```go
// 发送请求
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close() // 确保响应体被关闭
```
**字面意思**:发送 HTTP 请求并获取响应
**运行时作用**`c.HTTP.Do(req)` 发送请求,返回响应对象。
**小白补充**
- `defer resp.Body.Close()`**非常重要!** 确保响应体被关闭,避免资源泄漏
- `defer` 是 Go 的关键字,它会在函数返回前执行后面的语句
- 如果不关闭 `resp.Body`HTTP 连接池会被占满,导致后续请求失败
### ⑤ 读取响应体(第 94-101 行)
```go
// 读取响应体
respData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// 调试模式:打印响应信息
if c.Debug {
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
}
```
**字面意思**:把响应体读取成字节数组
**运行时作用**`io.ReadAll(resp.Body)` 读取整个响应体内容。
**小白补充**
- `resp.StatusCode` 是 HTTP 状态码200=成功404=未找到500=服务器错误)
### ⑥ HTTP 状态码检查(第 103-113 行)
```go
// 检查 HTTP 状态码
if resp.StatusCode >= 400 {
info := lookupStatusInfo(resp.StatusCode)
return nil, &APIError{
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
Kind: info.kind,
Suggestion: info.suggestion,
}
}
```
**字面意思**:如果状态码 >= 400返回错误
**运行时作用**HTTP 4xx/5xx 都是错误,这里封装成 `APIError` 返回。
**小白补充**
- `lookupStatusInfo(resp.StatusCode)` 根据状态码查找对应的错误分类和建议
### ⑦ JSON 解析(第 115-120 行)
```go
// 解析 JSON 响应
var raw map[string]interface{}
if err := json.Unmarshal(respData, &raw); err != nil {
// 不是 JSON直接返回原始内容
return output.SuccessEnvelope(string(respData), nil), nil
}
```
**字面意思**:把响应体解析成 Go 的 map
**运行时作用**`json.Unmarshal` 把 JSON 字符串转成 Go 的 `map[string]interface{}`
**小白补充**
- `json.Unmarshal` 的第二个参数需要传递**指针**`&raw`
- `interface{}` 是 Go 的"万能类型",可以存储任何值
- 如果响应不是 JSON比如返回的是 HTML 错误页面),就直接返回字符串
### ⑧ GitLink 业务错误检查(第 122-142 行)
```go
// 检查 GitLink 业务错误(响应体中的 status 字段)
if status, ok := raw["status"]; ok {
var statusCode float64
switch v := status.(type) {
case float64:
statusCode = v
case int:
statusCode = float64(v)
}
// status 不为 0、1、200 都是错误
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
msg, _ := raw["message"].(string)
info := lookupStatusInfo(int(statusCode))
return output.ErrorEnvelope(int(statusCode), msg, info.suggestion), &APIError{
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
Kind: info.kind,
Suggestion: info.suggestion,
}
}
}
```
**字面意思**:检查 GitLink API 返回的业务错误码
**运行时作用**GitLink API 有时 HTTP 状态码是 200但响应体中的 `status` 字段表示业务失败(如参数校验失败)。
**小白补充**
GitLink API 的响应格式:
```json
{
"status": 0, // 0=失败, 1=成功, 200=成功
"message": "...", // 错误信息
"data": {...} // 数据
}
```
### ⑨ 自动解析 JSON 字符串数据(第 144-150 行)
```go
// 自动解析 JSON 字符串数据GitLink API 的一个特性)
if dataStr, ok := raw["data"].(string); ok {
var parsedData interface{}
if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil {
raw["data"] = json.RawMessage(dataStr)
}
}
```
**字面意思**:处理 data 字段是 JSON 字符串的情况
**运行时作用**GitLink 某些 API 返回的 `data` 字段是字符串形式的 JSON需要再次解析。
**小白补充**
比如响应是这样的:
```json
{
"status": 1,
"data": "{\"name\": \"test\"}" // data 是字符串!
}
```
这里需要把 `"{\"name\": \"test\"}"` 再解析成 `{"name": "test"}`
### ⑩ 构建分页元数据(第 152-166 行)
```go
// 构建分页元数据
var meta *output.Meta
if tc, ok := raw["total_count"]; ok {
meta = &output.Meta{}
if v, ok := tc.(float64); ok {
meta.TotalCount = int(v)
}
if v, ok := raw["page"].(float64); ok {
meta.Page = int(v)
}
if v, ok := raw["limit"].(float64); ok {
meta.Limit = int(v)
}
}
// 返回成功的 Envelope
return output.SuccessEnvelope(raw, meta), nil
```
**字面意思**:从响应中提取分页信息
**运行时作用**:如果 API 返回了分页信息total_count/page/limit提取出来作为 `Meta`
---
## 第 170-184 行:便捷方法
```go
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
return c.Do("GET", path, nil, query)
}
func (c *Client) Post(path string, body interface{}) (*output.Envelope, error) {
return c.Do("POST", path, body, nil)
}
func (c *Client) Put(path string, body interface{}) (*output.Envelope, error) {
return c.Do("PUT", path, body, nil)
}
func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) {
return c.Do("DELETE", path, nil, query)
}
```
**字面意思**:封装常见的 HTTP 方法
**运行时作用**:提供更简洁的调用方式,比如 `client.Get("/users/me", nil)` 而不是 `client.Do("GET", "/users/me", nil, nil)`
---
## 第 186-225 行:错误信息映射
```go
type statusInfo struct {
kind clierrors.ErrorKind
message string
suggestion string
}
var statusMessages = map[int]statusInfo{
-2: {clierrors.KindAuth, "未登录或 Token 已过期",
"运行 gitlink-cli auth login 重新登录"},
-1: {clierrors.KindInput, "参数校验失败",
"检查必填参数是否缺失"},
401: {clierrors.KindAuth, "认证失败",
"运行 gitlink-cli auth login 登录"},
403: {clierrors.KindForbidden, "权限不足",
"请确认账号有此仓库的访问权限"},
404: {clierrors.KindNotFound, "资源不存在",
"检查 owner/repo/id 是否正确"},
// ... 更多状态码
}
func lookupStatusInfo(code int) statusInfo {
if info, ok := statusMessages[code]; ok {
return info
}
return statusInfo{
kind: clierrors.KindUnknown,
message: fmt.Sprintf("API 返回错误码 %d", code),
}
}
```
**字面意思**:根据错误码查找对应的错误信息
**运行时作用**:把枯燥的错误码转换成人类可读的错误信息和解决建议。
---
## 第 227-246 行:`shouldAppendJSONSuffix` 方法
```go
func (c *Client) shouldAppendJSONSuffix(path string) bool {
// 1. 如果设置了 SkipJSONSuffix不添加
if c.SkipJSONSuffix {
return false
}
// 2. 如果已经有 .json 后缀,不添加
if strings.HasSuffix(path, ".json") {
return false
}
// 3. 如果是 raw 内容路径,不添加
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range parts {
if part == "raw" && i >= 2 && i+2 < len(parts) {
return false
}
}
// 4. 其他情况,添加 .json 后缀
return true
}
```
**字面意思**:判断是否应该添加 `.json` 后缀
**运行时作用**:控制是否自动添加 `.json` 后缀。
**小白补充**
为什么需要这个方法?
- Wiki Gateway API 不需要 `.json` 后缀(设置 `SkipJSONSuffix: true`
- 某些路径(如 `/owner/repo/raw/...`)返回的是原始文件内容,不是 JSON
---
## 完整调用流程
```
ctx.CallAPI("GET", "/users/me", nil)
Client.Do("GET", "/users/me", nil, nil)
1. 路径处理:/users/me → /users/me.json
2. 构建 URLhttps://www.gitlink.org.cn/api/users/me.json
3. 创建 HTTP 请求http.NewRequest("GET", url, nil)
4. 发送请求c.HTTP.Do(req)
↓ (auth.NewHTTPClient() 自动添加 Authorization 头)
5. 读取响应体io.ReadAll(resp.Body)
6. 检查状态码:如果 >= 400返回 APIError
7. 解析 JSONjson.Unmarshal → map[string]interface{}
8. 检查业务错误:判断 status 字段
9. 返回 Envelopeoutput.SuccessEnvelope(raw, meta)
```

View File

@ -0,0 +1,706 @@
# shortcuts/wiki/wiki.go 阅读笔记(面向 Go 小白)
---
## 第 1 行:`package wiki`
**字面意思**:声明这个文件属于 `wiki`
**运行时作用**Go 语言规定每个文件必须属于一个包。包名决定了其他文件如何引用这里的函数/变量。
**小白补充**
- 包就像"工具箱"`wiki` 包就是专门处理 Wiki 功能的工具箱
- 同一个包下的文件可以直接互相调用函数,不需要导入
- 包名一般和目录名一致(这里文件在 `shortcuts/wiki/` 目录下,所以包名是 `wiki`
---
## 第 3-22 行import 导入依赖
```go
import (
"encoding/base64" // Base64 编解码
"encoding/json" // JSON 序列化/反序列化
"errors" // 错误处理
"fmt" // 格式化输出(类似 Python 的 print
"net/http" // HTTP 客户端
"net/url" // URL 编码/解析
"os" // 操作系统交互(读文件等)
"regexp" // 正则表达式
"strconv" // 字符串转数字
"strings" // 字符串操作
"sync" // 并发同步(锁、线程安全)
"time" // 时间处理
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端封装
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // CLI 错误定义
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 通用工具
)
```
**字面意思**:导入需要用到的外部库/包
**运行时作用**:告诉 Go 编译器,我需要使用这些包提供的功能。编译时会把这些包的代码链接进来。
**小白补充**
| 包名 | 一句话解释 | 在本文件中的用途 |
|------|-----------|-----------------|
| `encoding/base64` | 把文字转成 Base64 编码 | Wiki 内容需要用 Base64 编码后发送 |
| `encoding/json` | 处理 JSON 数据 | 解析 API 返回的 JSON |
| `errors` | Go 标准错误处理工具 | 判断错误类型 |
| `fmt` | 格式化打印 | 输出错误信息、拼接字符串 |
| `net/http` | HTTP 协议客户端 | 发送 HTTP 请求 |
| `net/url` | URL 处理 | 构建查询参数、URL 编码 |
| `os` | 操作系统接口 | 读取本地文件内容 |
| `regexp` | 正则表达式 | 匹配 Markdown 链接和图片 |
| `strconv` | 字符串转换 | 把字符串转成数字 |
| `strings` | 字符串操作 | 切割、查找、替换字符串 |
| `sync` | 并发同步 | 提供线程安全的缓存(`sync.Map` |
| `time` | 时间处理 | 设置 HTTP 请求超时 |
| `internal/auth` | 项目内部认证模块 | 获取带 Token 的 HTTP 客户端 |
| `internal/client` | 项目内部客户端模块 | 封装 API 调用逻辑 |
| `internal/errors` | 项目内部错误定义 | 自定义错误类型 |
| `internal/output` | 项目内部输出模块 | 格式化输出结果JSON/Table |
| `shortcuts/common` | 通用工具模块 | 提供 RuntimeContext 等基础结构 |
---
## 第 24 行:`var projectIDCache sync.Map`
**字面意思**:声明一个全局变量 `projectIDCache`,类型是 `sync.Map`
**运行时作用**:这是一个**线程安全的缓存**,用来存储 `owner/repo -> projectID` 的映射关系,避免重复调用 API 获取项目 ID。
**小白补充**
- `var` 是 Go 声明变量的关键字
- `sync.Map` 是 Go 标准库提供的**并发安全的 map**(普通 map 在多线程下读写会崩溃)
- `projectIDCache` 是全局变量(在函数外面声明),整个包内都可以访问
- 为什么需要缓存?因为每次操作 Wiki 都需要 projectID但获取 projectID 需要调用一次 API缓存可以节省网络请求
---
## 第 26-28 行:`wikiPath` 函数
```go
func wikiPath(endpoint string) string {
return "/wiki/open/" + endpoint
}
```
**字面意思**:定义一个函数 `wikiPath`,接收一个字符串参数 `endpoint`返回一个字符串Wiki 功能调用的是 Gateway API (网关 API所有 Wiki 相关的接口都有一个固定的前缀 /wiki/open/
**运行时作用**:拼接 Wiki API 的路径前缀。比如传入 `"wikiPages"`,返回 `"/wiki/open/wikiPages"`
**小白补充**
- `func` 是 Go 定义函数的关键字
- `wikiPath(endpoint string)`:函数名是 `wikiPath`,参数名是 `endpoint`,参数类型是 `string`
- `string`(返回类型):表示函数执行完返回一个字符串
- 这是一个**工具函数**,用来避免重复写相同的路径前缀
---
## 第 30-49 行:`getGatewayClient` 函数
```go
func getGatewayClient(ctx *common.RuntimeContext) *client.Client {
baseURL := ctx.GatewayBaseURL
if baseURL == "" {
baseURL = "https://gateway.gitlink.org.cn/api"
}
httpClient := ctx.GatewayHTTPClient
if httpClient == nil {
httpClient = auth.NewHTTPClient()
}
return &client.Client{
HTTP: httpClient,
BaseURL: baseURL,
SkipJSONSuffix: true,
Debug: ctx.Client.Debug,
}
}
```
**字面意思**:定义一个函数 `getGatewayClient`,接收 `*common.RuntimeContext` 类型的指针参数 `ctx`,返回 `*client.Client` 类型的指针
**运行时作用**:创建一个专门访问 **Wiki Gateway API** 的客户端实例。
**小白补充**
### ① 为什么需要单独的 Gateway 客户端?
GitLink 的 Wiki API 和主 API 不在同一个域名:
- 主 API`https://www.gitlink.org.cn/api`(用于获取项目信息等)
- Wiki Gateway API`https://gateway.gitlink.org.cn/api`(专门处理 Wiki 操作)
### ② 代码逐句解析:
```go
baseURL := ctx.GatewayBaseURL // 从上下文获取 Gateway 地址
if baseURL == "" { // 如果没配置,用默认地址
baseURL = "https://gateway.gitlink.org.cn/api"
}
```
```go
httpClient := ctx.GatewayHTTPClient // 获取自定义的 HTTP 客户端
if httpClient == nil { // 如果没有自定义的,创建一个带认证的默认客户端
httpClient = auth.NewHTTPClient()
}
```
```go
return &client.Client{...} // 创建并返回 Client 结构体实例
```
### ③ 结构体初始化语法:
```go
&client.Client{
HTTP: httpClient, // 使用上面创建的 HTTP 客户端
BaseURL: baseURL, // Gateway API 地址
SkipJSONSuffix: true, // 关键Gateway API 不需要 .json 后缀
Debug: ctx.Client.Debug, // 继承调试模式
}
```
- `&` 符号表示取地址返回指针Go 中结构体传参常用指针,避免拷贝)
- `client.Client` 是一个**结构体类型**,里面定义了客户端的各种配置
---
## 第 51-58 行:`callWikiAPI` 函数
```go
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, body, nil)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
```
**字面意思**:定义函数 `callWikiAPI`接收上下文、HTTP 方法、路径、请求体,返回 `(*output.Envelope, error)`
**运行时作用**:封装对 Wiki Gateway API 的调用流程。
**小白补充**
### ① 参数说明:
- `ctx *common.RuntimeContext`运行时上下文包含认证信息、owner/repo 等
- `method string`HTTP 方法GET/POST/PUT/DELETE
- `path string`API 路径
- `body interface{}`请求体可以是任何类型Go 中 `interface{}` 表示万能类型)
### ② 返回值说明:
- `(*output.Envelope, error)`Go 可以返回多个值!第一个是 API 返回的包装数据,第二个是错误
### ③ 执行流程:
1. `gc := getGatewayClient(ctx)` → 获取 Gateway 客户端
2. `gc.Do(...)` → 调用客户端的 Do 方法发送 HTTP 请求
3. `unwrapGatewayResponse(env)` → 解析并处理响应(后面会讲)
---
## 第 60-67 行:`callWikiAPIWithQuery` 函数
```go
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, nil, query)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
```
**字面意思**:和 `callWikiAPI` 类似,但专门用于带查询参数的请求
**运行时作用**:当需要发送带 `?key=value` 查询参数的 GET 请求时使用。
**小白补充**
- `url.Values` 是 Go 标准库类型,本质是 `map[string][]string`,用来存储 URL 查询参数
- 比如 `?owner=zzx&repo=test` 会被表示为 `{"owner": ["zzx"], "repo": ["test"]}`
---
## 第 69-99 行:`unwrapGatewayResponse` 函数(核心!)
```go
func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
// 1. 尝试把响应数据转成 map
resp, ok := env.Data.(map[string]interface{})
if !ok {
return env, nil // 不是 map 格式,直接返回
}
// 2. 检查响应中的 code 字段
if code, ok := resp["code"]; ok {
switch v := code.(type) {
case float64:
// HTTP 2xx 都算成功(包括 200/201/204 等)
if v < 200 || v >= 300 {
// 错误情况:提取错误信息
msg, _ := resp["msg"].(string)
kind := clierrors.KindServer
if int(v) == 404 {
kind = clierrors.KindNotFound
} else if int(v) == 401 || int(v) == 403 {
kind = clierrors.KindForbidden
}
// 返回自定义错误
return nil, clierrors.New(kind, msg,
"检查 owner/repo 是否正确,或确认仓库已在 GitLink 网页端开启 Wiki 功能")
}
}
}
// 3. 如果响应有 data 字段,提取出来作为新的响应数据
if innerData, ok := resp["data"]; ok {
return output.SuccessEnvelope(innerData, env.Meta), nil
}
return env, nil
}
```
**字面意思**"拆开" Gateway API 的响应,提取真正的数据
**运行时作用**:处理 Gateway API 返回的特殊格式,统一成标准的 `Envelope` 结构。
**小白补充**
### ① Gateway API 的响应格式:
Gateway API 返回的 JSON 格式是这样的:
```json
{
"code": 200,
"msg": "success",
"data": { "真正的数据在这里" }
}
```
而我们需要的是直接拿到 `data` 里面的内容。
### ② 类型断言Go 的特色语法):
```go
resp, ok := env.Data.(map[string]interface{})
```
- 这是**类型断言**,把 `env.Data`(类型是 `interface{}`)转换成 `map[string]interface{}`
- `ok` 是一个布尔值,表示转换是否成功
- 如果转换失败(比如 `env.Data` 是个字符串而不是 map`ok` 就是 `false`
### ③ switch type 语法:
```go
switch v := code.(type) {
case float64:
// code 是浮点数类型时执行这里
}
```
- 这是 Go 的**类型 switch**,用来判断一个 `interface{}` 变量的具体类型
- JSON 解析数字时,默认会转成 `float64` 类型
### ④ 为什么要判断 2xx 状态码?
```go
if v < 200 || v >= 300 {
// 错误处理
}
```
- HTTP 状态码中200-299 表示成功
- 之前的代码只判断了 200/201导致 DELETE 返回 204No Content时被误判为失败
- 现在扩展到所有 2xx 都算成功
---
## 第 101-135 行:`resolveProjectID` 函数(核心!)
```go
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
// 1. 生成缓存 key
key := ctx.Owner + "/" + ctx.Repo
// 2. 先查缓存
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil // 缓存命中,直接返回
}
// 3. 缓存没命中,调用主 API 获取项目详情
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return "", fmt.Errorf("failed to fetch project details (needed for projectId): %w", err)
}
// 4. 从响应中提取 project_id
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("unexpected response from project detail API")
}
pid, ok := data["project_id"]
if !ok {
return "", fmt.Errorf("project_id not found in project detail response")
}
// 5. 处理 project_id 的不同类型(可能是 float64 或 int
var pidStr string
switch v := pid.(type) {
case float64:
pidStr = fmt.Sprintf("%.0f", v)
case int:
pidStr = fmt.Sprintf("%d", v)
default:
pidStr = fmt.Sprintf("%v", v)
}
// 6. 存入缓存
projectIDCache.Store(key, pidStr)
return pidStr, nil
}
```
**字面意思**:根据 owner/repo 解析出项目的数字 ID
**运行时作用**Wiki API 需要 `projectId`(数字),但用户只知道 `owner/repo`(字符串),这个函数就是做转换的。
**小白补充**
### ① 为什么需要 projectID
GitLink 的 Wiki Gateway API 设计要求传入数字形式的 `projectId`,而不是字符串形式的 `owner/repo`。所以必须先调用主 API 获取项目详情,从中提取 `project_id`
### ② 缓存机制:
```go
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil
}
```
- `projectIDCache.Load(key)` 从缓存中查找
- 如果找到(`ok == true`),直接返回缓存的值,不需要再调用 API
- 这是**性能优化**,避免重复请求
### ③ fmt.Sprintf 的用法:
```go
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
```
- 类似 Python 的 `"%s/%s/detail" % (owner, repo)`
- `%s` 是占位符,会被后面的参数替换
### ④ ctx.CallAPI 是什么?
```go
env, err := ctx.CallAPI("GET", path, nil)
```
- `ctx``*common.RuntimeContext` 类型
- `CallAPI``RuntimeContext` 结构体的**方法**(后面会详细讲)
- 它内部调用 `ctx.Client.Do()` 发送 HTTP 请求
---
## 第 137-140 行:`parseProjectIDInt` 函数
```go
func parseProjectIDInt(pid string) int {
n, _ := strconv.Atoi(pid)
return n
}
```
**字面意思**:把字符串形式的 projectID 转成整数
**运行时作用**Wiki API 的某些接口要求 `projectId` 是整数类型,所以需要转换。
**小白补充**
- `strconv.Atoi` 是 string convert to int 的缩写
- `_` 是 Go 语言的"忽略符",表示忽略返回的错误(这里假设 pid 一定是合法数字)
---
## 第 142-154 行:`resolveUpdateContent` 函数
```go
func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (string, error) {
if text != "" {
return text, nil // 直接使用提供的文本
}
if filePath != "" {
data, err := os.ReadFile(filePath) // 从文件读取
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("no content provided")
}
```
**字面意思**:解析更新 Wiki 时的内容来源
**运行时作用**:支持两种方式提供内容:直接文本(`--cover`)或文件路径(`--file`)。
---
## 第 156-179 行:`fetchPageContent` 函数(带自动重试)
```go
func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (content string, actualPageName string, err error) {
// 第一次尝试
c, actual, err := fetchPageContentOnce(ctx, projectID, pageName)
if err == nil {
return c, actual, nil // 成功了,直接返回
}
// 第一次失败,且 pageName 不带 ".-" 后缀,自动重试
if !strings.HasSuffix(pageName, ".-") {
c2, actual2, err2 := fetchPageContentOnce(ctx, projectID, pageName+".-")
if err2 == nil {
return c2, actual2, nil // 重试成功
}
}
// 两次都失败
return "", "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err)
}
```
**字面意思**:获取 Wiki 页面的明文内容,带自动重试机制
**运行时作用**:解决 GitLink 后端的一个命名问题。
**小白补充**
### ① GitLink 后端的命名 bug
GitLink 创建 Wiki 页面时,会自动给内部存储的 `sub_url` 追加 `".-"` 后缀。但 `wiki +list` 返回的 `title` 不带后缀。
比如:
- 用户创建页面 "Home"
- 后端实际存储的 key 是 "Home.-"
- 但 list API 返回的 title 是 "Home"
所以用 "Home" 去查询会 404必须用 "Home.-" 才能查到。
### ② 自动重试逻辑:
1. 先用原始 `pageName` 尝试查询
2. 如果失败,且 `pageName` 不带 `".-"` 后缀
3. 自动用 `pageName+".-"` 重试一次
---
## 第 181-205 行:`fetchPageContentOnce` 函数(单次查询)
```go
func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string) (string, string, error) {
// 构建查询参数
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
// 调用 API
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if err != nil {
return "", "", err
}
// 解析响应
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("unexpected response from getWiki")
}
// 提取 base64 编码的内容
b64, _ := data["content_base64"].(string)
if b64 == "" {
return "", pageName, nil // 内容为空,返回空字符串
}
// Base64 解码
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", "", fmt.Errorf("failed to decode page content: %w", err)
}
return string(decoded), pageName, nil
}
```
**字面意思**:单次尝试获取 Wiki 页面内容
**运行时作用**:发送 GET 请求到 `/wiki/open/getWiki`,获取页面数据并解码。
**小白补充**
### ① URL 查询参数构建:
```go
q := url.Values{}
q.Set("owner", ctx.Owner)
```
- `url.Values` 是 map 类型,用来存储查询参数
- 最终会变成 `?owner=zzx&repo=test&projectId=12345&pageName=Home`
### ② Base64 编解码:
```go
b64, _ := data["content_base64"].(string) // 获取 base64 编码的内容
decoded, err := base64.StdEncoding.DecodeString(b64) // 解码
return string(decoded), pageName, nil // 转成字符串返回
```
- Wiki API 返回的内容是 Base64 编码的(可能是为了支持二进制文件)
- 需要解码才能得到人类可读的文本
---
## 第 207-216 行:`fetchWikiPage` 函数
```go
func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
}
```
**字面意思**:获取 Wiki 页面的完整响应(不解码)
**运行时作用**:和 `fetchPageContent` 类似,但返回完整的 `Envelope` 而不是解码后的文本。
---
## 第 218-230 行:`resolveContent` 函数
```go
func resolveContent(ctx *common.RuntimeContext) (string, error) {
if content := ctx.Arg("content"); content != "" {
return content, nil
}
if filePath := ctx.Arg("file"); filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("--content or --file is required to provide wiki page content")
}
```
**字面意思**:解析创建 Wiki 时的内容来源
**运行时作用**:支持 `--content` 直接传内容,或 `--file` 从文件读取。
**小白补充**
- `ctx.Arg("content")` 是从命令行参数中获取 `--content` 的值
- 如果两个参数都没提供,返回错误
---
## 第 232-249 行:`cleanWikiList` 函数
```go
func cleanWikiList(env *output.Envelope) {
// 把 data 转成 slice
items, ok := env.Data.([]interface{})
if !ok {
return
}
// 遍历每个 wiki 页面
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
// 删除不需要的字段
delete(m, "wiki_clone_link")
// URL 解码 sub_url
if raw, ok := m["sub_url"].(string); ok {
if decoded, err := url.QueryUnescape(raw); err == nil {
m["sub_url"] = decoded
}
}
}
}
```
**字面意思**:清理 Wiki 列表数据
**运行时作用**:对 `wiki +list` 返回的数据进行清洗,去掉无用字段,解码 URL。
---
## 第 251-299 行:`outputWithDecodedContent` 函数
```go
func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error {
data := env.Data
// 处理 JSON 字符串形式的 data
if raw, ok := data.(json.RawMessage); ok {
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err == nil {
data = m
env.Data = m
}
}
// 转成 map
m, ok := data.(map[string]interface{})
if !ok {
return ctx.Output(env)
}
// content_base64 → content重命名并解码
if b64, ok := m["content_base64"].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m["content"] = string(decoded)
delete(m, "content_base64") // 删除原字段
}
}
// sidebar / footer 原地解码
for _, field := range []string{"sidebar", "footer"} {
if b64, ok := m[field].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m[field] = string(decoded)
}
}
}
return ctx.Output(env)
}
```
**字面意思**:解码 Wiki 响应中的所有 Base64 字段,用明文替换
**运行时作用**:让返回的 Wiki 内容更易读,同时节省 tokenBase64 编码会增加约 33% 的体积)。
---
## 第 301-526 行Lint 相关

View File

@ -0,0 +1,510 @@
# 逐行讲解 shortcuts/webhook/webhook.go面向 Go 小白)
## 文件概述
这个文件实现了 **Webhook 管理**功能,可以对 GitLink 仓库的 Webhook 进行增删改查操作。
---
## 一、包声明和导入
```go
package webhook
import (
"fmt"
"net/url"
"strings"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
```
| 导入库 | 作用 |
|-------|------|
| `fmt` | 格式化输出,用于拼接字符串和格式化错误信息 |
| `net/url` | URL 相关操作,用于构建查询参数 |
| `strings` | 字符串处理,用于分割、修剪等操作 |
| `clierrors` | 自定义 CLI 错误类型,用于返回友好的错误提示 |
| `output` | 输出格式化,用于返回统一格式的结果 |
| `common` | 公共工具包,包含 Shortcut、RuntimeContext 等核心类型 |
---
## 二、支持的 Webhook 事件类型
```go
var supportedEvents = []string{
"push",
"pull_request",
"issue",
"issue_assign",
"issue_comment",
"pull_request_assign",
"pull_request_comment",
"merge_request",
"repository",
"branch",
"tag",
}
```
这是一个**全局变量**,定义了 GitLink 支持的所有 Webhook 事件类型:
- `push`:代码推送事件
- `pull_request`PR 事件
- `issue`Issue 事件
- `issue_assign`Issue 分配事件
- `issue_comment`Issue 评论事件
- `pull_request_assign`PR 分配事件
- `pull_request_comment`PR 评论事件
- `merge_request`:合并请求事件
- `repository`:仓库事件
- `branch`:分支创建/删除事件
- `tag`:标签创建/删除事件
---
## 三、事件验证函数
```go
func isEventSupported(event string) bool {
for _, supported := range supportedEvents {
if event == supported {
return true
}
}
return false
}
```
**功能**:检查某个事件类型是否被支持
**工作原理**:遍历 `supportedEvents` 数组,逐一比对,如果找到匹配项就返回 `true`,否则返回 `false`
---
## 四、解析事件字符串
```go
func parseEvents(eventsStr string) []string {
if eventsStr == "" {
return []string{"push"} // 默认事件
}
events := strings.Split(eventsStr, ",")
var validEvents []string
for _, event := range events {
event = strings.TrimSpace(event)
if isEventSupported(event) {
validEvents = append(validEvents, event)
}
}
return validEvents
}
```
**功能**:把用户输入的逗号分隔的事件字符串(如 `"push,pull_request"`)解析成事件数组
**逐行解读**
1. 如果输入为空,返回默认值 `["push"]`
2. 使用 `strings.Split` 按逗号分割字符串
3. 遍历每个事件,用 `strings.TrimSpace` 去掉前后空格
4. 用 `isEventSupported` 验证有效性,有效才加入结果数组
5. 返回过滤后的有效事件数组
---
## 五、API 路径构建函数
```go
func webhookRepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
```
**功能**:构建 Webhook API 的基础路径
**参数**`ctx` 是运行时上下文,包含 `Owner`(仓库所有者)和 `Repo`(仓库名)
**返回值**:类似 `/v1/owner/repo` 的字符串
**注意**:注释说明了 BaseURL 已经包含 `/api` 前缀,所以这里不需要再加
---
## 六、Shortcuts 主函数
```go
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// list 命令
// create 命令
// update 命令
// delete 命令
// test 命令
// info 命令
// events 命令
}
}
```
**功能**:返回所有 Webhook 相关的 CLI 命令列表
这个函数是整个文件的核心它定义了7个命令
1. `list` - 列出所有 Webhook
2. `create` - 创建新 Webhook
3. `update` - 更新现有 Webhook
4. `delete` - 删除 Webhook
5. `test` - 测试 Webhook 发送
6. `info` - 查看 Webhook 详情
7. `events` - 列出所有支持的事件类型
---
## 七、命令详解
### 7.1 list 命令
```go
{
Name: "list",
Description: "List all webhooks for a repository",
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", webhookRepoPath(ctx)+"/webhooks", q)
if err != nil {
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
}
return ctx.Output(env)
},
}
```
**Flags 参数说明**
- `--page/-p`页码默认第1页
- `--limit/-l`每页条数默认20条
**执行流程**
1. 调用 `ctx.ResolveOwnerRepo()` 解析仓库信息
2. 创建 URL 查询参数 `url.Values{}`
3. 设置 `page``limit` 参数
4. 调用 `CallAPIWithQuery` 发送 GET 请求到 `/v1/owner/repo/webhooks`
5. 返回结果给用户
---
### 7.2 create 命令
```go
{
Name: "create",
Description: "Create a new webhook",
Flags: []common.Flag{
{Name: "url", Short: "u", Usage: "Webhook callback URL", Required: true},
{Name: "events", Short: "e", Usage: "Trigger events", Default: "push"},
{Name: "active", Usage: "Webhook active status", Default: "true"},
{Name: "secret", Usage: "Webhook secret for HMAC verification"},
{Name: "description", Short: "d", Usage: "Webhook description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
webhookURL, err := ctx.RequireArg("url", "--url https://example.com/hook")
if err != nil {
return err
}
events := parseEvents(ctx.Arg("events"))
if len(events) == 0 {
return clierrors.InputError(...)
}
payload := map[string]interface{}{
"url": webhookURL,
"http_method": "POST",
"active": true,
"content_type": "json",
}
// 添加可选参数
if len(events) > 0 {
payload["events"] = events
}
if secret := ctx.Arg("secret"); secret != "" {
payload["secret"] = secret
}
if description := ctx.Arg("description"); description != "" {
payload["description"] = description
}
env, err := ctx.CallAPI("POST", webhookRepoPath(ctx)+"/webhooks", payload)
if err != nil {
return fmt.Errorf("创建 Webhook 失败: %w", err)
}
return ctx.Output(env)
},
}
```
**执行流程**
1. 解析仓库信息
2. 必须获取 `--url` 参数(用 `RequireArg`,如果没提供会报错)
3. 解析事件类型
4. 创建 payload 映射,包含必填字段
5. 添加可选的 secret 和 description
6. 发送 POST 请求创建 Webhook
---
### 7.3 update 命令
```go
{
Name: "update",
Description: "Update an existing webhook",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "url", Short: "u", Usage: "Webhook callback URL"},
{Name: "events", Short: "e", Usage: "Trigger events"},
{Name: "active", Usage: "Webhook active status"},
{Name: "content_type", Usage: "Content type"},
{Name: "secret", Usage: "Webhook secret"},
{Name: "description", Short: "d", Usage: "Webhook description"},
},
Run: func(ctx *common.RuntimeContext) error {
// ... 解析仓库和 ID
webhookURL := ctx.Arg("url")
if webhookURL == "" {
// 如果用户没提供 URL先获取当前 URL
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
// ... 解析响应获取当前 URL
webhookURL = currentURL
}
payload["url"] = webhookURL
// ... 发送 PUT 请求
},
}
```
**亮点**:如果用户没有提供新的 URL会自动调用 GET API 获取当前 URL这样就不需要用户重复输入
---
### 7.4 delete 命令
```go
{
Name: "delete",
Description: "Delete a webhook",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
// ... 解析仓库和 ID
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if delErr != nil {
// 验证是否真的删除成功
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
if viewErr != nil {
// GET 也失败,说明 Webhook 确实不存在了,删除成功
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "Webhook deleted successfully",
}, nil))
}
return fmt.Errorf("删除 Webhook 失败: %w", delErr)
}
return ctx.Output(output.SuccessEnvelope(...))
},
}
```
**亮点**:删除操作有一个**双重验证**机制:
1. 先调用 DELETE 请求
2. 如果 DELETE 返回错误,再调用 GET 请求检查 Webhook 是否还存在
3. 如果 GET 也失败,说明 Webhook 已经被删除了,视为成功
---
### 7.5 test 命令
```go
{
Name: "test",
Description: "Test a webhook delivery",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "event", Short: "e", Usage: "Event type to test", Default: "push"},
},
Run: func(ctx *common.RuntimeContext) error {
// ... 解析参数
eventType := ctx.Arg("event")
if !isEventSupported(eventType) {
return clierrors.InputError(...)
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
// ...
},
}
```
**功能**:向指定的 Webhook 发送测试请求,验证 Webhook 是否正常工作
---
### 7.6 info 命令
```go
{
Name: "info",
Description: "Show webhook details",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
// ... 调用 GET /v1/owner/repo/webhooks/{id}
},
}
```
**功能**:查看单个 Webhook 的详细信息
---
### 7.7 events 命令
```go
{
Name: "events",
Description: "List all supported event types for webhooks",
Run: func(ctx *common.RuntimeContext) error {
eventInfo := make([]map[string]interface{}, 0)
for _, event := range supportedEvents {
eventInfo = append(eventInfo, map[string]interface{}{
"event": event,
"supported": true,
"description": getEventDescription(event),
})
}
return ctx.Output(output.SuccessEnvelope(eventInfo, nil))
},
}
```
**功能**:列出所有支持的 Webhook 事件类型及其描述
---
## 八、事件描述函数
```go
func getEventDescription(event string) string {
descriptions := map[string]string{
"push": "Code push events",
"pull_request": "Pull request events",
"issue": "Issue events",
// ... 其他事件描述
}
if desc, ok := descriptions[event]; ok {
return desc
}
return "Custom event"
}
```
**功能**:返回事件类型的英文描述
**工作原理**:使用 map 查找事件对应的描述,如果找不到就返回 "Custom event"
---
## 九、完整调用流程
```
用户命令 (gitlink webhook list)
解析命令行参数
Shortcuts() 返回命令列表
匹配到 "list" 命令
执行 Run 函数
ctx.ResolveOwnerRepo() → 解析仓库信息
ctx.CallAPIWithQuery() → 调用 HTTP 客户端
内部调用 client.Do() → 发送 GET 请求
解析响应 → ctx.Output() → 格式化输出给用户
```
---
## 十、Go 语言知识点
### 1. map[string]interface{} 类型
```go
payload := map[string]interface{}{
"url": webhookURL,
"http_method": "POST",
"active": true,
}
```
这是一个**万能类型**,可以存储任意类型的值:
- `"url"` 对应字符串
- `"active"` 对应布尔值
- `"events"` 对应字符串数组
### 2. 字符串拼接
```go
fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
```
类似 Python 的 `f"/v1/{owner}/{repo}"`,用 `%s` 占位符
### 3. 错误包装
```go
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
```
`%w` 是 Go 1.13+ 的错误包装语法,保留原始错误信息
### 4. 函数作为参数
```go
Run: func(ctx *common.RuntimeContext) error {
// 匿名函数
}
```
这是一个**匿名函数**,作为 `Shortcut` 结构体的 `Run` 字段值
### 5. 字符串分割
```go
events := strings.Split(eventsStr, ",")
```
按逗号分割字符串,返回字符串数组

View File

@ -0,0 +1,546 @@
# 逐行讲解 shortcuts/issue/batch.go面向 Go 小白)
## 文件概述
这个文件实现了 **Issue 批量操作**功能,可以对多个 Issue 进行批量关闭、修改状态、修改优先级、分配人和修改标签等操作。
---
## 一、包声明和导入
```go
package issue
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
```
| 导入库 | 作用 |
|-------|------|
| `encoding/csv` | CSV 文件解析,用于从文件读取 Issue 编号 |
| `fmt` | 格式化输出 |
| `os` | 文件操作,用于打开 CSV 文件 |
| `strconv` | 字符串和数字之间的转换 |
| `strings` | 字符串处理 |
| `common` | 公共工具包 |
---
## 二、常量定义
```go
const (
priorityLow = 1
priorityNormal = 2
priorityHigh = 3
priorityUrgent = 4
)
```
**优先级常量**:定义了 Issue 优先级对应的数字 ID
```go
const (
statusNew = 1
statusInProgress = 2
statusResolved = 3
statusClosed = 5
statusRejected = 6
)
```
**状态常量**:定义了 Issue 状态对应的数字 ID
```go
const (
trackerBug = 1
trackerFeature = 2
trackerSupport = 3
trackerDoc = 4
trackerTest = 5
trackerDuplicate = 6
trackerQuestion = 7
)
```
**类型常量**:定义了 Issue 类型对应的数字 ID
---
## 三、名称映射表
```go
var priorityNames = map[int]string{
priorityLow: "low",
priorityNormal: "normal",
priorityHigh: "high",
priorityUrgent: "urgent",
}
var statusNames = map[int]string{
statusNew: "new",
statusInProgress: "in-progress",
statusResolved: "resolved",
statusClosed: "closed",
statusRejected: "rejected",
}
var trackerNames = map[int]string{
trackerBug: "bug",
trackerFeature: "feature",
// ...
}
```
**作用**:把数字 ID 转换成可读的英文名称,方便输出结果
---
## 四、标签 ID 映射
```go
var tagIDs = map[string]int{
"缺陷": 315526,
"功能": 315527,
"文档": 315533,
"重复": 315525,
"疑问": 315528,
"支持": 315529,
"任务": 315530,
"测试": 315534,
"协助": 315531,
"搁置": 315532,
}
```
**作用**:中文标签名称到 GitLink 标签 ID 的映射
**注意**:这些 ID 是从网页端 DevTools 抓包获取的,不同项目可能不同
---
## 五、结果结构体
```go
type BatchResult 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"`
}
```
**单个操作结果**:记录单个 Issue 的操作结果
```go
type BatchSummary struct {
Repository string `json:"repository" yaml:"repository"`
Action string `json:"action" yaml:"action"`
Value string `json:"value,omitempty" yaml:"value,omitempty"`
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"`
}
```
**批量操作汇总**:记录整个批量操作的统计信息
---
## 六、批量操作命令
### 6.1 batch-close 命令
```go
func newBatchCloseShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-close",
Description: "Close multiple issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchClose,
}
}
```
**执行函数**
```go
func runBatchClose(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "close",
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "close"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "closed"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
```
**执行流程**
1. 解析仓库信息
2. 收集 Issue 编号(从 `--numbers` 参数或 CSV 文件)
3. 初始化 `BatchSummary` 汇总对象
4. 遍历每个 Issue 编号:
- 如果是 dry-run直接标记为 planned
- 否则调用 `updateIssueField` 更新状态为 closed
5. 输出汇总结果
---
### 6.2 batch-status 命令
```go
func runBatchStatus(ctx *common.RuntimeContext) error {
// ... 解析参数
state := ctx.Arg("state")
statusID, err := parseStatus(state)
if err != nil {
return err
}
// ... 遍历更新
updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID})
}
```
**功能**:批量修改 Issue 状态
**参数**`--state` 指定目标状态new/in-progress/resolved/closed/rejected
---
### 6.3 batch-priority 命令
```go
func runBatchPriority(ctx *common.RuntimeContext) error {
// ...
priority := ctx.Arg("priority")
priorityID, err := parsePriority(priority)
// ...
updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID})
}
```
**功能**:批量修改 Issue 优先级
**参数**`--priority` 指定目标优先级low/normal/high/urgent
---
### 6.4 batch-assign 命令
```go
func runBatchAssign(ctx *common.RuntimeContext) error {
// ...
assignee := ctx.Arg("assignee")
var assigneeID interface{}
if !dryRun {
id, err := resolveUserID(ctx, assignee)
assigneeID = id
}
// ...
updateIssueField(ctx, number, map[string]interface{}{"assigned_to_id": assigneeID})
}
```
**功能**:批量分配 Issue 给指定用户
**亮点**:需要先把用户名转换成用户 ID
---
### 6.5 batch-label 命令
```go
func runBatchLabel(ctx *common.RuntimeContext) error {
// ...
label := ctx.Arg("label")
trackerID, err := parseTracker(label)
// ...
updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{trackerID}})
}
```
**功能**:批量修改 Issue 的标签
**参数**`--label` 可以是英文bug/feature或中文缺陷/功能)
---
## 七、核心辅助函数
### 7.1 updateIssueField
```go
func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error {
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("fetch issue #%s: %w", number, err)
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
}
for k, v := range fields {
body[k] = v
}
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("update issue #%s: %w", number, err)
}
return nil
}
```
**功能**:更新 Issue 的指定字段
**关键点**
1. 先调用 `fetchExistingIssue` 获取当前 Issue 的标题和描述
2. 必须在请求体中包含 `subject``description`,否则会被清空
3. 把要更新的字段合并到 body 中
4. 发送 PATCH 请求
---
### 7.2 resolveUserID
```go
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
if id, err := strconv.Atoi(login); err == nil {
return id, nil
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
if err != nil {
return nil, fmt.Errorf("lookup user %q: %w", login, err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response for user %q", login)
}
idFloat, ok := data["id"].(float64)
if ok {
return int(idFloat), nil
}
userIDFloat, ok := data["user_id"].(float64)
if ok {
return int(userIDFloat), nil
}
return nil, fmt.Errorf("cannot determine user ID for %q", login)
}
```
**功能**:把用户名转换成用户 ID
**工作原理**
1. 如果输入已经是数字,直接返回
2. 否则调用 `/users/{login}` API 获取用户信息
3. 从响应中提取 `id``user_id` 字段
4. API 返回的数字是 float64 类型,需要转换成 int
---
### 7.3 collectIssueNumbers
```go
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := parseIssueNumbers(numbersValue)
if err != nil {
return nil, err
}
if csvPath == "" {
return numbers, nil
}
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
if err != nil {
return nil, err
}
return mergeIssueNumbers(numbers, csvNumbers), nil
}
```
**功能**:从 `--numbers` 参数和 CSV 文件中收集 Issue 编号
---
### 7.4 readIssueNumbersFromCSV
```go
func readIssueNumbersFromCSV(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
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
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if numberColumn >= len(record) {
continue
}
values = append(values, record[numberColumn])
}
return normalizeIssueNumbers(values)
}
```
**功能**:从 CSV 文件读取 Issue 编号
**智能表头识别**
- 自动识别 `number`、`issue_number`、`project_issues_index` 列
- 如果没有匹配的表头,默认使用第一列
- 跳过表头行,从第二行开始读取
---
## 八、类型转换函数
```go
func parseStatus(state string) (int, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "new":
return statusNew, nil
case "in-progress", "in_progress", "inprogress":
return statusInProgress, nil
// ...
default:
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid state %q", state)
}
}
```
**功能**:把用户输入的状态字符串转换成数字 ID
**容错处理**
- 支持多种写法:`in-progress`、`in_progress`、`inprogress`
- 如果输入是数字,直接返回
`parsePriority``parseTracker` 函数类似
---
## 九、Go 语言知识点
### 1. const 常量定义
```go
const (
priorityLow = 1
priorityNormal = 2
)
```
`const` 块中后续常量会继承前一个常量的值并自动加1
### 2. defer 语句
```go
file, err := os.Open(path)
defer file.Close()
```
`defer` 会在函数返回前执行,确保文件被关闭
### 3. map 遍历
```go
for k, v := range fields {
body[k] = v
}
```
遍历 map 的键值对
### 4. type assertion类型断言
```go
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response")
}
```
把接口类型转换成具体类型,`ok` 表示转换是否成功
### 5. strconv.Atoi
```go
id, err := strconv.Atoi(login)
```
把字符串转换成整数,如果失败返回错误

View File

@ -0,0 +1,673 @@
# 逐行讲解 shortcuts/issue/batch_create.go面向 Go 小白)
## 文件概述
这个文件实现了 **Issue 批量创建**功能,可以从命令行或 CSV 文件批量创建多个 Issue并支持 bug 和 feature 两种模板。
---
## 一、包声明和导入
```go
package issue
import (
"encoding/csv"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"sync"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
```
| 导入库 | 作用 |
|-------|------|
| `encoding/csv` | CSV 文件解析 |
| `fmt` | 格式化输出 |
| `net/url` | URL 查询参数构建 |
| `os` | 文件操作 |
| `strconv` | 字符串和数字转换 |
| `strings` | 字符串处理 |
| `sync` | 并发安全,用于缓存 |
| `common` | 公共工具包 |
---
## 二、标签缓存
```go
var issueTagCache sync.Map
```
**作用**:缓存项目的标签列表,避免重复请求 API
**sync.Map**Go 语言提供的并发安全的 map可以在多个 goroutine 中安全地读写
---
## 三、resolveIssueTags 函数
```go
func resolveIssueTags(ctx *common.RuntimeContext) (map[string]int, error) {
key := ctx.Owner + "/" + ctx.Repo
if cached, ok := issueTagCache.Load(key); ok {
return cached.(map[string]int), nil
}
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo)
q := url.Values{}
q.Set("only_name", "true")
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return nil, fmt.Errorf("获取项目标签列表失败: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("标签列表响应格式异常")
}
rawTags, ok := data["issue_tags"].([]interface{})
if !ok {
return nil, fmt.Errorf("标签列表响应缺少 issue_tags 字段")
}
tags := make(map[string]int, len(rawTags))
for _, item := range rawTags {
tag, ok := item.(map[string]interface{})
if !ok {
continue
}
name, _ := tag["name"].(string)
if name == "" {
continue
}
var id int
switch v := tag["id"].(type) {
case float64:
id = int(v)
case int:
id = v
default:
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
}
if id == 0 {
continue
}
tags[name] = id
}
if len(tags) == 0 {
return nil, fmt.Errorf("项目没有配置任何标签,请先在 GitLink 网页端创建标签")
}
issueTagCache.Store(key, tags)
return tags, nil
}
```
**功能**:获取项目的 Issue 标签列表,并缓存结果
**执行流程**
1. 构建缓存 keyowner/repo
2. 先从缓存中查找,如果有就直接返回
3. 如果缓存中没有,调用 API 获取标签列表
4. 解析 API 响应,提取标签名称和 ID
5. 处理多种 ID 类型float64、int、其他
6. 把结果存入缓存
7. 返回标签映射
---
## 四、命令定义
```go
func newBatchCreateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-create",
Description: "Create multiple issues from CLI flags or a CSV file",
Flags: []common.Flag{
{Name: "titles", Usage: "Comma-separated issue titles"},
{Name: "priority", Short: "p", Usage: "Priority: low, normal, high, urgent"},
{Name: "label", Short: "l", Usage: "Label name"},
{Name: "assignee", Short: "a", Usage: "Assignee login name"},
{Name: "state", Short: "s", Usage: "Initial state", Default: "new"},
{Name: "from", Usage: "CSV file path"},
{Name: "template", Short: "t", Usage: "Template: bug or feature"},
{Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"},
},
Run: runBatchCreate,
}
}
```
**Flags 参数说明**
- `--titles`:逗号分隔的 Issue 标题
- `--priority/-p`:优先级
- `--label/-l`:标签
- `--assignee/-a`:分配人
- `--state/-s`:初始状态
- `--from`CSV 文件路径
- `--template/-t`模板类型bug/feature
- `--dry-run`:预览模式
---
## 五、输入结构体
```go
type createIssueInput struct {
Title string
Body string
Priority string
Label string
Assignee string
Status string
// template-specific fields
Version string
Severity string
Steps string
Expected string
Actual string
UserStory string
Acceptance string
}
```
**作用**:存储创建 Issue 的所有输入参数
**模板专用字段**
- `Version`、`Severity`、`Steps`、`Expected`、`Actual`:用于 bug 模板
- `UserStory`、`Acceptance`:用于 feature 模板
---
## 六、runBatchCreate 主函数
```go
func runBatchCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
tags, err := resolveIssueTags(ctx)
if err != nil {
return err
}
dryRun := parseBool(ctx.Arg("dry-run"))
template := strings.ToLower(strings.TrimSpace(ctx.Arg("template")))
var inputs []createIssueInput
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
}
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
if err != nil {
return err
}
inputs = append(inputs, csvInputs...)
}
if len(inputs) == 0 {
return fmt.Errorf("no issue titles provided")
}
cliState := ctx.Arg("state")
for i := range inputs {
if inputs[i].Status == "" {
inputs[i].Status = cliState
}
}
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "create",
Value: template,
DryRun: dryRun,
Total: len(inputs),
Results: make([]BatchResult, 0, len(inputs)),
}
for i, input := range inputs {
label := fmt.Sprintf("#%d", i+1)
if input.Title != "" {
label = truncate(input.Title, 40)
}
result := BatchResult{Number: label, Action: "create"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
body := buildCreateBody(ctx, input, template, tags)
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "created"
if data, ok := env.Data.(map[string]interface{}); ok {
if num, ok := data["project_issues_index"]; ok {
result.Number = fmt.Sprintf("%v", num)
}
}
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed to create", summary.Failed, summary.Total)
}
return nil
}
```
**执行流程**
1. 解析仓库信息
2. 获取项目标签列表
3. 收集输入(从 `--titles` 和/或 `--from`
4. 为没有指定状态的输入应用默认状态
5. 遍历创建每个 Issue
- 如果是 dry-run标记为 planned
- 否则构建请求体并调用 API
- 从响应中提取新创建的 Issue 编号
6. 输出汇总结果
---
## 七、buildCreateBody 函数
```go
func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string, tags map[string]int) map[string]interface{} {
statusID := statusNew
if input.Status != "" {
if sid, err := parseStatus(input.Status); err == nil {
statusID = sid
}
}
body := map[string]interface{}{
"subject": input.Title,
"status_id": statusID,
"priority_id": priorityNormal,
"done_ratio": 0,
}
if template != "" {
body["description"] = buildTemplateDescription(input, template)
if template == "bug" {
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
} else if template == "feature" {
body["issue_tag_ids"] = []interface{}{tags["功能"]}
}
} else if input.Body != "" {
body["description"] = input.Body
}
if input.Priority != "" {
if pid, err := parsePriority(input.Priority); err == nil {
body["priority_id"] = pid
}
}
if input.Label != "" {
if tid, err := parseLabel(input.Label, tags); err == nil {
body["issue_tag_ids"] = []interface{}{tid}
}
}
if input.Assignee != "" {
if id, err := resolveUserID(ctx, input.Assignee); err == nil {
body["assigner_ids"] = []interface{}{id}
}
}
return body
}
```
**功能**:构建创建 Issue 的请求体
**逻辑**
1. 设置默认值(状态、优先级、完成比例)
2. 如果指定了模板,构建模板描述并设置对应的标签
3. 否则使用自定义描述
4. 应用优先级、标签、分配人等可选参数
---
## 八、模板描述构建
### 8.1 buildTemplateDescription
```go
func buildTemplateDescription(input createIssueInput, template string) string {
switch template {
case "bug":
return buildBugDescription(input)
case "feature":
return buildFeatureDescription(input)
default:
return input.Body
}
}
```
### 8.2 buildBugDescription
```go
func buildBugDescription(input createIssueInput) string {
var b strings.Builder
b.WriteString("## Bug 描述\n")
b.WriteString(input.Title)
b.WriteString("\n")
if input.Version != "" {
b.WriteString("\n## 版本\n")
b.WriteString(input.Version)
}
if input.Severity != "" {
b.WriteString("\n## 严重程度\n")
b.WriteString(input.Severity)
}
if input.Steps != "" {
b.WriteString("\n## 复现步骤\n")
b.WriteString(input.Steps)
}
if input.Expected != "" {
b.WriteString("\n## 期望结果\n")
b.WriteString(input.Expected)
}
if input.Actual != "" {
b.WriteString("\n## 实际结果\n")
b.WriteString(input.Actual)
}
return b.String()
}
```
**功能**:构建标准化的 Bug 描述
**输出格式**
```markdown
## Bug 描述
标题内容
## 版本
v1.0.0
## 严重程度
## 复现步骤
步骤1
步骤2
## 期望结果
期望的行为
## 实际结果
实际的行为
```
### 8.3 buildFeatureDescription
```go
func buildFeatureDescription(input createIssueInput) string {
var b strings.Builder
b.WriteString("## 用户故事\n")
if input.UserStory != "" {
b.WriteString(input.UserStory)
} else {
b.WriteString(input.Title)
}
if input.Body != "" {
b.WriteString("\n## 描述\n")
b.WriteString(input.Body)
}
if input.Acceptance != "" {
b.WriteString("\n## 验收标准\n")
b.WriteString(input.Acceptance)
}
if input.Priority != "" {
b.WriteString("\n## 优先级\n")
b.WriteString(input.Priority)
}
return b.String()
}
```
**功能**:构建标准化的 Feature 描述
**输出格式**
```markdown
## 用户故事
作为用户,我想...
## 描述
详细描述
## 验收标准
- 标准1
- 标准2
## 优先级
high
```
---
## 九、CSV 读取
```go
func readCreateInputsFromCSV(path string, template string) ([]createIssueInput, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
}
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[normalizeHeader(h)] = i
}
if _, ok := col["title"]; !ok {
return nil, fmt.Errorf("CSV must have a 'title' column")
}
var inputs []createIssueInput
for _, record := range records[1:] {
input := createIssueInput{
Title: getCol(record, col, "title"),
Body: getCol(record, col, "body"),
Priority: getCol(record, col, "priority"),
Label: getCol(record, col, "label"),
Assignee: getCol(record, col, "assignee"),
Status: getCol(record, col, "status"),
Version: getCol(record, col, "version"),
Severity: getCol(record, col, "severity"),
Steps: getCol(record, col, "steps"),
Expected: getCol(record, col, "expected"),
Actual: getCol(record, col, "actual"),
UserStory: getCol(record, col, "user_story"),
Acceptance: getCol(record, col, "acceptance"),
}
if input.UserStory == "" {
input.UserStory = getCol(record, col, "user story")
}
if input.Title == "" {
continue
}
inputs = append(inputs, input)
}
return inputs, nil
}
```
**CSV 列支持**
- `title`必填Issue 标题
- `body`:描述内容
- `priority`:优先级
- `label`:标签
- `assignee`:分配人
- `status`:状态
- `version`版本bug 模板)
- `severity`严重程度bug 模板)
- `steps`复现步骤bug 模板)
- `expected`期望结果bug 模板)
- `actual`实际结果bug 模板)
- `user_story` / `user story`用户故事feature 模板)
- `acceptance`验收标准feature 模板)
---
## 十、辅助函数
### 10.1 parseTitles
```go
func parseTitles(titlesStr string, ctx *common.RuntimeContext) []createIssueInput {
parts := strings.Split(titlesStr, ",")
inputs := make([]createIssueInput, 0, len(parts))
for _, title := range parts {
title = strings.TrimSpace(title)
if title == "" {
continue
}
inputs = append(inputs, createIssueInput{
Title: title,
Priority: ctx.Arg("priority"),
Label: ctx.Arg("label"),
Assignee: ctx.Arg("assignee"),
Status: ctx.Arg("state"),
})
}
return inputs
}
```
**功能**:从逗号分隔的标题字符串创建输入对象
### 10.2 normalizeHeader
```go
func normalizeHeader(h string) string {
return strings.ToLower(strings.TrimSpace(h))
}
```
**功能**:标准化 CSV 表头(转小写、去空格)
### 10.3 getCol
```go
func getCol(record []string, col map[string]int, name string) string {
if idx, ok := col[name]; ok && idx < len(record) {
return strings.TrimSpace(record[idx])
}
return ""
}
```
**功能**:从 CSV 记录中获取指定列的值
### 10.4 truncate
```go
func truncate(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "..."
}
```
**功能**:截断字符串到指定长度,超出部分用 `...` 表示
**注意**:使用 `[]rune` 处理,可以正确处理中文等多字节字符
---
## 十一、Go 语言知识点
### 1. sync.Map
```go
var issueTagCache sync.Map
// 读取
if cached, ok := issueTagCache.Load(key); ok {
return cached.(map[string]int), nil
}
// 写入
issueTagCache.Store(key, tags)
```
**作用**:并发安全的 map用于多个 goroutine 同时读写
### 2. strings.Builder
```go
var b strings.Builder
b.WriteString("## Bug 描述\n")
b.WriteString(input.Title)
return b.String()
```
**作用**:高效拼接字符串,避免产生大量临时字符串
### 3. []interface{}
```go
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
```
**作用**:创建一个包含任意类型的数组,用于 JSON 序列化
### 4. switch 类型断言
```go
switch v := tag["id"].(type) {
case float64:
id = int(v)
case int:
id = v
default:
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
}
```
**作用**:根据值的实际类型执行不同的处理逻辑
### 5. 可变参数
```go
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
```
`...` 表示把切片展开成多个参数

View File

@ -0,0 +1,405 @@
# 逐行讲解 shortcuts/repo/batch_create.go面向 Go 小白)
## 文件概述
这个文件实现了 **仓库批量创建**功能,可以从命令行或 CSV 文件批量创建多个 GitLink 仓库。
---
## 一、包声明和导入
```go
package repo
import (
"encoding/csv"
"fmt"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
```
| 导入库 | 作用 |
|-------|------|
| `encoding/csv` | CSV 文件解析 |
| `fmt` | 格式化输出 |
| `os` | 文件操作 |
| `strings` | 字符串处理 |
| `common` | 公共工具包 |
---
## 二、结构体定义
### 2.1 repoCreateInput
```go
type repoCreateInput struct {
Name string
Description string
Private bool
}
```
**作用**:存储创建单个仓库的输入参数
| 字段 | 类型 | 说明 |
|-----|------|------|
| `Name` | string | 仓库名称 |
| `Description` | string | 仓库描述 |
| `Private` | bool | 是否私有仓库 |
### 2.2 repoBatchResult
```go
type repoBatchResult struct {
Name string `json:"name" yaml:"name"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
```
**作用**:存储单个仓库创建的结果
| 字段 | 类型 | 说明 |
|-----|------|------|
| `Name` | string | 仓库名称 |
| `Status` | string | 创建状态planned/created/failed |
| `Error` | string | 错误信息(如果失败) |
### 2.3 repoBatchSummary
```go
type repoBatchSummary struct {
Owner string `json:"owner" yaml:"owner"`
Action string `json:"action" yaml:"action"`
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 []repoBatchResult `json:"results" yaml:"results"`
}
```
**作用**:存储批量创建的汇总结果
| 字段 | 类型 | 说明 |
|-----|------|------|
| `Owner` | string | 仓库所有者(用户名) |
| `Action` | string | 操作类型create |
| `DryRun` | bool | 是否是预览模式 |
| `Total` | int | 总数量 |
| `Succeeded` | int | 成功数量 |
| `Failed` | int | 失败数量 |
| `Results` | []repoBatchResult | 每个仓库的详细结果 |
---
## 三、命令定义
```go
func newBatchCreateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-create",
Description: "Create multiple repositories from CLI flags or a CSV file",
Flags: []common.Flag{
{Name: "names", Short: "n", Usage: "Comma-separated repository names"},
{Name: "from", Usage: "CSV file path"},
{Name: "description", Short: "d", Usage: "Shared description for all repos"},
{Name: "private", Usage: "Make repos private", Bool: true, Default: "false"},
{Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"},
},
Run: runBatchCreate,
}
}
```
**Flags 参数说明**
- `--names/-n`:逗号分隔的仓库名称
- `--from`CSV 文件路径
- `--description/-d`:所有仓库共享的描述
- `--private`:创建私有仓库
- `--dry-run`:预览模式
---
## 四、runBatchCreate 主函数
```go
func runBatchCreate(ctx *common.RuntimeContext) error {
var inputs []repoCreateInput
if namesStr := ctx.Arg("names"); namesStr != "" {
for _, name := range strings.Split(namesStr, ",") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
inputs = append(inputs, repoCreateInput{
Name: name,
Description: ctx.Arg("description"),
Private: ctx.Arg("private") == "true",
})
}
}
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readRepoInputsFromCSV(csvPath)
if err != nil {
return err
}
inputs = append(inputs, csvInputs...)
}
if len(inputs) == 0 {
return fmt.Errorf("no repository names provided")
}
dryRun := ctx.Arg("dry-run") == "true"
var login string
var userID int
if !dryRun {
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return fmt.Errorf("failed to get current user: %w", err)
}
userData, _ := userEnv.Data.(map[string]interface{})
login, _ = userData["login"].(string)
if login == "" {
return fmt.Errorf("cannot determine current user login")
}
if uid, ok := userData["user_id"].(float64); ok {
userID = int(uid)
}
}
summary := repoBatchSummary{
Owner: login,
Action: "create",
DryRun: dryRun,
Total: len(inputs),
Results: make([]repoBatchResult, 0, len(inputs)),
}
for _, input := range inputs {
result := repoBatchResult{Name: input.Name}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
body := map[string]interface{}{
"name": input.Name,
"repository_name": input.Name,
"user_id": userID,
}
if input.Description != "" {
body["description"] = input.Description
}
if input.Private {
body["private"] = true
}
if _, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, input.Name), body); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "created"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d repo(s) failed to create", summary.Failed, summary.Total)
}
return nil
}
```
**执行流程**
1. 收集输入(从 `--names` 和/或 `--from`
2. 如果不是 dry-run调用 `/users/me` 获取当前用户信息
3. 初始化汇总对象
4. 遍历每个仓库:
- 如果是 dry-run标记为 planned
- 否则构建请求体并调用 API
- 记录结果
5. 输出汇总结果
---
## 五、readRepoInputsFromCSV 函数
```go
func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
}
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[strings.ToLower(strings.TrimSpace(h))] = i
}
if _, ok := col["name"]; !ok {
return nil, fmt.Errorf("CSV must have a 'name' column")
}
var inputs []repoCreateInput
for _, record := range records[1:] {
name := getCol(record, col, "name")
if name == "" {
continue
}
private := false
if p := strings.ToLower(getCol(record, col, "private")); p == "true" || p == "1" {
private = true
}
inputs = append(inputs, repoCreateInput{
Name: name,
Description: getCol(record, col, "description"),
Private: private,
})
}
return inputs, nil
}
```
**CSV 列支持**
- `name`(必填):仓库名称
- `description`:仓库描述
- `private`是否私有true/false 或 1/0
---
## 六、getCol 函数
```go
func getCol(record []string, col map[string]int, name string) string {
if idx, ok := col[name]; ok && idx < len(record) {
return strings.TrimSpace(record[idx])
}
return ""
}
```
**功能**:从 CSV 记录中获取指定列的值
**逻辑**
1. 查找列名对应的索引
2. 检查索引是否有效
3. 返回该位置的值(去除前后空格)
4. 如果找不到,返回空字符串
---
## 七、完整调用流程
```
用户命令 (gitlink repo batch-create -n repo-a,repo-b)
解析命令行参数
newBatchCreateShortcut() 返回命令定义
执行 runBatchCreate 函数
收集输入(解析 --names 参数)
调用 /users/me 获取当前用户信息
遍历每个仓库名称:
构建请求体name, repository_name, user_id
调用 POST /{login}/{repo_name} 创建仓库
记录创建结果
输出汇总结果
```
---
## 八、Go 语言知识点
### 1. 结构体标签Struct Tags
```go
type repoBatchResult struct {
Name string `json:"name" yaml:"name"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
```
**作用**:告诉序列化库(如 JSON、YAML如何给字段命名
- `json:"name"`JSON 序列化时使用 `name` 作为字段名
- `json:"error,omitempty"`:如果 `Error` 为空JSON 中不包含这个字段
### 2. 布尔值判断
```go
dryRun := ctx.Arg("dry-run") == "true"
```
**注意**`ctx.Arg()` 返回的是字符串,需要和字符串 `"true"` 比较,不能直接用 `bool()` 转换
### 3. interface{} 类型断言
```go
userData, _ := userEnv.Data.(map[string]interface{})
login, _ = userData["login"].(string)
```
**作用**:把 `interface{}` 类型转换成具体类型
### 4. float64 转 int
```go
if uid, ok := userData["user_id"].(float64); ok {
userID = int(uid)
}
```
**原因**JSON 解析后,数字默认是 `float64` 类型,需要手动转换成 `int`
### 5. defer 语句
```go
file, err := os.Open(path)
defer file.Close()
```
**作用**:确保文件在函数返回前被关闭,防止资源泄漏
### 6. make 和预分配容量
```go
Results: make([]repoBatchResult, 0, len(inputs))
```
**作用**:创建一个初始长度为 0、容量为 `len(inputs)` 的切片,避免动态扩容的性能开销

83
doc/reading_notes/1.txt Normal file
View File

@ -0,0 +1,83 @@
ctx 就是 *common.RuntimeContext。一句话它是每个 Shortcut
命令的"工具箱"所有能力发API请求、读参数、输出结果都挂在这个对象上。
---
它长什么样shortcuts/common/types.go:41-50
type RuntimeContext struct {
Client *client.Client // ← 发 HTTP 请求的客户端
Owner string // ← --owner 的值(如 "zzx-coder"
Repo string // ← --repo 的值(如 "gitlink-cli"
Format string // ← --format 的值("json" / "table" / "yaml"
CommandName string // ← 当前命令名(如 "wiki +delete"
Args map[string]string // ← 所有 flag 的键值对(如 {"title":"Home","dry-run":"false"}
GatewayBaseURL string // ← Wiki/Webhook网关地址跟标准API不同
GatewayHTTPClient *http.Client // ← 网关专用 HTTP 客户端nil 则自动创建)
}
---
它怎么创建出来的types.go:53-80
你敲 gitlink-cli wiki +delete --title "Home" 时:
第1步cobra 解析命令行 → flagValues = {"title": "Home"}
第2步runner.go:34 → NewRuntimeContext(flagValues, "wiki +delete")
第3步NewRuntimeContext 内部:
→ client.New() // 读取配置文件,拿到 BaseURL + 带 auth 的 HTTP Client
→ 读取全局 flag // cmdutil.Owner, cmdutil.Repo, cmdutil.Format
→ 组装成 RuntimeContext // 把所有东西塞进去
第4步传给 s.Run(ctx) // 你的业务逻辑拿到这个 ctx
---
它上面的方法(你可以直接用 ctx.XXX() 调用的)
┌──────────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────┐
│ 方法 │ 做什么 │ 例 │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.Arg("title") │ 读用户传入的 flag 值 │ "Home" │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.RequireArg("title", "提示") │ 读必填参数,为空就报 CLIError │ 同上,但自动校验 │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.CallAPI("GET", path, body) │ 调 GitLink 标准 API │ ctx.CallAPI("GET", "/users/me", nil) │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.CallAPIWithQuery("GET", path, │ 带查询参数的 API │ ctx.CallAPIWithQuery("GET", │
│ query) │ │ "/issues", q) │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.Output(env) │ 输出 API 响应(自动选 │ ctx.Output(env) │
│ │ json/table/yaml │ │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.OutputData(data) │ 包装数据成成功 envelope 再输出 │ ctx.OutputData(myStruct) │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.ResolveOwnerRepo() │ 从 git remote 自动推断 owner/repo │ 没传 --owner 时自动填充 │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.RepoPath() │ 返回 "/owner/repo" 字符串 │ "/zzx-coder/gitlink-cli" │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.IsDryRun() │ 判断用户是否传了 --dry-run │ true / false │
└──────────────────────────────────────┴───────────────────────────────────┴──────────────────────────────────────┘
---
一条命令里 ctx 的完整生命周期
以 wiki +delete --title "Home" 为例:
1. runner.go:20-31 收集 flag
flagValues = {"title": "Home"}
2. runner.go:34 创建 ctx
ctx = NewRuntimeContext({"title":"Home"}, "wiki +delete")
→ ctx.Client = 带认证的HTTP客户端BaseURL = https://gitlink.org.cn/api
→ ctx.Owner = "zzx-coder"(从 git remote 或 --owner 来的)
→ ctx.Repo = "gitlink-cli"
→ ctx.Format = "table"(默认值)
→ ctx.Args = {"title": "Home"}
3. runner.go:60 调用你的业务逻辑
err = s.Run(ctx)
4. wiki.go:747 你的 Run 函数里用 ctx
title, _ = ctx.RequireArg("title", ...) // → "Home"
projectID, _ = resolveProjectID(ctx) // → ctx 传进子函数
body = {..., "pageName": actualPageName}
callWikiAPI(ctx, "DELETE", ..., body) // → ctx 用于构造网关 Client
ctx.OutputData(result) // → ctx.Format 决定输出格式
---

787
doc/代码逻辑.md Normal file
View File

@ -0,0 +1,787 @@
# gitlink-cli 子任务一代码逻辑说明
## ? 目录
- [系统架构概述](#系统架构概述)
- [Wiki 管理功能](#wiki-管理功能)
- [Webhook 管理功能](#webhook-管理功能)
- [批量操作功能](#批量操作功能)
- [Raw API 功能](#raw-api-功能)
- [命令优化功能](#命令优化功能)
- [跨平台兼容性](#跨平台兼容性)
## ?? 系统架构概述
### 核心设计模式
**Shortcut 架构模式**:
- 每个功能模块wiki、webhook、issue等实现一个 `Shortcuts()` 函数
- 返回 `[]*common.Shortcut` 切片,每个 Shortcut 代表一个命令
- 命令执行通过 `Run: func(ctx *common.RuntimeContext) error` 实现
**RuntimeContext 上下文**:
```go
type RuntimeContext struct {
Client *client.Client // HTTP 客户端
Owner string // 仓库所有者
Repo string // 仓库名称
Format string // 输出格式 (json/table/yaml)
Args map[string]string // 命令行参数
}
```
**API 调用流程**:
1. `ctx.ResolveOwnerRepo()` - 解析 owner/repo支持 git remote 自动解析)
2. `ctx.CallAPI()` - 发送 HTTP 请求到 GitLink API
3. `ctx.Output()` - 格式化输出结果
---
## ? Wiki 管理功能
### 核心架构
**双重 API 调用机制**:
- `BaseURL`: `https://www.gitlink.org.cn/api` - 主 API获取项目信息
- `GatewayBaseURL`: `https://gateway.gitlink.org.cn/api` - Gateway APIWiki 操作)
### 关键代码逻辑
#### 1. Project ID 解析机制 (`resolveProjectID`)
**问题**: Wiki API 需要 `projectId` 数字 ID而用户只知道 `owner/repo`
**解决方案**:
```go
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
key := ctx.Owner + "/" + ctx.Repo
// 1. 检查缓存 (sync.Map 实现线程安全)
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil
}
// 2. 调用主 API 获取项目详情
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("GET", path, nil)
// 3. 提取 project_id 并缓存
projectIDCache.Store(key, pidStr)
return pidStr, nil
}
```
#### 2. Base64 编解码处理
**Wiki 内容编码**:
```go
// 创建 Wiki 时编码
body["content_base64"] = base64.StdEncoding.EncodeToString([]byte(content))
// 查看 Wiki 时解码
if b64, ok := data["content_base64"].(string); ok {
decoded, err := base64.StdEncoding.DecodeString(b64)
data["content_decoded"] = string(decoded) // 额外提供解码后的内容
}
```
#### 3. Wiki 更新策略 (`update` 命令)
**三种更新模式**:
```go
if coverText != "" || filePath != "" && coverText == "" && addText == "" {
// --cover 或 --file: 完全覆盖内容
finalContent = content
} else if addText != "" {
// --add: 追加到现有内容
existing, err := fetchPageContent(ctx, projectID, pageName)
finalContent = existing + newPart
}
```
#### 4. 删除验证机制
**问题**: API 删除操作可能返回错误但实际删除成功
**解决方案**:
```go
delErr := callWikiAPI(ctx, "DELETE", wikiPath("deleteWiki"), body)
if delErr != nil {
// 验证是否真的删除成功
_, viewErr := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if viewErr != nil {
// GET 也失败,说明已删除成功
return ctx.OutputData(map[string]string{"message": "Wiki page deleted successfully"})
}
return delErr // GET 成功,说明删除确实失败
}
```
### API 路径设计
| 命令 | HTTP 方法 | Gateway API 路径 |
|------|----------|-----------------|
| list | GET | `/wiki/open/wikiPages` |
| view | GET | `/wiki/open/getWiki` |
| create | POST | `/wiki/open/createWiki` |
| update | PUT | `/wiki/open/updateWiki` |
| delete | DELETE | `/wiki/open/deleteWiki` |
---
## ? Webhook 管理功能
### 核心设计
**统一的 API 路径前缀**:
```go
func webhookRepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
```
### 关键代码逻辑
#### 1. 事件类型管理
**支持的事件列表**:
```go
var supportedEvents = []string{
"push", "pull_request", "issue", "issue_assign", "issue_comment",
"pull_request_assign", "pull_request_comment", "merge_request",
"repository", "branch", "tag",
}
// 事件解析和验证
func parseEvents(eventsStr string) []string {
events := strings.Split(eventsStr, ",")
for _, event := range events {
if isEventSupported(event) {
validEvents = append(validEvents, event)
}
}
return validEvents
}
```
#### 2. Webhook 创建逻辑
**完整的 Payload 构造**:
```go
payload := map[string]interface{}{
"url": webhookURL, // 必需
"http_method": "POST", // 固定
"active": true, // 默认激活
"content_type": "json", // 默认 JSON
"events": validEvents, // 事件列表
}
// 可选字段
if secret := ctx.Arg("secret"); secret != "" {
payload["secret"] = secret // HMAC 验证密钥
}
if description := ctx.Arg("description"); description != "" {
payload["description"] = description
}
```
#### 3. 智能 URL 获取Update 命令)
**问题**: 更新 Webhook 时用户不记得当前 URL
**解决方案**:
```go
webhookURL := ctx.Arg("url")
if webhookURL == "" {
// 自动获取当前 Webhook 的 URL
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
webhookData := getEnv.Data.(map[string]interface{})
currentURL := webhookData["url"].(string)
webhookURL = currentURL // 使用现有 URL
}
payload["url"] = webhookURL
```
#### 4. 删除验证机制
```go
delErr := ctx.CallAPI("DELETE", webhookPath, nil)
if delErr != nil {
// 验证是否真的删除成功
_, viewErr := ctx.CallAPI("GET", webhookPath, nil)
if viewErr != nil {
// GET 返回错误,说明已删除
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "Webhook deleted successfully",
}, nil))
}
return delErr
}
```
#### 5. Test 端点修复
**正确路径**: `/webhooks/{id}/tests` (复数)
```go
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
```
### API 路径设计
| 命令 | HTTP 方法 | API 路径 |
|------|----------|---------|
| list | GET | `/v1/{owner}/{repo}/webhooks` |
| create | POST | `/v1/{owner}/{repo}/webhooks` |
| update | PUT | `/v1/{owner}/{repo}/webhooks/{id}` |
| delete | DELETE | `/v1/{owner}/{repo}/webhooks/{id}` |
| test | POST | `/v1/{owner}/{repo}/webhooks/{id}/tests` |
| info | GET | `/v1/{owner}/{repo}/webhooks/{id}` |
| events | - | (本地静态列表,不调用 API) |
---
## ? 批量操作功能
### 核心设计模式
**统一的结果统计结构**:
```go
type BatchSummary struct {
Repository string // 仓库标识
Action string // 操作类型
Value string // 操作值
DryRun bool // 是否预览
Total int // 总数
Succeeded int // 成功数
Failed int // 失败数
Results []BatchResult // 详细结果
}
```
### 关键代码逻辑
#### 1. Issue 批量创建 (`batch_create.go`)
**输入源合并**:
```go
var inputs []createIssueInput
// 1. 从命令行参数收集
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
}
// 2. 从 CSV 文件收集
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
inputs = append(inputs, csvInputs...)
}
```
**模板支持**:
```go
func buildCreateBody(input createIssueInput, template string) map[string]interface{} {
if template == "bug" {
body["description"] = buildBugDescription(input)
body["issue_tag_ids"] = []interface{}{tagIDs["缺陷"]}
} else if template == "feature" {
body["description"] = buildFeatureDescription(input)
body["issue_tag_ids"] = []interface{}{tagIDs["功能"]}
}
}
```
#### 2. Issue 批量操作 (`batch.go`)
**通用批量操作流程**:
```go
func runBatchClose(ctx *common.RuntimeContext) error {
// 1. 收集 Issue 编号
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
// 2. 初始化统计结构
summary := BatchSummary{Total: len(numbers)}
// 3. 逐个处理
for _, number := range numbers {
if dryRun {
result.Status = "planned" // 预览模式
} else {
err := updateIssueField(ctx, number, payload)
if err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "closed"
summary.Succeeded++
}
}
summary.Results = append(summary.Results, result)
}
// 4. 输出统计结果
return ctx.OutputData(summary)
}
```
#### 3. 仓库批量创建 (`batch_create.go`)
**用户信息获取**:
```go
// 获取当前用户信息
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
login := userData["login"].(string) // 用于 API 路径
userID := int(userData["user_id"].(float64)) // 用于请求体
// 创建仓库
body := map[string]interface{}{
"name": repoName,
"repository_name": repoName,
"user_id": userID,
}
ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, repoName), body)
```
#### 4. CSV 文件处理
**通用 CSV 读取模式**:
```go
func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) {
file, err := os.Open(path)
reader := csv.NewReader(file)
records, err := reader.ReadAll()
// 1. 解析表头
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[strings.ToLower(strings.TrimSpace(h))] = i
}
// 2. 验证必需列
if _, ok := col["name"]; !ok {
return nil, fmt.Errorf("CSV must have a 'name' column")
}
// 3. 读取数据行
for _, record := range records[1:] {
name := getCol(record, col, "name")
inputs = append(inputs, repoCreateInput{Name: name})
}
return inputs, nil
}
```
#### 5. Issue 编号收集
**多源合并**:
```go
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
// 1. 解析命令行参数
numbers, err := parseIssueNumbers(numbersValue) // "1,2,3" -> []string{"1","2","3"}
// 2. 读取 CSV 文件
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
// 3. 合并去重
return mergeIssueNumbers(numbers, csvNumbers)
}
```
### 批量操作对比
| 功能 | 输入源 | 特殊处理 |
|------|--------|----------|
| repo +batch-create | names CSV | 获取当前用户 login/userID |
| issue +batch-create | titles CSV | 支持模板 (bug/feature) |
| issue +batch-close | numbers CSV | 状态 ID 转换 (closed=5) |
| issue +batch-status | numbers CSV | 状态 ID 转换 |
| issue +batch-priority | numbers CSV | 优先级 ID 转换 |
| issue +batch-assign | numbers CSV | 用户名→用户ID解析 |
| issue +batch-label | numbers CSV | 标签名→标签ID映射 |
---
## ? Raw API 功能
### 核心设计
**统一的 HTTP 客户端**:
```go
type Client struct {
HTTP *http.Client
BaseURL string
Debug bool
SkipJSONSuffix bool // Wiki Gateway 不需要 .json 后缀
}
```
### 关键代码逻辑
#### 1. 请求路径处理
**自动添加 .json 后缀**:
```go
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
// GitLink API 约定: 所有路径需要 .json 后缀
if !c.SkipJSONSuffix {
if !strings.HasSuffix(path, ".json") {
path += ".json"
}
}
// 构造完整 URL
fullURL := c.BaseURL + path
if query != nil {
fullURL += "?" + query.Encode()
}
// 发送 HTTP 请求
req, _ := http.NewRequest(method, fullURL, bodyReader)
resp, _ := c.HTTP.Do(req)
}
```
#### 2. 响应解析策略
**多层错误处理**:
```go
// 1. HTTP 状态码检查
if resp.StatusCode >= 400 {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, body),
}
}
// 2. 解析 JSON 响应
var raw map[string]interface{}
json.Unmarshal(respData, &raw)
// 3. GitLink 业务错误检查
if status, ok := raw["status"]; ok {
if statusCode != 0 && statusCode != 200 {
msg := raw["message"].(string)
suggestion := suggestFix(int(statusCode)) // 智能错误提示
return ErrorEnvelope(code, msg, suggestion)
}
}
// 4. 处理 JSON 字符串数据 (GitLink API 特性)
if dataStr, ok := raw["data"].(string); ok {
var parsedData interface{}
json.Unmarshal([]byte(dataStr), &parsedData)
raw["data"] = parsedData // 自动解析嵌套 JSON
}
```
#### 3. 智能错误提示
```go
func suggestFix(code int) string {
switch code {
case 401:
return "请先运行 gitlink-cli auth login 登录"
case 403:
return "权限不足,请确认账户权限或联系项目管理员"
case 404:
return "资源不存在,请检查 owner/repo/id 是否正确"
case 422:
return "参数校验失败,请检查请求参数"
}
}
```
#### 4. HTTP 方法封装
```go
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
return c.Do("GET", path, nil, query)
}
func (c *Client) Post(path string, body interface{}) (*output.Envelope, error) {
return c.Do("POST", path, body, nil)
}
func (c *Client) Put(path string, body interface{}) (*output.Envelope, error) {
return c.Do("PUT", path, body, nil)
}
func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) {
return c.Do("DELETE", path, nil, query)
}
```
### Raw API 使用示例
```bash
# GET 请求
./gitlink-cli.exe api GET /users/me
# POST 请求
./gitlink-cli.exe api POST /zzx-coder/gitlink-cli/issues --body '{"subject":"测试"}'
# PUT 请求
./gitlink-cli.exe api PUT /zzx-coder/gitlink-cli/issues/123 --body '{"status":"closed"}'
# DELETE 请求
./gitlink-cli.exe api DELETE /zzx-coder/gitlink-cli/issues/123
# 带查询参数
./gitlink-cli.exe api GET "/zzx-coder/gitlink-cli/issues" --query "status=open&limit=20"
```
---
## ? 命令优化功能
### 1. 参数设计优化
**短参数支持**:
```go
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
{Name: "body", Short: "b", Usage: "Issue description"},
}
// 用户可以使用:
// --title "Bug" 或 -t "Bug"
```
**参数别名和映射**:
```go
func parseStatus(state string) (int, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
return 1, nil
case "closed":
return 5, nil
case "in-progress", "in_progress", "inprogress": // 支持多种格式
return 2, nil
}
}
```
### 2. 输出格式优化
**Envelope 结构**:
```go
type Envelope struct {
OK bool // 操作是否成功
Data interface{} // 数据
Error *ErrorInfo // 错误信息
Meta *Meta // 元数据 (分页等)
}
```
**格式化输出**:
```go
// JSON 格式
{
"ok": true,
"data": {...},
"meta": {
"total_count": 100,
"page": 1,
"limit": 20
}
}
// Table 格式 (自动格式化)
+----+-------------------+---------+
| ID | Title | Status |
+----+-------------------+---------+
| 1 | Bug fix | open |
+----+-------------------+---------+
```
### 3. 错误提示优化
**友好的错误消息**:
```go
type ErrorInfo struct {
Code interface{} // 错误代码
Message string // 错误描述
Suggestion string // 解决建议 (新增)
}
// 示例:
{
"ok": false,
"error": {
"code": 401,
"message": "Authentication failed",
"suggestion": "请先运行 gitlink-cli auth login 登录"
}
}
```
**智能错误处理**:
```go
// Wiki 创建时的错误处理
if err != nil {
if strings.Contains(err.Error(), "404") {
return fmt.Errorf("Wiki page not found\n\nSuggestions:\n- Check if the wiki page exists\n- Verify you have the correct permissions\n- Use 'gitlink-cli wiki +list' to see available pages")
}
return err
}
```
### 4. CSV 编码错误提示
```go
// 批量操作时的编码检查
if !isUTF8CSV(file) {
return fmt.Errorf(`? CSV 文件编码错误
文件编码不是 UTF-8当前编码: %s
解决方案:
1. 使用支持 UTF-8 的编辑器重新保存文件
2. 或使用以下命令创建 UTF-8 文件:
cat > repos.csv << 'EOF'
name,description,private
test1,测试1,false
EOF`, currentEncoding)
}
```
---
## ? 跨平台兼容性
### 1. 路径处理
**配置目录解析**:
```go
func ConfigDir() string {
// 优先使用环境变量
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
return dir
}
// 跨平台主目录
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli")
}
// Windows: C:\Users\{user}\.config\gitlink-cli
// Linux/Mac: /home/{user}/.config/gitlink-cli
```
### 2. Git Remote 解析
**自动解析仓库路径**:
```go
// 1. 从 git remote 获取 owner/repo
gitRemote := "https://gitlink.org.cn/zzx-coder/gitlink-cli.git"
owner, repo := "zzx-coder", "gitlink-cli"
// 2. 支持多种 remote 格式
// https://gitlink.org.cn/owner/repo.git
// git@gitlink.org.cn:owner/repo.git
// ssh://git@gitlink.org.cn/owner/repo.git
```
### 3. 字符编码处理
**Base64 编解码**:
```go
// Wiki 内容处理 (支持多语言)
content := "# 中文内容\n\nThis is English."
encoded := base64.StdEncoding.EncodeToString([]byte(content))
decoded := base64.StdEncoding.DecodeString(encoded)
```
**Emoji 支持**:
```go
// 支持 Emoji 字符
title := "? Feature request ?"
body := "Add emoji support ?"
```
### 4. 平台特定处理
**文件权限**:
```go
// 配置文件权限: 0600 (仅用户可读写)
os.WriteFile(configPath, data, 0600)
// 目录权限: 0700 (仅用户可访问)
os.MkdirAll(configDir, 0700)
```
**二进制文件**:
```bash
# Windows: gitlink-cli.exe
# Linux/Mac: gitlink-cli
```
---
## ? 数据流程示例
### Issue 创建完整流程
```
用户输入:
./gitlink-cli.exe issue +create --owner zzx-coder --repo gitlink-cli --title "Bug" --body "Fix it"
1. 参数解析
Args = {"owner": "zzx-coder", "repo": "gitlink-cli", "title": "Bug", "body": "Fix it"}
2. 创建 RuntimeContext
ctx = RuntimeContext{
Client: httpClient,
Owner: "zzx-coder",
Repo: "gitlink-cli",
Format: "json",
Args: Args
}
3. API 调用
path = "/v1/zzx-coder/gitlink-cli/issues.json"
body = {
"subject": "Bug",
"description": "Fix it",
"status_id": 1,
"priority_id": 2
}
4. HTTP 请求
POST https://www.gitlink.org.cn/api/v1/zzx-coder/gitlink-cli/issues.json
Authorization: Bearer {token}
Content-Type: application/json
5. 响应处理
解析 JSON → Envelope{OK: true, Data: {...}}
6. 输出格式化
格式化为 JSON/Table → 输出到终端
```
### Wiki 创建完整流程
```
用户输入:
./gitlink-cli.exe wiki +create --owner zzx-coder --repo gitlink-cli --title "Test" --content "# Test"
1. 第一次 API 调用 (获取 Project ID)
GET https://www.gitlink.org.cn/api/zzx-coder/gitlink-cli/detail.json
响应: {"project_id": 12345}
2. Project ID 缓存
projectIDCache.Store("zzx-coder/gitlink-cli", "12345")
3. 第二次 API 调用 (创建 Wiki)
POST https://gateway.gitlink.org.cn/api/wiki/open/createWiki
body = {
"owner": "zzx-coder",
"repo": "gitlink-cli",
"projectId": 12345,
"pageName": "Test",
"content_base64": "I1BUV\Q==" // Base64 编码
}
4. Gateway 响应处理
解析 {"code": 200, "data": {...}} → 提取 data 部分

View File

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(python -c \"import docx; print\\(''ok''\\)\")",
"Bash(pip install:*)",
"Bash(python generate_report.py)"
]
}
}

View File

@ -0,0 +1,535 @@
# GitLink CLI 命令系统优化报告
## 一、参数设计
### 问题:枚举参数无校验,错误信息延迟到 API 调用才暴露
命令 `pr +merge --method` 接受 `merge`、`rebase`、`squash` 三种值,`issue +list --state` 接受 `open`、`closed`、`all`,但输入非法值时没有任何拦截。用户输入 `--method unknown` 会直接发往 API等到服务端返回 422 才知道参数错了,反馈链路长。
**修改前** — 校验逻辑散落在 Run 函数内部:
```go
// shortcuts/issue/issue.go
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
}
// 枚举值定义在 Usage 文本里(给人看),校验在 Run() 里手写(给机器做)
// 两者没有关联,容易出现文本和代码不同步
func normalizeIssueStatus(state string) (interface{}, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
return 1, nil
case "closed":
return 5, nil
default:
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
}
}
```
**问题总结**:每个有枚举值的 flag 都需要在 `Run()` 里手写一个 validate 函数,重复劳动且容易遗漏;枚举值在 Usage 文本里写一遍、在代码里再写一遍,两处不同步时有发生。
**解决方案**:在 `Flag` 结构体中增加 `Choices` 字段,框架层在 parse 阶段自动校验,同时将可选值追加到 `--help` 输出。
**修改后**
```go
// ① 结构体扩展 — shortcuts/common/types.go
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
Choices []string // 新增:枚举校验
Validate func(value string) error // 新增:自定义校验函数
}
// ② 框架自动校验 — shortcuts/common/runner.go
for _, f := range s.Flags {
val := getFlagValue(cmd, f)
// Choices 枚举校验
if len(f.Choices) > 0 && val != "" && val != "false" {
if !contains(f.Choices, val) {
return clierrors.InputError(
fmt.Sprintf("invalid value %q for --%s", val, f.Name),
fmt.Sprintf("有效值: %s。运行 'gitlink-cli %s --help' 查看用法。",
strings.Join(f.Choices, ", "), commandName),
).WithCommand(commandName)
}
}
// 自定义校验
if f.Validate != nil && val != "" {
if err := f.Validate(val); err != nil {
return clierrors.InputError(
fmt.Sprintf("invalid --%s: %v", f.Name, err),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
}
// ③ 命令行只需声明 Choices — shortcuts/pr/pr.go
Flags: []common.Flag{
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge",
Choices: []string{"merge", "rebase", "squash"}},
}
```
Choices 声明后,无需再写校验函数,`--help` 也会自动追加 `[merge|rebase|squash]`
---
### 问题:跨参数约束靠手写 fmt.Errorf格式不统一
`issue +update` 要求 "至少提供 --title、--body 或 --state 中的一个"`repo +update` 要求 "至少提供 --description 或 --private 中的一个"。这类跨参数约束都散落在 `Run()` 里用 `fmt.Errorf` 写死,每种错误格式各异、中英文混杂。
**修改前**
```go
// shortcuts/issue/issue.go — 在 Run() 内部手写校验
if title == "" && description == "" && state == "" {
return fmt.Errorf("at least one of --title, --body, or --state is required")
}
// shortcuts/repo/repo.go — 同样手写,格式不同
if len(body) == 0 {
return fmt.Errorf("at least one of --description, --private is required")
}
```
**解决方案**:在 `Shortcut` 结构体中增加 `Validate` 字段,支持声明式跨参数校验;同时引入 `clierrors.InputError` 统一错误格式(英文技术消息 + 中文操作建议)。
**修改后**
```go
// ① Shortcut 结构体新增 Validate 字段 — shortcuts/common/types.go
type Shortcut struct {
Name string
Description string
Flags []Flag
Validate func(args map[string]string) error // 新增:跨参数校验
Run func(ctx *RuntimeContext) error
}
// ② MountShortcut 中自动执行 — shortcuts/common/runner.go
if s.Validate != nil {
if err := s.Validate(flagValues); err != nil {
return clierrors.InputError(
err.Error(),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
// ③ 命令中统一使用 InputError — shortcuts/issue/issue.go
if title == "" && description == "" && state == "" {
return clierrors.InputError(
"at least one of --title, --body, or --state is required",
"至少需要提供 --title、--body 或 --state 中的一个参数",
).WithCommand(ctx.CommandName)
}
```
---
## 二、输出格式
### 问题:默认格式与帮助文本不一致
`cmd/root.go``--format` 帮助文本写明 "default: table",但 `shortcuts/common/types.go``NewRuntimeContext` 的代码默认值是 `"json"`。用户不传 `--format` 时拿到的是 JSON 而非表格。
**修改前**
```go
// cmd/root.go — 帮助文本说 "default: table"
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "",
"Output format: json, table, yaml (default: table)")
// shortcuts/common/types.go — 代码实际默认 json
format := cmdutil.Format
if format == "" {
format = "json"
}
```
**解决方案**:将代码默认值改为 `"table"`,同时将 persistent flag 的默认值参数从空字符串改为 `"table"`,让 cobra 显示的默认值与实际行为一致。
**修改后**
```go
// cmd/root.go — 默认值显式化为 "table"
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "table",
"Output format: json, table, yaml")
// shortcuts/common/types.go — 代码默认值与帮助文本一致
format := cmdutil.Format
if format == "" {
format = "table"
}
```
---
### 问题:表格输出无列过滤、长文本被硬截断、无可读性增强
现状表格渲染时列全量输出、复杂值JSON 嵌套、长文本)在 60 字符处硬截断后加 `...`、无色彩区分。用户想只看 id + title 两列也要扛着十几列的输出;想看完整描述被 `...` 截断无能为力。
**修改前**
```go
// internal/output/formatter.go — 截断硬编码,无列过滤
func formatValue(v interface{}) string {
// ...
if len(s) > 60 {
return s[:57] + "..." // 硬截断,不可配置
}
return s
}
func Print(envelope *Envelope, format string) error {
// 无任何渲染选项
return PrintTo(os.Stdout, envelope, format)
}
```
**解决方案**:引入 `PrintOptions` 结构体,支持 `Columns`(列过滤)、`NoTruncate`(关闭截断)、`UseColor`(彩色表头);新增 3 个全局 persistent flag`--columns`、`--no-truncate`、`--no-color`);通过 `RuntimeContext` 透明传递到所有输出调用。
**修改后**
```go
// ① PrintOptions 结构体 — internal/output/formatter.go
type PrintOptions struct {
Columns []string // 要显示的列nil = 全部
NoTruncate bool // 禁用 60 字符截断
UseColor bool // 启用 ANSI 颜色
}
func PrintWithOpts(envelope *Envelope, format string, opts PrintOptions) error {
// ...
case "table":
return printTableOpts(w, envelope, opts) // 传递 opts
}
// ② 列过滤实现
func filterColumns(all, wanted []string) []string {
wantedSet := make(map[string]bool, len(wanted))
for _, w := range wanted { wantedSet[w] = true }
result := make([]string, 0, len(wanted))
for _, h := range all {
if wantedSet[h] { result = append(result, h) }
}
return result
}
// ③ 可配置截断
func formatValueOpts(v interface{}, noTruncate bool) string {
if !noTruncate && len(s) > 60 {
return s[:57] + "..."
}
return s
}
// ④ 彩色表头(仅终端且 --no-color 未设置)
func isTerminal(w io.Writer) bool {
if f, ok := w.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
// ⑤ RuntimeContext 无缝传递 — shortcuts/common/types.go
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
opts := output.PrintOptions{
NoTruncate: ctx.NoTruncate,
UseColor: !ctx.NoColor,
}
if ctx.Columns != "" {
// "id,title,state" → []string{"id", "title", "state"}
for _, p := range strings.Split(ctx.Columns, ",") {
p = strings.TrimSpace(p)
if p != "" { opts.Columns = append(opts.Columns, p) }
}
}
return output.PrintWithOpts(env, ctx.Format, opts)
}
// ⑥ 全局 flag 注册 — cmd/root.go
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoTruncate, "no-truncate", false,
"Disable value truncation in table output")
rootCmd.PersistentFlags().StringVar(&cmdutil.Columns, "columns", "",
"Columns to show in table output (comma-separated)")
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoColor, "no-color", false,
"Disable colored output")
```
用法:
```
gitlink-cli issue +list --columns id,subject,status
gitlink-cli issue +list --no-truncate
gitlink-cli issue +list --no-color
```
---
## 三、错误提示
### 问题中英文混用fmt.Errorf 不被框架识别
现状:各快捷命令中的错误用 `fmt.Errorf` 随意构造,中文和英文混用。`fmt.Errorf` 生成的错误不是 `CLIError` 类型,不被 `TryPrintError` 识别,只能走 `cmd.Execute` 的 stderr 兜底输出,无法享受 envelope 结构化错误格式。
**修改前** — 同一项目中三种不同风格:
```go
// 风格 A中文 — shortcuts/issue/issue.go
return fmt.Errorf("获取 Issue 列表失败: %w", err)
return fmt.Errorf("创建 Issue 失败: %w", err)
// 风格 B英文 — shortcuts/repo/repo.go
return fmt.Errorf("failed to list members for %s/%s: %w", ctx.Owner, ctx.Repo, err)
return fmt.Errorf("cannot determine current user login")
// 风格 C中英混合 — shortcuts/pr/pr.go
return fmt.Errorf("获取 PR 列表失败: %w", err)
return fmt.Errorf("添加 PR 评论失败: %w", err)
```
**问题根源**:没有统一的错误构造入口,开发者各自手写 `fmt.Errorf`
**解决方案**:新增 `OpError` 构造函数,入参只需动词和资源名,自动生成英文 `Message`(给脚本 / jq 解析)和中文 `Suggestion`(给用户阅读),且返回 `*CLIError` 类型可被框架自动识别为 envelope 格式。
**修改后**
```go
// ① 统一构造函数 — internal/errors/errors.go
func OpError(kind ErrorKind, op, resource string, cause error) *CLIError {
msg := fmt.Sprintf("failed to %s %s", op, resource)
sugg := opSuggestion(op, resource)
return Wrap(kind, msg, sugg, cause)
}
func opSuggestion(op, resource string) string {
suggestions := map[string]string{
"list": "获取列表失败,请检查参数或网络连接,稍后重试",
"create": "创建失败,请检查必填参数是否正确(--help 查看用法)或 API 权限",
"view": "查看失败,请确认资源 ID 是否存在",
"update": "更新失败,请检查参数值或资源 ID 是否正确",
"delete": "删除失败,请确认资源是否存在或是否有删除权限",
"close": "关闭失败,请确认资源是否存在或已被关闭",
"merge": "合并失败,请检查是否有冲突或权限不足",
"comment": "添加评论失败,请确认资源是否存在",
"fork": "Fork 失败,请确认仓库存在或有权限",
"invite": "邀请失败,请确认用户 ID 是否正确",
"remove": "移除失败,请确认成员存在",
}
if s, ok := suggestions[op]; ok { return s }
return "操作失败,请稍后重试或运行 --help 查看用法"
}
// ② 命令中一行调用 — shortcuts/issue/issue.go
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
// ③ 框架自动识别 — shortcuts/common/error_print.go
// TryPrintError 检测到 *CLIError 类型后自动输出为结构化 JSON
var cliErr *clierrors.CLIError
if errors.As(err, &cliErr) {
env := output.ErrorEnvelope(kindToCode(cliErr.Kind), cliErr.Message, cliErr.Suggestion)
_ = output.Print(env, format)
return true
}
```
输出效果:
```json
{
"ok": false,
"error": {
"code": 500,
"message": "failed to list issues",
"suggestion": "获取列表失败,请检查参数或网络连接,稍后重试"
}
}
```
`message` 用英文保证脚本可解析,`suggestion` 用中文直接给人看。
---
## 四、帮助文档
### 问题Shortcut 无详细帮助Group 命令只有一行描述
现状:`Shortcut` 结构体只有 `Description` 一个短描述字段,没有 `Long`(详细说明)和 `Example`(使用示例)。`--help` 输出只有一个命令行和 flag 列表,用户看不到用法示例。
Group 命令(`repo`、`issue`、`pr` 等同理15 个 group 全部只有一行 `Short`
```go
descriptions := map[string]string{
"repo": "Repository operations",
"pr": "Pull request operations",
// 14 个 group 完全一样...
}
```
**修改前**
```go
// shortcuts/common/types.go — 结构体缺少 Long 和 Example
type Shortcut struct {
Name string
Description string // 仅此一个描述字段
Flags []Flag
Run func(ctx *RuntimeContext) error
}
// shortcuts/common/runner.go — cobra 命令只有 Use 和 Short
cmd := &cobra.Command{
Use: "+" + s.Name,
Short: s.Description,
RunE: /* ... */,
}
// shortcuts/register.go — group 命令也只有 Short
groupCmd := &cobra.Command{
Use: name,
Short: descriptions[name],
}
```
**问题总结**`--help` 输出仅包含一句话描述 + 参数列表,没有用法示例。对于 `pr +merge` 这类参数较多的命令,用户无从得知 `--method` 有哪些可选值、典型调用怎么写。
**解决方案**`Shortcut` 结构体新增 `Long``Example` 字段,挂载到 cobra 的 `Long``Example``register.go` 中 15 个 Group 命令全部补充 Long 描述和使用示例;新增 `completion` 子命令支持 4 种 shell 的自动补全。
**修改后**
```go
// ① Shortcut 结构体扩展 — shortcuts/common/types.go
type Shortcut struct {
Name string
Description string
Long string // 新增:详细帮助文本
Example string // 新增:使用示例
Flags []Flag
Run func(ctx *RuntimeContext) error
}
// ② MountShortcut 挂载到 cobra — shortcuts/common/runner.go
cmd := &cobra.Command{
Use: "+" + s.Name,
Short: s.Description,
Long: s.Long, // 新增
Example: s.Example, // 新增
RunE: /* ... */,
}
// ③ 命令中填写 — shortcuts/pr/pr.go
{
Name: "merge",
Description: "Merge a pull request",
Example: " gitlink-cli pr +merge --id 42\n" +
" gitlink-cli pr +merge --id 42 --method rebase\n" +
" gitlink-cli pr +merge --id 42 --method squash --dry-run",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge",
Choices: []string{"merge", "rebase", "squash"}},
},
}
// ④ Group 命令补充 Long 和 Example — shortcuts/register.go
type groupInfo struct {
Short string
Long string
Example string
}
infos := map[string]groupInfo{
"pr": {
Short: "Pull request operations",
Long: "Manage pull requests: list, create, view, merge, close, review, and view changed files.",
Example: " gitlink-cli pr +list --state open\n" +
" gitlink-cli pr +create --title \"Fix login\" --head feat-branch\n" +
" gitlink-cli pr +merge --id 42",
},
// 其余 14 个 group 同上
}
groupCmd := &cobra.Command{
Use: name,
Short: info.Short,
Long: info.Long,
Example: info.Example,
}
```
### 问题:无 Shell 自动补全
cobra 框架原生支持 bash/zsh/fish/powershell 的补全生成,但 gitlink-cli 没有暴露这个能力。用户需要记忆 15 个 group 和 80+ 个子命令的完整名称。
**解决方案**:新增 `completion` 子命令。
```go
// cmd/root.go
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
RunE: func(cmd *cobra.Command, args []string) error {
shell := "bash"
if len(args) > 0 { shell = args[0] }
switch shell {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell: %s (valid: bash, zsh, fish, powershell)", shell)
}
},
}
```
启用:
```bash
source <(gitlink-cli completion bash) # Bash
source <(gitlink-cli completion zsh) # Zsh
gitlink-cli completion fish | source # fish
gitlink-cli completion powershell | Out-String | Invoke-Expression # PowerShell
```
---
## 五、影响范围
| 文件 | 改动性质 |
|------|----------|
| `cmd/cmdutil/globals.go` | 新增 NoTruncate / Columns / NoColor 全局变量 |
| `cmd/root.go` | 新增 3 个 persistent flag + completion 子命令 + format 默认值修正 |
| `shortcuts/common/types.go` | Shortcut / Flag / RuntimeContext 结构体扩展6 个新增字段) |
| `shortcuts/common/runner.go` | Choices/Validate 校验逻辑 + Long/Example 挂载 + Choices→Usage 自动追加 |
| `internal/output/formatter.go` | PrintOptions + PrintWithOpts + 列过滤 + 去截断 + 彩色表头 |
| `internal/errors/errors.go` | OpError 统一构造函数 |
| `shortcuts/register.go` | 15 个 Group 命令补充 Long / Example |
| `shortcuts/issue/issue.go` | 错误统一 + Choices(state) + Long/Examplelist, create, view, close, update |
| `shortcuts/pr/pr.go` | 错误统一 + Choices(state, method) + Long/Examplelist, create, merge |
| `shortcuts/repo/repo.go` | 错误统一 + Choices(category) + Long/Examplelist, create, update |
所有新增字段零值安全,现有 80+ Shortcut 无需修改即可编译。`output.Print()` 签名不变,内部包装为 `PrintWithOpts`。Workflow 脚本显式使用 `--format json`,不受默认格式变化影响。
验证:`go build` 通过,`go vet` 通过(仅 pre-existing milestone 包有构建错误),`go test ./...` 7 个测试包全部通过。

View File

@ -0,0 +1,224 @@
# Board (看板) Shortcut 修改笔记
## 一、功能概述
新增 `board` 快捷命令组,提供项目看板的查看、筛选、任务操作和统计分析功能。
| 命令 | 类型 | 说明 |
|------|------|------|
| `board +view` | 读 | 按状态分组显示看板全貌 |
| `board +columns` | 读 | 列出各状态列及 issue 数量 |
| `board +issues` | 读 | 按状态/指派人/优先级筛选任务 |
| `board +move` | 写 | 移动任务状态(支持 dry-run |
| `board +assign` | 写 | 指派任务给用户(支持 dry-run |
| `board +stats` | 读 | 完成率、工作负载、瓶颈分析 |
---
## 二、解决思路
### 2.1 API 选型
最初计划使用 PM 看板 API (`GET /pm/dashboards?project_id=...`),但实际测试发现该端点不存在(返回 HTML 页面。skill 参考文档 `pm-kanban.md` 中的 API 描述有误。
**最终方案**:基于已有的 issue list API (`GET /v1/{owner}/{repo}/issues`) 实现看板视图。每个 issue 带有 `status_id`、`status_name`、`assigners`、`priority` 等字段,按 `status_id` 分组即可构建看板。
### 2.2 看板列设计
`status_id` 映射为 5 列:
| status_id | 列名 |
|-----------|------|
| 1 | 待处理 |
| 2 | 进行中 |
| 3 | 已解决 |
| 5 | 已关闭 |
| 6 | 已拒绝 |
### 2.3 写操作复用
`board +move``board +assign` 复用 issue PATCH API (`PATCH /v1/{owner}/{repo}/issues/{id}`)。关键点PATCH body 必须包含 `subject` + `description`,否则会被清空。
---
## 三、代码指令
### 3.1 文件变更
```
新建shortcuts/board/board.go # 6 个命令 + 辅助函数(约 580 行)
修改shortcuts/register.go # 添加 board 组的 import + 注册
```
### 3.2 编译部署
```bash
# 编译
cd /c/Users/Lenovo/Desktop/soft运维/gitlink-cli
go install .
# 同步到 npmnpm shim 调用同目录的 gitlink-cli.exe
cp ~/go/bin/gitlink-cli.exe ~/AppData/Roaming/npm/node_modules/@gitlink-ai/cli/bin/gitlink-cli.exe
```
### 3.3 核心辅助函数
```go
// fetchAllIssuesWithState — 分页获取所有 issue
func fetchAllIssuesWithState(ctx *common.RuntimeContext, state string) ([]issueItem, error)
// groupByStatus — 按 status_id 分组
func groupByStatus(issues []issueItem) map[int][]issueItem
// fetchExistingIssue — 获取 issue 的 subject+descriptionPATCH 前必须调用)
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (string, string, error)
// resolveUserID — 用户名转用户 ID调用 GET /users/{login}
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error)
```
### 3.4 register.go 变更
```go
// 新增 import
"github.com/gitlink-org/gitlink-cli/shortcuts/board"
// groups map 新增
"board": board.Shortcuts(),
// infos map 新增
"board": {
Short: "Board (kanban) operations",
Long: "View and manage kanban boards: ...",
Example: " gitlink-cli board +view\n ...",
},
```
---
## 四、输出参考
### 4.1 board --help
```
View and manage kanban boards: view board layout, list columns, filter issues,
move tasks between columns, assign people, and analyze workload.
Usage:
gitlink-cli board [command]
Available Commands:
+assign Assign an issue to someone
+columns List status columns with issue counts
+issues List issues with optional filters
+move Move an issue to a different status
+stats Show board analytics and statistics
+view View kanban board layout
```
### 4.2 board +view
```json
{
"ok": true,
"data": {
"repository": "zzx-coder/gitlink-cli",
"total_issues": 44,
"columns": [
{
"status_id": 1,
"status_name": "待处理",
"issue_count": 0,
"issues": []
},
{
"status_id": 2,
"status_name": "进行中",
"issue_count": 0,
"issues": []
},
{
"status_id": 3,
"status_name": "已解决",
"issue_count": 44,
"issues": [
{"number": 16, "id": 143115, "subject": "fix:增加相关test.go文件", "priority": "normal"},
{"number": 25, "id": 143700, "subject": "fix: 统一错误提示优化", "priority": "normal"}
]
},
{"status_id": 5, "status_name": "已关闭", "issue_count": 0, "issues": []},
{"status_id": 6, "status_name": "已拒绝", "issue_count": 0, "issues": []}
]
}
}
```
### 4.3 board +columns
```json
{
"ok": true,
"data": [
{"status_id": 1, "status_name": "待处理", "issue_count": 0},
{"status_id": 2, "status_name": "进行中", "issue_count": 0},
{"status_id": 3, "status_name": "已解决", "issue_count": 44},
{"status_id": 5, "status_name": "已关闭", "issue_count": 0},
{"status_id": 6, "status_name": "已拒绝", "issue_count": 0}
]
}
```
### 4.4 board +issues --status resolved --limit 3
```json
{
"ok": true,
"data": [
{"number": 16, "id": 143115, "subject": "fix:增加相关test.go文件", "status": "已解决", "priority": "normal", "assigned_to": ""},
{"number": 25, "id": 143700, "subject": "fix: 统一错误提示优化", "status": "已解决", "priority": "normal", "assigned_to": ""},
{"number": 2, "id": 142700, "subject": "新增issue批量操作", "status": "已解决", "priority": "normal", "assigned_to": ""}
]
}
```
### 4.5 board +move --number 16 --status in-progress --dry-run
```
[dry-run] Move issue #16 to status "in-progress"
Proceed? [y/N] Aborted.
```
### 4.6 board +stats
```json
{
"ok": true,
"data": {
"repository": "zzx-coder/gitlink-cli",
"total_issues": 44,
"completion_rate": 100,
"column_breakdown": [
{"status_name": "待处理", "count": 0, "percentage": 0},
{"status_name": "进行中", "count": 0, "percentage": 0},
{"status_name": "已解决", "count": 44, "percentage": 100},
{"status_name": "已关闭", "count": 0, "percentage": 0},
{"status_name": "已拒绝", "count": 0, "percentage": 0}
],
"assignee_load": [
{"assignee": "(unassigned)", "count": 44}
],
"bottleneck": "已解决"
}
}
```
---
## 五、踩坑记录
| 问题 | 原因 | 解决 |
|------|------|------|
| `unknown command "board"` | npm shim (`cli.js`) 调用的是 npm 包内的 `gitlink-cli.exe`,不是 `go/bin` 的 | 编译后同步覆盖 npm 包内的 exe |
| `failed to parse dashboard data` | `/pm/dashboards` API 端点不存在,返回 HTML | 改用 issue list API + 客户端分组 |
| `json: cannot unmarshal string` | API 返回 HTML 字符串而非 JSON 对象 | 同上,放弃 PM API |

View File

@ -0,0 +1,386 @@
"""生成子赛题一报告 Word 文档"""
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
doc = Document()
# === 样式设置 ===
style = doc.styles['Normal']
style.font.name = '宋体'
style.font.size = Pt(12)
style.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
def add_heading(text, level=1):
h = doc.add_heading(text, level=level)
for run in h.runs:
run.font.name = '黑体'
run.element.rPr.rFonts.set(qn('w:eastAsia'), '黑体')
return h
def add_para(text, bold=False):
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = bold
run.font.name = '宋体'
run.font.size = Pt(12)
run.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
return p
def add_table(headers, rows):
table = doc.add_table(rows=1, cols=len(headers), style='Table Grid')
table.alignment = WD_TABLE_ALIGNMENT.CENTER
for i, h in enumerate(headers):
cell = table.rows[0].cells[i]
cell.text = h
for p in cell.paragraphs:
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in p.runs:
run.bold = True
run.font.size = Pt(10)
for row_data in rows:
row = table.add_row()
for i, val in enumerate(row_data):
row.cells[i].text = str(val)
for p in row.cells[i].paragraphs:
for run in p.runs:
run.font.size = Pt(10)
return table
def add_code(text):
p = doc.add_paragraph()
run = p.add_run(text)
run.font.name = 'Consolas'
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
return p
# ============================================================
# 正文
# ============================================================
add_heading('第二章 子赛题一:增强与完善 GitLink-CLI 能力', level=1)
# 2.1
add_heading('2.1 任务目标与整体思路', level=2)
add_para(
'子赛题一的定位是扩展 gitlink-cli 的功能覆盖面和使用体验。本项目选择的切入方向有三个:'
'新增 Shortcut 命令(填补功能空白)、优化命令系统框架(提升开发效率和用户体验)、'
'补全 Raw API 封装(对齐 OpenAPI 接口)。'
)
add_para(
'整体设计思路是"先框架后业务":先完善底层的 Shortcut 抽象层(参数校验、输出格式、错误处理、帮助文档),'
'再基于这个框架快速新增业务命令。这样做的好处是,新增的命令天然继承框架能力'
'dry-run、枚举校验、结构化错误输出不需要每个命令重复造轮子。'
)
# 2.2
add_heading('2.2 三层命令体系架构', level=2)
add_para('gitlink-cli 采用三层命令体系:')
add_table(
['层级', '名称', '说明', '示例'],
[
['第一层', 'Shortcuts+前缀命令)', '人类和 AI Agent 直接使用,参数精简,结构化输出', 'issue +list --state open'],
['第二层', 'API Commands元数据驱动', '自动从 OpenAPI spec 生成,覆盖所有 API 端点', 'api get /v1/{owner}/{repo}/issues'],
['第三层', 'Raw APIHTTP 原始调用)', '完全透传,适合调试和边缘场景', 'raw GET /v1/owner/repo/issues'],
]
)
add_para('')
add_para(
'本次工作主要落在第一层Shortcuts同时涉及底层框架的增强。新增了 board、file、milestone '
'三个 Shortcut 模块,以及对 issue、pr、repo 等已有模块的扩展。'
)
add_para(
'架构说明cmd/root.go 是 cobra 根命令,通过 shortcuts/register.go 挂载 15 个命令组,'
'每个组的子命令通过 common/runner.go 的 MountShortcut() 自动注册 flags、校验逻辑和错误处理。'
'业务模块只需定义 Shortcut 结构体数组。'
)
# 2.3
add_heading('2.3 新增 Shortcut 命令', level=2)
# 2.3.1 Wiki
add_heading('2.3.1 Wiki 管理命令', level=3)
add_para('新增 wiki 命令组,提供 6 个子命令:')
add_table(
['命令', '功能', 'DryRun'],
[
['wiki +list', '列出所有 Wiki 页面', '-'],
['wiki +view', '查看指定页面内容', '-'],
['wiki +create', '创建 Wiki 页面', 'Yes'],
['wiki +update', '更新 Wiki 页面', 'Yes'],
['wiki +delete', '删除 Wiki 页面(带二次验证)', 'Yes'],
['wiki +lint', '检查 Wiki 内容质量', '-'],
]
)
add_para('技术要点:')
add_para('1Wiki API 走网关gateway.gitlink.org.cn/api而非主 API。')
add_para('2内容使用 base64 编码传输outputWithDecodedContent() 自动解码,节省 AI Agent token。')
add_para('3+delete 有删除后验证机制GET 确认页面真的被删除了。')
add_para('4+lint 检查项:空内容、标题层级、内容过短、链接有效性、图片引用。')
# 2.3.2 Webhook
add_heading('2.3.2 Webhook 配置命令', level=3)
add_para('新增 webhook 命令组,提供 7 个子命令:')
add_table(
['命令', '功能', 'DryRun'],
[
['webhook +list', '列出所有 webhook', '-'],
['webhook +create', '创建 webhook', 'Yes'],
['webhook +update', '更新 webhook智能获取当前 URL', 'Yes'],
['webhook +delete', '删除 webhook双重验证', 'Yes'],
['webhook +test', '测试 webhook 触发', '-'],
['webhook +info', '查看 webhook 详情', '-'],
['webhook +events', '列出支持的事件类型', '-'],
]
)
add_para('支持 11 种事件类型push, pull_request, issue, issue_assign, issue_comment, pull_request_assign, pull_request_comment, merge_request, repository, branch, tag。')
# 2.3.3 Board
add_heading('2.3.3 项目看板Board', level=3)
add_para('新增 board 命令组,提供 6 个子命令:')
add_table(
['命令', '功能', '实现方式'],
[
['board +view', '按状态分组显示看板全貌', '基于 issue list API + 客户端按 status_id 分组'],
['board +columns', '列出各状态列及 issue 数量', '同上'],
['board +issues', '按状态/指派人/优先级筛选任务', '同上,增加过滤逻辑'],
['board +move', '移动任务状态', '复用 issue PATCH API'],
['board +assign', '指派任务给用户', '先 resolveUserID再 PATCH'],
['board +stats', '完成率、工作负载、瓶颈分析', '统计聚合'],
]
)
add_para(
'设计决策:最初计划使用 PM 看板 API/pm/dashboards但实际测试发现该端点不存在。'
'最终方案基于已有的 issue list API 实现,按 status_id 映射为 5 列'
'(待处理/进行中/已解决/已关闭/已拒绝)。'
)
# 2.3.4 Milestone
add_heading('2.3.4 里程碑管理Milestone', level=3)
add_para('新增 milestone 命令组,提供 6 个子命令:')
add_table(
['命令', '功能'],
[
['milestone +list', '列出里程碑,支持按状态筛选和排序'],
['milestone +create', '创建里程碑'],
['milestone +view', '查看里程碑详情及关联 Issue'],
['milestone +update', '更新里程碑(自动获取当前值,只覆盖指定字段)'],
['milestone +delete', '删除里程碑(先 GET 获取必填字段再 DELETE'],
['milestone +status', '打开或关闭里程碑'],
]
)
# 2.3.5 File
add_heading('2.3.5 文件操作File', level=3)
add_para('新增 file 命令组,提供 11 个子命令:')
add_table(
['命令', '功能', 'DryRun'],
[
['file +ls', '列出根目录文件', '-'],
['file +tree', '查看子目录/文件详情', '-'],
['file +read', '读取文件内容', '-'],
['file +readme', '读取 README', '-'],
['file +search', '按文件名搜索', '-'],
['file +create', '创建文件(自动 base64 编码)', 'Yes'],
['file +update', '更新文件(自动获取 SHA', 'Yes'],
['file +delete', '删除文件(自动获取 SHA', 'Yes'],
['file +batch', '批量创建/更新/删除文件', 'Yes'],
['file +commits', '提交历史', '-'],
['file +diff', '查看 commit diff', '-'],
]
)
# 2.4
add_heading('2.4 优化部分', level=2)
add_heading('2.4.1 参数设计优化', level=3)
add_para(
'问题:枚举参数无校验,错误信息延迟到 API 调用才暴露。'
'pr +merge --method 接受 merge/rebase/squash但输入非法值时直接发往 API'
'等到服务端返回 422 才知道参数错了。'
)
add_para('方案:在 Flag 结构体中增加 Choices 和 Validate 字段,框架层在 parse 阶段自动校验。')
add_code(
'type Flag struct {\n'
' Name string\n'
' Choices []string // 枚举校验\n'
' Validate func(value string) error // 自定义校验\n'
'}'
)
add_para('同时Choices 声明后 --help 会自动追加 [merge|rebase|squash],无需手动维护。')
add_para('跨参数约束Shortcut 结构体新增 Validate 字段,支持声明式校验(如"至少提供 --title、--body 或 --state 中的一个")。')
add_heading('2.4.2 输出格式优化', level=3)
add_para('问题:默认格式与帮助文本不一致(帮助说 table代码默认 json表格输出无列过滤、长文本被硬截断。')
add_para('方案:')
add_para('1修正默认值为 table。')
add_para('2引入 PrintOptions 结构体,支持 --columns列过滤、--no-truncate关闭截断、--no-color禁用彩色')
add_para('3通过 RuntimeContext 透明传递到所有输出调用。')
add_heading('2.4.3 错误提示优化', level=3)
add_para('问题中英文混用fmt.Errorf 不被框架识别,无法输出结构化错误。')
add_para('方案:新增 OpError 统一构造函数,入参只需动词和资源名,自动生成英文 Message给脚本解析和中文 Suggestion给用户阅读')
add_code('clierrors.OpError(clierrors.KindServer, "list", "issues", err)\n// 输出failed to list issues / 获取列表失败,请检查参数或网络连接')
add_heading('2.4.4 帮助文档优化', level=3)
add_para('问题Shortcut 只有短描述无使用示例15 个 Group 命令全部只有一行 Short。')
add_para('方案Shortcut 结构体新增 Long 和 Example 字段,挂载到 cobra 的对应字段15 个 Group 命令全部补充 Long 描述和使用示例;新增 completion 子命令支持 bash/zsh/fish/powershell 的自动补全。')
# 2.5
add_heading('2.5 批量操作能力增强', level=2)
add_heading('Issue 批量操作6 个)', level=3)
add_table(
['命令', '功能'],
[
['issue +batch-close', '批量关闭 Issue'],
['issue +batch-status', '批量修改状态'],
['issue +batch-priority', '批量修改优先级'],
['issue +batch-assign', '批量指派负责人'],
['issue +batch-label', '批量添加/移除标签'],
['issue +batch-create', '批量创建 Issue'],
]
)
add_heading('Repo 批量操作4 个)', level=3)
add_table(
['命令', '功能'],
[
['repo +batch-create', '批量创建仓库'],
['repo +batch-update', '批量更新仓库设置'],
['repo +batch-delete', '批量删除仓库'],
['repo +batch-member', '批量邀请/移除成员'],
]
)
add_heading('Issue 增强4 个元数据查询 + 3 个评论管理)', level=3)
add_table(
['命令', '功能'],
[
['issue +statuses', '获取所有可用的 Issue 状态'],
['issue +authors', '获取发布过 Issue 的用户列表'],
['issue +assigners', '获取可被指派的用户列表'],
['issue +priorities', '获取所有可用的优先级'],
['issue +comment-edit', '编辑 Issue 评论'],
['issue +comment-delete', '删除 Issue 评论'],
['issue +replies', '查看评论下的回复'],
]
)
add_heading('PR 增强6 个命令 + 2 个评论管理)', level=3)
add_table(
['命令', '功能'],
[
['pr +reopen', '重新打开已关闭的 PR'],
['pr +update', '更新 PR 标题/描述/分支'],
['pr +commits', '查看 PR 中的所有提交'],
['pr +versions', '查看 PR 版本历史'],
['pr +vdiff', '查看 PR 某版本的 diff'],
['pr +filesv1', '查看 PR 变更文件列表v1 API'],
['pr +comment-edit', '编辑 PR 审查评论'],
['pr +comment-delete', '删除 PR 审查评论'],
]
)
# 2.6
add_heading('2.6 跨平台兼容性与安装体验', level=2)
add_para('本项目支持 macOS、Linux、Windowsx64/arm64三个平台。')
add_para('安装方式:')
add_para('1一键安装脚本curl -sSL .../install.sh | bash自动检测平台和架构。')
add_para('2npm 安装npm install -g @gitlink-ai/clipostinstall 自动下载对应平台二进制。')
add_para('3源码编译go install . 后手动同步到 npm 包。')
add_para('Windows 特殊处理npm shimcli.js调用的是 npm 包内的 gitlink-cli.exe编译后需要同步覆盖。路径处理兼容 Windows 反斜杠。')
# 2.7
add_heading('2.7 Raw API 封装补全', level=2)
add_para('对齐 GitLink OpenAPI新封装的接口清单')
add_table(
['模块', '接口数', '涉及 API 端点'],
[
['File 操作', '11', 'entries, sub_entries, readme, files, create_file, update_file, delete_file, batch, commits, diff'],
['Issue 元数据', '4', 'issue_statues, issue_authors, issue_assigners, issue_priorities'],
['Milestone', '6', 'milestones CRUD + update_status'],
['PR 增强', '6', 'reopen, update, commits, versions, versions/diff, files(v1)'],
['评论管理', '5', 'issue journals CRUD + children_journals, PR journals CRUD'],
['合计', '32', '-'],
]
)
# 2.8
add_heading('2.8 单元测试与命令帮助文档', level=2)
add_heading('测试文件位置', level=3)
add_table(
['模块', '测试文件'],
[
['board', 'shortcuts/board/board_test.go'],
['file', 'shortcuts/file/file_test.go'],
['milestone', 'shortcuts/milestone/milestone_test.go'],
['wiki', 'shortcuts/wiki/wiki_test.go'],
['webhook', 'shortcuts/webhook/webhook_test.go'],
['issue', 'shortcuts/issue/issue_test.go, batch_test.go, batch_create_test.go'],
['pr', 'shortcuts/pr/pr_test.go'],
['repo', 'shortcuts/repo/batch_test.go, batch_delete_test.go'],
['common', 'shortcuts/common/runner_test.go'],
]
)
add_heading('测试方法', level=3)
add_para('使用 httptest.NewServer mock API构造 RuntimeContext 直接调用 Run 函数,验证输出和 PATCH body 内容。')
add_heading('帮助文档更新', level=3)
add_para('1所有 Shortcut 的 Long 和 Example 字段已填写。')
add_para('215 个 Group 命令补充了详细描述和使用示例。')
add_para('3新增 completion 子命令支持 4 种 shell 自动补全。')
# 2.9
add_heading('2.9 PR 提交记录与变更说明', level=2)
add_table(
['PR', '标题', '内容摘要', '状态'],
[
['#27', '实现场景1社区运营自动化', 'Webhook 接收器 + 部署 + systemd', '已合并'],
['-', 'feat(board): 新增项目看板 shortcut', '6 个看板命令 + 单元测试', '待提交'],
['-', 'feat(file): 新增文件操作模块', '11 个文件命令 + 单元测试', '待提交'],
['-', 'feat(milestone): 新增里程碑管理', '6 个里程碑命令 + 单元测试', '待提交'],
['-', 'feat(wiki): 新增 Wiki 管理命令', '6 个 Wiki 命令 + lint', '待提交'],
['-', 'feat(webhook): 新增 Webhook 配置', '7 个 Webhook 命令', '待提交'],
['-', 'refactor(shortcuts): 优化命令框架', 'Choices/Validate/PrintOptions/OpError', '待提交'],
['-', 'feat(issue): 批量操作 + 元数据查询 + 评论管理', '13 个命令', '待提交'],
['-', 'feat(pr): PR 增强 + 评论管理', '8 个命令', '待提交'],
]
)
add_para('')
add_para('涉及文件变更汇总:', bold=True)
add_table(
['文件', '改动性质'],
[
['shortcuts/common/types.go', '结构体扩展6 个新增字段)'],
['shortcuts/common/runner.go', '校验逻辑 + Long/Example 挂载'],
['shortcuts/register.go', '注册 board/file/milestone 模块'],
['internal/errors/errors.go', 'OpError 统一构造函数'],
['internal/output/formatter.go', 'PrintOptions + 列过滤 + 彩色表头'],
['cmd/root.go', '3 个 persistent flag + completion 子命令'],
['cmd/cmdutil/globals.go', '新增全局变量'],
['shortcuts/board/board.go', '新建6 个命令'],
['shortcuts/file/file.go', '新建11 个命令'],
['shortcuts/milestone/milestone.go', '新建6 个命令'],
['shortcuts/wiki/wiki.go', '新建6 个命令'],
['shortcuts/webhook/webhook.go', '新建7 个命令'],
['shortcuts/issue/issue.go', '新增 13 个命令'],
['shortcuts/pr/pr.go', '新增 8 个命令'],
]
)
add_para('')
add_para('验证结果go build 通过go test ./... 全部通过。')
# === 保存 ===
output_path = r'C:\Users\Lenovo\Desktop\soft运维\gitlink-cli\doc\任务一\子赛题一报告.docx'
doc.save(output_path)
print(f'报告已生成: {output_path}')

View File

@ -0,0 +1,645 @@
# Raw API 封装修改笔记
## 第一批:代码/文件操作模块file
### 1. file +ls — 根目录文件列表
**功能**:列出项目根目录下的文件和子目录。
**解决思路**:调用 `GET /{owner}/{repo}/entries` API支持 `--ref` 指定分支。
**代码指令**
```bash
gitlink-cli file +ls --owner <user> --repo <repo>
gitlink-cli file +ls --ref develop
```
**输出参考**
```json
{
"ok": true,
"data": {
"entries": [
{"name": "README.md", "path": "README.md", "type": "file", "sha": "abc123"},
{"name": "src", "path": "src", "type": "dir", "sha": "def456"}
],
"last_commit": {"message": "update readme", "author": {"name": "user"}},
"commits_count": 42
}
}
```
---
### 2. file +tree — 子目录/文件详情
**功能**:查看指定路径的目录结构或文件元信息。
**解决思路**:调用 `GET /{owner}/{repo}/sub_entries``filepath` 参数必填。
**代码指令**
```bash
gitlink-cli file +tree --path src/
gitlink-cli file +tree --path src/main.go --ref v1.0
```
**输出参考**
```json
{
"ok": true,
"data": {
"entries": {
"name": "main.go",
"path": "src/main.go",
"type": "file",
"size": 1234,
"sha": "abc123",
"commit": {"message": "add main.go"}
}
}
}
```
---
### 3. file +read — 读取文件内容
**功能**:读取指定文件的实际内容。
**解决思路**:调用 `GET /{owner}/{repo}/sub_entries`,响应的 `entries.content` 字段包含文件内容。
**代码指令**
```bash
gitlink-cli file +read --path README.md
gitlink-cli file +read --path go.mod --ref develop
```
**输出参考**
```json
{
"ok": true,
"data": {
"entries": {
"name": "README.md",
"path": "README.md",
"content": "# Project Title\n\nThis is the content...",
"sha": "abc123",
"size": 567
}
}
}
```
---
### 4. file +readme — 读取 README
**功能**:读取项目的 README 文件,支持子目录 README。
**解决思路**:调用 `GET /{owner}/{repo}/readme`,可选 `filepath``ref` 参数。
**代码指令**
```bash
gitlink-cli file +readme
gitlink-cli file +readme --path docs/
```
**输出参考**
```json
{
"ok": true,
"data": {
"name": "README.md",
"content": "# Project Title\n\nDescription...",
"sha": "abc123",
"encoding": "text"
}
}
```
---
### 5. file +search — 搜索文件
**功能**:按文件名关键词搜索。
**解决思路**:调用 `GET /{owner}/{repo}/files``search` 查询参数。
**代码指令**
```bash
gitlink-cli file +search --q "test"
gitlink-cli file +search --q ".go" --ref main
```
**输出参考**
```json
{
"ok": true,
"data": [
{"name": "main_test.go", "path": "main_test.go", "sha": "abc", "size": 234},
{"name": "util_test.go", "path": "util/util_test.go", "sha": "def", "size": 567}
]
}
```
---
### 6. file +create — 创建文件
**功能**:在指定分支创建新文件。
**解决思路**:调用 `POST /{owner}/{repo}/create_file`。注意 `content``base64_filepath` 都需要 Base64 编码CLI 自动处理。
**代码指令**
```bash
gitlink-cli file +create --path docs/new.md --content "# New Doc" --branch master --message "add doc"
```
**输出参考**
```json
{
"ok": true,
"data": {
"name": "new.md",
"sha": "abc123",
"size": 9,
"encoding": "base64",
"commit": {"message": "add doc", "author": {"name": "user"}}
}
}
```
---
### 7. file +update — 更新文件
**功能**:更新已有文件内容。如果未提供 `--sha`CLI 自动通过 sub_entries API 获取。
**解决思路**:调用 `PUT /{owner}/{repo}/update_file`。`content` 传明文(非 Base64需要文件当前 `sha`
**代码指令**
```bash
# 自动获取 sha
gitlink-cli file +update --path README.md --content "updated" --branch master --message "update readme"
# 手动指定 sha
gitlink-cli file +update --path README.md --content "updated" --branch master --sha abc123 --message "update"
```
**输出参考**
```json
{
"ok": true,
"data": {"status": 1, "message": "更新成功"}
}
```
---
### 8. file +delete — 删除文件
**功能**:删除指定文件。如果未提供 `--sha`CLI 自动获取。
**解决思路**:调用 `DELETE /{owner}/{repo}/delete_file`,需要 `sha`。body 参数(非 query
**代码指令**
```bash
gitlink-cli file +delete --path old-file.txt --branch master
gitlink-cli file +delete --path old.txt --branch master --sha abc123
```
**输出参考**
```json
{
"ok": true,
"data": {"status": 1, "message": "文件删除成功"}
}
```
---
### 9. file +batch — 批量提交文件
**功能**:在一个 commit 中同时创建/更新/删除多个文件。
**解决思路**:调用 `POST /v1/{owner}/{repo}/contents/batch`。`--files` 接收 JSON 数组,每个元素含 `action_type`、`file_path`、`content`。
**代码指令**
```bash
gitlink-cli file +batch --branch master --message "batch update" --files '[
{"action_type":"create","file_path":"a.txt","content":"hello"},
{"action_type":"update","file_path":"b.txt","content":"world"},
{"action_type":"delete","file_path":"c.txt"}
]'
```
**输出参考**
```json
{
"ok": true,
"data": {
"commit": {"sha": "abc123", "message": "batch update"},
"contents": [
{"name": "a.txt", "path": "a.txt", "sha": "def"},
{"name": "b.txt", "path": "b.txt", "sha": "ghi"}
]
}
}
```
---
### 10. file +commits — 提交历史
**功能**:查看项目的提交历史列表。
**解决思路**:调用 `GET /v1/{owner}/{repo}/commits`,支持分页和 `--ref` 过滤。
**代码指令**
```bash
gitlink-cli file +commits
gitlink-cli file +commits --ref main --page 2 --limit 10
```
**输出参考**
```json
{
"ok": true,
"data": [
{"sha": "abc123", "message": "fix bug", "author": {"name": "user"}, "authored_date": "2026-07-09"},
{"sha": "def456", "message": "add feature", "author": {"name": "user"}, "authored_date": "2026-07-08"}
]
}
```
---
### 11. file +diff — 提交 diff
**功能**:查看某次提交的文件变更 diff。
**解决思路**:调用 `GET /v1/{owner}/{repo}/commits/{sha}/diff`
**代码指令**
```bash
gitlink-cli file +diff --sha abc1234
```
**输出参考**
```json
{
"ok": true,
"data": {
"files": [
{"filename": "main.go", "status": "modified", "additions": 5, "deletions": 2, "patch": "@@ -10,3 +10,6 @@..."}
]
}
}
```
---
## 涉及文件
| 文件 | 操作 | 说明 |
|------|------|------|
| `shortcuts/file/file.go` | 新建 | file 模块11 个 shortcut |
| `shortcuts/register.go` | 修改 | 注册 file 模块 |
---
## 第二批Issue 增强 + PR 增强 + 评论管理21 命令)
### A. Issue 元数据查询4 命令)
### 12. issue +statuses — 疑修状态列表
**功能**:获取项目所有可用的 Issue 状态(新增、正在解决、已解决、关闭、拒绝)。
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_statues`,无参数。
**代码指令**
```bash
gitlink-cli issue +statuses
```
**输出参考**
```json
{"ok":true,"data":{"total_count":5,"statues":[{"id":1,"name":"新增"},{"id":2,"name":"正在解决"},{"id":3,"name":"已解决"},{"id":5,"name":"关闭"},{"id":6,"name":"拒绝"}]}}
```
---
### 13. issue +authors — 发布人列表
**功能**:获取项目中发布过 Issue 的用户列表。
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_authors`,支持 `--keyword` 搜索。
**代码指令**
```bash
gitlink-cli issue +authors
gitlink-cli issue +authors --keyword zhang
```
**输出参考**
```json
{"ok":true,"data":{"total_count":3,"authors":[{"id":1,"name":"张三","login":"zhangsan","type":"User"}]}}
```
---
### 14. issue +assigners — 负责人列表
**功能**:获取项目中可被指派为负责人的用户列表。
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_assigners`,支持 `--keyword` 搜索。
**代码指令**
```bash
gitlink-cli issue +assigners
```
---
### 15. issue +priorities — 优先级列表
**功能**:获取项目所有可用的 Issue 优先级。
**解决思路**:调用 `GET /v1/{owner}/{repo}/issue_priorities`,无参数。
**代码指令**
```bash
gitlink-cli issue +priorities
```
**输出参考**
```json
{"ok":true,"data":{"total_count":5,"priorities":[{"id":1,"name":"低"},{"id":2,"name":"正常"},{"id":3,"name":"高"},{"id":4,"name":"紧急"}]}}
```
---
### B. 里程碑管理6 命令)
### 16. milestone +list — 里程碑列表
**功能**:列出项目的里程碑,支持按状态筛选和排序。
**解决思路**:调用 `GET /v1/{owner}/{repo}/milestones`,支持 `--category`、`--keyword`、`--sort-by`、分页。
**代码指令**
```bash
gitlink-cli milestone +list
gitlink-cli milestone +list --category opening
gitlink-cli milestone +list --sort-by issues_count --limit 5
```
**输出参考**
```json
{"ok":true,"data":{"total_count":3,"opening_milestone_count":2,"closed_milestone_count":1,"milestones":[{"id":1,"name":"v1.0","status":"open","issues_count":10,"percent":60}]}}
```
---
### 17. milestone +create — 创建里程碑
**功能**:创建新里程碑。
**解决思路**:调用 `POST /v1/{owner}/{repo}/milestones`body 必填 `name`、`description`、`effective_date`。
**代码指令**
```bash
gitlink-cli milestone +create --name "v1.0" --description "First release" --date 2026-12-31
```
**输出参考**
```json
{"ok":true,"data":{"status":0,"message":"success"}}
```
---
### 18. milestone +view — 里程碑详情
**功能**:查看里程碑详情及其关联的 Issue 列表。
**解决思路**:调用 `GET /v1/{owner}/{repo}/milestones/{id}`,支持 `--category` 过滤 Issue 状态。
**代码指令**
```bash
gitlink-cli milestone +view --id 1
gitlink-cli milestone +view --id 1 --category opened
```
**输出参考**
```json
{"ok":true,"data":{"milestone":{"id":1,"name":"v1.0","percent":60},"total_issues_count":10,"issues":[{"id":42,"subject":"Fix login","status_name":"新增"}]}}
```
---
### 19. milestone +update — 更新里程碑
**功能**:更新里程碑的名称、描述或截止日期。自动获取当前值,只覆盖用户指定的字段。
**解决思路**:先 `GET` 获取当前里程碑,再 `PATCH` 提交修改后的完整数据。
**代码指令**
```bash
gitlink-cli milestone +update --id 1 --name "v1.0-rc1"
gitlink-cli milestone +update --id 1 --date 2027-01-31
```
---
### 20. milestone +delete — 删除里程碑
**功能**:删除指定里程碑。
**解决思路**API 要求 body 中包含 `name`/`description`/`effective_date`,先 GET 获取再 DELETE。
**代码指令**
```bash
gitlink-cli milestone +delete --id 1 --dry-run
```
---
### 21. milestone +status — 里程碑状态变更
**功能**:打开或关闭里程碑。
**解决思路**:调用 `POST /{owner}/{repo}/milestones/{id}/update_status`(注意无 `/v1/` 前缀)。
**代码指令**
```bash
gitlink-cli milestone +status --id 1 --status closed
gitlink-cli milestone +status --id 1 --status opening
```
---
### C. PR 增强6 命令)
### 22. pr +reopen — 重新打开 PR
**功能**:重新打开已关闭的 PR。
**解决思路**:调用 `POST /v1/{owner}/{repo}/pulls/{index}/reopen`,无 body。
**代码指令**
```bash
gitlink-cli pr +reopen --id 42
```
---
### 23. pr +update — 更新 PR
**功能**:更新 PR 的标题、描述、源分支或目标分支。自动获取当前值,只覆盖用户指定的字段。
**解决思路**:先 GET 获取当前 PR 数据,再 PUT 提交API 要求所有字段必填)。
**代码指令**
```bash
gitlink-cli pr +update --id 42 --title "New title"
gitlink-cli pr +update --id 42 --body "Updated description"
```
---
### 24. pr +commits — PR 提交列表
**功能**:查看 PR 中的所有提交。
**解决思路**:调用 `GET /{owner}/{repo}/pulls/{id}/commits`(无 `/v1/` 前缀)。
**代码指令**
```bash
gitlink-cli pr +commits --id 42
```
**输出参考**
```json
{"ok":true,"data":{"commits_count":3,"commits":[{"sha":"abc","message":"fix bug","author":{"name":"user"}}]}}
```
---
### 25. pr +versions — PR 版本列表
**功能**:查看 PR 的版本历史(每次 push 新提交会生成新版本)。
**解决思路**:调用 `GET /v1/{owner}/{repo}/pulls/{index}/versions`
**代码指令**
```bash
gitlink-cli pr +versions --id 42
```
---
### 26. pr +vdiff — PR 版本 diff
**功能**:查看 PR 某个版本的文件变更 diff。
**解决思路**:调用 `GET /v1/{owner}/{repo}/pulls/{index}/versions/{version_id}/diff`,支持 `--filepath` 过滤。
**代码指令**
```bash
gitlink-cli pr +vdiff --id 42 --version 5
gitlink-cli pr +vdiff --id 42 --version 5 --filepath main.go
```
---
### 27. pr +filesv1 — PR 文件列表(v1)
**功能**:使用 v1 API 查看 PR 变更的文件列表,支持分页。
**解决思路**:调用 `GET /v1/{owner}/{repo}/pulls/{index}/files`,支持 `--filepath` 过滤和分页。
**代码指令**
```bash
gitlink-cli pr +filesv1 --id 42
gitlink-cli pr +filesv1 --id 42 --filepath src/
```
---
### D. 评论管理5 命令)
### 28. issue +comment-edit — 编辑 Issue 评论
**功能**:修改已有 Issue 评论的内容。
**解决思路**:调用 `PATCH /v1/{owner}/{repo}/issues/{index}/journals/{id}`body 需 `notes`(复数)和 `attachment_ids`
**代码指令**
```bash
gitlink-cli issue +comment-edit --number 42 --comment-id 100 --body "修正后的评论"
```
---
### 29. issue +comment-delete — 删除 Issue 评论
**功能**:删除 Issue 的某条评论。
**解决思路**:调用 `DELETE /v1/{owner}/{repo}/issues/{index}/journals/{id}`
**代码指令**
```bash
gitlink-cli issue +comment-delete --number 42 --comment-id 100
```
---
### 30. issue +replies — 子评论列表
**功能**:查看某条评论下的所有回复。
**解决思路**:调用 `GET /v1/{owner}/{repo}/issues/{index}/journals/{id}/children_journals`
**代码指令**
```bash
gitlink-cli issue +replies --number 42 --comment-id 100
```
---
### 31. pr +comment-edit — 编辑 PR 评论
**功能**:修改 PR 审查评论。注意 body 字段是 `note`(单数,与 Issue 的 `notes` 不同)。
**解决思路**:调用 `PUT /v1/{owner}/{repo}/pulls/{index}/journals/{id}`body 需 `note`、`commit_id`、`state`。
**代码指令**
```bash
gitlink-cli pr +comment-edit --id 42 --comment-id 100 --body "updated" --state resolved
```
---
### 32. pr +comment-delete — 删除 PR 评论
**功能**:删除 PR 的某条审查评论。
**解决思路**:调用 `DELETE /v1/{owner}/{repo}/pulls/{index}/journals/{id}`
**代码指令**
```bash
gitlink-cli pr +comment-delete --id 42 --comment-id 100
```
---
## 涉及文件
| 文件 | 操作 | 说明 |
|------|------|------|
| `shortcuts/file/file.go` | 新建 | file 模块11 个 shortcut |
| `shortcuts/issue/issue.go` | 修改 | 新增 7 个命令4 元数据 + 3 评论管理) |
| `shortcuts/milestone/milestone.go` | 新建 | milestone 模块6 个 shortcut |
| `shortcuts/pr/pr.go` | 修改 | 新增 8 个命令6 PR 增强 + 2 评论管理) |
| `shortcuts/register.go` | 修改 | 注册 file + milestone 模块 |

View File

@ -0,0 +1,39 @@
子赛题一增加和完善GitLink-CLI能力
定位扩展CLI功能丨难度中高丨需要Go语言基础
为gitlink-cli增加新功能或优化现有功能包括但不限于
·新增Shortcut命令如Wiki管理、Webhook配置、项目看板增强
·优化现有命令的参数设计、输出格式、错误提示和帮助文档
·增加批量操作能力如批量Issue操作、批量仓库管理、批量成员邀请)
·提升跨平台兼容性和安装体验
·补全RawAPI封装对齐GitLinkOpenAPI中尚未封装的接口
交付要求:
·向gitlink-cli主仓库提交PR可以是多个
·每个PR包含功能代码+单元测试+命令帮助文档更新
·提供变更说明文档
模板:
第二章 子赛题一:增强与完善 GitLink-CLI 能力
2.1 任务目标与整体思路
【占位】说明本子赛题的定位(扩展 CLI 功能)、你选择的切入方向、整体设计思路。
2.2 三层命令体系架构
【占位】用一段话+架构图说明Shortcuts+前缀)→ API Commands元数据驱动→ Raw API 三层结构,以及本次工作落在哪一层。
【占位】此处插入架构图(三层命令体系)。
2.3 新增 Shortcut 命令
2.3.1 Wiki 管理命令
【占位】列出 wiki +list/+view/+create/+update/+delete说明参数设计、输出格式、使用示例。
2.3.2 Webhook 配置命令
【占位】列出 webhook +list/+create/+update/+test/+delete/+events/+info说明设计要点与示例。
2.3.3 项目看板
【占位】列出 milestone 相关命令及使用示例
2.4优化部分
2.5批量操作能力增强 都有哪些,,按大类区分
2.6 跨平台兼容性与安装体验
【占位】Windows PowerShell 脚本、路径处理、一键安装脚本、npm 安装等改进点。
2.7 Raw API 封装补全
【占位】列出对齐 GitLink OpenAPI 新封装的接口清单。
2.8 单元测试与命令帮助文档
【占位】测试文件位置、覆盖率、关键用例help 文本与示例更新说明。
2.9 PR 提交记录与变更说明
【占位】以表格列出各 PR编号、标题、内容摘要、合并状态、链接。

56
doc/作业要求.txt Normal file
View File

@ -0,0 +1,56 @@
子赛题一增加和完善GitLink-CLI能力
定位扩展CLI功能|难度:中高|需要Go语言基础
为gitlink-cli增加新功能或优化现有功能包括但不限于
●新增Shortcut命令(如Wiki管理、Webhook配置、项目看板增强、代码片段管理等)
●优化现有命令的参数设计、输出格式、错误提示和帮助文档
·增加批量操作能力(如批量Issue操作、批量仓库管理、批量成员邀请)
·提升跨平台兼容性和安装体验
·补全Raw API封装(对齐GitLink OpenAPI中尚未封装的接口)
子赛题二编写和丰富GitLink Skills
定位开发Agent Skill|难度:中|无需Go,Markdown +CLI调用即可
基于gitlink-cli开发新的AI Agent Skill。核心交付物是Skill本身(SKILLmd+使用示例+Agent平台验证结果)。场景示例(不限于此):
·智能代码审查分析PR diff,输出结构化Review意见并自动评论
·Issue自动分拣根据内容自动分类、打标签、分配责任人
·Release Notes生成根据commit和PR记录生成结构化版本说明
·项目健康度报告统计Issue响应时间、PR合并效率、贡献者活跃度
·许可证合规检查:扫描仓库的许可证合规性和敏感信息泄露风险
·新人引导为good-first-issue自动添加引导评论降低新贡献者参与门槛
子赛题三:构建端到端自动化工作流
定位:组合现有能力解决实际问题丨难度:低-中「无需写底层代码
组合gitlink-cli已有命令和Skills可包含自定义Skill完成一个可复现的端到端自动化场景。与子赛题二的区别在于子赛题二
交付的是独立可复用的Skill子赛题三交付的是串联多个步骤的完整解决方案。
场景示例(不限于此):
·项目一键初始化输入项目描述→创建仓库→生成README/LICENSE/CI配置→创建初始IsSUe和里程碑
·多仓库协同跨多个仓库的统一Issue追踪、PR状态看板、Release协调发布
·贡献者成长体系追踪贡献者的PR/Issue活动→生成贡献排行→自动颁发徽章
子赛题四:应用 GitLink 辅助科研(可选完成,视完成情况额外加分)
 定位:科研场景智能化赋能 | 难度:中 | 要求:无需 GoMarkdown + CLI 调用即可
 任务:依托 gitlink-cli 数据获取、命令调用与 AI Agent 能力融合数据分析、知识图谱、AI 挖掘技术,面向
科研工作者、课题组与科研团队,将 GitLink 平台代码托管、协作数据转化为科研创新支撑能力,实现科研项目
分析、主体画像、热点追踪、创新启发、合规校验等全链路科研辅助服务,打通开源代码生态与学术科研的融
合通道,包括但不限于以下场景:
• 仓库级科研项目洞悉
• 科研热点追踪与知识图谱构建
• 科研项目合规与复现性检查
• 科研协作智能匹配
• 科研进度智能跟踪与预警

View File

@ -0,0 +1,294 @@
# 子任务一CLI 命令系统优化
## PR: feat: CLI 命令系统优化 — 参数校验、输出格式、错误提示、帮助文档
### Summary
本次优化针对 gitlink-cli 的命令系统进行了四个方面的改进,提升用户体验和开发者效率:
1. **参数设计** — 新增 `Choices` 枚举校验和 `Validate` 自定义校验,非法参数在本地拦截而非等待 API 返回 422
2. **输出格式** — 新增 `--columns`、`--no-truncate`、`--no-color` 全局 flag支持列过滤、禁用截断、彩色表头
3. **错误提示** — 统一 `OpError` 构造函数,错误信息包含英文 `message`(脚本可解析)+ 中文 `suggestion`(用户可读)
4. **帮助文档**`Shortcut` 新增 `Long`/`Example` 字段15 个 Group 命令补充详细说明,新增 `completion` 子命令
---
### 一、参数设计优化
#### 问题
- 枚举参数无校验,非法值直接发往 API等服务端返回 422 才知道参数错误
- 跨参数约束散落在 `Run()` 函数中,格式不统一,中英文混杂
#### 解决方案
**1. Choices 枚举校验**
```go
// Flag 结构体新增 Choices 字段
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
Choices []string // 新增:枚举校验
Validate func(value string) error // 新增:自定义校验函数
}
// 命令中声明 Choices
Flags: []common.Flag{
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge",
Choices: []string{"merge", "rebase", "squash"}},
}
```
Choices 声明后,无需再写校验函数,`--help` 自动追加 `[merge|rebase|squash]`
**2. Validate 自定义校验**
```go
// Shortcut 结构体新增 Validate 字段
type Shortcut struct {
Name string
Description string
Flags []Flag
Validate func(args map[string]string) error // 新增:跨参数校验
Run func(ctx *RuntimeContext) error
}
```
**3. 统一错误格式**
```go
// 使用 clierrors.InputError 统一错误格式
if title == "" && description == "" && state == "" {
return clierrors.InputError(
"at least one of --title, --body, or --state is required",
"至少需要提供 --title、--body 或 --state 中的一个参数",
).WithCommand(ctx.CommandName)
}
```
---
### 二、输出格式优化
#### 问题
- 表格输出列全量显示,无法过滤
- 长文本在 60 字符处硬截断,不可配置
- 无色彩区分,可读性差
#### 解决方案
**1. PrintOptions 结构体**
```go
type PrintOptions struct {
Columns []string // 要显示的列nil = 全部
NoTruncate bool // 禁用 60 字符截断
UseColor bool // 启用 ANSI 颜色
}
```
**2. 新增全局 Flag**
```bash
gitlink-cli issue +list --columns id,subject,status
gitlink-cli issue +list --no-truncate
gitlink-cli issue +list --no-color
```
**3. 默认格式修正**
`--format` 默认值从 `json` 改为 `table`,与 `--help` 文本一致。
---
### 三、错误提示优化
#### 问题
- 中英文混用,格式不统一
- `fmt.Errorf` 不被框架识别,无法输出结构化错误
#### 解决方案
**1. OpError 统一构造函数**
```go
// 入参只需动词和资源名
func OpError(kind ErrorKind, op, resource string, cause error) *CLIError {
msg := fmt.Sprintf("failed to %s %s", op, resource)
sugg := opSuggestion(op, resource)
return Wrap(kind, msg, sugg, cause)
}
// 命令中一行调用
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
```
**2. 结构化输出**
```json
{
"ok": false,
"error": {
"code": 500,
"message": "failed to list issues",
"suggestion": "获取列表失败,请检查参数或网络连接,稍后重试"
}
}
```
`message` 用英文保证脚本可解析,`suggestion` 用中文直接给人看。
---
### 四、帮助文档优化
#### 问题
- Shortcut 无详细帮助,`--help` 只有命令行和 flag 列表
- Group 命令只有一行描述
- 无 Shell 自动补全
#### 解决方案
**1. Shortcut 新增 Long/Example 字段**
```go
type Shortcut struct {
Name string
Description string
Long string // 新增:详细帮助文本
Example string // 新增:使用示例
Flags []Flag
Run func(ctx *RuntimeContext) error
}
// 命令中填写 Example
{
Name: "merge",
Description: "Merge a pull request",
Example: " gitlink-cli pr +merge --id 42\n" +
" gitlink-cli pr +merge --id 42 --method rebase\n" +
" gitlink-cli pr +merge --id 42 --method squash --dry-run",
}
```
**2. 15 个 Group 命令补充 Long/Example**
```go
infos := map[string]groupInfo{
"pr": {
Short: "Pull request operations",
Long: "Manage pull requests: list, create, view, merge, close, review, and view changed files.",
Example: " gitlink-cli pr +list --state open\n" +
" gitlink-cli pr +create --title \"Fix login\" --head feat-branch\n" +
" gitlink-cli pr +merge --id 42",
},
// 其余 14 个 group 同上
}
```
**3. 新增 completion 子命令**
```bash
source <(gitlink-cli completion bash) # Bash
source <(gitlink-cli completion zsh) # Zsh
gitlink-cli completion fish | source # fish
gitlink-cli completion powershell | Out-String | Invoke-Expression # PowerShell
```
---
### Modified Files
| 文件 | 改动 |
|------|------|
| `cmd/cmdutil/globals.go` | 新增 NoTruncate/Columns/NoColor 全局变量 |
| `cmd/root.go` | 新增 3 个 persistent flag + completion 子命令 + format 默认值修正 |
| `shortcuts/common/types.go` | Shortcut/Flag/RuntimeContext 结构体扩展 |
| `shortcuts/common/runner.go` | Choices/Validate 校验逻辑 + Long/Example 挂载 |
| `internal/output/formatter.go` | PrintOptions + 列过滤 + 去截断 + 彩色表头 |
| `internal/errors/errors.go` | OpError 统一构造函数 |
| `shortcuts/register.go` | 15 个 Group 命令补充 Long/Example |
| `shortcuts/issue/issue.go` | 错误统一 + Choices + Long/Example |
| `shortcuts/pr/pr.go` | 错误统一 + Choices + Long/Example |
| `shortcuts/repo/repo.go` | 错误统一 + Choices + Long/Example |
| `shortcuts/milestone/milestone.go` | 修复 milestone +view 输出问题 |
| `shortcuts/board/board.go` | 修正 assign 字段 |
---
### Testing
- `go build` 通过
- `go vet` 通过
- `go test ./...` 7 个测试包全部通过
- 所有新增字段零值安全,现有 80+ Shortcut 无需修改即可编译
---
### Breaking Changes
无。`output.Print()` 签名不变,内部包装为 `PrintWithOpts`。Workflow 脚本显式使用 `--format json`,不受默认格式变化影响。
---
### 验证示例
**1. 枚举校验**
```bash
$ gitlink-cli pr +merge --id 42 --method unknown
Error: invalid value "unknown" for --method
有效值: merge, rebase, squash。运行 'gitlink-cli pr +merge --help' 查看用法。
```
**2. 跨参数约束**
```bash
$ gitlink-cli issue +update --number 42
Error: at least one of --title, --body, or --state is required
至少需要提供 --title、--body 或 --state 中的一个参数
```
**3. 列过滤**
```bash
$ gitlink-cli issue +list --columns id,subject,status
id subject status
-- ------- ------
42 Fix login bug open
43 Add unit tests closed
```
**4. 结构化错误**
```bash
$ gitlink-cli issue +view --number 999999 --format json
{
"ok": false,
"error": {
"code": 404,
"message": "failed to view issue",
"suggestion": "查看失败,请确认资源 ID 是否存在"
}
}
```
**5. Shell 自动补全**
```bash
$ source <(gitlink-cli completion bash)
$ gitlink-cli pr +<TAB>
+list +create +view +merge +close +reopen +update +files +diff +comment
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,408 @@
目录
1. 项目概述
2、软件总体设计
2.1 软件体系结构设计
2.2 用户界面设计
2.3 数据库设计
2.4 系统度量
3、系统静态模型
3.1 命令框架层设计
3.2 快捷命令层设计
3.3 核心库层设计
4、系统动态模型
4.1 用例一:用户认证与仓库操作
4.2 用例二Issue 批量管理
4.3 用例三AI 自动化工作流
5、系统部署模型
1. 项目概述
gitlink-cli 是 GitLink确实开源平台的命令行工具。GitLink 是 CCF 官方开源协作平台,后端提供 490+ API 端点,但此前缺少官方 CLI 工具。本项目填补了这个空白。
应用背景:
开发者在日常工作中需要频繁操作仓库、Issue、PR、分支等资源。浏览器操作效率低无法批量处理也不能与 CI/CD 流水线集成。gitlink-cli 让开发者在终端中完成所有平台操作,并通过 AI Agent Skill 支持 Claude Code 自动化执行复杂工作流。
功能描述:
- 仓库管理创建、列表、详情、Fork、删除、设置、批量操作
- Issue 管理列表、创建、查看、更新、关闭、评论、标签、6 种批量命令
- PR 管理:列表、创建、查看、合并、关闭、代码审查、变更文件查看
- 分支管理:列表、创建、删除、保护
- 发布管理:列表、创建、查看、删除
- Wiki 管理:列表、创建、查看、更新、删除
- Webhook 管理:列表、创建、更新、删除、测试
- CI/CD 管理:构建列表、日志、重启、停止
- 组织与团队管理:组织列表、详情、成员、团队 CRUD
- 合规检查:许可证扫描、敏感信息检测、依赖审计
- 搜索:仓库搜索、用户搜索
- AI 工作流:社区运营、代码审查、项目初始化、多仓库协同、贡献者成长
性能要求:
- 单次 API 调用响应时间 < 2 秒
- 批量操作支持并发执行,错误不中断
- 分页自动遍历,支持大数据量场景
- 跨平台支持Windows、macOS、Linux
2、软件总体设计
2.1 软件体系结构设计
本项目采用分层架构,从上到共分为五层:
1入口层main.go 和 cmd/root.go基于 Cobra 框架构建命令树。
2命令层cmd/ 目录,包含 auth、api、config、compliance 四个顶级命令。
3快捷命令层shortcuts/ 目录16 个资源组,每组通过 +动词 子命令暴露操作。
4核心库层internal/ 目录,包含 HTTP 客户端、认证、配置、输出格式化、错误处理。
5AI 扩展层skills/ 目录24 个 SKILL.md和 workflows/ 目录bash/PowerShell 脚本)。
各层的职责划分:
[入口层] main.go → cmd.Execute() → Cobra root 命令
[命令层] auth / api / config / compliance
[快捷命令层] shortcuts/ → repo/issue/pr/wiki/... → common.Shortcut 声明式定义
[核心库层] internal/client → HTTP 请求 → internal/auth → Token 注入
internal/output → 格式化输出JSON/Table/YAML
internal/config → 配置文件读写
internal/context → git remote 解析 owner/repo
[AI 扩展层] skills/ → SKILL.md 定义(供 Claude Code 调用)
workflows/ → 可执行脚本bash/PowerShell
核心设计模式是声明式 Shortcut 框架。每个快捷命令通过 common.Shortcut 结构体定义 Name、Flags、Run 函数,由 runner.go 统一挂载到 Cobra 命令树。开发者新增命令只需实现 Run 函数,不需要关心命令注册和参数解析的细节。
三层命令体系:
- Layer 1 Shortcuts语义化封装覆盖 16 个领域共 80+ 命令(如 issue +create
- Layer 2 API Commands原始 HTTP 调用api GET/POST/PUT/DELETE
- Layer 3 Raw API覆盖全部 490+ 端点,自动注入认证 Header
2.2 用户界面设计
本项目是 CLI 工具,用户界面是终端命令行。界面设计遵循以下原则:
1命令格式统一gitlink-cli <资源> +<动作> [flags]
例如gitlink-cli issue +create --title "Bug" --body "描述"
2输出格式三选一通过 --format 参数选择 json、table、yaml默认 table。
table 格式使用 tabwriter 对齐,支持 ANSI 颜色高亮表头。
json 格式使用统一的 Envelope 结构:{ok, data, error, meta}。
3全局参数--owner、--repo自动从 git remote 解析)、--format、--debug、--no-truncate、--columns、--no-color。
4Shell 补全:支持 bash、zsh、fish、powershell 四种 shell 的自动补全。
5帮助系统每个命令支持 --help显示用法、参数说明和示例。
用户操作流程:
用户打开终端 → 输入 gitlink-cli auth login 登录 → 使用具体命令操作资源
→ 输出结果以 table/json/yaml 格式显示 → 可通过管道传递给其他工具
2.3 数据库设计
本项目不使用传统数据库。数据存储分为两部分:
1配置文件存储
路径:~/.config/gitlink-cli/config.yaml
内容base_urlAPI 地址、default_format输出格式、editor、pager
格式YAML
2凭证存储
使用 go-keyring 库,调用操作系统原生密钥管理:
- macOSKeychain
- LinuxSecret ServiceGNOME Keyring / KDE Wallet
- WindowsCredential Manager
Fallback~/.config/gitlink-cli/credentials文件权限 0600
存储的数据结构:
Token字符串→ 关联 gitlink.org.cn 的 Bearer Token
ConfigYAML→ base_url、default_format、editor、pager 等配置项
本项目不需要关系型数据库,所有数据来自 GitLink 平台 API 的实时查询。
2.4 系统度量
1代码规模
- Go 源文件60 个
- Go 代码总行数14,932 行
- 测试文件12 个,共 4,279 行
- 测试覆盖率:测试代码占总代码的 22.3%
2按模块统计
模块 文件数 代码行数
main入口 1 13
cmd命令层 5 379
internal核心库 13 2,242
shortcuts快捷层 41 12,398
合计 60 14,932
3功能规模
- Shortcut 命令组16 个
- Shortcut 命令总数80+
- AI Skill 定义24 个 SKILL.md
- 自动化工作流脚本15+ 个bash/PowerShell
- 支持的 Shell 补全4 种bash/zsh/fish/powershell
4依赖规模
- 直接依赖4 个cobra、go-keyring、golang.org/x/term、yaml.v3
- 编译产物大小:约 11.9 MB单二进制文件
3、系统静态模型
3.1 命令框架层设计
命令框架层由 cmd/ 包和 shortcuts/common/ 包组成,定义了整个 CLI 的骨架。
核心类结构:
1cobra.Command来自 spf13/cobra 库)
- 每个命令对应一个 cobra.Command 实例
- rootCmd 是根命令,所有子命令通过 AddCommand 挂载
- root.go 定义全局 PersistentFlags--owner、--repo、--format 等)
2common.Shortcut 结构体:
type Shortcut struct {
Name string
Description string
Long string
Example string
Flags []Flag
DryRun bool
DryRunHint func(ctx *RuntimeContext) (string, error)
Validate func(args map[string]string) error
Run func(ctx *RuntimeContext) error
}
3common.Flag 结构体:
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
Choices []string
Validate func(value string) error
}
4common.RuntimeContext 结构体:
type RuntimeContext struct {
Client *client.Client
Owner string
Repo string
Format string
CommandName string
Args map[string]string
GatewayBaseURL string
GatewayHTTPClient *http.Client
NoTruncate bool
Columns string
NoColor bool
}
RuntimeContext 提供以下核心方法:
- ResolveOwnerRepo():从 git remote 或 flags 解析 owner/repo
- CallAPI(method, path, body):发起 HTTP 请求
- PaginateAll(path, params):自动遍历分页
- Output(env):格式化输出
- OutputData(data):封装为成功 Envelope 并输出
5register.go 的注册机制:
RegisterAll(root) 遍历 16 个命令组,每组创建 cobra.Command 并调用 MountShortcuts 挂载子命令。MountShortcuts 遍历 Shortcut 切片,为每个 Shortcut 创建对应的 cobra.Command绑定 flags 和 RunE 函数。
3.2 快捷命令层设计
快捷命令层位于 shortcuts/ 目录,包含 16 个命令组。每个组是一个独立的 Go 包,通过 Shortcuts() 函数返回 []*common.Shortcut 切片。
命令组列表:
- board看板操作view/columns/issues/move/assign/stats— 832 行
- branch分支操作list/create/delete/protect— 157 行
- ciCI/CD 操作builds/logs/restart/stop— 117 行
- file文件操作ls/read/create/update/delete/commits/diff— 886 行
- issueIssue 操作14 个命令,含 6 种批量操作)— 3,058 行
- milestone里程碑操作list/create/view/update/delete/status— 525 行
- org组织操作list/info/members/create/update— 476 行
- prPR 操作list/create/view/merge/close/review/files/diff— 973 行
- release发布操作list/create/view/delete— 179 行
- repo仓库操作含 batch-create/batch-update/batch-delete/batch-member— 1,781 行
- search搜索操作repos/users— 88 行
- team团队操作list/create/delete/members— 192 行
- user用户操作me/info— 41 行
- webhookWebhook 操作list/create/view/update/delete/test/events— 649 行
- wikiWiki 操作list/create/view/update/delete/lint/fix/sync— 1,612 行
最大的两个模块是 issue3,058 行)和 wiki1,612 行。issue 模块包含批量操作功能wiki 模块处理双域名架构和 base64 编解码。
批量操作设计模式(以 issue 为例):
- BatchResult单条操作结果number/action/status/error
- BatchSummary汇总信息total/succeeded/failed/results
- 输入灵活:--numbers内联和 --fromCSV 文件)可同时使用
- dry-run 统一:所有批量命令支持 --dry-run 预览
- 错误不中断:单条失败不影响后续处理
3.3 核心库层设计
核心库层位于 internal/ 目录,包含 7 个子包:
1internal/auth — 认证模块3 个文件272 行)
- login.go登录流程用户名密码或 Token 粘贴)
- token_store.goToken 存储go-keyring 跨平台支持)
- transport.goHTTP Transport自动在请求 Header 中注入 Bearer Token
2internal/client — HTTP 客户端2 个文件319 行)
- client.go封装 Get/Post/Put/Delete 方法,自动追加 .json 后缀双重错误检查HTTP 状态码 + JSON body 中的 status 字段)
- pagination.go分页迭代器自动遍历 Kaminari 风格分页
3internal/compliance — 合规检查4 个文件685 行)
- cmd.gocompliance 子命令定义
- scanner.go扫描器实现
- license.go许可证检测
- rules.go合规规则定义
4internal/config — 配置管理1 个文件125 行)
- config.go读写 ~/.config/gitlink-cli/config.yaml
5internal/context — 上下文解析1 个文件91 行)
- repo.go从 git remote 的 origin URL 自动解析 owner 和 repo
6internal/errors — 错误处理1 个文件150 行)
- errors.go统一错误类型 CLIError包含 Kind错误分类、Message、Suggestion、Command 字段
- 错误分类KindAuth、KindInput、KindForbidden、KindNotFound、KindServer、KindUnknown
7internal/output — 输出格式化3 个文件425 行)
- envelope.go定义 Envelope 结构体 {OK, Data, Error, Meta}
- formatter.go三种输出格式JSON/YAML/Tabletable 使用 tabwriter 对齐,支持 ANSI 颜色、列过滤、值截断控制
4、系统动态模型
4.1 用例一:用户认证与仓库操作
用例描述:用户首次使用 gitlink-cli完成登录并查看仓库信息。
参与者:开发者(终端用户)
前置条件:已安装 gitlink-cli网络可访问 gitlink.org.cn
主要流程:
1用户执行 gitlink-cli auth login
2系统提示输入用户名和密码
3系统调用 POST /api/accounts/login 获取 Token
4Token 存入 OS Keychain
5用户进入 git 仓库目录,执行 gitlink-cli repo +info
6系统从 .git/config 解析 remote origin URL提取 owner/repo
7系统调用 GET /api/{owner}/{repo}/detail.json
8系统以 table 格式输出仓库信息
异常流程:
- Token 过期7 天有效期):系统返回 401 错误,提示运行 gitlink-cli auth login 重新登录
- 非 git 目录:系统提示使用 --owner 和 --repo 参数显式指定
API 调用序列:
POST /api/accounts/login → {token: "..."}
GET /api/{owner}/{repo}/detail.json → {name, description, default_branch, ...}
4.2 用例二Issue 批量管理
用例描述:项目维护者批量关闭过期 Issue 并批量创建新 Issue。
参与者:项目维护者
前置条件:已登录,有仓库写权限
主要流程:
1用户执行 gitlink-cli issue +batch-close --numbers 101,102,103,104 --dry-run
2系统预览显示 4 个 Issue 将被关闭,不实际执行
3用户确认后去掉 --dry-run重新执行
4系统逐个调用 PUT /api/{owner}/{repo}/issues/{number},设置 state 为 closed
5系统输出 BatchSummarytotal=4, succeeded=4, failed=0
6用户准备 CSV 文件 issues.csv
title,body,priority,label
"登录页面样式异常","点击按钮后样式错乱","high","缺陷"
"新增数据导出功能","支持 CSV 和 JSON 格式导出","normal","功能"
7用户执行 gitlink-cli issue +batch-create --from issues.csv --template bug
8系统逐个调用 POST /api/{owner}/{repo}/issues 创建 Issue
9系统输出 BatchSummarytotal=2, succeeded=2, failed=0
异常流程:
- 单条操作失败:记录错误,继续处理后续条目,最终 exit code = 1
- API 字段静默失败GitLink 对不认识的字段返回 200 而非报错,需通过浏览器 DevTools 确认字段名
4.3 用例三AI 自动化工作流
用例描述:使用 Claude Code 和 gitlink-cli Skill 自动完成社区运营任务。
参与者:社区运营者(通过 Claude Code 交互)
前置条件Claude Code 已安装gitlink-cli 已登录
主要流程:
1用户在 Claude Code 中说"帮我跑社区运营工作流"
2Claude Code 触发 gitlink-workflows skill显示功能菜单
3用户选择"社区运营自动化"
4Claude Code 调用 gitlink-cli issue +list --state open --format json
5Claude Code 分析 Issue 列表按类型分类bug/feature/question
6Claude Code 调用 gitlink-cli issue +label-add 添加分类标签
7Claude Code 调用 gitlink-cli pr +list 获取近期 PR
8Claude Code 生成周报摘要,包含 Issue 统计、PR 合并情况、贡献者活跃度
Skill 调用链:
gitlink-shared认证检查→ gitlink-issueIssue 操作)→ gitlink-prPR 操作)→ gitlink-workflow工作流编排
5、系统部署模型
本项目采用单二进制分发模式,部署简单。
1构建环境
- Go 1.26.1+
- Makefile 管理构建流程
- 构建命令make build通过 -ldflags 注入版本号)
2分发方式
- 直接下载:从 GitHub Releases 下载预编译二进制
- npm 安装npm install -g gitlink-cli
- 源码编译go install github.com/gitlink-org/gitlink-cli@latest
- 安装脚本install.shLinux/macOS、install.ps1Windows
3目标平台
- Windowsamd64
- macOSamd64、arm64
- Linuxamd64、arm64
4依赖服务
- GitLink APIhttps://www.gitlink.org.cn/api主站 API
- GitLink Gatewayhttps://gateway.gitlink.org.cn/apiWiki API 专用)
- OS Keychain系统密钥管理服务存储 Token
5CI/CD 集成:
- GitHub Actions.github/workflows/release.yml 自动构建和发布
- GitLink DevOps.devops/gitlink-cli-autodeploy.yml 平台自动部署
6部署架构
用户终端
gitlink-cli本地二进制
↓ HTTP/HTTPS
GitLink API Servergitlink.org.cn
GitLink Gatewaygateway.gitlink.org.cnWiki 专用)
gitlink-cli 是纯客户端工具,不运行后台服务。所有数据存储在用户本地(配置文件 + OS Keychain业务数据完全来自 GitLink 平台 API 的实时查询。

BIN
gitlink-cli.exe Normal file

Binary file not shown.

441
install.ps1 Normal file
View File

@ -0,0 +1,441 @@
# GitLink CLI Windows 安装脚本
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1
# 支持: .\install.ps1 -Version 0.1.0 -InstallDir "C:\GitLink"
param(
[string]$Version = "latest",
[string]$InstallDir = "$HOME\.gitlink-cli\bin",
[switch]$Help = $false,
[switch]$Uninstall = $false,
[switch]$ListVersions = $false,
[switch]$Debug = $false
)
$ErrorActionPreference = "Stop"
$REPO_OWNER = "Gitlink"
$REPO_NAME = "gitlink-cli"
$BINARY_NAME = "gitlink-cli"
$API_BASE = "https://www.gitlink.org.cn"
$SKILLS_DIR = "$HOME\.gitlink\skills"
# 颜色输出
function Info {
param([string]$msg)
Write-Host "[INFO] $msg" -ForegroundColor Green
}
function Warn {
param([string]$msg)
Write-Host "[WARN] $msg" -ForegroundColor Yellow
}
function ErrorMsg {
param([string]$msg)
Write-Host "[ERROR] $msg" -ForegroundColor Red
}
function Step {
param([string]$msg)
Write-Host "[STEP] $msg" -ForegroundColor Cyan
}
function DebugMsg {
param([string]$msg)
if ($Debug) {
Write-Host "[DEBUG] $msg" -ForegroundColor Blue
}
}
# 显示帮助
function Show-Help {
Write-Host @"
GitLink CLI Windows 安装脚本
用法:
.\install.ps1 [选项]
选项:
-Version <version> 指定版本 (默认: latest)
-InstallDir <path> 安装目录 (默认: $HOME\.gitlink-cli\bin)
-Uninstall 卸载
-ListVersions 列出可用版本
-Debug 显示调试信息
-Help 显示此帮助
示例:
# 安装最新版本
.\install.ps1
# 安装指定版本
.\install.ps1 -Version "0.1.0"
# 安装到指定目录
.\install.ps1 -InstallDir "C:\Tools\gitlink-cli"
# 列出可用版本
.\install.ps1 -ListVersions
# 卸载
.\install.ps1 -Uninstall
# 在线安装
powershell -NoProfile -ExecutionPolicy Bypass -Command "iex (irm https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.ps1)"
"@
}
# 检测架构
function Get-PlatformInfo {
$arch = switch ([System.Runtime.InteropServices::RuntimeInformation]::OSArchitecture) {
"X64" { "amd64" }
"Arm64" { "arm64" }
"X86" { "386"; Warn "32位系统支持有限" }
default { throw "不支持的架构: $arch" }
}
return @{ Platform = "windows"; Arch = $arch }
}
# 检查环境
function Test-Environment {
Step "检查安装环境..."
# 检查磁盘空间至少50MB
$drive = (Get-Item $InstallDir).PSDrive.Name
$driveInfo = Get-PSDrive $drive
$freeSpaceMB = [math]::Round($driveInfo.Free / 1MB, 2)
if ($freeSpaceMB -lt 50) {
ErrorMsg "磁盘空间不足需要至少50MB可用: ${freeSpaceMB}MB"
exit 1
}
DebugMsg "磁盘空间检查通过: ${freeSpaceMB}MB"
# 检查网络连接
try {
$response = Invoke-WebRequest -Uri $API_BASE -UseBasicParsing -TimeoutSec 5 -Method Head
DebugMsg "网络连接检查通过"
}
catch {
ErrorMsg "无法连接到 GitLink 服务器: $API_BASE"
ErrorMsg "请检查网络连接"
exit 1
}
Info "环境检查通过"
}
# 下载文件(带重试)
function Download-File {
param(
[string]$Url,
[string]$Output,
[int]$MaxAttempts = 3
)
$attempt = 1
while ($attempt -le $MaxAttempts) {
try {
Step "下载 (尝试 $attempt/$MaxAttempts): $(Split-Path $Url -Leaf)"
DebugMsg "URL: $Url"
# 使用WebClient支持进度显示
$webClient = New-Object System.Net.WebClient
# 注册下载进度事件
Register-ObjectEvent -InputObject $webClient -EventName DownloadProgressChanged -SourceIdentifier WebClient.DownloadProgressChanged -Action {
global:Progress = $EventArgs.ProgressPercentage
Write-Progress -Activity "下载中" -Status "$($EventArgs.ProgressPercentage)% 完成" -PercentComplete $EventArgs.ProgressPercentage
} | Out-Null
# 注册下载完成事件
Register-ObjectEvent -InputObject $webClient -EventName DownloadFileCompleted -SourceIdentifier WebClient.DownloadFileCompleted -Action {
global:DownloadComplete = $true
} | Out-Null
# 开始下载
$webClient.DownloadFileAsync($Url, $Output)
# 等待下载完成
while (-not $global:DownloadComplete) {
Start-Sleep -Milliseconds 100
}
Write-Progress -Activity "下载中" -Completed
# 清理事件
Unregister-Event -SourceIdentifier WebClient.DownloadProgressChanged -ErrorAction SilentlyContinue
Unregister-Event -SourceIdentifier WebClient.DownloadFileCompleted -ErrorAction SilentlyContinue
$webClient.Dispose()
if (Test-Path $Output) {
$size = [math]::Round((Get-Item $Output).Length / 1MB, 2)
Info "下载成功: $(Split-Path $Output -Leaf) (${size}MB)"
return $true
}
else {
Warn "下载文件为空"
}
}
catch {
Warn "下载失败: $_"
}
if ($attempt -lt $MaxAttempts) {
$waitTime = $attempt * 2
Warn "等待 ${waitTime}s 后重试..."
Start-Sleep -Seconds $waitTime
}
$attempt++
}
ErrorMsg "下载失败,已尝试 $MaxAttempts"
ErrorMsg "URL: $Url"
return $false
}
# 获取最新版本
function Get-LatestVersion {
Step "获取最新版本..."
try {
$releasesUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases.json"
$releases = Invoke-RestMethod -Uri $releasesUrl -TimeoutSec 30 -UseBasicParsing
if ($releases -and $releases.Count -gt 0) {
return $releases[0].tag_name
}
else {
Warn "无法获取版本信息,使用默认版本"
return "v0.1.0"
}
}
catch {
Warn "获取版本失败: $_"
return "v0.1.0"
}
}
# 列出可用版本
function Show-AvailableVersions {
Step "查询可用版本..."
try {
$releasesUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases.json"
$releases = Invoke-RestMethod -Uri $releasesUrl -TimeoutSec 30 -UseBasicParsing
if ($releases -and $releases.Count -gt 0) {
Info "可用版本:"
foreach ($release in $releases | Select-Object -First 10) {
Write-Host " - $($release.tag_name)" -ForegroundColor Cyan
}
}
else {
Warn "无法获取版本列表"
}
}
catch {
ErrorMsg "获取版本列表失败: $_"
}
}
# 解压zip文件
function Expand-ZipFile {
param(
[string]$ZipPath,
[string]$DestDir
)
Step "解压..."
DebugMsg "解压 $ZipPath$DestDir"
try {
Expand-Archive -Force -Path $ZipPath -DestinationPath $DestDir
Info "解压完成"
}
catch {
ErrorMsg "解压失败: $_"
throw
}
}
# 添加到PATH
function Add-ToPath {
param([string]$Dir)
$path = [Environment]::GetEnvironmentVariable("Path", "User")
if ($path -notlike "*$Dir*") {
Step "添加到PATH: $Dir"
[Environment]::SetEnvironmentVariable("Path", "$path;$Dir", "User")
Warn "请重启终端使PATH生效"
}
else {
DebugMsg "已在PATH中: $Dir"
}
}
# 验证安装
function Test-Installation {
Step "验证安装..."
$binaryPath = Join-Path $InstallDir "$BINARY_NAME.exe"
if (Test-Path $binaryPath) {
try {
$versionOutput = & $binaryPath version 2>$null
Info "安装成功! $versionOutput"
# 添加到PATH
Add-ToPath $InstallDir
Write-Host ""
Info "快速开始:"
Info " gitlink-cli auth login # 登录账号"
Info " gitlink-cli --help # 查看所有命令"
Info " gitlink-cli version # 查看版本信息"
Write-Host ""
return $true
}
catch {
ErrorMsg "执行二进制文件失败: $_"
return $false
}
}
else {
ErrorMsg "安装验证失败: $binaryPath 不存在"
return $false
}
}
# 主安装流程
function Install-CLI {
Write-Host ""
Write-Host " ╔══════════════════════════════════════╗"
Write-Host " ║ GitLink CLI 一键安装 (Windows) ║"
Write-Host " ╚══════════════════════════════════════╝"
Write-Host ""
# 检测平台
$platform = Get-PlatformInfo
Info "检测到平台: $($platform.Platform)-$($platform.Arch)"
# 创建安装目录
if (!(Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
Info "创建安装目录: $InstallDir"
}
# 检查环境
Test-Environment
# 获取版本
if ($Version -eq "latest") {
$Version = Get-LatestVersion
Info "最新版本: $Version"
}
else {
Info "指定版本: $Version"
}
# 下载
$zipName = "$BINARY_NAME_${Version}_windows_$($platform.Arch).zip"
$zipUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases/$Version/assets/$zipName"
$zipPath = Join-Path $env:TEMP $zipName
if (-not (Download-File -Url $zipUrl -Output $zipPath)) {
ErrorMsg "请确认以下版本已发布: $Version"
ErrorMsg "发布页: $API_BASE/$REPO_OWNER/$REPO_NAME/releases"
exit 1
}
# 解压
Expand-ZipFile -ZipPath $zipPath -DestDir $InstallDir
Remove-Item $zipPath
# 安装skills
Step "安装 skills..."
if (!(Test-Path $SKILLS_DIR)) {
New-Item -ItemType Directory -Path $SKILLS_DIR -Force | Out-Null
}
$skillsZipName = "$BINARY_NAME_${Version}_skills.zip"
$skillsUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases/$Version/assets/$skillsZipName"
$skillsZipPath = Join-Path $env:TEMP $skillsZipName
if (Download-File -Url $skillsUrl -Output $skillsZipPath) {
try {
Expand-Archive -Force -Path $skillsZipPath -DestinationPath $SKILLS_DIR
Info "Skills 安装到: $SKILLS_DIR"
}
catch {
Warn "Skills 解压失败(可稍后手动安装)"
}
Remove-Item $skillsZipPath -ErrorAction SilentlyContinue
}
else {
Warn "Skills 包不可用(可稍后手动安装)"
}
# 验证
if (-not (Test-Installation)) {
exit 1
}
}
# 卸载
function Uninstall-CLI {
Step "卸载 $BINARY_NAME..."
# 删除二进制
$binaryPath = Join-Path $InstallDir "$BINARY_NAME.exe"
if (Test-Path $binaryPath) {
Remove-Item $binaryPath -Force
Info "已删除: $binaryPath"
}
# 删除skills
if (Test-Path $SKILLS_DIR) {
Remove-Item $SKILLS_DIR -Recurse -Force
Info "已删除: $SKILLS_DIR"
}
# 删除配置(可选)
$response = Read-Host "是否删除配置文件? [y/N]"
if ($response -eq 'y' -or $response -eq 'Y') {
$configDir = Join-Path $env:APPDATA "gitlink-cli"
if (Test-Path $configDir) {
Remove-Item $configDir -Recurse -Force
Info "已删除配置文件"
}
}
Info "卸载完成"
}
# 主函数
function Main {
if ($Help) {
Show-Help
exit 0
}
if ($ListVersions) {
Show-AvailableVersions
exit 0
}
if ($Uninstall) {
Uninstall-CLI
exit 0
}
try {
Install-CLI
}
catch {
ErrorMsg "安装失败: $_"
exit 1
}
}
Main

435
install.sh Executable file
View File

@ -0,0 +1,435 @@
#!/bin/bash
# GitLink CLI 一键安装脚本(改进版)
# 自动检测平台,下载预编译二进制,安装 skills
# 用法: curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 支持: VERSION=0.1.0 INSTALL_DIR=$HOME/.local/bin bash install.sh
set -e
REPO_OWNER="Gitlink"
REPO_NAME="gitlink-cli"
BINARY_NAME="gitlink-cli"
API_BASE="https://www.gitlink.org.cn"
INSTALL_DIR="${INSTALL_DIR:-auto}"
SKILLS_DIR="${HOME}/.gitlink/skills"
VERSION="${VERSION:-latest}"
MAX_ATTEMPTS=3
DEBUG="${DEBUG:-false}"
# ---------- 颜色输出 ----------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
debug() { [[ "${DEBUG}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $*"; }
# ---------- 环境检测 ----------
check_environment() {
step "检查安装环境..."
# 检查必需命令
local required_commands=("curl" "tar")
for cmd in "${required_commands[@]}"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
error "缺少必需命令: $cmd"
error "请安装后再试:"
error " Ubuntu/Debian: sudo apt-get install $cmd"
error " CentOS/RHEL: sudo yum install $cmd"
error " macOS: brew install $cmd"
exit 1
fi
done
debug "必需命令检查通过"
# 检查磁盘空间至少50MB
local available_space
available_space=$(df -m "$HOME" | tail -1 | awk '{print $4}')
if [ "$available_space" -lt 50 ]; then
error "磁盘空间不足需要至少50MB可用: ${available_space}MB"
exit 1
fi
debug "磁盘空间检查通过: ${available_space}MB"
# 检查网络连接
if ! curl -sSL --connect-timeout 5 "${API_BASE}" >/dev/null 2>&1; then
error "无法连接到 GitLink 服务器: ${API_BASE}"
error "请检查网络连接"
exit 1
fi
debug "网络连接检查通过"
info "环境检查通过"
}
# ---------- 智能权限处理 ----------
select_install_dir() {
local dirs=("$HOME/.local/bin" "$HOME/bin" "/usr/local/bin")
local selected_dir=""
if [ "${INSTALL_DIR}" != "auto" ]; then
echo "${INSTALL_DIR}"
return
fi
step "选择安装目录..."
# 优先级:用户目录 > 系统目录
for dir in "${dirs[@]}"; do
if [ -w "$dir" ] 2>/dev/null || mkdir -p "$dir" 2>/dev/null; then
selected_dir="$dir"
info "选择用户目录: $selected_dir"
break
fi
done
# 如果用户目录都不可写,尝试系统目录
if [ -z "$selected_dir" ]; then
warn "无法写入用户目录将使用系统目录需要sudo权限"
selected_dir="/usr/local/bin"
fi
echo "$selected_dir"
}
# ---------- 下载重试机制 ----------
download_with_retry() {
local url="$1"
local output="$2"
local attempt=1
while [ $attempt -le $MAX_ATTEMPTS ]; do
step "下载 (尝试 $attempt/$MAX_ATTEMPTS): $(basename "$url")"
if curl -sSL --connect-timeout 10 --max-time 120 --progress-bar "$url" -o "$output" 2>&1; then
if [ -s "$output" ]; then
info "下载成功: $(basename "$output") ($(du -h "$output" | cut -f1))"
return 0
else
warn "下载文件为空"
fi
else
warn "下载失败"
fi
if [ $attempt -lt $MAX_ATTEMPTS ]; then
local wait_time=$((attempt * 2))
warn "等待 ${wait_time}s 后重试..."
sleep $wait_time
fi
attempt=$((attempt + 1))
done
error "下载失败,已尝试 $MAX_ATTEMPTS"
error "URL: $url"
error "请检查网络连接或手动下载"
return 1
}
# ---------- 平台检测 ----------
detect_platform() {
local os arch
case "$(uname -s)" in
Linux) os="linux" ;;
Darwin) os="darwin" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
*) error "不支持的操作系统: $(uname -s)"; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64) arch="amd64" ;;
aarch64|arm64) arch="arm64" ;;
*) error "不支持的架构: $(uname -m)"; exit 1 ;;
esac
echo "${os}/${arch}"
}
# ---------- 获取最新版本 ----------
fetch_latest_version() {
local releases_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json"
info "获取最新版本..."
local releases
releases=$(curl -sSL --connect-timeout 10 --max-time 30 "${releases_url}" 2>/dev/null || true)
if [ -z "$releases" ]; then
error "无法获取发布列表: ${releases_url}"
exit 1
fi
# 尝试解析第一个 release 的 tag_name
local tag
tag=$(echo "$releases" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
if [ -z "$tag" ]; then
# 备选:尝试直接匹配数组第一个元素的 tag_name
tag=$(echo "$releases" | grep -oP '"tag_name"\s*:\s*"\K[^"]+' | head -1)
fi
echo "${tag:-v0.1.0}"
}
# ---------- 显示已安装版本 ----------
show_installed_version() {
if command -v "${BINARY_NAME}" >/dev/null 2>&1; then
"${BINARY_NAME}" version 2>/dev/null || echo "未知版本"
elif [ -x "${INSTALL_DIR}/${BINARY_NAME}" ]; then
"${INSTALL_DIR}/${BINARY_NAME}" version 2>/dev/null || echo "未知版本"
else
echo "未安装"
fi
}
# ---------- 列出可用版本 ----------
list_versions() {
step "查询可用版本..."
local releases_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json"
curl -sSL "$releases_url" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -10
}
# ---------- 回滚功能 ----------
rollback_version() {
local target_version="$1"
if [ -z "$target_version" ]; then
error "请指定要回滚到的版本"
exit 1
fi
step "回滚到版本: ${target_version}"
# 重新安装指定版本
VERSION="$target_version"
main
}
# ---------- 卸载功能 ----------
uninstall() {
step "卸载 ${BINARY_NAME}..."
# 删除二进制
if [ -f "${INSTALL_DIR}/${BINARY_NAME}" ]; then
if [ -w "${INSTALL_DIR}" ]; then
rm -f "${INSTALL_DIR}/${BINARY_NAME}"
info "已删除: ${INSTALL_DIR}/${BINARY_NAME}"
else
sudo rm -f "${INSTALL_DIR}/${BINARY_NAME}"
info "已删除: ${INSTALL_DIR}/${BINARY_NAME} (使用sudo)"
fi
fi
# 删除skills
if [ -d "${SKILLS_DIR}" ]; then
rm -rf "${SKILLS_DIR}"
info "已删除: ${SKILLS_DIR}"
fi
# 删除配置(可选)
echo -n "是否删除配置文件? [y/N] "
read -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
rm -rf "$HOME/.config/gitlink-cli"
rm -rf "$HOME/.gitlink"
info "已删除配置文件"
fi
info "卸载完成"
exit 0
}
# ---------- 下载二进制 ----------
download_binary() {
local platform="$1"
local version="$2"
local os="${platform%/*}"
local arch="${platform#*/}"
local archive_name="${BINARY_NAME}_${version}_${os}_${arch}.tar.gz"
local download_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${archive_name}"
info "下载: ${archive_name}"
debug "URL: ${download_url}"
local tmpdir
tmpdir="$(mktemp -d)"
trap "rm -rf ${tmpdir}" EXIT
if ! download_with_retry "${download_url}" "${tmpdir}/${archive_name}"; then
error "请确认以下版本已发布: ${version}"
error "发布页: ${API_BASE}/${REPO_OWNER}/${REPO_NAME}/releases"
exit 1
fi
step "解压..."
tar -xzf "${tmpdir}/${archive_name}" -C "${tmpdir}"
local binary_path="${tmpdir}/${BINARY_NAME}"
if [ ! -f "${binary_path}" ]; then
# 可能在子目录中
binary_path=$(find "${tmpdir}" -name "${BINARY_NAME}" -type f 2>/dev/null | head -1)
fi
if [ ! -f "${binary_path}" ]; then
error "解压后未找到二进制文件"
exit 1
fi
chmod +x "${binary_path}"
# ---------- 安装 ----------
step "安装到 ${INSTALL_DIR}/${BINARY_NAME}"
if [ ! -w "${INSTALL_DIR}" ]; then
warn "需要管理员权限写入 ${INSTALL_DIR}"
sudo mkdir -p "${INSTALL_DIR}"
sudo mv "${binary_path}" "${INSTALL_DIR}/${BINARY_NAME}"
sudo chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
else
mkdir -p "${INSTALL_DIR}"
mv "${binary_path}" "${INSTALL_DIR}/${BINARY_NAME}"
fi
info "二进制: ${INSTALL_DIR}/${BINARY_NAME}"
}
# ---------- 安装 skills ----------
install_skills() {
local version="$1"
step "安装 skills..."
mkdir -p "${SKILLS_DIR}"
local skills_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${BINARY_NAME}_${version}_skills.tar.gz"
local tmpdir
tmpdir="$(mktemp -d)"
if download_with_retry "${skills_url}" "${tmpdir}/skills.tar.gz" 2>/dev/null; then
tar -xzf "${tmpdir}/skills.tar.gz" -C "${SKILLS_DIR}" 2>/dev/null || true
info "Skills 安装到: ${SKILLS_DIR}"
else
warn "Skills 包不可用(可稍后通过 gitlink-cli-install-skills 安装)"
fi
rm -rf "${tmpdir}"
}
# ---------- 验证安装 ----------
verify() {
step "验证安装..."
if command -v "${BINARY_NAME}" >/dev/null 2>&1; then
info "安装成功! $("${BINARY_NAME}" version 2>/dev/null || echo "${BINARY_NAME}")"
elif [ -x "${INSTALL_DIR}/${BINARY_NAME}" ]; then
info "安装成功! $("${INSTALL_DIR}/${BINARY_NAME}" version 2>/dev/null || echo "${INSTALL_DIR}/${BINARY_NAME}")"
warn "请将 ${INSTALL_DIR} 添加到 PATH: export PATH=${INSTALL_DIR}:\$PATH"
else
error "安装验证失败"
exit 1
fi
}
# ---------- 显示帮助 ----------
show_help() {
cat << EOF
GitLink CLI 安装脚本
用法:
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash [选项]
选项:
VERSION=0.1.0 指定版本
INSTALL_DIR=/path 指定安装目录
DEBUG=true 显示调试信息
环境变量:
INSTALL_DIR 安装目录(默认: auto自动选择
VERSION 版本(默认: latest
命令:
list 列出可用版本
uninstall 卸载
rollback VERSION 回滚到指定版本
示例:
# 安装最新版本
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 安装指定版本
VERSION=0.1.0 curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 安装到用户目录
INSTALL_DIR=$HOME/.local/bin curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash
# 列出可用版本
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- list
# 卸载
curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- uninstall
EOF
}
# ---------- main ----------
main() {
echo ""
echo " ╔══════════════════════════════════════╗"
echo " ║ GitLink CLI 一键安装 ║"
echo " ╚══════════════════════════════════════╝"
echo ""
# 处理命令行参数
case "${1:-}" in
list)
list_versions
exit 0
;;
uninstall)
INSTALL_DIR="${INSTALL_DIR:-$(select_install_dir)}"
uninstall
;;
rollback)
rollback_version "$2"
;;
help|--help|-h)
show_help
exit 0
;;
esac
# 环境检测
check_environment
# 选择安装目录
INSTALL_DIR=$(select_install_dir)
export INSTALL_DIR
local platform
platform=$(detect_platform)
info "检测到平台: ${platform}"
local version
if [ "${VERSION}" = "latest" ]; then
version=$(fetch_latest_version)
info "最新版本: ${version}"
else
version="${VERSION}"
info "指定版本: ${version}"
fi
download_binary "${platform}" "${version}"
install_skills "${version}"
verify
echo ""
info "快速开始:"
info " gitlink-cli auth login # 登录账号"
info " gitlink-cli --help # 查看所有命令"
info " gitlink-cli version # 查看版本信息"
echo ""
}
main "$@"

View File

@ -71,7 +71,7 @@ func Login(username, password string) (*LoginResult, error) {
// Collect auth cookies from response (GitLink uses autologin_trustie for session persistence)
var authCookies []string
for _, cookie := range resp.Cookies() {
if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" {
if cookie.Name == "Authorization" || cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" || cookie.Name == "autologin" || cookie.Name == "login" {
authCookies = append(authCookies, cookie.Name+"="+cookie.Value)
}
}
@ -79,7 +79,7 @@ func Login(username, password string) (*LoginResult, error) {
if len(authCookies) == 0 {
if u, err := url.Parse(loginURL); err == nil {
for _, cookie := range jar.Cookies(u) {
if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" {
if cookie.Name == "Authorization" || cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" || cookie.Name == "autologin" || cookie.Name == "login" {
authCookies = append(authCookies, cookie.Name+"="+cookie.Value)
}
}

View File

@ -11,19 +11,23 @@ import (
"github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/config"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
type Client struct {
HTTP *http.Client
BaseURL string
Debug bool
HTTP *http.Client
BaseURL string
Debug bool
SkipJSONSuffix bool
}
type APIError struct {
StatusCode int
Code interface{}
Message string
Kind clierrors.ErrorKind
Suggestion string
}
func (e *APIError) Error() string {
@ -44,14 +48,14 @@ func New() (*Client, error) {
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
// Append .json suffix if not already present (GitLink API convention)
// Handle paths that may already contain query strings (e.g., /path?key=val)
if idx := strings.Index(path, "?"); idx != -1 {
basePath := path[:idx]
queryStr := path[idx:]
if !strings.HasSuffix(basePath, ".json") {
if c.shouldAppendJSONSuffix(path) {
if idx := strings.Index(path, "?"); idx != -1 {
basePath := path[:idx]
queryStr := path[idx:]
path = basePath + ".json" + queryStr
} else {
path += ".json"
}
} else if !strings.HasSuffix(path, ".json") {
path += ".json"
}
fullURL := c.BaseURL + path
if query != nil && len(query) > 0 {
@ -98,10 +102,13 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
// Check HTTP-level errors
if resp.StatusCode >= 400 {
info := lookupStatusInfo(resp.StatusCode)
return nil, &APIError{
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
Kind: info.kind,
Suggestion: info.suggestion,
}
}
@ -123,11 +130,13 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
}
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
msg, _ := raw["message"].(string)
suggestion := suggestFix(int(statusCode))
return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{
info := lookupStatusInfo(int(statusCode))
return output.ErrorEnvelope(int(statusCode), msg, info.suggestion), &APIError{
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
Kind: info.kind,
Suggestion: info.suggestion,
}
}
}
@ -174,17 +183,64 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
return c.Do("DELETE", path, nil, query)
}
func suggestFix(code int) string {
switch code {
case 401:
return "请先运行 gitlink-cli auth login 登录"
case 403:
return "权限不足,请确认账户权限或联系项目管理员"
case 404:
return "资源不存在,请检查 owner/repo/id 是否正确"
case 422:
return "参数校验失败,请检查请求参数"
default:
return ""
type statusInfo struct {
kind clierrors.ErrorKind
message string
suggestion string
}
var statusMessages = map[int]statusInfo{
-2: {clierrors.KindAuth, "未登录或 Token 已过期",
"运行 gitlink-cli auth login 重新登录,或检查 GITLINK_TOKEN 环境变量"},
-1: {clierrors.KindInput, "参数校验失败",
"检查必填参数是否缺失、参数格式是否正确,运行 gitlink-cli <命令> --help 查看用法"},
0: {clierrors.KindUnknown, "操作失败", ""},
// Standard HTTP codes
401: {clierrors.KindAuth, "认证失败",
"运行 gitlink-cli auth login 登录,或检查 GITLINK_TOKEN 环境变量"},
403: {clierrors.KindForbidden, "权限不足",
"请确认账号有此仓库的访问权限,或联系项目管理员"},
404: {clierrors.KindNotFound, "资源不存在",
"检查 owner/repo/id 是否正确,资源可能已被删除"},
422: {clierrors.KindInput, "参数校验失败",
"检查请求参数格式,运行 gitlink-cli <命令> --help 查看用法"},
429: {clierrors.KindServer, "请求过于频繁",
"稍等片刻后重试"},
500: {clierrors.KindServer, "服务器内部错误",
"稍等后重试,如持续出现请联系平台管理员"},
502: {clierrors.KindServer, "网关错误",
"服务器暂时不可用,稍等后重试"},
503: {clierrors.KindServer, "服务暂时不可用",
"服务器正在维护,稍等后重试"},
}
func lookupStatusInfo(code int) statusInfo {
if info, ok := statusMessages[code]; ok {
return info
}
return statusInfo{
kind: clierrors.KindUnknown,
message: fmt.Sprintf("API 返回错误码 %d", code),
}
}
// shouldAppendJSONSuffix reports whether the .json suffix should be appended to path.
// Returns false (skip append) when:
// - c.SkipJSONSuffix is set (explicit opt-out for non-JSON endpoints such as gateway)
// - path already ends with .json
// - path matches the raw content pattern (e.g., /api/:owner/:repo/raw/...)
func (c *Client) shouldAppendJSONSuffix(path string) bool {
if c.SkipJSONSuffix {
return false
}
if strings.HasSuffix(path, ".json") {
return false
}
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range parts {
if part == "raw" && i >= 2 && i+2 < len(parts) {
return false
}
}
return true
}

229
internal/compliance/cmd.go Normal file
View File

@ -0,0 +1,229 @@
package compliance
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
// NewCommand returns the top-level compliance command.
func NewCommand() *cobra.Command {
var format string
cmd := &cobra.Command{
Use: "compliance",
Short: "Compliance and security scan operations",
Long: "Run license compliance checks, dependency scanning, secret detection, and exposure analysis on local repositories.",
}
cmd.PersistentFlags().StringVar(&format, "format", "table", "Output format: json, table")
cmd.AddCommand(newScanCmd(&format))
cmd.AddCommand(newLicenseCmd(&format))
cmd.AddCommand(newDepsCmd(&format))
cmd.AddCommand(newSecretsCmd(&format))
cmd.AddCommand(newExposureCmd(&format))
cmd.AddCommand(newVocabCmd(&format))
return cmd
}
func newScanCmd(format *string) *cobra.Command {
var module string
cmd := &cobra.Command{
Use: "+scan",
Short: "Full compliance scan (all five modules)",
RunE: func(cmd *cobra.Command, args []string) error {
root, err := repoRoot()
if err != nil {
return err
}
modules := []string{"secrets", "exposure", "vocab"}
if module != "" {
modules = parseModules(module)
}
var allFindings []Finding
for _, m := range modules {
switch m {
case "license":
allFindings = append(allFindings, checkLicense(root)...)
break
case "deps":
allFindings = append(allFindings, checkDeps(root)...)
break
case "secrets", "exposure", "vocab":
rules := allRules()[m]
allFindings = append(allFindings, scanFiles(root, rules)...)
break
}
}
return outputReport(allFindings, modules, *format)
},
}
cmd.Flags().StringVarP(&module, "module", "m", "", "Comma-separated modules: license,deps,secrets,exposure,vocab")
return cmd
}
func newLicenseCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+license",
Short: "License compliance check",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := checkLicense(root)
return outputReport(findings, []string{"license"}, *format)
},
}
}
func newDepsCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+deps",
Short: "Dependency license check",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := checkDeps(root)
return outputReport(findings, []string{"deps"}, *format)
},
}
}
func newSecretsCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+secrets",
Short: "Hardcoded secrets scan",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["secrets"])
return outputReport(findings, []string{"secrets"}, *format)
},
}
}
func newExposureCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+exposure",
Short: "PII and network exposure scan",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["exposure"])
return outputReport(findings, []string{"exposure"}, *format)
},
}
}
func newVocabCmd(format *string) *cobra.Command {
return &cobra.Command{
Use: "+vocab",
Short: "Sensitive vocabulary scan",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := repoRoot()
findings := scanFiles(root, allRules()["vocab"])
return outputReport(findings, []string{"vocab"}, *format)
},
}
}
func repoRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return dir, nil
}
dir = parent
}
}
func parseModules(s string) []string {
var result []string
seen := map[string]bool{}
for _, m := range strings.Split(s, ",") {
m = strings.TrimSpace(m)
valid := map[string]bool{"license": true, "deps": true, "secrets": true, "exposure": true, "vocab": true}
if valid[m] && !seen[m] {
result = append(result, m)
seen[m] = true
}
}
return result
}
// summary holds aggregated stats.
type summary struct {
Total int `json:"total"`
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
}
type reportData struct {
Modules []string `json:"modules"`
Findings []Finding `json:"findings"`
Summary summary `json:"summary"`
}
func outputReport(findings []Finding, modules []string, format string) error {
s := summary{}
for _, f := range findings {
s.Total++
switch f.Severity {
case "critical":
s.Critical++
case "high":
s.High++
case "medium":
s.Medium++
case "low":
s.Low++
}
}
if format == "json" {
return output.Print(output.SuccessEnvelope(reportData{Modules: modules, Findings: findings, Summary: s}, nil), format)
}
fmt.Println()
printHR()
fmt.Printf(" Compliance Scan Report\n")
fmt.Printf(" Modules: %s | Findings: %d (critical:%d high:%d medium:%d low:%d)\n",
strings.Join(modules, ", "), s.Total, s.Critical, s.High, s.Medium, s.Low)
printHR()
if len(findings) == 0 {
fmt.Println(" All clear — no issues found.")
} else {
printFindings(findings)
}
printHR()
return nil
}
func printHR() {
fmt.Println(strings.Repeat("─", 60))
}
func printFindings(findings []Finding) {
labels := map[string]string{
"critical": "CRIT", "high": "HIGH", "medium": "MED", "low": "LOW",
}
for _, f := range findings {
label := labels[f.Severity]
if label == "" {
label = f.Severity
}
fmt.Printf(" [%s] %s %s:%d %s\n", label, f.ID, f.File, f.Line, f.Summary)
}
}

View File

@ -0,0 +1,188 @@
package compliance
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
// checkLicense performs static license compliance checks (no regex scanning needed).
func checkLicense(root string) []Finding {
var findings []Finding
// L-001: LICENSE file existence
files := []string{"LICENSE", "LICENSE.md", "LICENSE.txt"}
found := false
for _, name := range files {
if _, err := os.Stat(filepath.Join(root, name)); err == nil {
found = true
break
}
}
if !found {
findings = append(findings, Finding{
ID: "L-001", Severity: "medium", Module: "license",
File: "-", Line: 0,
Summary: "缺少 LICENSE 文件",
})
}
// L-002: check npm/package.json license vs root LICENSE
rootLicense := detectLicense(root)
npmLicense := detectNpmLicense(root)
if rootLicense != "" && npmLicense != "" && !strings.EqualFold(rootLicense, npmLicense) {
findings = append(findings, Finding{
ID: "L-002", Severity: "medium", Module: "license",
File: "npm/package.json", Line: 1,
Summary: fmt.Sprintf("许可证声明不一致:根 LICENSE 为 %snpm/package.json 声明 %s", rootLicense, npmLicense),
})
}
// L-004: placeholder check in LICENSE file
for _, name := range files {
path := filepath.Join(root, name)
if f, err := os.Open(path); err == nil {
sc := bufio.NewScanner(f)
line := 0
for sc.Scan() {
line++
t := sc.Text()
if strings.Contains(t, "[year]") || strings.Contains(t, "[Year]") ||
strings.Contains(t, "[name of copyright holder]") || strings.Contains(t, "[yyyy]") {
findings = append(findings, Finding{
ID: "L-004", Severity: "low", Module: "license",
File: name, Line: line,
Summary: "LICENSE 中占位符未填写([Year] / [name of copyright holder]",
})
break
}
}
f.Close()
break
}
}
return findings
}
func detectLicense(root string) string {
for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt"} {
path := filepath.Join(root, name)
data, err := os.ReadFile(path)
if err != nil {
continue
}
text := string(data)
switch {
case strings.Contains(text, "Mulan Permissive Software License"):
return "MulanPSL-2.0"
case strings.Contains(text, "Apache License") && strings.Contains(text, "Version 2.0"):
return "Apache-2.0"
case strings.Contains(text, "MIT License") || strings.Contains(text, "Permission is hereby granted, free of charge"):
return "MIT"
case strings.Contains(text, "GNU AFFERO GENERAL PUBLIC LICENSE"):
return "AGPL-3.0"
case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 3"):
return "GPL-3.0"
case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 2"):
return "GPL-2.0"
case strings.Contains(text, "GNU LESSER GENERAL PUBLIC LICENSE"):
return "LGPL"
case strings.Contains(text, "BSD") && strings.Count(text, "Redistribution") >= 3:
return "BSD-3-Clause"
case strings.Contains(text, "BSD"):
return "BSD-2-Clause"
case strings.Contains(text, "Mozilla Public License"):
return "MPL-2.0"
default:
return "unknown"
}
}
return ""
}
func detectNpmLicense(root string) string {
path := filepath.Join(root, "npm", "package.json")
data, err := os.ReadFile(path)
if err != nil {
return ""
}
// simple string search for "license": "xxx"
for _, line := range strings.Split(string(data), "\n") {
if strings.Contains(line, "\"license\"") {
line = strings.TrimSpace(line)
// "license": "Apache-2.0",
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
v := strings.TrimSpace(parts[1])
v = strings.Trim(v, "\",")
return v
}
}
}
return ""
}
// checkDeps inspects go.mod for copyleft dependencies.
func checkDeps(root string) []Finding {
var findings []Finding
path := filepath.Join(root, "go.mod")
f, err := os.Open(path)
if err != nil {
// no go.mod — not a Go project
return nil
}
defer f.Close()
// GPL/AGPL keywords in module names
copyleft := []string{"gpl", "agpl", "gnu"}
sc := bufio.NewScanner(f)
line := 0
for sc.Scan() {
line++
text := strings.ToLower(sc.Text())
if !strings.Contains(text, "require") && !strings.Contains(text, "require") {
continue
}
// Check lines after "require" block until blank
}
f.Close()
// re-read go.mod and check require block
data, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(data), "\n")
inRequire := false
for _, l := range lines {
trimmed := strings.TrimSpace(l)
if strings.HasPrefix(trimmed, "require") && !strings.Contains(trimmed, "// indirect") {
inRequire = true
continue
}
if inRequire && trimmed == "" {
break
}
if inRequire && strings.HasPrefix(trimmed, ")") {
break
}
if inRequire {
lower := strings.ToLower(trimmed)
for _, kw := range copyleft {
if strings.Contains(lower, kw) {
findings = append(findings, Finding{
ID: "D-003", Severity: "high", Module: "deps",
File: "go.mod", Line: 0,
Summary: fmt.Sprintf("Copyleft 依赖风险: %s", trimmed),
})
break
}
}
}
}
return findings
}

View File

@ -0,0 +1,58 @@
package compliance
import "regexp"
// allRules returns the complete set of scan rules grouped by module.
func allRules() map[string][]scanRule {
return map[string][]scanRule{
"secrets": secretRules(),
"exposure": exposureRules(),
"vocab": vocabRules(),
}
}
func compileRE(expr string) *regexp.Regexp {
return regexp.MustCompile(expr)
}
// ----- secrets (S-001 ~ S-010) -----
func secretRules() []scanRule {
return []scanRule{
{SID("001"), "high", compileRE(`(?i)access_token|private_token`), "Token 作为 URL 查询参数泄露风险", nil},
{SID("002"), "critical", compileRE(`(?i)password\s*[:=]\s*"[^"]+"`), "硬编码密码", nil},
{SID("003"), "critical", compileRE(`(?i)api[_-]?key\s*[:=]\s*"[a-zA-Z0-9_-]{8,}"`), "硬编码 API Key", nil},
{SID("004"), "critical", compileRE(`BEGIN.*PRIVATE KEY`), "私钥文件内容", []string{"*"}},
{SID("005"), "high", compileRE(`token\s*[:=]\s*"[A-Za-z0-9+/=_-]{32,}"`), "长 Token 硬编码", nil},
{SID("006"), "high", compileRE(`(?i)secret\s*[:=]\s*"[^"]{8,}"`), "Secret 硬编码", nil},
{SID("008"), "medium", compileRE(`(?i)(fmt|log)\.(Print|Debug|Info).*[Tt]oken`), "Debug 输出可能泄露 Token", []string{"*.go"}},
{SID("010"), "high", compileRE(`(?i)(mongodb|mysql|postgres|redis)://[^@]*@`), "数据库连接串含凭据", nil},
}
}
// ----- exposure (P-001 ~ E-005) -----
func exposureRules() []scanRule {
return []scanRule{
{PID("001"), "low", compileRE(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`), "邮箱地址泄露", nil},
{PID("002"), "low", compileRE(`\b1[3-9]\d{9}\b`), "手机号泄露", nil},
{EID("001"), "medium", compileRE(`\b(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+)\b`), "内网 IP 暴露", nil},
{EID("002"), "low", compileRE(`(localhost|127\.0\.0\.1):\d+`), "本地开发地址残留", nil},
{EID("003"), "low", compileRE(`\b\w+\.(local|internal|test)\b`), "内部域名暴露", nil},
}
}
// ----- vocab (C-001 ~ C-006) -----
func vocabRules() []scanRule {
return []scanRule{
{CID("001"), "high", compileRE(`军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军|军事基地|作战指挥|军事演习|战备|动员令|驻地|番号`), "军事相关敏感词汇", nil},
{CID("002"), "high", compileRE(`中央委员会|国务院|中央军委|部委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|红头文件|绝密|机密文件|内参|机要文件`), "党政机关敏感词汇", nil},
{CID("003"), "medium", compileRE(`内部系统|内部平台|内网地址|专网|涉密|非密|脱密|密码机|加密机|堡垒机|入侵检测|安全监测`), "内部系统标识泄露", nil},
{CID("004"), "medium", compileRE(`反洗钱|征信系统|个人隐私数据|数据出境|跨境传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保|密评|商用密码`), "监管合规敏感词", nil},
{CID("005"), "low", compileRE(`内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名录|内部API|私有接口|内部对接`), "组织内部敏感信息", nil},
{CID("006"), "medium", compileRE(`国密|SM2|SM3|SM4|SM9|密码卡|防火墙设备|入侵防御|WAF|DLP|上网行为|日志审计|终端管控`), "安全产品/密码学敏感词", nil},
}
}
func SID(num string) string { return "S-" + num }
func PID(num string) string { return "P-" + num }
func EID(num string) string { return "E-" + num }
func CID(num string) string { return "C-" + num }

View File

@ -0,0 +1,210 @@
package compliance
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// Finding represents a single scan result.
type Finding struct {
ID string `json:"id"`
Severity string `json:"severity"` // critical, high, medium, low
Module string `json:"module"` // license, deps, secrets, exposure, vocab
File string `json:"file"`
Line int `json:"line"`
Summary string `json:"summary"`
}
// ScanResult holds all findings for a module.
type ScanResult struct {
Module string `json:"module"`
Findings []Finding `json:"findings"`
}
// scanRule defines a pattern to search for.
type scanRule struct {
ID string
Severity string
Pattern *regexp.Regexp
Summary string
Globs []string // file globs to include, empty = all text files
}
// excludedDirs are directories skipped during scanning.
var excludedDirs = map[string]bool{
"vendor": true, "node_modules": true, ".git": true, ".claude": true,
"skills": true, // skill documentation, not project source
}
// excludedPaths are relative paths skipped (scanner's own source to avoid self-scan).
var excludedPaths = map[string]bool{
"internal/compliance": true,
}
// excludedExts are file extensions skipped during scanning.
var excludedExts = map[string]bool{
".exe": true, ".dll": true, ".so": true, ".dylib": true,
".bin": true, ".jpg": true, ".jpeg": true, ".png": true,
".gif": true, ".ico": true, ".svg": true, ".pdf": true,
".zip": true, ".gz": true, ".tgz": true,
}
// excludeFiles are specific files skipped during scanning.
var excludeFiles = map[string]bool{
"go.sum": true, "package-lock.json": true,
}
// textExts are extensions treated as text files.
var textExts = map[string]bool{
".go": true, ".js": true, ".ts": true, ".tsx": true, ".jsx": true,
".py": true, ".rb": true, ".java": true, ".c": true, ".h": true,
".cpp": true, ".hpp": true, ".rs": true, ".swift": true, ".kt": true,
".yaml": true, ".yml": true, ".json": true, ".xml": true, ".toml": true,
".md": true, ".txt": true, ".sh": true, ".bash": true, ".ps1": true,
".css": true, ".html": true, ".htm": true, ".sql": true, ".proto": true,
".cfg": true, ".conf": true, ".ini": true, ".env": true, ".lock": true,
".mod": true,
}
func isTextFile(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return textExts[ext]
}
func shouldSkip(path string, info os.FileInfo) bool {
name := info.Name()
if info.IsDir() {
if excludedDirs[name] {
return true
}
return false
}
if excludedExts[strings.ToLower(filepath.Ext(name))] {
return true
}
if excludeFiles[name] {
return true
}
return false
}
// walkFiles walks the repo and yields text file paths (relative to root).
func walkFiles(root string) ([]string, error) {
var files []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if shouldSkip(path, info) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
if !info.IsDir() && isTextFile(path) {
rel, _ := filepath.Rel(root, path)
rel = filepath.ToSlash(rel)
// skip excluded paths (scanner's own source)
for prefix := range excludedPaths {
if strings.HasPrefix(rel, prefix) {
return nil
}
}
files = append(files, rel)
}
return nil
})
return files, err
}
// scanFiles scans files against rules and returns deduplicated findings.
// When a line matches multiple rules, only the highest-severity rule is reported.
func scanFiles(root string, rules []scanRule) []Finding {
files, err := walkFiles(root)
if err != nil {
return []Finding{{ID: "ERR", Severity: "critical", Module: "scanner", Summary: fmt.Sprintf("walk error: %v", err)}}
}
// dedup by file+line, keeping the highest severity
sevRank := map[string]int{"critical": 4, "high": 3, "medium": 2, "low": 1}
seen := make(map[string]Finding) // key: "file:line"
for _, f := range files {
for _, rule := range rules {
if !ruleMatchesFile(f, rule.Globs) {
continue
}
for _, m := range scanFile(filepath.Join(root, f), rule) {
key := fmt.Sprintf("%s:%d", m.File, m.Line)
if prev, ok := seen[key]; !ok || sevRank[m.Severity] > sevRank[prev.Severity] {
seen[key] = m
}
}
}
}
var findings []Finding
for _, f := range seen {
findings = append(findings, f)
}
return findings
}
func ruleMatchesFile(file string, globs []string) bool {
if len(globs) == 0 {
return true
}
for _, g := range globs {
matched, _ := filepath.Match(g, filepath.Base(file))
if matched {
return true
}
}
return false
}
func scanFile(path string, rule scanRule) []Finding {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var findings []Finding
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
if rule.Pattern.MatchString(scanner.Text()) {
findings = append(findings, Finding{
ID: rule.ID,
Severity: rule.Severity,
Module: ruleMod(rule.ID),
File: path,
Line: lineNum,
Summary: rule.Summary,
})
}
}
return findings
}
func ruleMod(id string) string {
switch {
case strings.HasPrefix(id, "L-"):
return "license"
case strings.HasPrefix(id, "D-"):
return "deps"
case strings.HasPrefix(id, "S-"):
return "secrets"
case strings.HasPrefix(id, "P-"), strings.HasPrefix(id, "E-"):
return "exposure"
case strings.HasPrefix(id, "C-"):
return "vocab"
}
return "unknown"
}

View File

@ -8,21 +8,27 @@ import (
)
const (
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultFormat = "table"
DefaultBaseURL = "https://www.gitlink.org.cn/api"
DefaultGatewayBaseURL = "https://gateway.gitlink.org.cn/api"
DefaultFormat = "table"
// EnvGatewayBaseURL overrides GatewayBaseURL when set.
EnvGatewayBaseURL = "GITLINK_GATEWAY_URL"
)
type Config struct {
BaseURL string `yaml:"base_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
BaseURL string `yaml:"base_url"`
GatewayBaseURL string `yaml:"gateway_base_url,omitempty"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
}
func DefaultConfig() *Config {
return &Config{
BaseURL: DefaultBaseURL,
Format: DefaultFormat,
BaseURL: DefaultBaseURL,
GatewayBaseURL: DefaultGatewayBaseURL,
Format: DefaultFormat,
}
}
@ -53,6 +59,12 @@ func Load() (*Config, error) {
if cfg.BaseURL == "" {
cfg.BaseURL = DefaultBaseURL
}
if cfg.GatewayBaseURL == "" {
cfg.GatewayBaseURL = DefaultGatewayBaseURL
}
if v := os.Getenv(EnvGatewayBaseURL); v != "" {
cfg.GatewayBaseURL = v
}
if cfg.Format == "" {
cfg.Format = DefaultFormat
}
@ -79,6 +91,8 @@ func Get(key string) (string, error) {
switch key {
case "base_url":
return cfg.BaseURL, nil
case "gateway_base_url":
return cfg.GatewayBaseURL, nil
case "default_format":
return cfg.Format, nil
case "editor":
@ -98,6 +112,8 @@ func Set(key, value string) error {
switch key {
case "base_url":
cfg.BaseURL = value
case "gateway_base_url":
cfg.GatewayBaseURL = value
case "default_format":
cfg.Format = value
case "editor":

View File

@ -17,7 +17,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) {
owner, repo, err := fromGitRemote()
if err != nil {
if flagOwner == "" || flagRepo == "" {
return "", "", fmt.Errorf("cannot detect owner/repo from git remote: %w\nUse --owner and --repo flags to specify explicitly", err)
return "", "", fmt.Errorf("无法自动检测 owner/repo: %w\n 请使用 --owner 和 --repo 参数显式指定,或切换到 git 仓库目录下执行", err)
}
}
@ -59,11 +59,33 @@ func parseRemoteURL(remote string) (string, string, error) {
}
func parsePathSegments(path string) (string, string, error) {
path = strings.TrimPrefix(path, "/")
// 处理URL路径格式可能包含域名前缀
// 例如://www.gitlink.org.cn/zzx-coder/gitlink-cli 或 /zzx-coder/gitlink-cli
// 先去掉域名部分(如果存在)
if strings.HasPrefix(path, "//www.gitlink.org.cn/") {
path = strings.TrimPrefix(path, "//www.gitlink.org.cn/")
} else if strings.HasPrefix(path, "//") {
// 处理其他可能的域名格式:找到第二个斜杠后的内容
if idx := strings.Index(path[2:], "/"); idx != -1 {
path = path[2+idx+1:]
} else {
path = path[2:]
}
} else if strings.HasPrefix(path, "/") {
// 去掉单个前导斜杠
path = strings.TrimPrefix(path, "/")
}
// 去掉.git后缀
path = strings.TrimSuffix(path, ".git")
parts := strings.SplitN(path, "/", 3)
// 现在应该得到 "zzx-coder/gitlink-cli" 格式
parts := strings.Split(path, "/")
if len(parts) < 2 {
return "", "", fmt.Errorf("cannot extract owner/repo from path: %s", path)
}
// 第一个部分是owner第二个是repo可能还有更多部分但忽略
return parts[0], parts[1], nil
}

150
internal/errors/errors.go Normal file
View File

@ -0,0 +1,150 @@
package errors
import (
"fmt"
"strings"
)
// ErrorKind categorizes errors by user-actionability.
type ErrorKind string
const (
KindAuth ErrorKind = "auth" // Login/token issues — user can re-login
KindInput ErrorKind = "input" // Parameter issues — user can fix arguments
KindConfig ErrorKind = "config" // Config file issues — user can edit config
KindNetwork ErrorKind = "network" // Network issues — user can check/retry
KindGit ErrorKind = "git" // Git repo issues — user needs correct directory
KindServer ErrorKind = "server" // Server-side error — user should wait or contact admin
KindNotFound ErrorKind = "not_found" // Resource not found — user can check ID
KindForbidden ErrorKind = "forbidden" // Permission denied — user can request access
KindUnknown ErrorKind = "unknown" // Unclassified error
)
// CLIError is the unified CLI error type with multi-layered information.
type CLIError struct {
Kind ErrorKind // Error category for programmatic handling
Message string // Human-readable description of what went wrong
Detail string // Low-level technical detail (shown in debug mode)
Suggestion string // Actionable advice for the user
Command string // The command that triggered the error (e.g., "issue +create")
Cause error // The underlying error
}
func (e *CLIError) Error() string {
var b strings.Builder
// Header line: kind + command
b.WriteString(string(e.Kind))
b.WriteString(" error")
if e.Command != "" {
b.WriteString(" — ")
b.WriteString(e.Command)
}
// Body: message
if e.Message != "" {
b.WriteString("\n\n reason: ")
b.WriteString(e.Message)
}
// Suggestion
if e.Suggestion != "" {
b.WriteString("\n suggestion: ")
b.WriteString(e.Suggestion)
}
// Detail (always included in Error() so users see the raw cause)
if e.Detail != "" {
b.WriteString("\n detail: ")
b.WriteString(e.Detail)
}
return b.String()
}
func (e *CLIError) Unwrap() error {
return e.Cause
}
// New creates a CLIError with the given parameters.
func New(kind ErrorKind, message, suggestion string) *CLIError {
return &CLIError{
Kind: kind,
Message: message,
Suggestion: suggestion,
}
}
// Wrap creates a CLIError that wraps an underlying cause.
func Wrap(kind ErrorKind, message, suggestion string, cause error) *CLIError {
return &CLIError{
Kind: kind,
Message: message,
Suggestion: suggestion,
Cause: cause,
Detail: cause.Error(),
}
}
// WithCommand sets the command context on the error.
func (e *CLIError) WithCommand(cmd string) *CLIError {
e.Command = cmd
return e
}
// InputError is a convenience constructor for parameter errors.
func InputError(message, suggestion string) *CLIError {
return New(KindInput, message, suggestion)
}
// AuthError is a convenience constructor for authentication errors.
func AuthError(message, suggestion string) *CLIError {
return New(KindAuth, message, suggestion)
}
// ConfigError creates a config-related error with the config file path in the suggestion.
func ConfigError(message string, cause error) *CLIError {
return Wrap(KindConfig, message,
fmt.Sprintf("检查配置文件 %s 是否正确", configPathPlaceholder()), cause)
}
// OpError creates a unified operation-failure error.
// op is the English verb (e.g., "list", "create"), resource is the target (e.g., "issues").
// The Message is in English; Suggestion is in Chinese for user guidance.
func OpError(kind ErrorKind, op, resource string, cause error) *CLIError {
msg := fmt.Sprintf("failed to %s %s", op, resource)
sugg := opSuggestion(op, resource)
e := Wrap(kind, msg, sugg, cause)
return e
}
// opSuggestion returns a Chinese suggestion for the given operation.
func opSuggestion(op, resource string) string {
suggestions := map[string]string{
"list": "获取列表失败,请检查参数或网络连接,稍后重试",
"create": "创建失败,请检查必填参数是否正确(--help 查看用法)或 API 权限",
"view": "查看失败,请确认资源 ID 是否存在",
"update": "更新失败,请检查参数值或资源 ID 是否正确",
"delete": "删除失败,请确认资源是否存在或是否有删除权限",
"close": "关闭失败,请确认资源是否存在或已被关闭",
"reopen": "重新打开失败,请确认资源是否存在",
"merge": "合并失败,请检查是否有冲突或权限不足",
"comment": "添加评论失败,请确认资源是否存在",
"approve": "评审操作失败,请确认 PR 是否存在",
"scan": "扫描失败,请稍后重试",
"invite": "邀请失败,请确认用户 ID 是否正确",
"remove": "移除失败,请确认成员存在",
"fork": "Fork 失败,请确认仓库存在或有权限",
"search": "搜索失败,请稍后重试",
}
if s, ok := suggestions[op]; ok {
return s
}
return fmt.Sprintf("操作失败,请稍后重试或运行 --help 查看用法")
}
// configPathPlaceholder avoids circular import; the actual path will be resolved
// in output formatting.
func configPathPlaceholder() string {
return "~/.config/gitlink-cli/config.yaml"
}

View File

@ -9,29 +9,48 @@ import (
"strings"
"text/tabwriter"
"golang.org/x/term"
"gopkg.in/yaml.v3"
)
// PrintOptions controls table output rendering behavior.
type PrintOptions struct {
Columns []string // column names to show (nil = all)
NoTruncate bool // disable 60-char truncation
UseColor bool // enable ANSI color headers
}
// Print outputs the envelope in the given format with default options.
func Print(envelope *Envelope, format string) error {
return PrintWithOpts(envelope, format, PrintOptions{})
}
// PrintWithOpts outputs the envelope with rendering options.
func PrintWithOpts(envelope *Envelope, format string, opts PrintOptions) error {
if format == "" {
format = "json"
}
return PrintTo(os.Stdout, envelope, format)
return printToOpts(os.Stdout, envelope, format, opts)
}
func PrintTo(w io.Writer, envelope *Envelope, format string) error {
func printToOpts(w io.Writer, envelope *Envelope, format string, opts PrintOptions) error {
switch format {
case "json":
return printJSON(w, envelope)
case "yaml":
return printYAML(w, envelope)
case "table":
return printTable(w, envelope)
return printTableOpts(w, envelope, opts)
default:
return printJSON(w, envelope)
}
}
// PrintTo outputs the envelope to the given writer (legacy, no options).
func PrintTo(w io.Writer, envelope *Envelope, format string) error {
return printToOpts(w, envelope, format, PrintOptions{})
}
func printJSON(w io.Writer, envelope *Envelope) error {
data, err := json.MarshalIndent(envelope, "", " ")
if err != nil {
@ -50,7 +69,7 @@ func printYAML(w io.Writer, envelope *Envelope) error {
return err
}
func printTable(w io.Writer, envelope *Envelope) error {
func printTableOpts(w io.Writer, envelope *Envelope, opts PrintOptions) error {
if !envelope.OK {
if envelope.Error != nil {
fmt.Fprintf(w, "Error: %s\n", envelope.Error.Message)
@ -66,22 +85,72 @@ func printTable(w io.Writer, envelope *Envelope) error {
return nil
}
// Try to render as table if data is a slice of maps
switch data := envelope.Data.(type) {
case []interface{}:
return printSliceTable(w, data)
return printSliceTableOpts(w, data, opts)
case map[string]interface{}:
// For maps with nested structures, prefer JSON
if unwrapped := unwrapSingleListField(data); unwrapped != nil {
return printSliceTableOpts(w, unwrapped, opts)
}
if hasComplexValues(data) {
return printJSON(w, envelope)
}
return printMapTable(w, data)
return printMapTableOpts(w, data, opts)
default:
// Fallback to JSON
return printJSON(w, envelope)
}
}
// printTable is kept for backward compatibility with existing callers.
func printTable(w io.Writer, envelope *Envelope) error {
return printTableOpts(w, envelope, PrintOptions{})
}
// unwrapSingleListField detects wrapper map structures like {"items":[...], "count":N}
// and returns the inner slice for list rendering.
func unwrapSingleListField(m map[string]interface{}) []interface{} {
knownListFields := []string{
"projects", "webhooks", "issues", "users", "pull_requests",
"builds", "releases", "branches", "teams", "members",
"orgs", "items", "records", "results", "wikis", "search",
}
for _, name := range knownListFields {
if s, ok := m[name].([]interface{}); ok {
if isSliceOfMaps(s) {
return s
}
}
}
// fallback: single slice-of-maps field
var listField string
var listValue []interface{}
for k, v := range m {
s, ok := v.([]interface{})
if !ok {
continue
}
if !isSliceOfMaps(s) {
continue
}
if listField != "" {
return nil // multiple list fields, can't auto-unwrap
}
listField = k
listValue = s
}
return listValue
}
func isSliceOfMaps(s []interface{}) bool {
if len(s) == 0 {
return true
}
_, ok := s[0].(map[string]interface{})
return ok
}
func hasComplexValues(m map[string]interface{}) bool {
for _, v := range m {
switch v.(type) {
@ -92,13 +161,12 @@ func hasComplexValues(m map[string]interface{}) bool {
return false
}
func printSliceTable(w io.Writer, items []interface{}) error {
func printSliceTableOpts(w io.Writer, items []interface{}, opts PrintOptions) 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 {
data, _ := json.MarshalIndent(items, "", " ")
@ -107,10 +175,23 @@ func printSliceTable(w io.Writer, items []interface{}) error {
}
headers := collectKeys(first)
// apply --columns filter
if len(opts.Columns) > 0 {
headers = filterColumns(headers, opts.Columns)
}
useColor := opts.UseColor && isTerminal(w)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
// Print headers
fmt.Fprintln(tw, strings.Join(headers, "\t"))
headerLine := strings.Join(headers, "\t")
if useColor {
headerLine = colorHeader(headerLine)
}
fmt.Fprintln(tw, headerLine)
dashes := make([]string, len(headers))
for i, h := range headers {
dashes[i] = strings.Repeat("-", len(h))
@ -125,26 +206,43 @@ func printSliceTable(w io.Writer, items []interface{}) error {
}
vals := make([]string, len(headers))
for i, h := range headers {
vals[i] = formatValue(m[h])
vals[i] = formatValueOpts(m[h], opts.NoTruncate)
}
fmt.Fprintln(tw, strings.Join(vals, "\t"))
}
return tw.Flush()
}
func printMapTable(w io.Writer, m map[string]interface{}) error {
func printMapTableOpts(w io.Writer, m map[string]interface{}, opts PrintOptions) error {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "KEY\tVALUE")
headerLine := "KEY\tVALUE"
if opts.UseColor && isTerminal(w) {
headerLine = colorHeader(headerLine)
}
fmt.Fprintln(tw, headerLine)
fmt.Fprintln(tw, "---\t-----")
for k, v := range m {
fmt.Fprintf(tw, "%s\t%s\n", k, formatValue(v))
fmt.Fprintf(tw, "%s\t%s\n", k, formatValueOpts(v, opts.NoTruncate))
}
return tw.Flush()
}
func filterColumns(all, wanted []string) []string {
wantedSet := make(map[string]bool, len(wanted))
for _, w := range wanted {
wantedSet[w] = true
}
result := make([]string, 0, len(wanted))
for _, h := range all {
if wantedSet[h] {
result = append(result, h)
}
}
return result
}
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"}
seen := map[string]bool{}
for _, k := range priority {
@ -162,6 +260,10 @@ func collectKeys(m map[string]interface{}) []string {
}
func formatValue(v interface{}) string {
return formatValueOpts(v, false)
}
func formatValueOpts(v interface{}, noTruncate bool) string {
if v == nil {
return ""
}
@ -170,7 +272,7 @@ func formatValue(v interface{}) string {
case reflect.Map, reflect.Slice:
data, _ := json.Marshal(v)
s := string(data)
if len(s) > 60 {
if !noTruncate && len(s) > 60 {
return s[:57] + "..."
}
return s
@ -178,3 +280,21 @@ func formatValue(v interface{}) string {
return fmt.Sprintf("%v", v)
}
}
// --- color helpers ---
const (
ansiHeader = "\033[1;36m" // bold cyan
ansiReset = "\033[0m"
)
func colorHeader(s string) string {
return ansiHeader + s + ansiReset
}
func isTerminal(w io.Writer) bool {
if f, ok := w.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}

View File

@ -0,0 +1,81 @@
package output
import (
"bytes"
"strings"
"testing"
)
func TestPrintTable_WrappedEmptyList(t *testing.T) {
env := &Envelope{OK: true, Data: map[string]interface{}{
"count": 0,
"projects": []interface{}{},
}}
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo failed: %v", err)
}
got := buf.String()
if !strings.Contains(got, "No results") {
t.Errorf("expected 'No results', got: %q", got)
}
}
func TestPrintTable_WrappedList(t *testing.T) {
env := &Envelope{OK: true, Data: map[string]interface{}{
"count": 2,
"projects": []interface{}{
map[string]interface{}{"id": 1.0, "name": "alpha"},
map[string]interface{}{"id": 2.0, "name": "beta"},
},
}}
var buf bytes.Buffer
if err := PrintTo(&buf, env, "table"); err != nil {
t.Fatalf("PrintTo failed: %v", err)
}
got := buf.String()
if !strings.Contains(got, "alpha") || !strings.Contains(got, "beta") {
t.Errorf("expected alpha/beta in output, got: %q", got)
}
if !strings.Contains(got, "id") || !strings.Contains(got, "name") {
t.Errorf("expected header id/name, got: %q", got)
}
}
func TestUnwrapSingleListField_KnownName(t *testing.T) {
m := map[string]interface{}{
"count": 2.0,
"projects": []interface{}{map[string]interface{}{"id": 1.0}},
}
got := unwrapSingleListField(m)
if got == nil || len(got) != 1 {
t.Fatalf("expected slice len=1, got %v", got)
}
}
func TestUnwrapSingleListField_MultipleUnknownListsReturnsNil(t *testing.T) {
// 两个未知名字的 list 字段 — 无法自动选择,返回 nil
m := map[string]interface{}{
"foo_list": []interface{}{map[string]interface{}{"id": 1.0}},
"bar_list": []interface{}{map[string]interface{}{"id": 2.0}},
}
if got := unwrapSingleListField(m); got != nil {
t.Errorf("expected nil for multiple unknown list fields, got len=%d", len(got))
}
}
func TestUnwrapSingleListField_KnownNamePreferred(t *testing.T) {
// 已知 name 优先 — 即使有其他 list 字段也用 known name
m := map[string]interface{}{
"projects": []interface{}{map[string]interface{}{"id": 1.0}},
"users": []interface{}{map[string]interface{}{"id": 2.0}},
}
got := unwrapSingleListField(m)
if got == nil || len(got) != 1 {
t.Fatalf("expected projects slice len=1, got %v", got)
}
first := got[0].(map[string]interface{})
if first["id"] != 1.0 {
t.Errorf("expected projects[0].id=1, got %v", first["id"])
}
}

View File

@ -6,29 +6,74 @@ const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const ext = process.platform === "win32" ? ".exe" : "";
const binaryPath = path.join(__dirname, "gitlink-cli" + ext);
const BINARY_NAME = "gitlink-cli";
if (!fs.existsSync(binaryPath)) {
console.error(
`Error: gitlink-cli binary not found at ${binaryPath}\n\n` +
`The binary was not downloaded during installation.\n` +
`This usually happens when the postinstall script failed (e.g. network issues).\n\n` +
`To fix, try one of:\n` +
` 1. Reinstall: npm install -g @gitlink-ai/cli\n` +
` 2. Manual download from:\n` +
` https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases\n` +
` Then place the binary at: ${binaryPath}\n`
);
process.exit(1);
function getBinaryName(platform = process.platform) {
return platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME;
}
try {
execFileSync(binaryPath, process.argv.slice(2), { stdio: "inherit" });
} catch (err) {
if (err.status !== undefined) {
process.exit(err.status);
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 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);
}
if (!fs.existsSync(binaryPath)) {
return failMissingBinary();
}
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);
}
console.error(`Failed to run gitlink-cli: ${err.message}`);
process.exit(1);
}
if (require.main === module) {
run();
}
module.exports = {
getBinaryName,
getBinaryPath,
formatMissingBinaryError,
run,
};

BIN
npm/bin/gitlink-cli.exe Normal file

Binary file not shown.

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

175
npm/bin/uninstall.js Normal file
View File

@ -0,0 +1,175 @@
#!/usr/bin/env node
/**
* GitLink CLI 卸载命令
* 用法: gitlink-cli-uninstall [--purge]
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
// 颜色输出
const colors = {
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m',
blue: '\x1b[34m',
reset: '\x1b[0m'
};
function info(msg) {
console.log(`${colors.green}[INFO]${colors.reset} ${msg}`);
}
function warn(msg) {
console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
}
function error(msg) {
console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`);
}
function step(msg) {
console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`);
}
function ask(msg) {
console.log(`${colors.blue}[ASK]${colors.reset} ${msg}`);
}
// ---------- 删除文件/目录 ----------
function removeSync(target) {
try {
if (fs.existsSync(target)) {
const stat = fs.statSync(target);
if (stat.isDirectory()) {
fs.rmdirSync(target, { recursive: true });
return true;
} else {
fs.unlinkSync(target);
return true;
}
}
return false;
} catch (err) {
return false;
}
}
// ---------- 删除Skills ----------
function removeSkills() {
step('删除 Skills...');
const skillsDir = path.join(os.homedir(), '.gitlink', 'skills');
if (removeSync(skillsDir)) {
info('Skills已删除');
} else {
warn('Skills目录不存在或删除失败');
}
}
// ---------- 删除配置 ----------
function removeConfig(purgeAll = false) {
if (!purgeAll) {
info('保留配置文件');
return;
}
step('删除配置文件...');
const configPaths = [
path.join(os.homedir(), '.gitlink-cli'),
path.join(os.homedir(), '.config', 'gitlink-cli'),
path.join(os.homedir(), '.gitlink')
];
let removedCount = 0;
for (const configPath of configPaths) {
if (removeSync(configPath)) {
info(`已删除: ${configPath}`);
removedCount++;
}
}
if (removedCount > 0) {
info('配置文件已删除');
}
}
// ---------- 主流程 ----------
function main() {
const args = process.argv.slice(2);
const purgeAll = args.includes('--purge') || args.includes('-p');
const help = args.includes('--help') || args.includes('-h');
if (help) {
console.log('');
console.log('GitLink CLI 卸载命令');
console.log('');
console.log('用法: gitlink-cli-uninstall [选项]');
console.log('');
console.log('选项:');
console.log(' --purge, -p 删除所有文件(包括配置)');
console.log(' --help, -h 显示此帮助');
console.log('');
console.log('示例:');
console.log(' gitlink-cli-uninstall # 保留配置');
console.log(' gitlink-cli-uninstall --purge # 完全删除');
console.log('');
process.exit(0);
}
console.log('');
console.log('========================================');
info('GitLink CLI 卸载');
console.log('========================================');
console.log('');
step('卸载npm包...');
try {
// 执行npm uninstall
if (process.platform === 'win32') {
execSync('npm uninstall -g @gitlink-ai/cli', { stdio: 'inherit' });
} else {
execSync('npm uninstall -g @gitlink-ai/cli', { stdio: 'inherit' });
}
} catch (err) {
warn('npm卸载命令执行失败请手动运行: npm uninstall -g @gitlink-ai/cli');
}
// 删除skills
removeSkills();
// 删除配置
removeConfig(purgeAll);
console.log('');
console.log('========================================');
info('卸载完成!');
console.log('========================================');
console.log('');
if (!purgeAll) {
info('以下文件可能需要手动清理:');
console.log(` - ${path.join(os.homedir(), '.gitlink-cli')}`);
console.log(` - ${path.join(os.homedir(), '.gitlink')}`);
console.log('');
info('如需删除,请运行: gitlink-cli-uninstall --purge');
}
console.log('');
info('感谢使用 GitLink CLI');
console.log('');
}
// 运行
try {
main();
} catch (err) {
error(`卸载失败: ${err.message}`);
process.exit(1);
}

View File

@ -1,13 +1,17 @@
{
"name": "@gitlink-ai/cli",
"version": "0.1.13",
"version": "0.2.0",
"description": "GitLink 平台官方命令行工具 — 代码托管、协作开发和自动化",
"bin": {
"gitlink-cli": "bin/cli.js",
"gitlink-cli-install-skills": "bin/install-skills.js"
"gitlink-cli-install-skills": "bin/install-skills.js",
"gitlink-cli-uninstall": "bin/uninstall.js"
},
"scripts": {
"postinstall": "node scripts/install.js"
"postinstall": "node scripts/install.js",
"preuninstall": "node scripts/uninstall.js",
"uninstall": "node scripts/uninstall.js",
"test": "node test/install.test.js && node test/cli.test.js"
},
"keywords": [
"gitlink",

View File

@ -13,20 +13,14 @@ const PACKAGE = require("../package.json");
const VERSION = PACKAGE.version;
const BINARY_NAME = "gitlink-cli";
// GitLink release download (primary)
// 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";
// GitHub release download (fallback for users who cannot reach GitLink CDN)
const GITHUB_RELEASE_BASE = "https://github.com";
const GITHUB_REPO_OWNER = "ccfos";
const GITHUB_REPO_NAME = "gitlink-cli";
function getPlatformInfo() {
const platform = os.platform();
const arch = os.arch();
function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
const platformMap = {
darwin: "darwin",
linux: "linux",
@ -126,6 +120,7 @@ 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`;
@ -136,7 +131,6 @@ async function findReleaseAsset(platform, arch) {
// Find the release matching our version
let release = null;
const tagName = `v${VERSION}`;
if (Array.isArray(releases)) {
release = releases.find(
@ -183,9 +177,8 @@ async function findReleaseAsset(platform, arch) {
console.log(`Warning: Could not fetch release info: ${e.message}`);
}
// Fallback: download from GitHub releases
const tagName = `v${VERSION}`;
return `${GITHUB_RELEASE_BASE}/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/releases/download/${tagName}/${archiveName}`;
// Fallback: try direct download URL pattern
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
}
async function downloadAndExtract(url, destDir, platform) {
@ -238,9 +231,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)) {
@ -268,6 +267,12 @@ async function main() {
await downloadAndExtract(downloadUrl, binDir, platform);
} catch (err) {
console.error(`\nFailed to install ${BINARY_NAME}: ${err.message}`);
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` +
@ -278,4 +283,13 @@ async function main() {
}
}
main();
if (require.main === module) {
main();
}
module.exports = {
getPlatformInfo,
getBinaryName,
getArchiveName,
findReleaseAsset,
};

162
npm/scripts/uninstall.js Normal file
View File

@ -0,0 +1,162 @@
#!/usr/bin/env node
/**
* GitLink CLI npm 卸载脚本
* npm preuninstall 钩子自动运行
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
// 颜色输出(支持跨平台)
const colors = {
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m',
blue: '\x1b[34m',
reset: '\x1b[0m'
};
function info(msg) {
console.log(`${colors.green}[INFO]${colors.reset} ${msg}`);
}
function warn(msg) {
console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
}
function error(msg) {
console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`);
}
function step(msg) {
console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`);
}
// ---------- 删除文件/目录 ----------
function removeSync(target) {
try {
if (fs.existsSync(target)) {
const stat = fs.statSync(target);
if (stat.isDirectory()) {
fs.rmdirSync(target, { recursive: true });
info(`已删除目录: ${target}`);
} else {
fs.unlinkSync(target);
info(`已删除文件: ${target}`);
}
return true;
}
return false;
} catch (err) {
warn(`删除失败: ${target} - ${err.message}`);
return false;
}
}
// ---------- 删除Skills ----------
function removeSkills() {
step('删除 Skills...');
const skillsDir = path.join(os.homedir(), '.gitlink', 'skills');
if (!fs.existsSync(skillsDir)) {
warn('Skills目录不存在');
return;
}
// 统计skills数量
let skillCount = 0;
try {
const items = fs.readdirSync(skillsDir);
skillCount = items.filter(item => {
const itemPath = path.join(skillsDir, item);
return fs.statSync(itemPath).isDirectory();
}).length;
} catch (err) {
// 忽略错误
}
info(`找到 ${skillCount} 个Skills`);
if (removeSync(skillsDir)) {
info('Skills已删除');
}
}
// ---------- 删除配置(可选)----------
function removeConfig(purgeAll = false) {
if (!purgeAll) {
// npm卸载通常不删除配置
info('保留配置文件(用户数据)');
return;
}
step('删除配置文件...');
const configPaths = [
path.join(os.homedir(), '.gitlink-cli'),
path.join(os.homedir(), '.config', 'gitlink-cli'),
path.join(os.homedir(), '.gitlink')
];
let removedCount = 0;
for (const configPath of configPaths) {
if (removeSync(configPath)) {
removedCount++;
}
}
if (removedCount > 0) {
info('配置文件已删除');
} else {
warn('未找到配置文件');
}
}
// ---------- 主流程 ----------
function main() {
console.log('');
console.log('========================================');
info('GitLink CLI npm 卸载');
console.log('========================================');
console.log('');
// 检查环境变量
const purgeAll = process.env.GITLINK_UNINSTALL_PURGE === 'true' || process.argv.includes('--purge');
step('开始清理npm安装的文件...');
// 删除skills
removeSkills();
// 删除配置(如果指定--purge
removeConfig(purgeAll);
console.log('');
console.log('========================================');
info('卸载完成!');
console.log('========================================');
console.log('');
if (!purgeAll) {
info('以下文件可能需要手动清理:');
console.log(` - ${path.join(os.homedir(), '.gitlink-cli')}`);
console.log(` - ${path.join(os.homedir(), '.gitlink')}`);
console.log('');
info('如需删除,请运行: npm uninstall -g @gitlink-ai/cli --purge');
}
console.log('');
info('感谢使用 GitLink CLI');
console.log('');
}
// 运行
try {
main();
} catch (err) {
error(`卸载失败: ${err.message}`);
process.exit(1);
}

429
npm/scripts/update.js Normal file
View File

@ -0,0 +1,429 @@
#!/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://www.gitlink.org.cn";
const REPO_OWNER = "Gitlink";
const REPO_NAME = "gitlink-cli";
// 颜色输出
const colors = {
reset: "\x1b[0m",
green: "\x1b[32m",
yellow: "\x1b[33m",
red: "\x1b[31m",
cyan: "\x1b[36m",
blue: "\x1b[34m",
};
function info(msg) {
console.log(`${colors.green}[INFO]${colors.reset} ${msg}`);
}
function warn(msg) {
console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`);
}
function error(msg) {
console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`);
}
function step(msg) {
console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`);
}
function debug(msg) {
if (process.env.DEBUG === "true") {
console.log(`${colors.blue}[DEBUG]${colors.reset} ${msg}`);
}
}
function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap = {
x64: "amd64",
arm64: "arm64",
};
const goPlatform = platformMap[platform];
const goArch = archMap[arch];
if (!goPlatform || !goArch) {
throw new Error(
`Unsupported platform: ${platform}-${arch}. ` +
`Supported: darwin-x64, darwin-arm64, linux-x64, linux-arm64, win32-x64, win32-arm64`
);
}
return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" };
}
function getBinaryName(platform) {
return platform === "windows" ? `${BINARY_NAME}.exe` : BINARY_NAME;
}
function getArchiveName(platform, arch) {
const ext = platform === "windows" ? ".zip" : ".tar.gz";
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${ext}`;
}
function fetch(url, options = {}) {
return new Promise((resolve, reject) => {
const maxRedirects = options.maxRedirects || 5;
let redirectCount = 0;
function doRequest(currentUrl) {
const mod = currentUrl.startsWith("https") ? https : http;
const req = mod.get(currentUrl, (res) => {
// Follow redirects
if (
(res.statusCode === 301 ||
res.statusCode === 302 ||
res.statusCode === 307 ||
res.statusCode === 308) &&
res.headers.location
) {
redirectCount++;
if (redirectCount > maxRedirects) {
reject(new Error(`Too many redirects (max ${maxRedirects})`));
return;
}
let redirectUrl = res.headers.location;
if (redirectUrl.startsWith("/")) {
const parsed = new URL(currentUrl);
redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`;
}
doRequest(redirectUrl);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode} when downloading ${currentUrl}`));
return;
}
if (options.json) {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(body));
} catch (e) {
reject(e);
}
});
} else {
res.pipe(resolve);
}
});
req.on("error", reject);
req.setTimeout(options.timeout || 30000, () => {
req.destroy();
reject(new Error(`Request timeout: ${currentUrl}`));
});
}
doRequest(url);
});
}
// 下载文件(带重试)
async function downloadFile(url, outputPath, maxAttempts = 3) {
let attempt = 1;
while (attempt <= maxAttempts) {
try {
step(`下载 (尝试 ${attempt}/${maxAttempts}): ${path.basename(url)}`);
debug(`URL: ${url}`);
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(outputPath);
const mod = url.startsWith("https") ? https : http;
const req = mod.get(url, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
const totalSize = parseInt(res.headers["content-length"], 10);
let downloadedSize = 0;
res.on("data", (chunk) => {
downloadedSize += chunk.length;
if (totalSize) {
const progress = ((downloadedSize / totalSize) * 100).toFixed(1);
process.stdout.write(`\r下载进度: ${progress}%`);
}
});
res.pipe(file);
file.on("finish", () => {
file.close();
process.stdout.write("\r");
resolve();
});
file.on("error", (err) => {
fs.unlink(outputPath, () => {});
reject(err);
});
});
req.on("error", (err) => {
file.destroy();
fs.unlink(outputPath, () => {});
reject(err);
});
req.setTimeout(120000, () => {
req.destroy();
file.destroy();
fs.unlink(outputPath, () => {});
reject(new Error("下载超时"));
});
});
if (fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) {
const sizeMB = (fs.statSync(outputPath).size / (1024 * 1024)).toFixed(2);
info(`下载成功: ${path.basename(outputPath)} (${sizeMB}MB)`);
return true;
} else {
warn("下载文件为空");
}
} catch (err) {
warn(`下载失败: ${err.message}`);
}
if (attempt < maxAttempts) {
const waitTime = attempt * 2;
warn(`等待 ${waitTime}s 后重试...`);
await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
}
attempt++;
}
error("下载失败,已尝试 ${maxAttempts} 次");
return false;
}
// 获取最新版本
async function getLatestVersion() {
try {
step("检查更新...");
const releasesUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
const releases = await fetch(releasesUrl, { json: true, timeout: 30000 });
if (releases && releases.length > 0) {
return releases[0].tag_name;
}
} catch (err) {
debug(`获取版本失败: ${err.message}`);
}
return null;
}
// 检查是否有更新
async function checkForUpdate() {
try {
const latestVersion = await getLatestVersion();
if (!latestVersion) {
info("无法获取最新版本信息");
return;
}
const currentVersion = VERSION.startsWith("v") ? VERSION : `v${VERSION}`;
const latest = latestVersion.startsWith("v") ? latestVersion : `v${latestVersion}`;
info(`当前版本: ${currentVersion}`);
info(`最新版本: ${latest}`);
if (currentVersion === latest) {
info("已经是最新版本");
return;
}
// 简单的版本比较
if (latest > currentVersion) {
warn(`发现新版本: ${latest}`);
warn("运行 'npm update -g @gitlink-ai/cli' 更新");
} else if (latest < currentVersion) {
info("当前版本比最新发布版本更新(开发版本)");
}
} catch (err) {
debug(`检查更新失败: ${err.message}`);
}
}
// 安装二进制
async function installBinary() {
const platform = getPlatformInfo();
info(`平台: ${platform.platform}-${platform.arch}`);
const binaryName = getBinaryName(platform.platform);
const archiveName = getArchiveName(platform.platform, platform.arch);
const npmBinDir = path.dirname(process.execPath);
const installDir = path.join(npmBinDir, "..");
step(`安装二进制到: ${installDir}`);
const version = VERSION.startsWith("v") ? VERSION : `v${VERSION}`;
const binaryUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${archiveName}`;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlink-cli-"));
const archivePath = path.join(tmpDir, archiveName);
try {
// 下载
const success = await downloadFile(binaryUrl, archivePath);
if (!success) {
throw new Error("下载失败");
}
// 解压
step("解压...");
if (platform.isWindows) {
const AdmZip = require("adm-zip");
const zip = new AdmZip(archivePath);
zip.extractAllTo(installDir, true);
} else {
const tar = require("tar");
await tar.x({
file: archivePath,
cwd: installDir,
strip: 1,
});
}
// 设置执行权限Unix
if (!platform.isWindows) {
const binaryPath = path.join(installDir, binaryName);
if (fs.existsSync(binaryPath)) {
fs.chmodSync(binaryPath, "755");
}
}
info(`二进制安装成功: ${binaryName}`);
} finally {
// 清理临时文件
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
// 安装skills
async function installSkills() {
const version = VERSION.startsWith("v") ? VERSION : `v${VERSION}`;
const skillsArchive = `${BINARY_NAME}_${version}_skills.zip`;
const skillsUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${skillsArchive}`;
const skillsDir = path.join(os.homedir(), ".gitlink", "skills");
if (!fs.existsSync(skillsDir)) {
fs.mkdirSync(skillsDir, { recursive: true });
}
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlink-skills-"));
const archivePath = path.join(tmpDir, skillsArchive);
try {
step("安装 skills...");
const success = await downloadFile(skillsUrl, archivePath);
if (!success) {
warn("Skills 包下载失败(可稍后手动安装)");
return;
}
// 解压
const AdmZip = require("adm-zip");
const zip = new AdmZip(archivePath);
zip.extractAllTo(skillsDir, true);
info("Skills 安装成功");
} catch (err) {
warn(`Skills 安装失败: ${err.message}`);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
// 验证安装
function verifyInstallation() {
step("验证安装...";
try {
const result = execSync("gitlink-cli version", { encoding: "utf8" });
info(`安装成功! ${result.trim()}`);
return true;
} catch (err) {
warn("验证命令失败(可能需要重启终端)");
return false;
}
}
// 主函数
async function main() {
console.log("");
console.log(" ╔══════════════════════════════════════╗");
console.log(" ║ GitLink CLI npm 安装脚本 ║");
console.log(" ╚══════════════════════════════════════╝");
console.log("");
try {
await installBinary();
await installSkills();
verifyInstallation();
console.log("");
info("快速开始:");
info(" gitlink-cli auth login # 登录账号");
info(" gitlink-cli --help # 查看所有命令");
info(" gitlink-cli version # 查看版本信息");
console.log("");
} catch (err) {
error(`安装失败: ${err.message}`);
process.exit(1);
}
}
// 如果直接运行此脚本
if (require.main === module || process.argv[1].endsWith("update.js")) {
// 检查更新
if (process.argv.includes("--check")) {
(async () => {
await checkForUpdate();
})();
} else {
// 安装
(async () => {
await main();
})();
}
}
module.exports = {
main,
checkForUpdate,
installBinary,
installSkills,
};

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

@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>科研知识图谱 — 2026-07-06</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
.header { background: linear-gradient(135deg, #1a237e 0%, #3949ab 100%); color: #fff; padding: 36px 30px; }
.header h1 { font-size: 26px; margin-bottom: 6px; }
.header .subtitle { opacity: 0.8; font-size: 14px; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
.card { background: #fff; border-radius: 12px; padding: 18px; box-shadow: 0 2px 8px rgba(0,0,0,.08); text-align: center; }
.card .value { font-size: 32px; font-weight: 700; color: #1a237e; }
.card .label { font-size: 12px; color: #888; margin-top: 4px; }
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); margin-bottom: 24px; }
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
#graphChart { width: 100%; height: 600px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
th { background: #f5f7fa; color: #555; font-weight: 600; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; margin: 2px; }
.tag.rising { background: #e8f5e9; color: #2e7d32; }
.tag.stable { background: #e3f2fd; color: #1565c0; }
.tag.declining { background: #fce4ec; color: #c62828; }
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>科研知识图谱</h1>
<div class="subtitle">
关键词LLM,Agent &mdash;
仓库4 个 &mdash;
贡献者0 人 &mdash;
2026-07-06
</div>
</div>
<div class="container">
<div class="cards">
<div class="card"><div class="value">4</div><div class="label">仓库节点</div></div>
<div class="card"><div class="value">0</div><div class="label">贡献者节点</div></div>
<div class="card"><div class="value">2</div><div class="label">主题节点</div></div>
<div class="card"><div class="value">3</div><div class="label">关系边</div></div>
<div class="card"><div class="value">N/A</div><div class="label">最热仓库</div></div>
</div>
<div class="panel">
<h2>知识图谱 — 力导向布局</h2>
<div id="graphChart"></div>
</div>
<div class="panel">
<h2>热度排行榜</h2>
<table id="hotnessTable">
<thead><tr><th>排名</th><th>仓库</th><th>热度</th><th>语言</th><th>Stars</th><th>趋势</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="footer">Generated by GitLink Research Assistant — 2026-07-06</div>
<script>
var graph = echarts.init(document.getElementById('graphChart'));
graph.setOption({
tooltip: {
formatter: function(p) {
if (p.dataType === 'edge') return p.data.source + ' → ' + p.data.target + '<br/>' + p.data.evidence;
var d = p.data;
return '<b>' + d.label + '</b><br/>' + (d.desc || '') + '<br/>' +
(d.stars ? 'Stars: ' + d.stars : '') + (d.repo_count ? ' 关联仓库: ' + d.repo_count : '');
}
},
legend: [{
data: ['仓库', '贡献者', '主题', '论文', '组织'],
orient: 'vertical', right: 10, top: 20
}],
series: [{
type: 'graph',
layout: 'force',
roam: true,
draggable: true,
force: {
repulsion: 200,
edgeLength: [80, 300],
layoutAnimation: true
},
categories: [
{ name: '仓库', itemStyle: { color: '#5470c6' }, symbol: 'roundRect' },
{ name: '贡献者', itemStyle: { color: '#91cc75' }, symbol: 'circle' },
{ name: '主题', itemStyle: { color: '#fac858' }, symbol: 'diamond' },
{ name: '论文', itemStyle: { color: '#ee6666' }, symbol: 'triangle' },
{ name: '组织', itemStyle: { color: '#73c0de' }, symbol: 'pin' }
],
data: [{"id":"topic:llm","type":"topic","label":"LLM\n","symbolSize":30,"category":2},{"id":"topic:agent","type":"topic","label":"Agent\n","symbolSize":30,"category":2}],
links: [{"source":"repo:agent","target":"repo:ribo-agent","type":"related_to","weight":0.5,"evidence":"共同主题: Agent\n"},{"source":"repo:doutrip","target":"repo:agent","type":"related_to","weight":0.5,"evidence":"共同主题: Agent\n"},{"source":"repo:ribo-agent","target":"repo:wow-agent","type":"related_to","weight":0.5,"evidence":"共同主题: Agent\n"}],
label: { show: true, fontSize: 11, formatter: '{b}' },
emphasis: { focus: 'adjacency', label: { fontSize: 14 } },
lineStyle: { color: '#ccc', curveness: 0.1 }
}]
});
window.addEventListener('resize', function() { graph.resize(); });
</script>
</body>
</html>

View File

@ -0,0 +1,51 @@
{
"metadata": {
"generated_at": "2026-07-06T11:51:35+08:00",
"search_keywords": [
"LLM",
"Agent"
],
"total_repos_scanned": 4,
"total_contributors_found": 0,
"total_edges_inferred": 3
},
"nodes": [
{
"id": "topic:llm",
"type": "topic",
"label": "LLM\n",
"symbolSize": 30,
"category": 2
},
{
"id": "topic:agent",
"type": "topic",
"label": "Agent\n",
"symbolSize": 30,
"category": 2
}
],
"edges": [
{
"source": "repo:agent",
"target": "repo:ribo-agent",
"type": "related_to",
"weight": 0.5,
"evidence": "共同主题: Agent\n"
},
{
"source": "repo:doutrip",
"target": "repo:agent",
"type": "related_to",
"weight": 0.5,
"evidence": "共同主题: Agent\n"
},
{
"source": "repo:ribo-agent",
"target": "repo:wow-agent",
"type": "related_to",
"weight": 0.5,
"evidence": "共同主题: Agent\n"
}
]
}

View File

@ -0,0 +1,170 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>zzx-coder/gitlink-cli — 复现性评分卡</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
.header { background: linear-gradient(135deg, #1a237e 0%, #3949ab 100%); color: #fff; padding: 40px 30px; }
.header h1 { font-size: 26px; margin-bottom: 6px; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; margin-bottom: 24px; }
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
.chart { width: 100%; height: 350px; }
.grade-circle { text-align: center; padding: 20px; }
.grade-letter { font-size: 72px; font-weight: 900; }
.grade-A { color: #2e7d32; }
.grade-B { color: #558b2f; }
.grade-C { color: #f57c00; }
.grade-D { color: #e65100; }
.grade-F { color: #c62828; }
.grade-score { font-size: 24px; color: #888; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
th { background: #f5f7fa; color: #555; }
.bar { height: 8px; border-radius: 4px; background: #e0e0e0; margin-top: 4px; }
.bar-fill { height: 100%; border-radius: 4px; }
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
@media (max-width: 768px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<h1>zzx-coder/gitlink-cli — 科研复现性评分卡</h1>
<div style="opacity:0.8;font-size:14px;">2026-07-06</div>
</div>
<div class="container">
<div class="row">
<div class="panel grade-circle">
<div class="grade-letter grade-F">F</div>
<div class="grade-score">5.0 / 100</div>
<div style="margin-top:12px;color:#888;">
差 — 几乎不可复现
</div>
</div>
<div class="panel">
<h2>维度雷达图</h2>
<div id="radarChart" class="chart"></div>
</div>
<div class="panel">
<h2>维度明细</h2>
<table>
<tr><th>维度</th><th>评分</th><th>权重</th></tr>
<tr><td>许可证</td><td>0%</td><td>15%</td></tr>
<tr><td>无密钥/PII</td><td>0%</td><td>15%</td></tr>
<tr><td>README 完整</td><td>0%</td><td>15%</td></tr>
<tr><td>依赖声明</td><td>0%</td><td>15%</td></tr>
<tr><td>构建说明</td><td>50%</td><td>10%</td></tr>
<tr><td>CI 配置</td><td>0%</td><td>10%</td></tr>
<tr><td>测试证据</td><td>0%</td><td>10%</td></tr>
<tr><td>数据可用性</td><td>0%</td><td>10%</td></tr>
</table>
</div>
</div>
<div class="panel">
<h2>详细评估与改进建议</h2>
<table>
<tr><th>维度</th><th>评分</th><th>证据</th><th>建议</th></tr>
<tr>
<td>许可证</td>
<td></td>
<td>未扫描(无本地仓库)</td>
<td>建议添加 MIT/Apache-2.0/GPL-3.0 许可证</td>
</tr>
<tr>
<td>无密钥/PII</td>
<td>⚠️</td>
<td>未扫描(无本地仓库)</td>
<td>立即移除泄露的密钥,使用环境变量管理敏感信息</td>
</tr>
<tr>
<td>README 完整</td>
<td></td>
<td>README 缺失或过于简略</td>
<td>补充项目目的、安装、使用、许可和引用章节</td>
</tr>
<tr>
<td>依赖声明</td>
<td></td>
<td>无依赖声明</td>
<td>添加 package.json/go.mod/requirements.txt 等标准依赖文件</td>
</tr>
<tr>
<td>构建说明</td>
<td>⚠️</td>
<td>部分构建说明</td>
<td>添加 Makefile/Dockerfile + README 中的构建步骤</td>
</tr>
<tr>
<td>CI 配置</td>
<td></td>
<td>无 CI 配置</td>
<td>配置 GitLink CI 或 GitHub Actions 自动构建和测试</td>
</tr>
<tr>
<td>测试证据</td>
<td></td>
<td>无测试证据</td>
<td>添加单元测试和集成测试,在 README 中说明如何运行</td>
</tr>
<tr>
<td>数据可用性</td>
<td></td>
<td>无数据可用性声明</td>
<td>说明数据集来源,提供 Zenodo/Figshare 链接或生成脚本</td>
</tr>
</table>
</div>
</div>
<div class="footer">Generated by GitLink Research Assistant — 2026-07-06</div>
<script>
var radarChart = echarts.init(document.getElementById('radarChart'));
radarChart.setOption({
radar: {
indicator: [
{ name: '许可证', max: 100 },
{ name: '无密钥', max: 100 },
{ name: 'README', max: 100 },
{ name: '依赖', max: 100 },
{ name: '构建', max: 100 },
{ name: 'CI', max: 100 },
{ name: '测试', max: 100 },
{ name: '数据', max: 100 }
],
center: ['50%', '55%'],
radius: '70%'
},
series: [{
type: 'radar',
data: [{
value: [
0,
0,
0,
0,
50,
0,
0,
0
],
name: '复现性',
areaStyle: { color: 'rgba(57,73,171,0.3)' },
lineStyle: { color: '#3949ab' }
}]
}]
});
</script>
</body>
</html>

View File

@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>zzx-coder/gitlink-cli — 科研项目洞察报告</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
.header { background: linear-gradient(135deg, #1a237e 0%, #283593 50%, #3949ab 100%); color: #fff; padding: 40px 30px; }
.header h1 { font-size: 28px; margin-bottom: 8px; }
.header .subtitle { opacity: 0.85; font-size: 14px; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.card .label { font-size: 12px; color: #888; text-transform: uppercase; margin-bottom: 6px; }
.card .value { font-size: 28px; font-weight: 700; }
.card .value.hot { color: #e53935; }
.card .value.warm { color: #f57c00; }
.card .value.cool { color: #1565c0; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
.chart { width: 100%; height: 350px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; }
th { background: #f5f7fa; color: #555; font-weight: 600; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; margin: 2px; }
.tag.lang { background: #e3f2fd; color: #1565c0; }
.tag.research { background: #e8f5e9; color: #2e7d32; }
.tag.warn { background: #fff3e0; color: #e65100; }
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
@media (max-width: 768px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<h1>zzx-coder/gitlink-cli</h1>
<div class="subtitle">科研项目洞察报告 &mdash; 2026-07-06</div>
</div>
<div class="container">
<div class="cards">
<div class="card">
<div class="label">热度评分</div>
<div class="value hot">54.3</div>
<div class="label">Hot</div>
</div>
<div class="card">
<div class="label">Stars</div>
<div class="value">0</div>
</div>
<div class="card">
<div class="label">Forks</div>
<div class="value">0</div>
</div>
<div class="card">
<div class="label">贡献者</div>
<div class="value">3</div>
</div>
<div class="card">
<div class="label">开放 Issues</div>
<div class="value">44</div>
</div>
<div class="card">
<div class="label">PR 合并率</div>
<div class="value">50.0%</div>
</div>
</div>
<div class="row">
<div class="panel">
<h2>项目概况</h2>
<table>
<tr><th>项目名称</th><td>gitlink-cli</td></tr>
<tr><th>描述</th><td>No description</td></tr>
<tr><th>主要语言</th><td><span class="tag lang">Unknown</span></td></tr>
<tr><th>技术栈</th><td><span class="tag lang">Unknown</span></td></tr>
<tr><th>创建时间</th><td></td></tr>
<tr><th>最后更新</th><td> (365 天前)</td></tr>
<tr><th>科研特征</th><td></td></tr>
</table>
</div>
<div class="panel">
<h2>活动概览</h2>
<div id="activityChart" class="chart"></div>
</div>
</div>
<div class="row">
<div class="panel">
<h2>健康指标</h2>
<table>
<tr><th>指标</th><th>数值</th><th>状态</th></tr>
<tr><td>Issue 总量</td><td>44 开放 / 44 已关闭</td><td><span class="tag warn">需关注</span></td></tr>
<tr><td>PR 合并率</td><td>50.0%</td><td><span class="tag warn">需改进</span></td></tr>
<tr><td>Release 数</td><td>6</td><td><span class="tag research">已发布</span></td></tr>
<tr><td>CI 通过率</td><td>0% (0 次构建)</td><td><span class="tag warn">不稳定</span></td></tr>
<tr><td>贡献者数</td><td>3 人</td><td><span class="tag warn">单人项目</span></td></tr>
<tr><td>活跃度</td><td>365 天前更新</td><td><span class="tag warn">不活跃</span></td></tr>
</table>
</div>
<div class="panel">
<h2>热度构成</h2>
<div id="hotnessChart" class="chart"></div>
</div>
</div>
</div>
<div class="footer">Generated by GitLink Research Assistant &mdash; 2026-07-06</div>
<script>
var hotnessChart = echarts.init(document.getElementById('hotnessChart'));
hotnessChart.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [{
type: 'pie',
radius: ['45%', '75%'],
label: { formatter: '{b}\n{d}%' },
data: [
{ name: 'Stars', value: 0.0, itemStyle: { color: '#5470c6' } },
{ name: 'Forks', value: 0.0, itemStyle: { color: '#91cc75' } },
{ name: 'Issues', value: 88.0, itemStyle: { color: '#fac858' } },
{ name: 'PRs', value: 133.3, itemStyle: { color: '#ee6666' } },
{ name: 'Releases', value: 60.0, itemStyle: { color: '#73c0de' } },
{ name: 'Recency', value: 10, itemStyle: { color: '#fc8452' } }
]
}]
});
var activityChart = echarts.init(document.getElementById('activityChart'));
activityChart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: ['Issues', 'PRs', 'Releases', 'CI Builds'] },
yAxis: { type: 'value' },
series: [
{ name: '开放/进行中', type: 'bar', data: [44, 20, 0, 0], itemStyle: { color: '#fac858' } },
{ name: '已完成', type: 'bar', data: [44, 20, 6, 0], itemStyle: { color: '#91cc75' } }
]
});
</script>
</body>
</html>

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "gitlink-cli",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@ -1 +0,0 @@
PR Test 2026年 4月 7日 星期二 11时45分56秒 CST

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

@ -17,10 +17,6 @@ const RELEASE_BASE = "https://www.gitlink.org.cn";
const REPO_OWNER = "Gitlink";
const REPO_NAME = "gitlink-cli";
const GITHUB_RELEASE_BASE = "https://github.com";
const GITHUB_REPO_OWNER = "ccfos";
const GITHUB_REPO_NAME = "gitlink-cli";
function getPlatformInfo() {
const platform = os.platform();
const arch = os.arch();
@ -34,18 +30,6 @@ function getPlatformInfo() {
return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" };
}
function getBinaryName(isWindows) {
return isWindows ? BINARY_NAME + ".exe" : BINARY_NAME;
}
function getArchiveExt(isWindows) {
return isWindows ? ".zip" : ".tar.gz";
}
function getArchiveName(platform, arch, isWindows) {
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${getArchiveExt(isWindows)}`;
}
function fetch(url, options = {}) {
return new Promise((resolve, reject) => {
const maxRedirects = options.maxRedirects || 5;
@ -81,14 +65,14 @@ function fetch(url, options = {}) {
}
});
req.on("error", reject);
req.setTimeout(60000, () => { req.destroy(); reject(new Error("Timeout")); });
req.setTimeout(30000, () => { req.destroy(); reject(new Error("Timeout")); });
}
doRequest(url);
});
}
async function findReleaseAsset(platform, arch, isWindows) {
const archiveName = getArchiveName(platform, arch, isWindows);
async function findReleaseAsset(platform, arch) {
const archiveName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`;
const tagName = `v${VERSION}`;
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
@ -101,7 +85,7 @@ async function findReleaseAsset(platform, arch, isWindows) {
if (release && release.attachments) {
let asset = release.attachments.find(a => a.title === archiveName || a.filename === archiveName);
if (!asset) {
const pattern = `_${platform}_${arch}${getArchiveExt(isWindows)}`;
const pattern = `_${platform}_${arch}.tar.gz`;
asset = release.attachments.find(a => (a.title || a.filename || "").endsWith(pattern));
}
if (asset) {
@ -110,53 +94,36 @@ async function findReleaseAsset(platform, arch, isWindows) {
return url;
}
}
} catch (e) {
console.log(`Warning: GitLink API failed: ${e.message}, trying GitHub...`);
}
} catch (e) {}
return `${GITHUB_RELEASE_BASE}/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/releases/download/${tagName}/${archiveName}`;
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
}
async function downloadAndExtract(url, destDir, isWindows) {
console.log(`Downloading ${BINARY_NAME} from ${url}...`);
async function downloadAndExtract(url, destDir, platform) {
const data = await fetch(url);
console.log(`Downloaded ${(data.length / 1024 / 1024).toFixed(1)} MB`);
const archivePath = path.join(destDir, "download" + getArchiveExt(isWindows));
const archivePath = path.join(destDir, "download.tar.gz");
fs.writeFileSync(archivePath, data);
if (isWindows) {
execSync(
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${destDir}'"`,
{ stdio: "pipe" }
);
} else {
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
}
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
fs.unlinkSync(archivePath);
const binaryName = getBinaryName(isWindows);
const binaryPath = path.join(destDir, binaryName);
const binaryPath = path.join(destDir, BINARY_NAME);
if (!fs.existsSync(binaryPath)) {
const files = fs.readdirSync(destDir);
for (const file of files) {
const subPath = path.join(destDir, file, binaryName);
const subPath = path.join(destDir, file, BINARY_NAME);
if (fs.existsSync(subPath)) { fs.renameSync(subPath, binaryPath); break; }
}
}
if (!fs.existsSync(binaryPath)) throw new Error(`Binary "${binaryName}" not found after extraction`);
if (!isWindows) fs.chmodSync(binaryPath, 0o755);
if (!fs.existsSync(binaryPath)) throw new Error("Binary not found after extraction");
fs.chmodSync(binaryPath, 0o755);
}
async function main() {
const { platform, arch, isWindows } = getPlatformInfo();
console.log(`Platform: ${platform}-${arch}`);
const { platform, arch } = getPlatformInfo();
const binDir = path.join(__dirname, "..", "bin");
if (!fs.existsSync(binDir)) { fs.mkdirSync(binDir, { recursive: true }); }
const binaryPath = path.join(binDir, getBinaryName(isWindows));
const binaryPath = path.join(binDir, BINARY_NAME);
if (fs.existsSync(binaryPath)) {
try {
const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 });
@ -164,24 +131,19 @@ async function main() {
console.log(`${BINARY_NAME} v${VERSION} already installed.`);
return;
}
console.log(`Version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`);
} catch (e) {}
fs.unlinkSync(binaryPath);
}
try {
const downloadUrl = await findReleaseAsset(platform, arch, isWindows);
await downloadAndExtract(downloadUrl, binDir, isWindows);
console.log(`${BINARY_NAME} v${VERSION} installed successfully.`);
const downloadUrl = await findReleaseAsset(platform, arch);
await downloadAndExtract(downloadUrl, binDir, platform);
console.log(`${BINARY_NAME} v${VERSION} installed.`);
} catch (err) {
console.error(`\n Failed to install ${BINARY_NAME}: ${err.message}`);
console.error(
`\n You can install manually:\n` +
` 1. Download from https://www.gitlink.org.cn/${REPO_OWNER}/${REPO_NAME}/releases\n` +
` 2. Extract and place the binary in: ${binDir}\n` +
` 3. Or retry: npm run postinstall\n`
);
process.exit(1);
// Don't fail npm install — binary can be installed later
console.warn(`${BINARY_NAME} binary download failed: ${err.message}`);
console.warn(` Skills are installed. You can install the binary manually later:`);
console.warn(` npm run postinstall`);
}
}

View File

@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"WebFetch(domain:apifox.com)",
"WebSearch",
"WebFetch(domain:gitlink.org.cn)",
"WebFetch(domain:www.gitlink.org.cn)",
"Bash(sed 's/^//')",
"Bash(sed 's/ *$//')",
"Bash(grep -h \"CallAPI\\\\|\\\\.Do\\(\" C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/*/*.go C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/*/*/*.go)",
"Bash(go build:*)",
"Bash(./gitlink-cli.exe file:*)",
"Bash(./gitlink-cli.exe issue:*)",
"Bash(./gitlink-cli.exe milestone:*)",
"Bash(./gitlink-cli.exe pr:*)",
"Bash(go vet:*)",
"Bash(go test:*)",
"Bash(cd:*)"
]
}
}

832
shortcuts/board/board.go Normal file
View File

@ -0,0 +1,832 @@
package board
import (
"fmt"
"net/url"
"sort"
"strconv"
"strings"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// === 状态/优先级常量 ===
const (
statusNew = 1
statusInProgress = 2
statusResolved = 3
statusClosed = 5
statusRejected = 6
)
const (
priorityLow = 1
priorityNormal = 2
priorityHigh = 3
priorityUrgent = 4
)
// 标准看板列顺序(按 status_id 排列)
var columnOrder = []struct {
ID int
Name string
}{
{statusNew, "待处理"},
{statusInProgress, "进行中"},
{statusResolved, "已解决"},
{statusClosed, "已关闭"},
{statusRejected, "已拒绝"},
}
// === 内部数据结构 ===
// issueItem 从 issue list API 解析的单条 issue
type issueItem struct {
ID int `json:"id"`
Subject string `json:"subject"`
StatusID int `json:"status_id"`
StatusName string `json:"status_name"`
PriorityID int `json:"priority_id"`
PriorityName string `json:"priority_name"`
ProjectIndex int `json:"project_issues_index"`
Assigners []struct {
Login string `json:"login"`
Name string `json:"name"`
} `json:"assigners"`
}
// === 输出结构 ===
type boardView struct {
Repository string `json:"repository"`
TotalIssues int `json:"total_issues"`
Columns []columnView `json:"columns"`
}
type columnView struct {
StatusID int `json:"status_id"`
StatusName string `json:"status_name"`
IssueCount int `json:"issue_count"`
Issues []issueBrief `json:"issues"`
}
type issueBrief struct {
Number int `json:"number"`
ID int `json:"id"`
Subject string `json:"subject"`
Priority string `json:"priority"`
AssignedTo string `json:"assigned_to,omitempty"`
}
type boardStats struct {
Repository string `json:"repository"`
TotalIssues int `json:"total_issues"`
CompletionRate float64 `json:"completion_rate"`
ColumnBreakdown []columnStat `json:"column_breakdown"`
AssigneeLoad []assigneeStat `json:"assignee_load"`
Bottleneck string `json:"bottleneck"`
}
type columnStat struct {
StatusName string `json:"status_name"`
Count int `json:"count"`
Percentage float64 `json:"percentage"`
}
type assigneeStat struct {
Assignee string `json:"assignee"`
Count int `json:"count"`
}
// === 辅助函数 ===
// v1RepoPath 返回 v1 API 路径前缀
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
// fetchAllIssues 获取所有 issue分页获取全部
func fetchAllIssues(ctx *common.RuntimeContext) ([]issueItem, error) {
var allIssues []issueItem
page := 1
for {
q := url.Values{}
q.Set("state", "all")
q.Set("page", strconv.Itoa(page))
q.Set("limit", "100")
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return nil, clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
break
}
issuesRaw, ok := data["issues"].([]interface{})
if !ok || len(issuesRaw) == 0 {
break
}
for _, raw := range issuesRaw {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
item := issueItem{
ID: getInt(m, "id"),
Subject: getString(m, "subject"),
StatusID: getInt(m, "status_id"),
StatusName: getString(m, "status_name"),
PriorityID: getInt(m, "priority_id"),
PriorityName: getString(m, "priority_name"),
ProjectIndex: getInt(m, "project_issues_index"),
}
// 解析 assigners
if assigners, ok := m["assigners"].([]interface{}); ok {
for _, a := range assigners {
if am, ok := a.(map[string]interface{}); ok {
login := getString(am, "login")
if login == "" {
login = getString(am, "name")
}
item.Assigners = append(item.Assigners, struct {
Login string `json:"login"`
Name string `json:"name"`
}{Login: login, Name: getString(am, "name")})
}
}
}
allIssues = append(allIssues, item)
}
// 检查是否还有下一页
totalCount := getInt(data, "total_count")
if totalCount == 0 {
totalCount = getInt(data, "total_issues_count")
}
if len(allIssues) >= totalCount || len(issuesRaw) < 100 {
break
}
page++
}
return allIssues, nil
}
// getString 从 map 中安全获取字符串
func getString(m map[string]interface{}, key string) string {
v, _ := m[key].(string)
return v
}
// getInt 从 map 中安全获取整数
func getInt(m map[string]interface{}, key string) int {
switch v := m[key].(type) {
case float64:
return int(v)
case int:
return v
default:
return 0
}
}
// extractAssignee 提取第一个指派人
func extractAssignee(assigners []struct {
Login string `json:"login"`
Name string `json:"name"`
}) string {
if len(assigners) == 0 {
return ""
}
if assigners[0].Login != "" {
return assigners[0].Login
}
return assigners[0].Name
}
// parseStatusID 状态名转 ID
func parseStatusID(s string) (int, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "new", "待处理":
return statusNew, nil
case "in-progress", "in_progress", "inprogress", "进行中":
return statusInProgress, nil
case "resolved", "已解决":
return statusResolved, nil
case "closed", "已关闭":
return statusClosed, nil
case "rejected", "已拒绝":
return statusRejected, nil
default:
if id, err := strconv.Atoi(s); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid status %q: use new, in-progress, resolved, closed, or rejected", s)
}
}
// statusIDToName 状态 ID 转名称
func statusIDToName(id int) string {
for _, c := range columnOrder {
if c.ID == id {
return c.Name
}
}
return fmt.Sprintf("status_%d", id)
}
// parsePriorityID 优先级名转 ID
func parsePriorityID(p string) (int, error) {
switch strings.ToLower(strings.TrimSpace(p)) {
case "low":
return priorityLow, nil
case "normal":
return priorityNormal, nil
case "high":
return priorityHigh, nil
case "urgent":
return priorityUrgent, nil
default:
if id, err := strconv.Atoi(p); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid priority %q: use low, normal, high, or urgent", p)
}
}
// priorityName 优先级 ID 转名称
func priorityName(id int) string {
switch id {
case priorityLow:
return "low"
case priorityNormal:
return "normal"
case priorityHigh:
return "high"
case priorityUrgent:
return "urgent"
default:
return fmt.Sprintf("%d", id)
}
}
// fetchExistingIssue 获取现有 issue 的 subject 和 description
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (subject, description string, err error) {
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return "", "", clierrors.OpError(clierrors.KindNotFound, "view", "issue", err).WithCommand(ctx.CommandName)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("failed to parse issue data")
}
subject, _ = data["subject"].(string)
if subject == "" {
return "", "", fmt.Errorf("issue #%s: missing subject field", number)
}
description, _ = data["description"].(string)
return subject, description, nil
}
// resolveUserID 把用户名转换成用户 ID
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
if id, err := strconv.Atoi(login); err == nil {
return id, nil
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
if err != nil {
return nil, fmt.Errorf("lookup user %q: %w", login, err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response for user %q", login)
}
if idFloat, ok := data["id"].(float64); ok {
return int(idFloat), nil
}
if userIDFloat, ok := data["user_id"].(float64); ok {
return int(userIDFloat), nil
}
return nil, fmt.Errorf("cannot determine user ID for %q", login)
}
// groupByStatus 将 issues 按 status_id 分组
func groupByStatus(issues []issueItem) map[int][]issueItem {
grouped := make(map[int][]issueItem)
for _, iss := range issues {
grouped[iss.StatusID] = append(grouped[iss.StatusID], iss)
}
return grouped
}
// === Shortcuts 入口 ===
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newViewShortcut(),
newColumnsShortcut(),
newIssuesShortcut(),
newMoveShortcut(),
newAssignShortcut(),
newStatsShortcut(),
}
}
// === board +view ===
func newViewShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "view",
Description: "View kanban board layout",
Long: "Display the kanban board with issues grouped by status columns.",
Example: " gitlink-cli board +view\n gitlink-cli board +view --state open",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
state := ctx.Arg("state")
if state == "" {
state = "open"
}
// 获取 issues
var allIssues []issueItem
page := 1
for {
q := url.Values{}
q.Set("state", state)
q.Set("page", strconv.Itoa(page))
q.Set("limit", "100")
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
break
}
issuesRaw, ok := data["issues"].([]interface{})
if !ok || len(issuesRaw) == 0 {
break
}
for _, raw := range issuesRaw {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
item := issueItem{
ID: getInt(m, "id"),
Subject: getString(m, "subject"),
StatusID: getInt(m, "status_id"),
StatusName: getString(m, "status_name"),
PriorityID: getInt(m, "priority_id"),
PriorityName: getString(m, "priority_name"),
ProjectIndex: getInt(m, "project_issues_index"),
}
if assigners, ok := m["assigners"].([]interface{}); ok {
for _, a := range assigners {
if am, ok := a.(map[string]interface{}); ok {
login := getString(am, "login")
if login == "" {
login = getString(am, "name")
}
item.Assigners = append(item.Assigners, struct {
Login string `json:"login"`
Name string `json:"name"`
}{Login: login, Name: getString(am, "name")})
}
}
}
allIssues = append(allIssues, item)
}
totalCount := getInt(data, "total_count")
if totalCount == 0 {
totalCount = getInt(data, "total_issues_count")
}
if len(allIssues) >= totalCount || len(issuesRaw) < 100 {
break
}
page++
}
// 按 status 分组
grouped := groupByStatus(allIssues)
// 构建看板视图
view := boardView{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
TotalIssues: len(allIssues),
}
for _, col := range columnOrder {
issues := grouped[col.ID]
cv := columnView{
StatusID: col.ID,
StatusName: col.Name,
IssueCount: len(issues),
Issues: make([]issueBrief, 0, len(issues)),
}
for _, iss := range issues {
cv.Issues = append(cv.Issues, issueBrief{
Number: iss.ProjectIndex,
ID: iss.ID,
Subject: iss.Subject,
Priority: priorityName(iss.PriorityID),
AssignedTo: extractAssignee(iss.Assigners),
})
}
view.Columns = append(view.Columns, cv)
}
return ctx.OutputData(view)
},
}
}
// === board +columns ===
func newColumnsShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "columns",
Description: "List status columns with issue counts",
Example: " gitlink-cli board +columns",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
state := ctx.Arg("state")
if state == "" {
state = "open"
}
issues, err := fetchAllIssuesWithState(ctx, state)
if err != nil {
return err
}
grouped := groupByStatus(issues)
type colInfo struct {
StatusID int `json:"status_id"`
StatusName string `json:"status_name"`
IssueCount int `json:"issue_count"`
}
var columns []colInfo
for _, col := range columnOrder {
columns = append(columns, colInfo{
StatusID: col.ID,
StatusName: col.Name,
IssueCount: len(grouped[col.ID]),
})
}
return ctx.OutputData(columns)
},
}
}
// fetchAllIssuesWithState 带状态过滤的全量 issue 获取
func fetchAllIssuesWithState(ctx *common.RuntimeContext, state string) ([]issueItem, error) {
var allIssues []issueItem
page := 1
for {
q := url.Values{}
q.Set("state", state)
q.Set("page", strconv.Itoa(page))
q.Set("limit", "100")
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return nil, clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
break
}
issuesRaw, ok := data["issues"].([]interface{})
if !ok || len(issuesRaw) == 0 {
break
}
for _, raw := range issuesRaw {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
item := issueItem{
ID: getInt(m, "id"),
Subject: getString(m, "subject"),
StatusID: getInt(m, "status_id"),
StatusName: getString(m, "status_name"),
PriorityID: getInt(m, "priority_id"),
PriorityName: getString(m, "priority_name"),
ProjectIndex: getInt(m, "project_issues_index"),
}
if assigners, ok := m["assigners"].([]interface{}); ok {
for _, a := range assigners {
if am, ok := a.(map[string]interface{}); ok {
login := getString(am, "login")
if login == "" {
login = getString(am, "name")
}
item.Assigners = append(item.Assigners, struct {
Login string `json:"login"`
Name string `json:"name"`
}{Login: login, Name: getString(am, "name")})
}
}
}
allIssues = append(allIssues, item)
}
totalCount := getInt(data, "total_count")
if totalCount == 0 {
totalCount = getInt(data, "total_issues_count")
}
if len(allIssues) >= totalCount || len(issuesRaw) < 100 {
break
}
page++
}
return allIssues, nil
}
// === board +issues ===
func newIssuesShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "issues",
Description: "List issues with optional filters",
Long: "List issues with filtering by status, assignee, and priority.",
Example: " gitlink-cli board +issues\n gitlink-cli board +issues --status in-progress --assignee zhangsan\n gitlink-cli board +issues --priority high --limit 10",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
{Name: "status", Usage: "Filter by status: new/in-progress/resolved/closed/rejected"},
{Name: "assignee", Short: "a", Usage: "Filter by assignee login"},
{Name: "priority", Short: "p", Usage: "Filter by priority: low/normal/high/urgent"},
{Name: "limit", Short: "l", Usage: "Max issues to show (0 = all)", Default: "0"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
state := ctx.Arg("state")
if state == "" {
state = "open"
}
issues, err := fetchAllIssuesWithState(ctx, state)
if err != nil {
return err
}
// 解析过滤条件
filterStatus := ctx.Arg("status")
filterAssignee := ctx.Arg("assignee")
filterPriority := ctx.Arg("priority")
limit, _ := strconv.Atoi(ctx.Arg("limit"))
var statusIDFilter int
if filterStatus != "" {
statusIDFilter, err = parseStatusID(filterStatus)
if err != nil {
return err
}
}
var priorityIDFilter int
if filterPriority != "" {
priorityIDFilter, err = parsePriorityID(filterPriority)
if err != nil {
return err
}
}
var filtered []map[string]interface{}
for _, iss := range issues {
if statusIDFilter != 0 && iss.StatusID != statusIDFilter {
continue
}
if filterAssignee != "" {
assignee := extractAssignee(iss.Assigners)
if !strings.EqualFold(assignee, filterAssignee) {
continue
}
}
if priorityIDFilter != 0 && iss.PriorityID != priorityIDFilter {
continue
}
filtered = append(filtered, map[string]interface{}{
"number": iss.ProjectIndex,
"id": iss.ID,
"subject": iss.Subject,
"status": iss.StatusName,
"priority": priorityName(iss.PriorityID),
"assigned_to": extractAssignee(iss.Assigners),
})
}
if limit > 0 && len(filtered) > limit {
filtered = filtered[:limit]
}
if filtered == nil {
filtered = []map[string]interface{}{}
}
return ctx.OutputData(filtered)
},
}
}
// === board +move ===
func newMoveShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "move",
Description: "Move an issue to a different status",
Long: "Change issue status. Accepts status names (new/in-progress/resolved/closed/rejected) or numeric IDs.",
Example: " gitlink-cli board +move --number 42 --status in-progress\n gitlink-cli board +move --number 42 --status closed --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("Move issue #%s to status %q", ctx.Arg("number"), ctx.Arg("status")), nil
},
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (project_issues_index)", Required: true},
{Name: "status", Short: "s", Usage: "Target status: new/in-progress/resolved/closed/rejected (or numeric ID)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
statusStr, err := ctx.RequireArg("status", "--status in-progress")
if err != nil {
return err
}
statusID, err := parseStatusID(statusStr)
if err != nil {
return err
}
subject, description, err := fetchExistingIssue(ctx, number)
if err != nil {
return err
}
body := map[string]interface{}{
"subject": subject,
"description": description,
"status_id": statusID,
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "move", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
}
}
// === board +assign ===
func newAssignShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "assign",
Description: "Assign an issue to someone",
Long: "Assign an issue to a user by login name or user ID.",
Example: " gitlink-cli board +assign --number 42 --assignee zhangsan\n gitlink-cli board +assign --number 42 --assignee 123 --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("Assign issue #%s to %s", ctx.Arg("number"), ctx.Arg("assignee")), nil
},
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (project_issues_index)", Required: true},
{Name: "assignee", Short: "a", Usage: "Assignee login name or user ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
assignee, err := ctx.RequireArg("assignee", "--assignee zhangsan")
if err != nil {
return err
}
assigneeID, err := resolveUserID(ctx, assignee)
if err != nil {
return fmt.Errorf("cannot resolve assignee %q: %w", assignee, err)
}
subject, description, err := fetchExistingIssue(ctx, number)
if err != nil {
return err
}
body := map[string]interface{}{
"subject": subject,
"description": description,
"assigner_ids": []interface{}{assigneeID},
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "assign", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
}
}
// === board +stats ===
func newStatsShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "stats",
Description: "Show board analytics and statistics",
Long: "Display completion rate, workload distribution, and bottleneck analysis.",
Example: " gitlink-cli board +stats\n gitlink-cli board +stats --state all",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "all"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
state := ctx.Arg("state")
if state == "" {
state = "all"
}
issues, err := fetchAllIssuesWithState(ctx, state)
if err != nil {
return err
}
grouped := groupByStatus(issues)
total := len(issues)
// 统计每人 issue 数量
assigneeCounts := make(map[string]int)
for _, iss := range issues {
a := extractAssignee(iss.Assigners)
if a == "" {
a = "(unassigned)"
}
assigneeCounts[a]++
}
// 完成率 = resolved + closed / total
completed := len(grouped[statusResolved]) + len(grouped[statusClosed])
completionRate := 0.0
if total > 0 {
completionRate = float64(completed) / float64(total) * 100
}
// 列统计
var colBreakdown []columnStat
for _, col := range columnOrder {
count := len(grouped[col.ID])
pct := 0.0
if total > 0 {
pct = float64(count) / float64(total) * 100
}
colBreakdown = append(colBreakdown, columnStat{
StatusName: col.Name,
Count: count,
Percentage: pct,
})
}
// 人员负载(降序)
var assigneeLoad []assigneeStat
for name, count := range assigneeCounts {
assigneeLoad = append(assigneeLoad, assigneeStat{Assignee: name, Count: count})
}
sort.Slice(assigneeLoad, func(i, j int) bool {
return assigneeLoad[i].Count > assigneeLoad[j].Count
})
// 瓶颈:非完成态中任务最多的列
bottleneck := ""
maxCount := 0
for _, col := range columnOrder[:3] { // 只看 new/in-progress/resolved
count := len(grouped[col.ID])
if count > maxCount {
maxCount = count
bottleneck = col.Name
}
}
stats := boardStats{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
TotalIssues: total,
CompletionRate: completionRate,
ColumnBreakdown: colBreakdown,
AssigneeLoad: assigneeLoad,
Bottleneck: bottleneck,
}
if stats.AssigneeLoad == nil {
stats.AssigneeLoad = []assigneeStat{}
}
return ctx.OutputData(stats)
},
}
}

View File

@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut {
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
if err != nil {
return err
return fmt.Errorf("获取分支列表失败: %w", err)
}
return ctx.Output(env)
},
@ -33,6 +33,15 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a branch",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
from := ctx.Arg("from")
if from == "" {
from = "master"
}
return fmt.Sprintf("Create branch: %s (from %s)", name, from), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"},
@ -41,7 +50,10 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name")
name, err := ctx.RequireArg("name", "--name feature/new-thing")
if err != nil {
return err
}
from := ctx.Arg("from")
if from == "" {
from = "master"
@ -52,7 +64,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload)
if err != nil {
return err
return fmt.Errorf("创建分支失败: %w", err)
}
return ctx.Output(env)
},
@ -60,6 +72,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "delete",
Description: "Delete a branch",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
return fmt.Sprintf("Delete branch: %s", name), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -67,13 +84,16 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name")
name, err := ctx.RequireArg("name", "--name feature/new-thing")
if err != nil {
return err
}
payload := map[string]interface{}{
"branch_name": name,
}
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
if err != nil {
return err
return fmt.Errorf("删除分支失败: %w", err)
}
return ctx.Output(env)
},
@ -81,6 +101,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "protect",
Description: "Set branch protection",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
return fmt.Sprintf("Protect branch: %s", name), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -88,13 +113,16 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name")
name, err := ctx.RequireArg("name", "--name feature/new-thing")
if err != nil {
return err
}
payload := map[string]interface{}{
"branch_name": name,
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
if err != nil {
return err
return fmt.Errorf("设置分支保护失败: %w", err)
}
return ctx.Output(env)
},
@ -102,6 +130,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "unprotect",
Description: "Remove branch protection",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
return fmt.Sprintf("Unprotect branch: %s", name), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
},
@ -109,11 +142,14 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
name, err := ctx.RequireArg("name", "--name feature/new-thing")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
if err != nil {
return fmt.Errorf("取消分支保护失败: %w", err)
}
return ctx.Output(env)
},
},

View File

@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut {
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
if err != nil {
return err
return fmt.Errorf("获取 CI 构建列表失败: %w", err)
}
return ctx.Output(env)
},
@ -42,7 +42,10 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
build, _ := ctx.RequireArg("build")
build, err := ctx.RequireArg("build", "--build 42")
if err != nil {
return err
}
stage := ctx.Arg("stage")
step := ctx.Arg("step")
if stage == "" {
@ -53,7 +56,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil)
if err != nil {
return err
return fmt.Errorf("获取构建日志失败: %w", err)
}
return ctx.Output(env)
},
@ -61,6 +64,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "restart",
Description: "Restart a build",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
build := ctx.Arg("build")
return fmt.Sprintf("Restart build #%s", build), nil
},
Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true},
},
@ -68,17 +76,25 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
build, _ := ctx.RequireArg("build")
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
build, err := ctx.RequireArg("build", "--build 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
if err != nil {
return fmt.Errorf("重启构建失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "stop",
Description: "Stop a build",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
build := ctx.Arg("build")
return fmt.Sprintf("Stop build #%s", build), nil
},
Flags: []common.Flag{
{Name: "build", Short: "b", Usage: "Build number", Required: true},
},
@ -86,11 +102,14 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
build, _ := ctx.RequireArg("build")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
build, err := ctx.RequireArg("build", "--build 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
if err != nil {
return fmt.Errorf("停止构建失败: %w", err)
}
return ctx.Output(env)
},
},

View File

@ -0,0 +1,68 @@
package common
import (
"errors"
"github.com/gitlink-org/gitlink-cli/internal/client"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
// TryPrintError 尝试把 error 转换为 envelope 格式并输出到 stdout。
// 返回 true 表示已识别并处理false 表示该 error 类型不被识别,应由调用方走原有路径。
//
// 设计意图:让 shortcut 的错误处理路径与 api 命令对齐,
// 使 `--format json` 输出可被 jq 解析的标准 envelope
//
// {"ok":false, "error":{"code":N, "message":"...", "suggestion":"..."}}
//
// 支持的 error 类型:
// - *client.APIError : HTTP 错误404/403/401 等code = HTTP 状态码
// - *clierrors.CLIError : 业务级错误(输入/认证/网络等code 由 kind 映射
//
// 注:放在 common 包(而非 output 包)以避免与 client 包形成导入循环。
func TryPrintError(err error, format string) bool {
if err == nil {
return false
}
var apiErr *client.APIError
if errors.As(err, &apiErr) {
env := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion)
_ = output.Print(env, format)
return true
}
var cliErr *clierrors.CLIError
if errors.As(err, &cliErr) {
env := output.ErrorEnvelope(kindToCode(cliErr.Kind), cliErr.Message, cliErr.Suggestion)
_ = output.Print(env, format)
return true
}
return false
}
// kindToCode 把 CLIError.Kind 映射到近似的 HTTP 状态码,用于 envelope.error.code 字段
func kindToCode(kind clierrors.ErrorKind) int {
switch kind {
case clierrors.KindAuth:
return 401
case clierrors.KindInput:
return 400
case clierrors.KindNotFound:
return 404
case clierrors.KindForbidden:
return 403
case clierrors.KindNetwork:
return 503
case clierrors.KindServer:
return 500
case clierrors.KindConfig:
return 500
case clierrors.KindGit:
return 500
default:
return 500
}
}

View File

@ -1,17 +1,52 @@
package common
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
)
// MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand.
func MountShortcut(parent *cobra.Command, s *Shortcut) {
cmd := &cobra.Command{
Use: "+" + s.Name,
Short: s.Description,
Use: "+" + s.Name,
Short: s.Description,
Long: s.Long,
Example: s.Example,
RunE: func(cmd *cobra.Command, args []string) error {
commandName := parent.Use + " +" + s.Name
// --- flag-level validation ---
for _, f := range s.Flags {
val := getFlagValue(cmd, f)
// choices enum validation
if len(f.Choices) > 0 && val != "" && val != "false" {
if !contains(f.Choices, val) {
return clierrors.InputError(
fmt.Sprintf("invalid value %q for --%s", val, f.Name),
fmt.Sprintf("有效值: %s。运行 'gitlink-cli %s --help' 查看用法。", strings.Join(f.Choices, ", "), commandName),
).WithCommand(commandName)
}
}
// custom validate function
if f.Validate != nil && val != "" {
if err := f.Validate(val); err != nil {
return clierrors.InputError(
fmt.Sprintf("invalid --%s: %v", f.Name, err),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
}
// Collect flag values
flagValues := make(map[string]string)
for _, f := range s.Flags {
@ -26,36 +61,104 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
}
}
ctx, err := NewRuntimeContext(flagValues)
// cross-flag validation
if s.Validate != nil {
if err := s.Validate(flagValues); err != nil {
return clierrors.InputError(
err.Error(),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
ctx, err := NewRuntimeContext(flagValues, commandName)
if err != nil {
return err
}
return s.Run(ctx)
// Dry-run interception
if s.DryRun {
if dryRunVal, _ := cmd.Flags().GetBool("dry-run"); dryRunVal {
flagValues["dry-run"] = "true"
if s.DryRunHint != nil {
hint, err := s.DryRunHint(ctx)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "[dry-run] %s\n", hint)
}
proceed, err := ConfirmAction(ctx)
if err != nil {
return err
}
if !proceed {
return nil
}
}
}
err = s.Run(ctx)
if err != nil {
// 当错误为已识别的 API/CLI 错误时,按 envelope 格式输出到 stdout
// 让 `--format json` 输出可被 jq 解析的标准结构。
// 已识别后返回 ErrSilent保留非零退出码但 cmd.Execute 不会再 stderr 重复输出。
if TryPrintError(err, ctx.Format) {
return cmdutil.ErrSilent
}
}
return err
},
}
for _, f := range s.Flags {
usage := f.Usage
if f.Required {
usage = usage + " [required]"
}
if len(f.Choices) > 0 {
usage = usage + " [" + strings.Join(f.Choices, "|") + "]"
}
if f.Bool {
defaultValue, _ := strconv.ParseBool(f.Default)
if f.Short != "" {
cmd.Flags().BoolP(f.Name, f.Short, defaultValue, f.Usage)
cmd.Flags().BoolP(f.Name, f.Short, defaultValue, usage)
} else {
cmd.Flags().Bool(f.Name, defaultValue, f.Usage)
cmd.Flags().Bool(f.Name, defaultValue, usage)
}
} else if f.Short != "" {
cmd.Flags().StringP(f.Name, f.Short, f.Default, f.Usage)
cmd.Flags().StringP(f.Name, f.Short, f.Default, usage)
} else {
cmd.Flags().String(f.Name, f.Default, f.Usage)
}
if f.Required {
cmd.MarkFlagRequired(f.Name)
cmd.Flags().String(f.Name, f.Default, usage)
}
}
// Auto-register --dry-run flag for shortcuts that support it
if s.DryRun {
cmd.Flags().Bool("dry-run", false, "Preview the operation without executing")
}
parent.AddCommand(cmd)
}
// getFlagValue returns the string value of a flag from a cobra command.
func getFlagValue(cmd *cobra.Command, f Flag) string {
if f.Bool {
val, _ := cmd.Flags().GetBool(f.Name)
return strconv.FormatBool(val)
}
val, _ := cmd.Flags().GetString(f.Name)
return val
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// MountShortcuts mounts multiple shortcuts under a parent command.
func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut) {
for _, s := range shortcuts {

View File

@ -1,13 +1,19 @@
package common
import (
"bufio"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/context"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
@ -15,7 +21,12 @@ import (
type Shortcut struct {
Name string
Description string
Long string // detailed help text (shown in --help)
Example string // usage examples (shown in --help)
Flags []Flag
DryRun bool // 是否支持 dry-run
DryRunHint func(ctx *RuntimeContext) (string, error) // 返回预览描述
Validate func(args map[string]string) error // cross-flag validation
Run func(ctx *RuntimeContext) error
}
@ -27,19 +38,27 @@ type Flag struct {
Required bool
Default string
Bool bool
Choices []string // allowed values (enum validation)
Validate func(value string) error // custom per-flag validation
}
// RuntimeContext provides helpers for shortcut implementations.
type RuntimeContext struct {
Client *client.Client
Owner string
Repo string
Format string
Args map[string]string
Client *client.Client
Owner string
Repo string
Format string
CommandName string
Args map[string]string
GatewayBaseURL string
GatewayHTTPClient *http.Client // optional; nil = use auth.NewHTTPClient (mainly for tests)
NoTruncate bool // --no-truncate: disable table column truncation
Columns string // --columns: comma-separated column filter
NoColor bool // --no-color: disable ANSI color output
}
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeContext, error) {
cli, err := client.New()
if err != nil {
return nil, err
@ -48,15 +67,26 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
format := cmdutil.Format
if format == "" {
format = "json"
format = "table"
}
gatewayBaseURL := config.DefaultGatewayBaseURL
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
gatewayBaseURL = cfg.GatewayBaseURL
}
return &RuntimeContext{
Client: cli,
Owner: cmdutil.Owner,
Repo: cmdutil.Repo,
Format: format,
Args: args,
Client: cli,
Owner: cmdutil.Owner,
Repo: cmdutil.Repo,
Format: format,
CommandName: commandName,
Args: args,
GatewayBaseURL: gatewayBaseURL,
GatewayHTTPClient: nil,
NoTruncate: cmdutil.NoTruncate,
Columns: cmdutil.Columns,
NoColor: cmdutil.NoColor,
}, nil
}
@ -88,12 +118,31 @@ func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.R
// Output prints the envelope in the configured format.
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
return output.Print(env, ctx.Format)
return output.PrintWithOpts(env, ctx.Format, ctx.printOpts())
}
// OutputData wraps data in a success envelope and prints it.
func (ctx *RuntimeContext) OutputData(data interface{}) error {
return output.Print(output.SuccessEnvelope(data, nil), ctx.Format)
return output.PrintWithOpts(output.SuccessEnvelope(data, nil), ctx.Format, ctx.printOpts())
}
// printOpts builds PrintOptions from the runtime context.
func (ctx *RuntimeContext) printOpts() output.PrintOptions {
opts := output.PrintOptions{
NoTruncate: ctx.NoTruncate,
UseColor: !ctx.NoColor,
}
if ctx.Columns != "" {
parts := strings.Split(ctx.Columns, ",")
opts.Columns = make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
opts.Columns = append(opts.Columns, p)
}
}
}
return opts
}
// RepoPath returns the API path prefix for the current owner/repo.
@ -109,11 +158,39 @@ func (ctx *RuntimeContext) Arg(name string) string {
return ""
}
// RequireArg returns a flag value or an error if not set.
func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
// RequireArg returns a flag value or a CLIError if not set.
func (ctx *RuntimeContext) RequireArg(name, example string) (string, error) {
v := ctx.Arg(name)
if v == "" {
return "", fmt.Errorf("required flag --%s is missing", name)
suggestion := fmt.Sprintf("请提供 --%s 参数", name)
if example != "" {
suggestion += fmt.Sprintf(",例如:%s", example)
}
return "", clierrors.InputError(
fmt.Sprintf("required flag --%s is missing", name),
suggestion,
).WithCommand(ctx.CommandName)
}
return v, nil
}
// IsDryRun checks the --dry-run flag.
func (ctx *RuntimeContext) IsDryRun() bool {
return ctx.Arg("dry-run") == "true"
}
// ConfirmAction prompts the user for confirmation when --dry-run is set.
func ConfirmAction(ctx *RuntimeContext) (bool, error) {
if !ctx.IsDryRun() {
return true, nil
}
fmt.Fprint(os.Stderr, "\nProceed? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "y" || answer == "yes" {
return true, nil
}
fmt.Fprintln(os.Stderr, "Aborted.")
return false, nil
}

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

@ -0,0 +1,427 @@
package file
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// v1RepoPath returns /v1/{owner}/{repo}
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
// === 浏览类 ===
{
Name: "ls",
Description: "List files in root directory",
Flags: []common.Flag{
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/entries", q)
if err != nil {
return fmt.Errorf("获取目录列表失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "tree",
Description: "Show subdirectory or file details",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File or directory path", Required: true},
{Name: "ref", 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", "--path src/main.go")
if err != nil {
return err
}
q := url.Values{}
q.Set("filepath", filepath)
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
if err != nil {
return fmt.Errorf("获取路径详情失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "read",
Description: "Read file content",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "ref", 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", "--path README.md")
if err != nil {
return err
}
q := url.Values{}
q.Set("filepath", filepath)
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
if err != nil {
return fmt.Errorf("读取文件失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "readme",
Description: "Read README file",
Flags: []common.Flag{
{Name: "path", Usage: "Subdirectory path for nested README"},
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if p := ctx.Arg("path"); p != "" {
q.Set("filepath", p)
}
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/readme", q)
if err != nil {
return fmt.Errorf("读取 README 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "search",
Description: "Search files by name",
Flags: []common.Flag{
{Name: "q", Short: "q", Usage: "Search keyword"},
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if kw := ctx.Arg("q"); kw != "" {
q.Set("search", kw)
}
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q)
if err != nil {
return fmt.Errorf("搜索文件失败: %w", err)
}
return ctx.Output(env)
},
},
// === 文件 CRUD ===
{
Name: "create",
Description: "Create a new file",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("创建文件 %s", ctx.Arg("path")), nil
},
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "content", Short: "c", Usage: "File content (plain text)", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "new-branch", Usage: "Create on a new branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filepath, err := ctx.RequireArg("path", "--path docs/new.md")
if err != nil {
return err
}
content, err := ctx.RequireArg("content", `--content "# Hello"`)
if err != nil {
return err
}
branch, err := ctx.RequireArg("branch", "--branch master")
if err != nil {
return err
}
message, err := ctx.RequireArg("message", `--message "add new file"`)
if err != nil {
return err
}
body := map[string]interface{}{
"filepath": filepath,
"base64_filepath": base64.StdEncoding.EncodeToString([]byte(filepath)),
"branch": branch,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
}
if nb := ctx.Arg("new-branch"); nb != "" {
body["new_branch"] = nb
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an existing file",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新文件 %s", ctx.Arg("path")), nil
},
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "content", Short: "c", Usage: "New file content (plain text)", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
{Name: "sha", Usage: "File SHA (auto-fetched if omitted)"},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "new-branch", Usage: "Create on a new branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filepath, err := ctx.RequireArg("path", "--path README.md")
if err != nil {
return err
}
content, err := ctx.RequireArg("content", `--content "updated content"`)
if err != nil {
return err
}
branch, err := ctx.RequireArg("branch", "--branch master")
if err != nil {
return err
}
message, err := ctx.RequireArg("message", `--message "update file"`)
if err != nil {
return err
}
// Auto-fetch sha if not provided
sha := ctx.Arg("sha")
if sha == "" {
sha, err = fetchFileSha(ctx, filepath, branch)
if err != nil {
return fmt.Errorf("自动获取文件 SHA 失败,请用 --sha 手动指定: %w", err)
}
}
body := map[string]interface{}{
"filepath": filepath,
"branch": branch,
"content": content,
"sha": sha,
"message": message,
}
if nb := ctx.Arg("new-branch"); nb != "" {
body["new_branch"] = nb
}
env, err := ctx.CallAPI("PUT", ctx.RepoPath()+"/update_file", body)
if err != nil {
return fmt.Errorf("更新文件失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a file",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除文件 %s", ctx.Arg("path")), nil
},
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
{Name: "sha", Usage: "File SHA (auto-fetched if omitted)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filepath, err := ctx.RequireArg("path", "--path old-file.txt")
if err != nil {
return err
}
branch, err := ctx.RequireArg("branch", "--branch master")
if err != nil {
return err
}
// Auto-fetch sha if not provided
sha := ctx.Arg("sha")
if sha == "" {
sha, err = fetchFileSha(ctx, filepath, branch)
if err != nil {
return fmt.Errorf("自动获取文件 SHA 失败,请用 --sha 手动指定: %w", err)
}
}
body := map[string]interface{}{
"filepath": filepath,
"branch": branch,
"sha": sha,
}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body)
if err != nil {
return fmt.Errorf("删除文件失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "batch",
Description: "Batch create/update/delete files in one commit",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("批量提交文件到分支 %s", ctx.Arg("branch")), nil
},
Flags: []common.Flag{
{Name: "branch", Short: "b", Usage: "Target branch", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "files", Short: "f", Usage: "JSON array: [{\"action_type\":\"create\",\"file_path\":\"x\",\"content\":\"y\"}]", Required: true},
{Name: "new-branch", Usage: "Create on a new branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
branch, err := ctx.RequireArg("branch", "--branch master")
if err != nil {
return err
}
message, err := ctx.RequireArg("message", `--message "batch update"`)
if err != nil {
return err
}
filesJSON, err := ctx.RequireArg("files", `--files '[{"action_type":"create","file_path":"a.txt","content":"hello"}]'`)
if err != nil {
return err
}
var files []map[string]interface{}
if err := json.Unmarshal([]byte(filesJSON), &files); err != nil {
return fmt.Errorf("--files JSON 解析失败: %w", err)
}
body := map[string]interface{}{
"branch": branch,
"message": message,
"files": files,
}
if nb := ctx.Arg("new-branch"); nb != "" {
body["new_branch"] = nb
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/contents/batch", body)
if err != nil {
return fmt.Errorf("批量提交失败: %w", err)
}
return ctx.Output(env)
},
},
// === Git 对象 ===
{
Name: "commits",
Description: "List commit history",
Flags: []common.Flag{
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
{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"))
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/commits", q)
if err != nil {
return fmt.Errorf("获取提交历史失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "diff",
Description: "Show diff of a commit",
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", "--sha abc1234")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/commits/%s/diff", v1RepoPath(ctx), sha), nil)
if err != nil {
return fmt.Errorf("获取 diff 失败: %w", err)
}
return ctx.Output(env)
},
},
}
}
// fetchFileSha retrieves the current SHA of a file via sub_entries API.
func fetchFileSha(ctx *common.RuntimeContext, filepath, ref string) (string, error) {
q := url.Values{}
q.Set("filepath", filepath)
if ref != "" {
q.Set("ref", ref)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
if err != nil {
return "", err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("响应格式异常")
}
entries, ok := data["entries"].(map[string]interface{})
if !ok {
// Try flat structure
if sha, ok := data["sha"].(string); ok {
return sha, nil
}
return "", fmt.Errorf("响应中未找到 entries 或 sha 字段")
}
sha, _ := entries["sha"].(string)
if sha == "" {
return "", fmt.Errorf("文件 SHA 为空")
}
return sha, nil
}

459
shortcuts/file/file_test.go Normal file
View File

@ -0,0 +1,459 @@
package file
import (
"encoding/base64"
"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"
)
// === 浏览类测试 ===
func TestLsCallsEntriesEndpoint(t *testing.T) {
var requestedPath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"entries": []interface{}{
map[string]interface{}{"name": "README.md", "type": "file"},
},
})
})
defer server.Close()
err := runFileShortcut(t, server, "ls", map[string]string{})
if err != nil {
t.Fatalf("ls failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/entries.json")
}
func TestLsWithRef(t *testing.T) {
var requestedRef string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedRef = r.URL.Query().Get("ref")
writeJSON(t, w, map[string]interface{}{"entries": []interface{}{}})
})
defer server.Close()
err := runFileShortcut(t, server, "ls", map[string]string{"ref": "develop"})
if err != nil {
t.Fatalf("ls with ref failed: %v", err)
}
assertEqual(t, requestedRef, "develop")
}
func TestTreeCallsSubEntriesWithFilePath(t *testing.T) {
var requestedPath, requestedFilepath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedFilepath = r.URL.Query().Get("filepath")
writeJSON(t, w, map[string]interface{}{
"entries": map[string]interface{}{
"name": "main.go", "type": "file", "sha": "abc123",
},
})
})
defer server.Close()
err := runFileShortcut(t, server, "tree", map[string]string{"path": "src/main.go"})
if err != nil {
t.Fatalf("tree failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/sub_entries.json")
assertEqual(t, requestedFilepath, "src/main.go")
}
func TestReadCallsSubEntriesWithFilePath(t *testing.T) {
var requestedFilepath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedFilepath = r.URL.Query().Get("filepath")
writeJSON(t, w, map[string]interface{}{
"entries": map[string]interface{}{
"name": "main.go", "content": "package main", "sha": "abc123",
},
})
})
defer server.Close()
err := runFileShortcut(t, server, "read", map[string]string{"path": "main.go"})
if err != nil {
t.Fatalf("read failed: %v", err)
}
assertEqual(t, requestedFilepath, "main.go")
}
func TestReadmeCallsReadmeEndpoint(t *testing.T) {
var requestedPath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"name": "README.md", "content": "# Hello",
})
})
defer server.Close()
err := runFileShortcut(t, server, "readme", map[string]string{})
if err != nil {
t.Fatalf("readme failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/readme.json")
}
func TestSearchCallsFilesWithKeyword(t *testing.T) {
var requestedPath, requestedSearch string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedSearch = r.URL.Query().Get("search")
writeJSON(t, w, []interface{}{
map[string]interface{}{"name": "test.go"},
})
})
defer server.Close()
err := runFileShortcut(t, server, "search", map[string]string{"q": "test"})
if err != nil {
t.Fatalf("search failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/files.json")
assertEqual(t, requestedSearch, "test")
}
// === 文件 CRUD 测试 ===
func TestCreateEncodesContentBase64(t *testing.T) {
var payload map[string]interface{}
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"name": "new.txt", "sha": "abc123",
})
})
defer server.Close()
err := runFileShortcut(t, server, "create", map[string]string{
"path": "docs/new.txt",
"content": "hello world",
"branch": "master",
"message": "add file",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
// Verify content is base64 encoded
encodedContent, ok := payload["content"].(string)
if !ok {
t.Fatal("content should be a string")
}
decoded, err := base64.StdEncoding.DecodeString(encodedContent)
if err != nil {
t.Fatalf("content is not valid base64: %v", err)
}
assertEqual(t, string(decoded), "hello world")
// Verify filepath is base64 encoded
encodedPath, ok := payload["base64_filepath"].(string)
if !ok {
t.Fatal("base64_filepath should be a string")
}
decodedPath, err := base64.StdEncoding.DecodeString(encodedPath)
if err != nil {
t.Fatalf("base64_filepath is not valid base64: %v", err)
}
assertEqual(t, string(decodedPath), "docs/new.txt")
assertEqual(t, payload["filepath"], "docs/new.txt")
assertEqual(t, payload["branch"], "master")
assertEqual(t, payload["message"], "add file")
}
func TestUpdateAutoFetchesSha(t *testing.T) {
var updatePayload map[string]interface{}
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/sub_entries.json":
writeJSON(t, w, map[string]interface{}{
"entries": map[string]interface{}{
"name": "README.md", "sha": "old-sha-123",
},
})
case r.Method == "PUT" && r.URL.Path == "/owner/repo/update_file.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(1), "message": "更新成功"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runFileShortcut(t, server, "update", map[string]string{
"path": "README.md",
"content": "updated content",
"branch": "master",
"message": "update readme",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
assertEqual(t, updatePayload["sha"], "old-sha-123")
assertEqual(t, updatePayload["content"], "updated content")
assertEqual(t, updatePayload["filepath"], "README.md")
}
func TestUpdateUsesProvidedSha(t *testing.T) {
var updatePayload map[string]interface{}
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PUT" {
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(1)})
}
})
defer server.Close()
err := runFileShortcut(t, server, "update", map[string]string{
"path": "README.md",
"content": "new content",
"branch": "master",
"sha": "manual-sha",
"message": "update",
})
if err != nil {
t.Fatalf("update with sha failed: %v", err)
}
assertEqual(t, updatePayload["sha"], "manual-sha")
}
func TestDeleteAutoFetchesSha(t *testing.T) {
var deletePayload map[string]interface{}
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
writeJSON(t, w, map[string]interface{}{
"entries": map[string]interface{}{
"name": "old.txt", "sha": "file-sha-456",
},
})
case r.Method == "DELETE":
deletePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(1), "message": "文件删除成功"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runFileShortcut(t, server, "delete", map[string]string{
"path": "old.txt",
"branch": "master",
})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
assertEqual(t, deletePayload["sha"], "file-sha-456")
assertEqual(t, deletePayload["filepath"], "old.txt")
assertEqual(t, deletePayload["branch"], "master")
}
func TestBatchSendsFilesArray(t *testing.T) {
var payload map[string]interface{}
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"commit": map[string]interface{}{"sha": "abc"},
})
})
defer server.Close()
err := runFileShortcut(t, server, "batch", map[string]string{
"branch": "master",
"message": "batch commit",
"files": `[{"action_type":"create","file_path":"a.txt","content":"hello"}]`,
})
if err != nil {
t.Fatalf("batch failed: %v", err)
}
files, ok := payload["files"].([]interface{})
if !ok {
t.Fatalf("files should be array, got %T", payload["files"])
}
assertEqual(t, len(files), 1)
assertEqual(t, payload["branch"], "master")
assertEqual(t, payload["message"], "batch commit")
}
func TestBatchRejectsInvalidJSON(t *testing.T) {
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, map[string]interface{}{})
})
defer server.Close()
err := runFileShortcut(t, server, "batch", map[string]string{
"branch": "master",
"message": "test",
"files": "not-json",
})
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}
// === Git 对象测试 ===
func TestCommitsCallsV1Endpoint(t *testing.T) {
var requestedPath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, []interface{}{
map[string]interface{}{"sha": "abc", "message": "test"},
})
})
defer server.Close()
err := runFileShortcut(t, server, "commits", map[string]string{})
if err != nil {
t.Fatalf("commits failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/commits.json")
}
func TestCommitsPassesPagination(t *testing.T) {
var page, limit string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
page = r.URL.Query().Get("page")
limit = r.URL.Query().Get("limit")
writeJSON(t, w, []interface{}{})
})
defer server.Close()
err := runFileShortcut(t, server, "commits", map[string]string{"page": "3", "limit": "10"})
if err != nil {
t.Fatalf("commits with pagination failed: %v", err)
}
assertEqual(t, page, "3")
assertEqual(t, limit, "10")
}
func TestDiffCallsCommitDiffEndpoint(t *testing.T) {
var requestedPath string
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"files": []interface{}{},
})
})
defer server.Close()
err := runFileShortcut(t, server, "diff", map[string]string{"sha": "abc1234"})
if err != nil {
t.Fatalf("diff failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/commits/abc1234/diff.json")
}
// === 必填参数验证 ===
func TestCreateRequiresPath(t *testing.T) {
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runFileShortcut(t, server, "create", map[string]string{
"content": "hello", "branch": "master", "message": "test",
})
if err == nil {
t.Fatal("expected error for missing --path")
}
}
func TestTreeRequiresPath(t *testing.T) {
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runFileShortcut(t, server, "tree", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --path")
}
}
func TestDiffRequiresSha(t *testing.T) {
server := newFileTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runFileShortcut(t, server, "diff", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --sha")
}
}
// === helpers ===
func runFileShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findFileShortcut(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 findFileShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("file shortcut %q not found", name)
return nil
}
func newFileTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
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("decode JSON failed: %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("write JSON failed: %v", err)
}
}
func assertEqual(t *testing.T, got, 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)
}
}
func assertPath(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Fatalf("path: got %s, want %s", got, want)
}
}

BIN
shortcuts/gitlink-cli.exe Normal file

Binary file not shown.

View File

@ -1,47 +1,564 @@
package issue
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"encoding/csv" // CSV 文件解析,用于从文件读取 Issue 编号
"fmt" // 格式化输出,用于构建字符串和错误信息
"os" // 文件操作,用于打开 CSV 文件
"strconv" // 字符串和数字之间的转换
"strings" // 字符串处理,用于分割、修剪等操作
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 公共工具包,包含 Shortcut、RuntimeContext 等
)
const closedIssueStatusID = 5
// === 优先级常量 ===
// priorityLow: 低优先级
// priorityNormal: 普通优先级(默认)
// priorityHigh: 高优先级
// priorityUrgent: 紧急优先级
const (
priorityLow = 1
priorityNormal = 2
priorityHigh = 3
priorityUrgent = 4
)
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"`
// === 状态常量 ===
// statusNew: 新建
// statusInProgress: 进行中
// statusResolved: 已解决
// statusClosed: 已关闭
// statusRejected: 已拒绝
const (
statusNew = 1
statusInProgress = 2
statusResolved = 3
statusClosed = 5
statusRejected = 6
)
// === 类型常量Tracker===
// trackerBug: 缺陷
// trackerFeature: 功能
// trackerSupport: 支持
// trackerDoc: 文档
// trackerTest: 测试
// trackerDuplicate: 重复
// trackerQuestion: 疑问
const (
trackerBug = 1
trackerFeature = 2
trackerSupport = 3
trackerDoc = 4
trackerTest = 5
trackerDuplicate = 6
trackerQuestion = 7
)
// === 名称映射表 ===
// priorityNames: 优先级数字 ID → 英文名称
// statusNames: 状态数字 ID → 英文名称
// trackerNames: 类型数字 ID → 英文名称
// 作用:把数字 ID 转换成可读的英文名称,方便输出结果
var priorityNames = map[int]string{
priorityLow: "low",
priorityNormal: "normal",
priorityHigh: "high",
priorityUrgent: "urgent",
}
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"`
var statusNames = map[int]string{
statusNew: "new",
statusInProgress: "in-progress",
statusResolved: "resolved",
statusClosed: "closed",
statusRejected: "rejected",
}
var trackerNames = map[int]string{
trackerBug: "bug",
trackerFeature: "feature",
trackerSupport: "support",
trackerDoc: "doc",
trackerTest: "test",
trackerDuplicate: "duplicate",
trackerQuestion: "question",
}
// === 标签 ID 映射 ===
// tagIDs: 中文标签名称 → GitLink 标签 ID
// 获取方式:从网页端 DevTools 抓包获取(修改标签 → 捕获 PATCH 请求体 → 获取 issue_tag_ids 值)
// 注意:这些 ID 是项目特定的,不同项目可能不同
var tagIDs = map[string]int{
"缺陷": 315526,
"功能": 315527,
"文档": 315533,
"重复": 315525,
"疑问": 315528,
"支持": 315529,
"任务": 315530,
"测试": 315534,
"协助": 315531,
"搁置": 315532,
}
// labelNames 返回所有已知的标签名称(逗号分隔)
// 参数: tags - 标签名称到 ID 的映射
// 返回: 所有标签名称的字符串,用逗号分隔
func labelNames(tags map[string]int) string {
var names []string
for name := range tags {
names = append(names, name)
}
return strings.Join(names, ", ")
}
// === 结果结构体 ===
// BatchResult 表示单个 Issue 的操作结果
type BatchResult struct {
Number string `json:"number" yaml:"number"` // Issue 编号
Action string `json:"action" yaml:"action"` // 操作类型close/set-status/set-priority/set-assignee/set-label
Status string `json:"status" yaml:"status"` // 操作状态planned预览/closed已关闭/failed失败
Error string `json:"error,omitempty" yaml:"error,omitempty"` // 错误信息(失败时)
}
// BatchSummary 表示批量操作的汇总报告
type BatchSummary struct {
Repository string `json:"repository" yaml:"repository"` // 仓库名称owner/repo
Action string `json:"action" yaml:"action"` // 操作类型
Value string `json:"value,omitempty" yaml:"value,omitempty"` // 操作目标值(如状态名、优先级名)
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-close 命令:批量关闭 Issue ===
// newBatchCloseShortcut 创建 batch-close 命令
func newBatchCloseShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-close",
Description: "Close multiple issues by issue numbers or a CSV file",
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"},
{Name: "dry-run", Usage: "Preview the issues that would be closed without changing them", Bool: true, Default: "false"},
{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 without making changes", Bool: true, Default: "false"},
},
Run: runBatchClose,
}
}
// runBatchClose 执行批量关闭操作
// 执行流程:
// 1. 解析仓库信息
// 2. 收集 Issue 编号(从 --numbers 参数或 CSV 文件)
// 3. 初始化 BatchSummary 汇总对象
// 4. 遍历每个 Issue 编号:
// - 如果是 dry-run直接标记为 planned
// - 否则调用 updateIssueField 更新状态为 closed
// 5. 输出汇总结果
func runBatchClose(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2收集 Issue 编号(支持 --numbers 参数和 --from CSV 文件)
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
// 步骤3初始化汇总对象
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "close",
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
// 步骤4遍历处理每个 Issue
for _, number := range numbers {
result := BatchResult{Number: number, Action: "close"}
if dryRun {
// 预览模式:不实际操作,只标记为 planned
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 实际操作:调用 updateIssueField 更新状态为 closed
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "closed"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
// 步骤5输出汇总结果
if err := ctx.OutputData(summary); err != nil {
return err
}
// 如果有失败的操作,返回错误
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// === batch-status 命令:批量修改 Issue 状态 ===
// newBatchStatusShortcut 创建 batch-status 命令
func newBatchStatusShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-status",
Description: "Change status for multiple issues",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Target state: new, in-progress, resolved, closed, rejected", Required: true},
{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 without making changes", Bool: true, Default: "false"},
},
Run: runBatchStatus,
}
}
// runBatchStatus 执行批量修改状态操作
// 参数 --state 指定目标状态new/in-progress/resolved/closed/rejected
func runBatchStatus(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取状态参数并转换为数字 ID
state := ctx.Arg("state")
statusID, err := parseStatus(state)
if err != nil {
return err
}
// 步骤3收集 Issue 编号
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
// 步骤4初始化汇总对象
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-status",
Value: state,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
// 步骤5遍历处理每个 Issue
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-status"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 更新状态
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = state
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
// 步骤6输出汇总结果
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// === batch-priority 命令:批量修改 Issue 优先级 ===
// newBatchPriorityShortcut 创建 batch-priority 命令
func newBatchPriorityShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-priority",
Description: "Change priority for multiple issues",
Flags: []common.Flag{
{Name: "priority", Short: "p", Usage: "Target priority: low, normal, high, urgent", Required: true},
{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 without making changes", Bool: true, Default: "false"},
},
Run: runBatchPriority,
}
}
// runBatchPriority 执行批量修改优先级操作
// 参数 --priority 指定目标优先级low/normal/high/urgent
func runBatchPriority(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 获取优先级参数并转换为数字 ID
priority := ctx.Arg("priority")
priorityID, err := parsePriority(priority)
if err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-priority",
Value: priority,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-priority"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 更新优先级
if err := updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = priority
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// === batch-assign 命令:批量分配 Issue ===
// newBatchAssignShortcut 创建 batch-assign 命令
func newBatchAssignShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-assign",
Description: "Change assignee for multiple issues",
Flags: []common.Flag{
{Name: "assignee", Short: "a", Usage: "Assignee login name or user ID", Required: true},
{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 without making changes", Bool: true, Default: "false"},
},
Run: runBatchAssign,
}
}
// runBatchAssign 执行批量分配操作
// 亮点:需要先把用户名转换成用户 ID通过 API 查询)
func runBatchAssign(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
assignee := ctx.Arg("assignee")
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-assignee",
Value: assignee,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
// 非预览模式下,先解析用户 ID
var assigneeID interface{}
if !dryRun {
id, err := resolveUserID(ctx, assignee)
if err != nil {
return fmt.Errorf("cannot resolve assignee %q: %w", assignee, err)
}
assigneeID = id
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-assignee"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 分配用户
if err := updateIssueField(ctx, number, map[string]interface{}{"assigner_ids": []interface{}{assigneeID}}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "assigned"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// === batch-label 命令:批量修改 Issue 标签 ===
// newBatchLabelShortcut 创建 batch-label 命令
func newBatchLabelShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-label",
Description: "Change tracker label for multiple issues",
Flags: []common.Flag{
{Name: "label", Short: "l", Usage: "Target label: bug, feature, support, doc, test, duplicate, question, or Chinese names (缺陷/功能/文档/重复/疑问/支持/任务/测试/协助/搁置)", Required: true},
{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 without making changes", Bool: true, Default: "false"},
},
Run: runBatchLabel,
}
}
// runBatchLabel 执行批量修改标签操作
// 参数 --label 可以是英文bug/feature或中文缺陷/功能)
func runBatchLabel(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 获取标签参数并转换为数字 ID
label := ctx.Arg("label")
tags, err := resolveIssueTags(ctx)
if err != nil {
return fmt.Errorf("cannot resolve issue tags: %w", err)
}
tagID, err := parseLabel(label, tags)
if err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-label",
Value: label,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-label"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 修改标签issue_tag_ids 是数组)
if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{tagID}}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = label
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
// === batch-destroy 命令:批量删除 Issue ===
// newBatchDestroyShortcut 创建 batch-destroy 命令
func newBatchDestroyShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-destroy",
Description: "Delete multiple issues by issue numbers or a CSV file",
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 without making changes", Bool: true, Default: "false"},
},
Run: runBatchDestroy,
}
}
// runBatchDestroy 执行批量删除操作
// 使用 GitLink 原生批量删除接口 DELETE /v1/{owner}/{repo}/issues/batch_destroy
// body: {"ids": [1, 2, 3]}
func runBatchDestroy(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
@ -55,59 +572,214 @@ func runBatchClose(ctx *common.RuntimeContext) error {
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchCloseSummary{
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "destroy",
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchCloseResult, 0, len(numbers)),
Results: make([]BatchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchCloseResult{Number: number, Action: "close"}
if dryRun {
result.Status = "planned"
if dryRun {
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "planned"})
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
} else {
// 构建 ids 数组
ids := make([]int, 0, len(numbers))
for _, number := range numbers {
id, err := strconv.Atoi(number)
if err != nil {
return fmt.Errorf("invalid issue number %q: %w", number, err)
}
ids = append(ids, id)
}
if err := closeIssue(ctx, number); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
// 调用原生批量删除接口
body := map[string]interface{}{"ids": ids}
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/batch_destroy", v1RepoPath(ctx)), body); err != nil {
// 整体失败,标记所有为 failed
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "failed", Error: err.Error()})
summary.Failed++
}
} else {
result.Status = "closed"
summary.Succeeded++
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "deleted"})
summary.Succeeded++
}
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total)
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
func closeIssue(ctx *common.RuntimeContext, number string) error {
// === 共享辅助函数 ===
// updateIssueField 更新 Issue 的指定字段
// 关键点:
// 1. 先调用 fetchExistingIssue 获取当前 Issue 的标题和描述
// 2. 必须在请求体中包含 subject 和 description否则会被清空
// 3. 把要更新的字段合并到 body 中
// 4. 发送 PATCH 请求
func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error {
// 获取当前 Issue 的标题和描述(避免更新时丢失)
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("fetch issue: %w", err)
return fmt.Errorf("fetch issue #%s: %w", number, err)
}
// 构建请求体,先包含必要的标题和描述
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": closedIssueStatusID,
}
// 合并要更新的字段
for k, v := range fields {
body[k] = v
}
// 发送 PATCH 请求更新 Issue
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("close issue: %w", err)
return fmt.Errorf("update issue #%s: %w", number, err)
}
return nil
}
// resolveUserID 把用户名转换成用户 ID
// 工作原理:
// 1. 如果输入已经是数字,直接返回
// 2. 否则调用 /users/{login} API 获取用户信息
// 3. 从响应中提取 id 或 user_id 字段
// 4. API 返回的数字是 float64 类型,需要转换成 int
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
// 如果输入是数字,直接返回
if id, err := strconv.Atoi(login); err == nil {
return id, nil
}
// 调用 API 获取用户信息
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
if err != nil {
return nil, fmt.Errorf("lookup user %q: %w", login, err)
}
// 类型断言:把 Data 转换为 map[string]interface{}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response for user %q", login)
}
// 尝试提取 id 字段
idFloat, ok := data["id"].(float64)
if ok {
return int(idFloat), nil
}
// 尝试提取 user_id 字段
userIDFloat, ok := data["user_id"].(float64)
if ok {
return int(userIDFloat), nil
}
return nil, fmt.Errorf("cannot determine user ID for %q", login)
}
// parseStatus 把用户输入的状态字符串转换成数字 ID
// 支持多种写法in-progress、in_progress、inprogress
// 如果输入是数字,直接返回
func parseStatus(state string) (int, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "new":
return statusNew, nil
case "in-progress", "in_progress", "inprogress":
return statusInProgress, nil
case "resolved":
return statusResolved, nil
case "closed":
return statusClosed, nil
case "rejected":
return statusRejected, nil
default:
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid state %q: use new, in-progress, resolved, closed, or rejected", state)
}
}
// parsePriority 把用户输入的优先级字符串转换成数字 ID
func parsePriority(p string) (int, error) {
switch strings.ToLower(strings.TrimSpace(p)) {
case "low":
return priorityLow, nil
case "normal":
return priorityNormal, nil
case "high":
return priorityHigh, nil
case "urgent":
return priorityUrgent, nil
default:
if id, err := strconv.Atoi(p); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid priority %q: use low, normal, high, or urgent", p)
}
}
// parseTracker 把用户输入的标签字符串转换成数字 ID
// 支持中英文标签:
// - 中文:缺陷/功能/文档/重复/疑问/支持/任务/测试/协助/搁置
// - 英文bug/feature/support/doc/test/duplicate/question
func parseTracker(label string) (int, error) {
trimmed := strings.TrimSpace(label)
// 先检查中文标签名称
if id, ok := tagIDs[trimmed]; ok {
return id, nil
}
// 再检查英文标签名称
switch strings.ToLower(trimmed) {
case "bug":
return trackerBug, nil
case "feature":
return trackerFeature, nil
case "support":
return trackerSupport, nil
case "doc":
return trackerDoc, nil
case "test":
return trackerTest, nil
case "duplicate":
return trackerDuplicate, nil
case "question":
return trackerQuestion, nil
default:
if id, err := strconv.Atoi(label); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid label %q: use bug, feature, support, doc, test, duplicate, question, or Chinese names (%s)", label, labelNames(tagIDs))
}
}
// parseLabel 根据项目的标签映射表,把标签名称转换成 GitLink 标签 ID
// 参数: name - 标签名称; tags - 项目的名称→ID 映射
func parseLabel(name string, tags map[string]int) (int, error) {
if id, ok := tags[name]; ok && id != 0 {
return id, nil
}
if id, err := strconv.Atoi(name); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid label %q: not found in project issue tags", name)
}
// collectIssueNumbers 从 --numbers 参数和 CSV 文件中收集 Issue 编号
// 参数: numbersValue - --numbers 参数的值; csvPath - CSV 文件路径
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := parseIssueNumbers(numbersValue)
if err != nil {
@ -124,6 +796,7 @@ func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
return mergeIssueNumbers(numbers, csvNumbers), nil
}
// parseIssueNumbers 解析逗号分隔的 Issue 编号字符串
func parseIssueNumbers(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
@ -131,59 +804,71 @@ func parseIssueNumbers(value string) ([]string, error) {
return normalizeIssueNumbers(strings.Split(value, ","))
}
// readIssueNumbersFromCSV 从 CSV 文件读取 Issue 编号
// 智能表头识别:
// - 自动识别 number、issue_number、project_issues_index 列
// - 如果没有匹配的表头,默认使用第一列
// - 跳过表头行,从第二行开始读取
func readIssueNumbersFromCSV(path string) ([]string, error) {
// 打开文件defer 确保函数返回前关闭文件)
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read issue numbers from CSV: %w", err)
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
// 创建 CSV 阅读器
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
reader.TrimLeadingSpace = true // 自动去除单元格前后空格
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err)
return nil, fmt.Errorf("parse CSV: %w", err)
}
if len(records) == 0 {
return nil, nil
}
// 智能识别表头:查找 number 列
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
startRow = 1 // 找到表头,从第二行开始读取
}
}
if numberColumn == -1 {
numberColumn = 0
numberColumn = 0 // 没有找到表头,默认使用第一列
}
// 提取 Issue 编号
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if numberColumn >= len(record) {
continue
continue // 跳过列数不足的行
}
values = append(values, record[numberColumn])
}
return normalizeIssueNumbers(values)
}
// normalizeIssueNumbers 规范化 Issue 编号列表
// 功能:去重、验证格式、过滤空值
func normalizeIssueNumbers(values []string) ([]string, error) {
numbers := make([]string, 0, len(values))
seen := map[string]bool{}
seen := map[string]bool{} // 用于去重
for _, value := range values {
number := strings.TrimSpace(value)
if number == "" {
continue
continue // 跳过空值
}
// 验证是否是有效的整数
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number)
return nil, fmt.Errorf("invalid issue number %q: must be an integer", number)
}
if seen[number] {
continue
continue // 跳过重复值
}
seen[number] = true
numbers = append(numbers, number)
@ -191,6 +876,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) {
return numbers, nil
}
// mergeIssueNumbers 合并多个 Issue 编号列表(去重)
func mergeIssueNumbers(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
@ -206,6 +892,8 @@ func mergeIssueNumbers(values ...[]string) []string {
return merged
}
// parseBool 解析布尔值字符串
// 返回 true 的条件:字符串解析成功且值为 true
func parseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed

View File

@ -0,0 +1,409 @@
package issue
import (
"encoding/csv"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"sync"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
var issueTagCache sync.Map //全局缓存变量
// 获取项目标签映射
// resolveIssueTags fetches the project's issue tags and returns a name→id mapping.
// Results are cached per owner/repo.
func resolveIssueTags(ctx *common.RuntimeContext) (map[string]int, error) {
key := ctx.Owner + "/" + ctx.Repo
if cached, ok := issueTagCache.Load(key); ok { //先从缓存查
return cached.(map[string]int), nil // 命中缓存则直接返回
}
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) //api路径
q := url.Values{}
q.Set("only_name", "true")
env, err := ctx.CallAPIWithQuery("GET", path, q) // 发送GET请求获取项目标签列表
if err != nil {
return nil, fmt.Errorf("获取项目标签列表失败: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("标签列表响应格式异常")
}
rawTags, ok := data["issue_tags"].([]interface{})
if !ok {
return nil, fmt.Errorf("标签列表响应缺少 issue_tags 字段")
}
tags := make(map[string]int, len(rawTags))
for _, item := range rawTags {
tag, ok := item.(map[string]interface{})
if !ok {
continue
}
name, _ := tag["name"].(string)
if name == "" {
continue
}
var id int
switch v := tag["id"].(type) {
case float64:
id = int(v)
case int:
id = v
default:
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
}
if id == 0 {
continue
}
tags[name] = id
}
if len(tags) == 0 {
return nil, fmt.Errorf("项目没有配置任何标签,请先在 GitLink 网页端创建标签")
}
issueTagCache.Store(key, tags)
return tags, nil
}
func newBatchCreateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-create",
Description: "Create multiple issues from CLI flags or a CSV file",
Flags: []common.Flag{
{Name: "titles", Usage: "Comma-separated issue titles, e.g. 标题1,标题2"},
{Name: "priority", Short: "p", Usage: "Priority: low, normal, high, urgent (default: normal)"},
{Name: "label", Short: "l", Usage: "Label name, e.g. 缺陷"},
{Name: "assignee", Short: "a", Usage: "Assignee login name"},
{Name: "state", Short: "s", Usage: "Initial state: new, in-progress, resolved, closed, rejected (default: new)", Default: "new"},
{Name: "from", Usage: "CSV file path"},
{Name: "template", Short: "t", Usage: "Template: bug or feature (only with --from)"},
{Name: "dry-run", Usage: "Preview without creating issues", Bool: true, Default: "false"},
},
Run: runBatchCreate,
}
}
type createIssueInput struct {
Title string
Body string
Priority string
Label string
Assignee string
Status string
// template-specific fields
Version string
Severity string
Steps string
Expected string
Actual string
UserStory string
Acceptance string
}
func runBatchCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
tags, err := resolveIssueTags(ctx)
if err != nil {
return err
}
dryRun := parseBool(ctx.Arg("dry-run"))
template := strings.ToLower(strings.TrimSpace(ctx.Arg("template")))
// Collect inputs from --titles and/or --from
var inputs []createIssueInput
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
}
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
if err != nil {
return err
}
inputs = append(inputs, csvInputs...)
}
if len(inputs) == 0 {
return fmt.Errorf("no issue titles provided; use --titles 标题1,标题2 or --from issues.csv")
}
// Apply CLI --state as fallback for inputs without an explicit status
cliState := ctx.Arg("state")
for i := range inputs {
if inputs[i].Status == "" {
inputs[i].Status = cliState
}
}
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "create",
Value: template,
DryRun: dryRun,
Total: len(inputs),
Results: make([]BatchResult, 0, len(inputs)),
}
for i, input := range inputs {
label := fmt.Sprintf("#%d", i+1)
if input.Title != "" {
label = truncate(input.Title, 40) // 用标题的前40个字符
}
result := BatchResult{Number: label, Action: "create"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
//把 createIssueInput 转换成 API 需要的 JSON map
body := buildCreateBody(ctx, input, template, tags)
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "created"
if data, ok := env.Data.(map[string]interface{}); ok {
if num, ok := data["project_issues_index"]; ok {
result.Number = fmt.Sprintf("%v", num)
}
}
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed to create", summary.Failed, summary.Total)
}
return nil
}
func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string, tags map[string]int) map[string]interface{} {
statusID := statusNew
if input.Status != "" {
if sid, err := parseStatus(input.Status); err == nil {
statusID = sid
}
}
body := map[string]interface{}{
"subject": input.Title,
"status_id": statusID,
"priority_id": priorityNormal,
"done_ratio": 0,
}
if template != "" {
body["description"] = buildTemplateDescription(input, template)
if template == "bug" {
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
} else if template == "feature" {
body["issue_tag_ids"] = []interface{}{tags["功能"]}
}
} else if input.Body != "" {
body["description"] = input.Body
}
if input.Priority != "" {
if pid, err := parsePriority(input.Priority); err == nil {
body["priority_id"] = pid
}
}
if input.Label != "" {
if tid, err := parseLabel(input.Label, tags); err == nil {
body["issue_tag_ids"] = []interface{}{tid}
}
}
if input.Assignee != "" {
if id, err := resolveUserID(ctx, input.Assignee); err == nil {
body["assigner_ids"] = []interface{}{id}
}
}
return body
}
func buildTemplateDescription(input createIssueInput, template string) string {
switch template {
case "bug":
return buildBugDescription(input)
case "feature":
return buildFeatureDescription(input)
default:
return input.Body
}
}
func buildBugDescription(input createIssueInput) string {
var b strings.Builder
b.WriteString("## Bug 描述\n")
b.WriteString(input.Title)
b.WriteString("\n")
if input.Version != "" {
b.WriteString("\n## 版本\n")
b.WriteString(input.Version)
b.WriteString("\n")
}
if input.Severity != "" {
b.WriteString("\n## 严重程度\n")
b.WriteString(input.Severity)
b.WriteString("\n")
}
if input.Steps != "" {
b.WriteString("\n## 复现步骤\n")
b.WriteString(input.Steps)
b.WriteString("\n")
}
if input.Expected != "" {
b.WriteString("\n## 期望结果\n")
b.WriteString(input.Expected)
b.WriteString("\n")
}
if input.Actual != "" {
b.WriteString("\n## 实际结果\n")
b.WriteString(input.Actual)
b.WriteString("\n")
}
return b.String()
}
func buildFeatureDescription(input createIssueInput) string {
var b strings.Builder
b.WriteString("## 用户故事\n")
if input.UserStory != "" {
b.WriteString(input.UserStory)
} else {
b.WriteString(input.Title)
}
b.WriteString("\n")
if input.Body != "" {
b.WriteString("\n## 描述\n")
b.WriteString(input.Body)
b.WriteString("\n")
}
if input.Acceptance != "" {
b.WriteString("\n## 验收标准\n")
b.WriteString(input.Acceptance)
b.WriteString("\n")
}
if input.Priority != "" {
b.WriteString("\n## 优先级\n")
b.WriteString(input.Priority)
b.WriteString("\n")
}
return b.String()
}
func readCreateInputsFromCSV(path string, template string) ([]createIssueInput, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
}
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[normalizeHeader(h)] = i
}
if _, ok := col["title"]; !ok {
return nil, fmt.Errorf("CSV must have a 'title' column")
}
var inputs []createIssueInput
for _, record := range records[1:] {
input := createIssueInput{
Title: getCol(record, col, "title"),
Body: getCol(record, col, "body"),
Priority: getCol(record, col, "priority"),
Label: getCol(record, col, "label"),
Assignee: getCol(record, col, "assignee"),
Status: getCol(record, col, "status"),
Version: getCol(record, col, "version"),
Severity: getCol(record, col, "severity"),
Steps: getCol(record, col, "steps"),
Expected: getCol(record, col, "expected"),
Actual: getCol(record, col, "actual"),
// Support alternate heading for feature template
UserStory: getCol(record, col, "user_story"),
Acceptance: getCol(record, col, "acceptance"),
}
if input.UserStory == "" {
input.UserStory = getCol(record, col, "user story")
}
if input.Title == "" {
continue
}
inputs = append(inputs, input)
}
return inputs, nil
}
func parseTitles(titlesStr string, ctx *common.RuntimeContext) []createIssueInput {
parts := strings.Split(titlesStr, ",")
inputs := make([]createIssueInput, 0, len(parts))
for _, title := range parts {
title = strings.TrimSpace(title)
if title == "" {
continue
}
inputs = append(inputs, createIssueInput{
Title: title,
Priority: ctx.Arg("priority"),
Label: ctx.Arg("label"),
Assignee: ctx.Arg("assignee"),
Status: ctx.Arg("state"),
})
}
return inputs
}
func normalizeHeader(h string) string {
return strings.ToLower(strings.TrimSpace(h))
}
func getCol(record []string, col map[string]int, name string) string {
if idx, ok := col[name]; ok && idx < len(record) {
return strings.TrimSpace(record[idx])
}
return ""
}
func truncate(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "..."
}

View File

@ -0,0 +1,678 @@
package issue
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// testTags is a static name→id mapping used by unit tests.
var testTags = map[string]int{
"缺陷": 315526,
"功能": 315527,
"文档": 315533,
"任务": 315530,
"测试": 315534,
}
// ---- helpers ----
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
// mockTagsHandler returns a handler that responds to the issue_tags API.
func mockTagsHandler(t *testing.T) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
tags := make([]map[string]interface{}, 0, len(testTags))
for name, id := range testTags {
tags = append(tags, map[string]interface{}{
"id": float64(id),
"name": name,
})
}
writeJSONResp(t, w, map[string]interface{}{"issue_tags": tags})
}
}
func runBatchCreateShortcut(t *testing.T, server *httptest.Server, args map[string]string) error {
t.Helper()
s := findShortcut(t, "batch-create")
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return s.Run(ctx)
}
func writeJSONResp(t *testing.T, w http.ResponseWriter, v interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func decodeReqBody(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("decode body: %v", err)
}
return payload
}
// ---- runBatchCreate tests ----
func TestBatchCreate_DryRun(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") {
mockTagsHandler(t)(w, r)
return
}
t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBatchCreateShortcut(t, server, map[string]string{
"titles": "标题1,标题2",
"dry-run": "true",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBatchCreate_FromTitles(t *testing.T) {
var createdBodies []map[string]interface{}
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"):
mockTagsHandler(t)(w, r)
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"):
callCount++
body := decodeReqBody(t, r)
createdBodies = append(createdBodies, body)
writeJSONResp(t, w, map[string]interface{}{
"project_issues_index": float64(100 + callCount),
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runBatchCreateShortcut(t, server, map[string]string{
"titles": "Bug修复,功能开发",
"state": "new",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 API calls, got %d", callCount)
}
if createdBodies[0]["subject"] != "Bug修复" {
t.Fatalf("first title: got %q, want %q", createdBodies[0]["subject"], "Bug修复")
}
if createdBodies[1]["subject"] != "功能开发" {
t.Fatalf("second title: got %q, want %q", createdBodies[1]["subject"], "功能开发")
}
// Verify required fields — values come through JSON as float64
for i, body := range createdBodies {
if body["done_ratio"] != float64(0) {
t.Fatalf("body[%d]: done_ratio = %v (type %T), want 0", i, body["done_ratio"], body["done_ratio"])
}
}
}
func TestBatchCreate_NoTitles(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") {
mockTagsHandler(t)(w, r)
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBatchCreateShortcut(t, server, map[string]string{
"dry-run": "false",
})
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestBatchCreate_FromCSV(t *testing.T) {
csvPath := writeTempCSV(t, "title,priority,label,status\nCSV标题1,high,缺陷,new\nCSV标题2,normal,功能,new\n")
var created []map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"):
mockTagsHandler(t)(w, r)
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"):
created = append(created, decodeReqBody(t, r))
writeJSONResp(t, w, map[string]interface{}{"project_issues_index": float64(1)})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runBatchCreateShortcut(t, server, map[string]string{
"from": csvPath,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(created) != 2 {
t.Fatalf("expected 2 creates, got %d", len(created))
}
if created[0]["subject"] != "CSV标题1" {
t.Fatalf("first subject: got %q", created[0]["subject"])
}
if created[1]["subject"] != "CSV标题2" {
t.Fatalf("second subject: got %q", created[1]["subject"])
}
}
func TestBatchCreate_CSVMissingTitleColumn(t *testing.T) {
csvPath := writeTempCSV(t, "name,description\nval1,desc1\n")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") {
mockTagsHandler(t)(w, r)
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBatchCreateShortcut(t, server, map[string]string{"from": csvPath})
if err == nil {
t.Fatal("expected error for missing title column, got nil")
}
}
func TestBatchCreate_CSVOnlyHeader(t *testing.T) {
csvPath := writeTempCSV(t, "title,description\n")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") {
mockTagsHandler(t)(w, r)
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBatchCreateShortcut(t, server, map[string]string{"from": csvPath})
if err == nil {
t.Fatal("expected error for header-only CSV, got nil")
}
}
func TestBatchCreate_PartialFailure(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"):
mockTagsHandler(t)(w, r)
case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"):
callCount++
if callCount == 2 {
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
writeJSONResp(t, w, map[string]interface{}{"project_issues_index": float64(callCount)})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runBatchCreateShortcut(t, server, map[string]string{
"titles": "ok1,fail1,ok2",
})
if err == nil {
t.Fatal("expected error from partial failure, got nil")
}
if !strings.Contains(err.Error(), "failed to create") {
t.Fatalf("error should mention failed count, got: %v", err)
}
}
// ---- buildCreateBody tests (direct call, values retain Go types) ----
func intVal(v interface{}) int {
switch n := v.(type) {
case int:
return n
case float64:
return int(n)
}
return -999
}
func TestBuildCreateBody_Basic(t *testing.T) {
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
input := createIssueInput{Title: "Test issue", Status: "new"}
body := buildCreateBody(ctx, input, "", testTags)
if body["subject"] != "Test issue" {
t.Fatalf("subject: got %v", body["subject"])
}
if intVal(body["done_ratio"]) != 0 {
t.Fatalf("done_ratio: got %v (%T), want 0", body["done_ratio"], body["done_ratio"])
}
if intVal(body["status_id"]) != 1 {
t.Fatalf("status_id: got %v (%T), want 1", body["status_id"], body["status_id"])
}
if intVal(body["priority_id"]) != 2 {
t.Fatalf("priority_id: got %v (%T), want 2", body["priority_id"], body["priority_id"])
}
}
func TestBuildCreateBody_BugTemplate(t *testing.T) {
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
input := createIssueInput{
Title: "登录报错",
Version: "v2.0",
Severity: "严重",
Steps: "1. 打开页面\n2. 点击登录",
Expected: "正常登录",
Actual: "报错 500",
}
body := buildCreateBody(ctx, input, "bug", testTags)
if body["subject"] != "登录报错" {
t.Fatalf("subject: got %v", body["subject"])
}
desc, _ := body["description"].(string)
if !strings.Contains(desc, "## Bug 描述") {
t.Fatal("bug description missing header")
}
if !strings.Contains(desc, "v2.0") {
t.Fatal("bug description missing version")
}
if !strings.Contains(desc, "严重") {
t.Fatal("bug description missing severity")
}
if rawTags, ok := body["issue_tag_ids"]; !ok {
t.Fatal("bug template missing issue_tag_ids")
} else {
ids := rawTags.([]interface{})
if intVal(ids[0]) != testTags["缺陷"] {
t.Fatalf("tag: got %v (type %T), want %v", ids[0], ids[0], testTags["缺陷"])
}
}
}
func TestBuildCreateBody_FeatureTemplate(t *testing.T) {
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
input := createIssueInput{
Title: "用户搜索",
UserStory: "作为用户,我想搜索内容",
Acceptance: "搜索结果正确显示",
}
body := buildCreateBody(ctx, input, "feature", testTags)
desc, _ := body["description"].(string)
if !strings.Contains(desc, "## 用户故事") {
t.Fatal("feature description missing user story header")
}
if !strings.Contains(desc, "作为用户") {
t.Fatal("feature description missing user story content")
}
if !strings.Contains(desc, "## 验收标准") {
t.Fatal("feature description missing acceptance criteria")
}
}
func TestBuildCreateBody_WithPriorityLabel(t *testing.T) {
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
input := createIssueInput{
Title: "紧急修复",
Priority: "high",
Label: "缺陷",
}
body := buildCreateBody(ctx, input, "", testTags)
if intVal(body["priority_id"]) != 3 {
t.Fatalf("priority_id: got %v (type %T), want 3 (high)", body["priority_id"], body["priority_id"])
}
if rawTags, ok := body["issue_tag_ids"]; ok {
ids := rawTags.([]interface{})
if intVal(ids[0]) != testTags["缺陷"] {
t.Fatalf("tag: got %v (type %T), want %v", ids[0], ids[0], testTags["缺陷"])
}
} else {
t.Fatal("missing issue_tag_ids")
}
}
// ---- buildBugDescription tests ----
func TestBuildBugDescription_AllFields(t *testing.T) {
input := createIssueInput{
Title: "登录报错",
Version: "v2.0",
Severity: "严重",
Steps: "1. 打开",
Expected: "正常",
Actual: "500错误",
}
result := buildBugDescription(input)
if !strings.Contains(result, "## Bug 描述") {
t.Fatal("missing Bug 描述")
}
if !strings.Contains(result, "登录报错") {
t.Fatal("missing title")
}
if !strings.Contains(result, "## 版本") {
t.Fatal("missing 版本")
}
if !strings.Contains(result, "## 严重程度") {
t.Fatal("missing 严重程度")
}
if !strings.Contains(result, "## 复现步骤") {
t.Fatal("missing 复现步骤")
}
if !strings.Contains(result, "## 期望结果") {
t.Fatal("missing 期望结果")
}
if !strings.Contains(result, "## 实际结果") {
t.Fatal("missing 实际结果")
}
}
func TestBuildBugDescription_PartialFields(t *testing.T) {
input := createIssueInput{Title: "小问题"}
result := buildBugDescription(input)
if !strings.Contains(result, "## Bug 描述") {
t.Fatal("missing header")
}
if strings.Contains(result, "## 版本") {
t.Fatal("should not have version section")
}
if strings.Contains(result, "## 严重程度") {
t.Fatal("should not have severity section")
}
}
// ---- buildFeatureDescription tests ----
func TestBuildFeatureDescription_AllFields(t *testing.T) {
input := createIssueInput{
Title: "搜索功能",
UserStory: "作为用户想搜索",
Body: "详细描述",
Acceptance: "搜索结果正确",
Priority: "high",
}
result := buildFeatureDescription(input)
if !strings.Contains(result, "## 用户故事") {
t.Fatal("missing user story")
}
if !strings.Contains(result, "作为用户想搜索") {
t.Fatal("missing user story content")
}
if !strings.Contains(result, "## 描述") {
t.Fatal("missing description")
}
if !strings.Contains(result, "## 验收标准") {
t.Fatal("missing acceptance criteria")
}
if !strings.Contains(result, "## 优先级") {
t.Fatal("missing priority")
}
}
func TestBuildFeatureDescription_FallbackToTitleAsUserStory(t *testing.T) {
input := createIssueInput{Title: "搜索功能"}
result := buildFeatureDescription(input)
if !strings.Contains(result, "搜索功能") {
t.Fatal("should fall back to title as user story")
}
}
// ---- readCreateInputsFromCSV tests ----
func TestReadCreateInputsFromCSV_Normal(t *testing.T) {
path := writeTempCSV(t, "title,priority,label,status,version,severity,steps,expected,actual\n标题1,high,缺陷,new,v1,严重,,,\n标题2,normal,功能,new,,,,,\n")
inputs, err := readCreateInputsFromCSV(path, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(inputs) != 2 {
t.Fatalf("got %d inputs, want 2", len(inputs))
}
if inputs[0].Title != "标题1" {
t.Fatalf("first title: got %q", inputs[0].Title)
}
if inputs[0].Severity != "严重" {
t.Fatalf("severity: got %q", inputs[0].Severity)
}
if inputs[1].Label != "功能" {
t.Fatalf("label: got %q", inputs[1].Label)
}
}
func TestReadCreateInputsFromCSV_MissingTitleColumn(t *testing.T) {
path := writeTempCSV(t, "name,description\nval1,desc1\n")
_, err := readCreateInputsFromCSV(path, "")
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestReadCreateInputsFromCSV_OnlyHeader(t *testing.T) {
path := writeTempCSV(t, "title,priority\n")
_, err := readCreateInputsFromCSV(path, "")
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestReadCreateInputsFromCSV_SkipsEmptyTitle(t *testing.T) {
path := writeTempCSV(t, "title,priority\n标题1,high\n,normal\n标题2,low\n")
inputs, err := readCreateInputsFromCSV(path, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(inputs) != 2 {
t.Fatalf("got %d inputs, want 2 (empty row skipped)", len(inputs))
}
}
// ---- parseTitles tests ----
func TestParseTitles_CommaSeparated(t *testing.T) {
ctx := &common.RuntimeContext{
Owner: "o", Repo: "r",
Args: map[string]string{"priority": "normal"},
}
inputs := parseTitles("标题1, 标题2, , 标题3", ctx)
if len(inputs) != 3 {
t.Fatalf("got %d inputs, want 3", len(inputs))
}
if inputs[0].Title != "标题1" {
t.Fatalf("got %q", inputs[0].Title)
}
if inputs[2].Title != "标题3" {
t.Fatalf("got %q", inputs[2].Title)
}
if inputs[0].Priority != "normal" {
t.Fatalf("priority not propagated: got %q", inputs[0].Priority)
}
}
// ---- normalizeHeader tests ----
func TestNormalizeHeader(t *testing.T) {
cases := []struct{ in, want string }{
{"Title", "title"},
{" PRIORITY ", "priority"},
{"user_story", "user_story"},
{"User Story", "user story"},
}
for _, c := range cases {
got := normalizeHeader(c.in)
if got != c.want {
t.Fatalf("normalizeHeader(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// ---- getCol tests ----
func TestGetCol(t *testing.T) {
col := map[string]int{"title": 0, "priority": 1}
record := []string{"测试标题", "high"}
if got := getCol(record, col, "title"); got != "测试标题" {
t.Fatalf("got %q", got)
}
if got := getCol(record, col, "missing"); got != "" {
t.Fatalf("got %q, want empty", got)
}
if got := getCol(record, col, "priority"); got != "high" {
t.Fatalf("got %q", got)
}
}
// ---- truncate tests ----
func TestTruncate(t *testing.T) {
if got := truncate("short", 40); got != "short" {
t.Fatalf("got %q", got)
}
long := "这是一个很长的标题用来测试截断功能一二三四五六七八九十"
got := truncate(long, 10)
if len([]rune(got)) > 13 {
t.Fatalf("truncated too long: %q (%d runes)", got, len([]rune(got)))
}
if !strings.HasSuffix(got, "...") {
t.Fatal("truncated string should end with ...")
}
}
// ---- priority/label/status parse helpers ----
func TestParsePriorityStrings(t *testing.T) {
cases := []struct {
in string
want int
}{
{"low", 1}, {"normal", 2}, {"high", 3}, {"urgent", 4},
{"LOW", 1}, {"High", 3},
}
for _, c := range cases {
got, err := parsePriority(c.in)
if err != nil {
t.Fatalf("parsePriority(%q): %v", c.in, err)
}
if got != c.want {
t.Fatalf("parsePriority(%q) = %d, want %d", c.in, got, c.want)
}
}
}
func TestParsePriorityNumeric(t *testing.T) {
got, err := parsePriority("5")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 5 {
t.Fatalf("got %d, want 5", got)
}
}
func TestParsePriorityInvalid(t *testing.T) {
if _, err := parsePriority("invalid"); err == nil {
t.Fatal("expected error")
}
}
func TestParseLabelValid(t *testing.T) {
id, err := parseLabel("缺陷", testTags)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != testTags["缺陷"] {
t.Fatalf("got %d, want %d", id, testTags["缺陷"])
}
}
func TestParseLabelInvalid(t *testing.T) {
if _, err := parseLabel("不存在的标签", testTags); err == nil {
t.Fatal("expected error")
}
}
func TestParseLabelNumeric(t *testing.T) {
id, err := parseLabel("999", testTags)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != 999 {
t.Fatalf("got %d, want 999", id)
}
}
func TestLabelNamesReturnsAll(t *testing.T) {
names := labelNames(testTags)
if !strings.Contains(names, "缺陷") {
t.Fatal("missing 缺陷 in label names")
}
if !strings.Contains(names, "功能") {
t.Fatal("missing 功能 in label names")
}
}
// ---- buildCreateBody status tests ----
func TestBuildCreateBody_DefaultStatus(t *testing.T) {
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
input := createIssueInput{Title: "t", Status: ""}
body := buildCreateBody(ctx, input, "", testTags)
if intVal(body["status_id"]) != 1 {
t.Fatalf("default status_id: got %v (type %T), want 1", body["status_id"], body["status_id"])
}
}
func TestBuildCreateBody_ClosedStatus(t *testing.T) {
ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}}
input := createIssueInput{Title: "t", Status: "closed"}
body := buildCreateBody(ctx, input, "", testTags)
if intVal(body["status_id"]) != 5 {
t.Fatalf("closed status_id: got %v (type %T), want 5", body["status_id"], body["status_id"])
}
}
// ---- regression ----
func TestCollectIssueNumbers_FromBatchCreatePerspective(t *testing.T) {
got, err := collectIssueNumbers("1,2,3", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"1", "2", "3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}

View File

@ -1,55 +1,80 @@
package issue
import (
"fmt"
"fmt" //格式化字符串
"net/url"
"strconv"
"strconv" //字符串和数字转换
"strings"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Ctx
// v1RepoPath returns the v1 API path prefix: /v1/{owner}/{repo}
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
//内部数据结构,标题+描述,保存从 API 取回来的 Issue 原始数据
type existingIssue struct {
Subject string
Description string
}
// 所有issue命令的注册入口返回所有issue命令
// 在终端敲入issue +list命令时遍历匹配到列表里的{Name: "list", ...}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchStatusShortcut(),
newBatchPriorityShortcut(),
newBatchAssignShortcut(),
newBatchLabelShortcut(),
newBatchCreateShortcut(),
newBatchDestroyShortcut(),
newLabelAddShortcut(),
newLabelRemoveShortcut(),
newLabelListShortcut(),
{
Name: "list",
Description: "List issues",
Long: "List issues in a repository with optional filtering by state and pagination.",
Example: " gitlink-cli issue +list --state open\n gitlink-cli issue +list --state closed --page 1 --limit 50\n gitlink-cli issue +list --columns id,subject,status,priority --state all",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
{Name: "state", Short: "s", Usage: "Filter by state", Default: "open", Choices: []string{"open", "closed", "all"}},
{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 {
//后面拼接 API 路径时,需要用到 ctx.Owner 和 ctx.Repo
// 如果不知道 owner 和 repo ,后续的 API 调用就不知道该往哪发请求,所以必须作为前置校验放在最前面。
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("state"); s != "" {
q := url.Values{} // 创建空的URL查询参数容器q后续通过set()添加键值对,最终拼接成形如 ?page=1&limit=20&state=open 的查询字符串
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit")) //从命令行参数中读取 page页码和 limit每页条数
if s := ctx.Arg("state"); s != "" { // 如果用户传入了state参数
q.Set("state", s)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
return ctx.Output(env) //按照用户指定的格式json/html/table...)输出issue列表
},
},
{
Name: "create",
Description: "Create a new issue",
Long: "Create a new issue in the repository. Requires --title. Supports --body, --assignee, --milestone, and --label.",
Example: " gitlink-cli issue +create --title \"Bug: login crash\" --body \"Steps to reproduce...\"\n gitlink-cli issue +create --title \"Feature request\" --assignee zhangsan --label 3",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Create issue: %s", title), nil
},
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
{Name: "body", Short: "b", Usage: "Issue description"},
@ -61,7 +86,7 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, err := ctx.RequireArg("title")
title, err := ctx.RequireArg("title", `--title "Bug: 登录页崩溃"`)
if err != nil {
return err
}
@ -75,14 +100,14 @@ func Shortcuts() []*common.Shortcut {
body["description"] = desc
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a
body["assigner_ids"] = []interface{}{a}
}
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "create", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -90,6 +115,7 @@ func Shortcuts() []*common.Shortcut {
{
Name: "view",
Description: "View issue details",
Example: " gitlink-cli issue +view --number 42",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
@ -97,13 +123,13 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return err
return clierrors.OpError(clierrors.KindNotFound, "view", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -111,6 +137,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "close",
Description: "Close an issue",
Example: " gitlink-cli issue +close --number 42\n gitlink-cli issue +close --number 42 --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
number := ctx.Arg("number")
return fmt.Sprintf("Close issue #%s", number), nil
},
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
@ -118,7 +150,7 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
@ -133,6 +165,37 @@ func Shortcuts() []*common.Shortcut {
"status_id": 5, // 5 = closed
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "close", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "reopen",
Description: "Reopen a closed issue",
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", "--number 42")
if err != nil {
return err
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return err
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": 1, // 1 = open
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
}
@ -142,17 +205,23 @@ func Shortcuts() []*common.Shortcut {
{
Name: "update",
Description: "Update an issue",
Example: " gitlink-cli issue +update --number 42 --title \"Updated title\"\n gitlink-cli issue +update --number 42 --state closed",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
number := ctx.Arg("number")
return fmt.Sprintf("Update issue #%s", number), nil
},
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: "state", Short: "s", Usage: "New state", Validate: validateIssueState},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
@ -160,7 +229,10 @@ func Shortcuts() []*common.Shortcut {
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")
return clierrors.InputError(
"at least one of --title, --body, or --state is required",
"至少需要提供 --title、--body 或 --state 中的一个参数",
).WithCommand(ctx.CommandName)
}
current, err := fetchExistingIssue(ctx, number)
@ -179,7 +251,7 @@ func Shortcuts() []*common.Shortcut {
body["description"] = b
}
if s := ctx.Arg("state"); s != "" {
statusID, err := normalizeIssueStatus(s)
statusID, err := issueStateToStatusID(s)
if err != nil {
return err
}
@ -187,7 +259,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "update", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -195,6 +267,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "comment",
Description: "Add a comment to an issue",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
number := ctx.Arg("number")
return fmt.Sprintf("Add comment to issue #%s", number), nil
},
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},
@ -203,11 +280,11 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
body, err := ctx.RequireArg("body", `--body "可以这样复现..."`)
if err != nil {
return err
}
@ -216,8 +293,176 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "comment", "issue", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === 元数据查询 ===
{
Name: "statuses",
Description: "List issue statuses",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_statues", nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue statuses", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "authors",
Description: "List issue authors",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if kw := ctx.Arg("keyword"); kw != "" {
q.Set("keyword", kw)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_authors", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue authors", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "assigners",
Description: "List issue assignees",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if kw := ctx.Arg("keyword"); kw != "" {
q.Set("keyword", kw)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_assigners", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue assignees", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "priorities",
Description: "List issue priorities",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_priorities", nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issue priorities", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === 评论管理 ===
{
Name: "comment-edit",
Description: "Edit an issue comment",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue 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
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
body, err := ctx.RequireArg("body", `--body "updated comment"`)
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
"attachment_ids": []int{},
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, commentID), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "edit", "comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "comment-delete",
Description: "Delete an issue comment",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除 Issue #%s 的评论 #%s", ctx.Arg("number"), ctx.Arg("comment-id")), nil
},
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue 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
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, commentID), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "delete", "comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "replies",
Description: "List replies to a comment",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
{Name: "comment-id", Short: "c", Usage: "Parent comment ID", 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", "--number 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
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/%s/children_journals", v1RepoPath(ctx), number, commentID), q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "replies", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
@ -227,15 +472,15 @@ func Shortcuts() []*common.Shortcut {
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return nil, err
return nil, clierrors.OpError(clierrors.KindNotFound, "view", "issue", err).WithCommand(ctx.CommandName)
}
issueData, ok := getEnv.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("failed to parse issue data")
return nil, clierrors.InputError("failed to parse issue data", "API 返回格式异常,请稍后重试").WithCommand(ctx.CommandName)
}
subject, _ := issueData["subject"].(string)
if subject == "" {
return nil, fmt.Errorf("failed to parse issue subject")
return nil, clierrors.InputError("failed to parse issue subject", "API 返回数据中缺少 subject 字段,请稍后重试").WithCommand(ctx.CommandName)
}
description, _ := issueData["description"].(string)
return &existingIssue{
@ -244,7 +489,18 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss
}, nil
}
func normalizeIssueStatus(state string) (interface{}, error) {
func validateIssueState(state string) error {
state = strings.ToLower(strings.TrimSpace(state))
if state == "open" || state == "closed" {
return nil
}
if _, err := strconv.Atoi(state); err == nil {
return nil
}
return fmt.Errorf("must be \"open\", \"closed\", or a numeric status_id, got %q", state)
}
func issueStateToStatusID(state string) (interface{}, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
return 1, nil
@ -254,6 +510,6 @@ func normalizeIssueStatus(state string) (interface{}, error) {
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
return nil, fmt.Errorf("invalid state: %s", state)
}
}

View File

@ -186,3 +186,201 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
// === 新增命令测试 ===
func TestStatusesCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"total_count": float64(2),
"statues": []interface{}{},
})
})
defer server.Close()
err := runIssueShortcut(t, server, "statuses", map[string]string{})
if err != nil {
t.Fatalf("statuses failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issue_statues.json")
}
func TestAuthorsCallsCorrectEndpoint(t *testing.T) {
var requestedPath, requestedKeyword string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedKeyword = r.URL.Query().Get("keyword")
writeJSON(t, w, map[string]interface{}{"authors": []interface{}{}})
})
defer server.Close()
err := runIssueShortcut(t, server, "authors", map[string]string{"keyword": "zhang"})
if err != nil {
t.Fatalf("authors failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issue_authors.json")
assertEqual(t, requestedKeyword, "zhang")
}
func TestAssignersCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"assigners": []interface{}{}})
})
defer server.Close()
err := runIssueShortcut(t, server, "assigners", map[string]string{})
if err != nil {
t.Fatalf("assigners failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issue_assigners.json")
}
func TestPrioritiesCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"priorities": []interface{}{}})
})
defer server.Close()
err := runIssueShortcut(t, server, "priorities", map[string]string{})
if err != nil {
t.Fatalf("priorities failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issue_priorities.json")
}
func TestCommentEditSendsNotesAndAttachmentIDs(t *testing.T) {
var payload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": float64(1)})
})
defer server.Close()
err := runIssueShortcut(t, server, "comment-edit", map[string]string{
"number": "42",
"comment-id": "100",
"body": "updated comment",
})
if err != nil {
t.Fatalf("comment-edit failed: %v", err)
}
assertEqual(t, payload["notes"], "updated comment")
}
func TestCommentEditRequiresNumber(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runIssueShortcut(t, server, "comment-edit", map[string]string{
"comment-id": "100", "body": "test",
})
if err == nil {
t.Fatal("expected error for missing --number")
}
}
func TestCommentDeleteCallsCorrectEndpoint(t *testing.T) {
var requestedPath, requestedMethod string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedMethod = r.Method
writeJSON(t, w, map[string]interface{}{"status": float64(1)})
})
defer server.Close()
err := runIssueShortcut(t, server, "comment-delete", map[string]string{
"number": "42",
"comment-id": "100",
})
if err != nil {
t.Fatalf("comment-delete failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issues/42/journals/100.json")
assertEqual(t, requestedMethod, "DELETE")
}
func TestBatchDestroyCallsBatchDestroyEndpoint(t *testing.T) {
var requestedPath, requestedMethod string
var payload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedMethod = r.Method
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "success"})
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-destroy", map[string]string{
"numbers": "1,2,3",
})
if err != nil {
t.Fatalf("batch-destroy failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issues/batch_destroy.json")
assertEqual(t, requestedMethod, "DELETE")
ids, ok := payload["ids"].([]interface{})
if !ok {
t.Fatalf("ids should be array, got %T", payload["ids"])
}
assertEqual(t, len(ids), 3)
assertEqual(t, ids[0], float64(1))
assertEqual(t, ids[1], float64(2))
assertEqual(t, ids[2], float64(3))
}
func TestBatchDestroyDryRun(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("should not call API in dry-run mode")
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-destroy", map[string]string{
"numbers": "10,20",
"dry-run": "true",
})
if err != nil {
t.Fatalf("batch-destroy dry-run failed: %v", err)
}
}
func TestBatchDestroyRequiresNumbers(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runIssueShortcut(t, server, "batch-destroy", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --numbers")
}
}
func TestRepliesCallsChildrenJournals(t *testing.T) {
var requestedPath string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"journals": []interface{}{}})
})
defer server.Close()
err := runIssueShortcut(t, server, "replies", map[string]string{
"number": "42",
"comment-id": "100",
})
if err != nil {
t.Fatalf("replies failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/issues/42/journals/100/children_journals.json")
}
func assertPath(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Fatalf("path: got %s, want %s", got, want)
}
}

92
shortcuts/issue/label.go Normal file
View File

@ -0,0 +1,92 @@
package issue
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func newLabelAddShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "label-add",
Description: "Add labels to an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
{Name: "labels", Short: "l", Usage: "Comma-separated label names or IDs", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
labelsStr, err := ctx.RequireArg("labels", `--labels "bug,urgent"`)
if err != nil {
return err
}
body := map[string]interface{}{
"labels": labelsStr,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), body)
if err != nil {
return fmt.Errorf("添加标签失败: %w", err)
}
return ctx.Output(env)
},
}
}
func newLabelRemoveShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "label-remove",
Description: "Remove a label from an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
{Name: "label", Short: "l", Usage: "Label ID to remove", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
label, err := ctx.RequireArg("label", "--label bug")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/labels/%s", v1RepoPath(ctx), number, label), nil)
if err != nil {
return fmt.Errorf("删除标签失败: %w", err)
}
return ctx.Output(env)
},
}
}
func newLabelListShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "label-list",
Description: "List labels on an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), nil)
if err != nil {
return fmt.Errorf("获取标签列表失败: %w", err)
}
return ctx.Output(env)
},
}
}

View File

@ -0,0 +1,254 @@
package milestone
import (
"fmt"
"net/url"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// v1RepoPath returns /v1/{owner}/{repo}
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List milestones",
Flags: []common.Flag{
{Name: "category", Short: "c", Usage: "Filter: opening, closed", Choices: []string{"opening", "closed"}},
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{Name: "sort-by", Usage: "Sort field: created_on, updated_on, effective_date, issues_count, percent"},
{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"))
if cat := ctx.Arg("category"); cat != "" {
q.Set("category", cat)
}
if kw := ctx.Arg("keyword"); kw != "" {
q.Set("keyword", kw)
}
if sb := ctx.Arg("sort-by"); sb != "" {
q.Set("sort_by", sb)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/milestones", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "milestones", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a milestone",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("创建里程碑: %s", ctx.Arg("name")), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Milestone name", Required: true},
{Name: "description", Short: "d", Usage: "Description"},
{Name: "due", Usage: "Due date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, _ := ctx.RequireArg("name", `--name "My Name"`)
body := map[string]interface{}{
"title": name,
}
if d := ctx.Arg("description"); d != "" {
body["description"] = d
}
if due := ctx.Arg("due"); due != "" {
body["due_date"] = due
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/milestones", body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "create", "milestone", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View milestone details with issues",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
{Name: "category", Short: "c", Usage: "Issue filter: all, opened, closed", Default: "all"},
{Name: "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
}
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if cat := ctx.Arg("category"); cat != "" {
q.Set("category", cat)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), q)
if err != nil {
return clierrors.OpError(clierrors.KindNotFound, "view", "milestone", err).WithCommand(ctx.CommandName)
}
// API returns {milestone: {...}, issues: [...], ...}; extract milestone for display.
if data, ok := env.Data.(map[string]interface{}); ok {
if ms, ok := data["milestone"]; ok {
return ctx.OutputData(ms)
}
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a milestone",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新里程碑 #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
{Name: "name", Short: "n", Usage: "New name"},
{Name: "description", Short: "d", Usage: "New description"},
{Name: "date", Usage: "New effective date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
// Fetch existing milestone to fill required fields
existing, err := fetchMilestone(ctx, id)
if err != nil {
return err
}
body := map[string]interface{}{
"name": existing["name"],
"description": existing["description"],
"effective_date": existing["effective_date"],
}
if n := ctx.Arg("name"); n != "" {
body["name"] = n
}
if d := ctx.Arg("description"); d != "" {
body["description"] = d
}
if dt := ctx.Arg("date"); dt != "" {
body["effective_date"] = dt
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "update", "milestone", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a milestone",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除里程碑 #%s", ctx.Arg("id")), nil
},
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", "--id 1")
if err != nil {
return err
}
// API requires body with name/description/effective_date
existing, err := fetchMilestone(ctx, id)
if err != nil {
return err
}
body := map[string]interface{}{
"name": existing["name"],
"description": existing["description"],
"effective_date": existing["effective_date"],
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "delete", "milestone", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "status",
Description: "Update milestone status (open/close)",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新里程碑 #%s 状态为 %s", ctx.Arg("id"), ctx.Arg("status")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
{Name: "status", Short: "s", Usage: "New status", Required: true, Choices: []string{"opening", "closed"}},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
status, err := ctx.RequireArg("status", "--status closed")
if err != nil {
return err
}
body := map[string]interface{}{
"status": status,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/milestones/%s/update_status", ctx.RepoPath(), id), body)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "update", "milestone status", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
}
}
// fetchMilestone retrieves a milestone to get its current fields (needed for update/delete).
func fetchMilestone(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return nil, clierrors.OpError(clierrors.KindNotFound, "view", "milestone", err).WithCommand(ctx.CommandName)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, clierrors.InputError("unexpected milestone format", "API 返回格式异常").WithCommand(ctx.CommandName)
}
// The response wraps in "milestone" key
if ms, ok := data["milestone"].(map[string]interface{}); ok {
return ms, nil
}
// Fallback: maybe flat structure
return data, nil
}

View File

@ -0,0 +1,264 @@
package milestone
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"
)
// === list ===
func TestListCallsMilestonesEndpoint(t *testing.T) {
var requestedPath string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"total_count": float64(2),
"opening_milestone_count": float64(1),
"closed_milestone_count": float64(1),
"milestones": []interface{}{},
})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("list failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/milestones.json")
}
func TestListPassesFilters(t *testing.T) {
var cat, kw, sortBy string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
cat = r.URL.Query().Get("category")
kw = r.URL.Query().Get("keyword")
sortBy = r.URL.Query().Get("sort_by")
writeJSON(t, w, map[string]interface{}{"milestones": []interface{}{}})
})
defer server.Close()
err := runShortcut(t, server, "list", map[string]string{
"category": "opening",
"keyword": "v1",
"sort-by": "issues_count",
})
if err != nil {
t.Fatalf("list with filters failed: %v", err)
}
assertEqual(t, cat, "opening")
assertEqual(t, kw, "v1")
assertEqual(t, sortBy, "issues_count")
}
// === create ===
func TestCreateSendsPayload(t *testing.T) {
var payload map[string]interface{}
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "success"})
})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{
"name": "v1.0",
"description": "First release",
"date": "2026-12-31",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
assertEqual(t, payload["name"], "v1.0")
assertEqual(t, payload["description"], "First release")
assertEqual(t, payload["effective_date"], "2026-12-31")
}
func TestCreateRequiresName(t *testing.T) {
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {})
defer server.Close()
err := runShortcut(t, server, "create", map[string]string{
"description": "desc", "date": "2026-12-31",
})
if err == nil {
t.Fatal("expected error for missing --name")
}
}
// === view ===
func TestViewCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"milestone": map[string]interface{}{"id": float64(1), "name": "v1.0"},
})
})
defer server.Close()
err := runShortcut(t, server, "view", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("view failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/milestones/1.json")
}
// === update ===
func TestUpdateAutoFetchesExisting(t *testing.T) {
var updatePayload map[string]interface{}
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
writeJSON(t, w, map[string]interface{}{
"milestone": map[string]interface{}{
"name": "v1.0",
"description": "Old desc",
"effective_date": "2026-12-31",
},
})
case r.Method == "PATCH":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
default:
t.Fatalf("unexpected: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "update", map[string]string{
"id": "1",
"name": "v1.0-rc1",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
assertEqual(t, updatePayload["name"], "v1.0-rc1")
assertEqual(t, updatePayload["description"], "Old desc")
assertEqual(t, updatePayload["effective_date"], "2026-12-31")
}
// === delete ===
func TestDeleteAutoFetchesExisting(t *testing.T) {
var deletePath string
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET":
writeJSON(t, w, map[string]interface{}{
"milestone": map[string]interface{}{
"name": "v1.0",
"description": "desc",
"effective_date": "2026-12-31",
},
})
case r.Method == "DELETE":
deletePath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
default:
t.Fatalf("unexpected: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runShortcut(t, server, "delete", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
assertPath(t, deletePath, "/v1/owner/repo/milestones/1.json")
}
// === status ===
func TestStatusCallsUpdateStatusEndpoint(t *testing.T) {
var requestedPath string
var payload map[string]interface{}
server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
})
defer server.Close()
err := runShortcut(t, server, "status", map[string]string{
"id": "1",
"status": "closed",
})
if err != nil {
t.Fatalf("status failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/milestones/1/update_status.json")
assertEqual(t, payload["status"], "closed")
}
// === helpers ===
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(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 findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("milestone shortcut %q not found", name)
return nil
}
func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
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("decode JSON failed: %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("write JSON failed: %v", err)
}
}
func assertEqual(t *testing.T, got, 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)
}
}
func assertPath(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Fatalf("path: got %s, want %s", got, want)
}
}

147
shortcuts/org/batch.go Normal file
View File

@ -0,0 +1,147 @@
package org
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// BatchShortcuts 返回组织级批量成员管理 Shortcut
func BatchShortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "batch-invite",
Description: "Batch invite members to a project (from --users list or --from CSV file)",
Flags: []common.Flag{
{Name: "users", Usage: "Comma-separated list of user IDs to invite"},
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
},
Run: runOrgBatchInvite,
},
{
Name: "batch-remove",
Description: "Batch remove members from a project (from --users list or --from CSV file)",
Flags: []common.Flag{
{Name: "users", Usage: "Comma-separated list of user IDs to remove"},
{Name: "from", Usage: "CSV file with user IDs (column: user_id)"},
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
{Name: "dry-run", Usage: "Preview operations without executing", Bool: true},
},
Run: runOrgBatchRemove,
},
}
}
// runOrgBatchInvite 组织级批量邀请
func runOrgBatchInvite(ctx *common.RuntimeContext) error {
userIDs, err := parseBatchUserIDs(ctx)
if err != nil {
return err
}
dryRun := ctx.Arg("dry-run") == "true"
return inviteToOrgProjects(ctx, userIDs, dryRun)
}
// runOrgBatchRemove 组织级批量移除
func runOrgBatchRemove(ctx *common.RuntimeContext) error {
userIDs, err := parseBatchUserIDs(ctx)
if err != nil {
return err
}
dryRun := ctx.Arg("dry-run") == "true"
return removeFromOrgProjects(ctx, userIDs, dryRun)
}
// parseBatchUserIDs 从 --users 或 --from CSV 解析用户 ID 列表
func parseBatchUserIDs(ctx *common.RuntimeContext) ([]int, error) {
usersStr := ctx.Arg("users")
csvFile := ctx.Arg("from")
if usersStr == "" && csvFile == "" {
return nil, fmt.Errorf("must specify --users (comma-separated user IDs) or --from (CSV file path)")
}
var userIDs []int
if usersStr != "" {
ids, err := parseUserIDList(usersStr)
if err != nil {
return nil, err
}
userIDs = append(userIDs, ids...)
}
if csvFile != "" {
ids, err := parseCSVUserIDs(csvFile)
if err != nil {
return nil, err
}
userIDs = append(userIDs, ids...)
}
if len(userIDs) == 0 {
return nil, fmt.Errorf("no valid user IDs found")
}
return userIDs, nil
}
// parseCSVUserIDs 从 CSV 文件读取 user_id 列
func parseCSVUserIDs(filePath string) ([]int, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open CSV file %s: %w", filePath, err)
}
defer f.Close()
reader := csv.NewReader(f)
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("failed to read CSV file: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV file must have a header row and at least one data row")
}
// 查找 user_id 列
header := records[0]
colIdx := -1
for i, h := range header {
if strings.TrimSpace(h) == "user_id" {
colIdx = i
break
}
}
if colIdx == -1 {
return nil, fmt.Errorf("CSV file must have a 'user_id' column")
}
var ids []int
for _, row := range records[1:] {
if len(row) <= colIdx {
continue
}
s := strings.TrimSpace(row[colIdx])
if s == "" {
continue
}
uid, err := strconv.Atoi(s)
if err != nil {
return nil, fmt.Errorf("invalid user ID in CSV: %s", s)
}
ids = append(ids, uid)
}
return ids, nil
}

View File

@ -3,12 +3,14 @@ package org
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
shortcuts := []*common.Shortcut{
{
Name: "list",
Description: "List organizations",
@ -22,7 +24,7 @@ func Shortcuts() []*common.Shortcut {
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", "/organizations", q)
if err != nil {
return err
return fmt.Errorf("获取组织列表失败: %w", err)
}
return ctx.Output(env)
},
@ -34,11 +36,14 @@ func Shortcuts() []*common.Shortcut {
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
id, err := ctx.RequireArg("id", "--id my-org")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
if err != nil {
return fmt.Errorf("查看组织失败: %w", err)
}
return ctx.Output(env)
},
},
@ -51,13 +56,16 @@ func Shortcuts() []*common.Shortcut {
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
id, _ := ctx.RequireArg("id")
id, err := ctx.RequireArg("id", "--id my-org")
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("/organizations/%s/organization_users", id), q)
if err != nil {
return err
return fmt.Errorf("获取组织成员失败: %w", err)
}
return ctx.Output(env)
},
@ -65,12 +73,20 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create an organization",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
return fmt.Sprintf("Create organization: %s", name), nil
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Organization name", Required: true},
{Name: "description", Short: "d", Usage: "Description"},
},
Run: func(ctx *common.RuntimeContext) error {
name, _ := ctx.RequireArg("name")
name, err := ctx.RequireArg("name", `--name "My Organization"`)
if err != nil {
return err
}
payload := map[string]interface{}{
"name": name,
}
@ -79,10 +95,235 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", "/organizations", payload)
if err != nil {
return err
return fmt.Errorf("创建组织失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an organization",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新组织 #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
{Name: "name", Short: "n", Usage: "New name"},
{Name: "description", Short: "d", Usage: "New description"},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id", "--id my-org")
if err != nil {
return err
}
body := map[string]interface{}{}
if n := ctx.Arg("name"); n != "" {
body["name"] = n
}
if d := ctx.Arg("description"); d != "" {
body["description"] = d
}
if len(body) == 0 {
return fmt.Errorf("at least one of --name, --description is required")
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/organizations/%s", id), body)
if err != nil {
return fmt.Errorf("更新组织失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete an organization",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除组织 #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id", "--id my-org")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s", id), nil)
if err != nil {
return fmt.Errorf("删除组织失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "invite",
Description: "Invite a member to a project",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
userID := ctx.Arg("user-id")
owner := ctx.Arg("owner")
repo := ctx.Arg("repo")
return fmt.Sprintf("Invite user %s to %s/%s", userID, owner, repo), nil
},
Flags: []common.Flag{
{Name: "user-id", Usage: "User ID to invite (required)", Required: true},
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
userID, err := ctx.RequireArg("user-id", "--user-id 42")
if err != nil {
return err
}
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
}
return inviteToOrgProjects(ctx, []int{uid}, false)
},
},
{
Name: "remove-member",
Description: "Remove a member from a project",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
userID := ctx.Arg("user-id")
owner := ctx.Arg("owner")
repo := ctx.Arg("repo")
return fmt.Sprintf("Remove user %s from %s/%s", userID, owner, repo), nil
},
Flags: []common.Flag{
{Name: "user-id", Usage: "User ID to remove (required)", Required: true},
{Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true},
{Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
userID, err := ctx.RequireArg("user-id", "--user-id 42")
if err != nil {
return err
}
uid, err := strconv.Atoi(userID)
if err != nil {
return fmt.Errorf("invalid user-id: %s (must be an integer)", userID)
}
return removeFromOrgProjects(ctx, []int{uid}, false)
},
},
}
// 合并批量成员管理命令
shortcuts = append(shortcuts, BatchShortcuts()...)
return shortcuts
}
// inviteToOrgProjects 向指定项目邀请用户
func inviteToOrgProjects(ctx *common.RuntimeContext, userIDs []int, dryRun bool) error {
owner, repo, err := resolveOrgProject(ctx)
if err != nil {
return err
}
results := make([]map[string]interface{}, 0, len(userIDs))
for _, uid := range userIDs {
if dryRun {
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", owner, repo),
"action": "invite",
"status": "would execute (dry-run)",
})
continue
}
body := map[string]interface{}{"user_id": uid}
_, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", owner, repo), body)
status := "success"
msg := ""
if err != nil {
status = "failed"
msg = err.Error()
}
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", owner, repo),
"action": "invite",
"status": status,
"message": msg,
})
}
return ctx.OutputData(results)
}
// removeFromOrgProjects 从指定项目移除用户
func removeFromOrgProjects(ctx *common.RuntimeContext, userIDs []int, dryRun bool) error {
owner, repo, err := resolveOrgProject(ctx)
if err != nil {
return err
}
results := make([]map[string]interface{}, 0, len(userIDs))
for _, uid := range userIDs {
if dryRun {
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", owner, repo),
"action": "remove",
"status": "would execute (dry-run)",
})
continue
}
body := map[string]interface{}{"user_id": uid}
_, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", owner, repo), body)
status := "success"
msg := ""
if err != nil {
status = "failed"
msg = err.Error()
}
results = append(results, map[string]interface{}{
"user_id": uid,
"project": fmt.Sprintf("%s/%s", owner, repo),
"action": "remove",
"status": status,
"message": msg,
})
}
return ctx.OutputData(results)
}
// resolveOrgProject 从 --owner/--repo 解析目标项目
func resolveOrgProject(ctx *common.RuntimeContext) (owner, repo string, err error) {
owner = ctx.Arg("owner")
repo = ctx.Arg("repo")
if owner == "" || repo == "" {
return "", "", fmt.Errorf("must specify --owner and --repo (e.g., --owner zzx-coder --repo gitlink-cli)")
}
return owner, repo, nil
}
// parseUserIDList 解析逗号分隔的用户ID字符串
func parseUserIDList(input string) ([]int, error) {
var ids []int
for _, s := range strings.Split(input, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
uid, err := strconv.Atoi(s)
if err != nil {
return nil, fmt.Errorf("invalid user ID: %s", s)
}
ids = append(ids, uid)
}
if len(ids) == 0 {
return nil, fmt.Errorf("no valid user IDs found")
}
return ids, nil
}

View File

@ -4,17 +4,22 @@ import (
"fmt"
"net/url"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newApproveShortcut(),
newRequestChangesShortcut(),
newReviewsShortcut(),
{
Name: "list",
Description: "List pull requests",
Example: " gitlink-cli pr +list --state open\n gitlink-cli pr +list --state merged --page 1 --limit 50\n gitlink-cli pr +list --columns id,title,state,user --state all",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
{Name: "state", Short: "s", Usage: "Filter by state", Default: "open", Choices: []string{"open", "merged", "closed", "all"}},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
@ -30,7 +35,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "list", "pull requests", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -38,6 +43,18 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a pull request",
Long: "Create a new pull request from --head branch to --base branch. Requires --title and --head.",
Example: " gitlink-cli pr +create --title \"Fix login crash\" --head feat/new-login\n gitlink-cli pr +create --title \"New feature\" --head dev --base master --body \"Description...\"",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
head := ctx.Arg("head")
base := ctx.Arg("base")
if base == "" {
base = "master"
}
return fmt.Sprintf("Create PR: %s (%s -> %s)", title, head, base), nil
},
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "PR title", Required: true},
{Name: "body", Short: "b", Usage: "PR description"},
@ -48,8 +65,14 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
title, _ := ctx.RequireArg("title")
head, _ := ctx.RequireArg("head")
title, err := ctx.RequireArg("title", `--title "Fix login crash"`)
if err != nil {
return err
}
head, err := ctx.RequireArg("head", `--head feat/new-login`)
if err != nil {
return err
}
base := ctx.Arg("base")
if base == "" {
base = "master"
@ -64,7 +87,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "create", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -79,26 +102,42 @@ 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", ctx.RepoPath(), id), nil)
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindNotFound, "view", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "merge",
Description: "Merge a pull request",
Example: " gitlink-cli pr +merge --id 42\n gitlink-cli pr +merge --id 42 --method rebase\n gitlink-cli pr +merge --id 42 --method squash --dry-run",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
id := ctx.Arg("id")
method := ctx.Arg("method")
if method == "" {
method = "merge"
}
return fmt.Sprintf("Merge PR #%s (method: %s)", id, method), nil
},
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: "method", Short: "m", Usage: "Merge method", Default: "merge", Choices: []string{"merge", "rebase", "squash"}},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
method := ctx.Arg("method")
if method == "" {
method = "merge"
@ -108,7 +147,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
if err != nil {
return err
return clierrors.OpError(clierrors.KindServer, "merge", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
@ -116,6 +155,11 @@ func Shortcuts() []*common.Shortcut {
{
Name: "close",
Description: "Close a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
id := ctx.Arg("id")
return fmt.Sprintf("Close PR #%s", id), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
@ -123,11 +167,14 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "close", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
@ -141,11 +188,14 @@ 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", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "PR files", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
@ -159,17 +209,25 @@ 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", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "view", "PR diff", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "comment",
Description: "Add a comment to a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
id := ctx.Arg("id")
return fmt.Sprintf("Add comment to PR #%s", id), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
@ -178,12 +236,18 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
body, _ := ctx.RequireArg("body")
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
body, err := ctx.RequireArg("body", `--body "Looks good to me"`)
if err != nil {
return err
}
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("fetch PR: %w", err)
return clierrors.OpError(clierrors.KindNotFound, "view", "pull request", err).WithCommand(ctx.CommandName)
}
issueID, err := extractIssueID(prEnv)
if err != nil {
@ -195,8 +259,268 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "comment", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === PR 增强 ===
{
Name: "reopen",
Description: "Reopen a closed pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("重新打开 PR #%s", ctx.Arg("id")), nil
},
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", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/pulls/%s/reopen", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "reopen", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新 PR #%s", ctx.Arg("id")), nil
},
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"},
{Name: "head", Usage: "Source branch"},
{Name: "base", Usage: "Target branch"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
// Fetch existing PR to fill required fields
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindNotFound, "view", "pull request", err).WithCommand(ctx.CommandName)
}
prData, ok := prEnv.Data.(map[string]interface{})
if !ok {
return clierrors.InputError("unexpected PR format", "API 返回格式异常").WithCommand(ctx.CommandName)
}
payload := map[string]interface{}{
"title": prData["title"],
"body": prData["body"],
"head": prData["head"],
"base": prData["base"],
"issue_tag_ids": []string{},
"receivers_login": []string{},
}
if t := ctx.Arg("title"); t != "" {
payload["title"] = t
}
if b := ctx.Arg("body"); b != "" {
payload["body"] = b
}
if h := ctx.Arg("head"); h != "" {
payload["head"] = h
}
if bs := ctx.Arg("base"); bs != "" {
payload["base"] = bs
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "update", "pull request", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "commits",
Description: "List commits in a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/commits", ctx.RepoPath(), id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "PR commits", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "versions",
Description: "List versions of a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/pulls/%s/versions", ctx.Owner, ctx.Repo, id), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "PR versions", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "vdiff",
Description: "Show diff of a specific PR version",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "version", Short: "v", Usage: "Version ID", Required: true},
{Name: "filepath", Usage: "Filter by file path"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
versionID, err := ctx.RequireArg("version", "--version 5")
if err != nil {
return err
}
q := url.Values{}
if fp := ctx.Arg("filepath"); fp != "" {
q.Set("filepath", fp)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/pulls/%s/versions/%s/diff", ctx.Owner, ctx.Repo, id, versionID), q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "view", "PR version diff", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "filesv1",
Description: "List changed files (v1 API with pagination)",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "filepath", Usage: "Filter by file path"},
{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
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if fp := ctx.Arg("filepath"); fp != "" {
q.Set("filepath", fp)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/pulls/%s/files", ctx.Owner, ctx.Repo, id), q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "PR files", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
// === PR 评论管理 ===
{
Name: "comment-edit",
Description: "Edit a PR review comment",
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},
{Name: "commit", Usage: "Commit SHA"},
{Name: "state", Usage: "Comment state", Default: "opened", Choices: []string{"opened", "resolved", "disabled"}},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
body, err := ctx.RequireArg("body", `--body "updated comment"`)
if err != nil {
return err
}
payload := map[string]interface{}{
"note": body,
"state": ctx.Arg("state"),
}
if commit := ctx.Arg("commit"); commit != "" {
payload["commit_id"] = commit
} else {
payload["commit_id"] = ""
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("/v1/%s/%s/pulls/%s/journals/%s", ctx.Owner, ctx.Repo, id, commentID), payload)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "edit", "PR comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
{
Name: "comment-delete",
Description: "Delete a PR review comment",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("删除 PR #%s 的评论 #%s", ctx.Arg("id"), ctx.Arg("comment-id")), nil
},
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", "--id 42")
if err != nil {
return err
}
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1/%s/%s/pulls/%s/journals/%s", ctx.Owner, ctx.Repo, id, commentID), nil)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "delete", "PR comment", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
},
},
@ -206,15 +530,15 @@ func Shortcuts() []*common.Shortcut {
func extractIssueID(env *output.Envelope) (int64, error) {
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected PR response format")
return 0, clierrors.InputError("unexpected PR response format", "API 返回格式异常,请稍后重试")
}
issue, ok := data["issue"].(map[string]interface{})
if !ok {
return 0, fmt.Errorf("PR response missing issue field")
return 0, clierrors.InputError("PR response missing issue field", "API 返回数据中缺少 issue 字段,请稍后重试")
}
idFloat, ok := issue["id"].(float64)
if !ok {
return 0, fmt.Errorf("PR response missing issue.id field")
return 0, clierrors.InputError("PR response missing issue.id field", "API 返回数据中缺少 issue.id 字段,请稍后重试")
}
return int64(idFloat), nil
}

View File

@ -141,3 +141,186 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func assertPath(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Fatalf("path: got %s, want %s", got, want)
}
}
// === PR 增强测试 ===
func TestReopenCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"status": float64(1)})
}))
defer server.Close()
err := runPRShortcut(t, server, "reopen", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("reopen failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/pulls/42/reopen.json")
}
func TestUpdateAutoFetchesExistingPR(t *testing.T) {
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/42.json":
writeJSON(t, w, map[string]interface{}{
"title": "Old title",
"body": "Old body",
"head": "feature",
"base": "master",
})
case r.Method == "PUT" && r.URL.Path == "/owner/repo/pulls/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": float64(1)})
default:
t.Fatalf("unexpected: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runPRShortcut(t, server, "update", map[string]string{
"id": "42",
"title": "New title",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
assertEqual(t, updatePayload["title"], "New title")
assertEqual(t, updatePayload["body"], "Old body")
assertEqual(t, updatePayload["head"], "feature")
assertEqual(t, updatePayload["base"], "master")
}
func TestCommitsCallsCorrectEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"commits_count": float64(2),
"commits": []interface{}{},
})
}))
defer server.Close()
err := runPRShortcut(t, server, "commits", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("commits failed: %v", err)
}
assertPath(t, requestedPath, "/owner/repo/pulls/42/commits.json")
}
func TestVersionsCallsV1Endpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"versions": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "versions", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("versions failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/pulls/42/versions.json")
}
func TestVdiffCallsVersionDiffEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "vdiff", map[string]string{
"id": "42",
"version": "5",
})
if err != nil {
t.Fatalf("vdiff failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/pulls/42/versions/5/diff.json")
}
func TestVdiffPassesFilepathFilter(t *testing.T) {
var fp string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fp = r.URL.Query().Get("filepath")
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "vdiff", map[string]string{
"id": "42",
"version": "5",
"filepath": "main.go",
})
if err != nil {
t.Fatalf("vdiff with filepath failed: %v", err)
}
assertEqual(t, fp, "main.go")
}
func TestFilesv1CallsV1FilesEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
}))
defer server.Close()
err := runPRShortcut(t, server, "filesv1", map[string]string{"id": "42"})
if err != nil {
t.Fatalf("filesv1 failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/pulls/42/files.json")
}
func TestCommentEditSendsNoteSingular(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": float64(1)})
}))
defer server.Close()
err := runPRShortcut(t, server, "comment-edit", map[string]string{
"id": "42",
"comment-id": "100",
"body": "updated review",
"state": "resolved",
})
if err != nil {
t.Fatalf("comment-edit failed: %v", err)
}
assertEqual(t, payload["note"], "updated review")
assertEqual(t, payload["state"], "resolved")
}
func TestCommentDeleteCallsCorrectEndpoint(t *testing.T) {
var requestedPath, requestedMethod string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
requestedMethod = r.Method
writeJSON(t, w, map[string]interface{}{"status": float64(1)})
}))
defer server.Close()
err := runPRShortcut(t, server, "comment-delete", map[string]string{
"id": "42",
"comment-id": "100",
})
if err != nil {
t.Fatalf("comment-delete failed: %v", err)
}
assertPath(t, requestedPath, "/v1/owner/repo/pulls/42/journals/100.json")
assertEqual(t, requestedMethod, "DELETE")
}

103
shortcuts/pr/review.go Normal file
View File

@ -0,0 +1,103 @@
package pr
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func newApproveShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "approve",
Description: "Approve a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("批准 PR #%s", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Review comment (optional)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
body := map[string]interface{}{
"state": "approved",
}
if b := ctx.Arg("body"); b != "" {
body["body"] = b
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), body)
if err != nil {
return fmt.Errorf("批准 PR 失败: %w", err)
}
return ctx.Output(env)
},
}
}
func newRequestChangesShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "request-changes",
Description: "Request changes on a pull request",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("请求 PR #%s 修改", ctx.Arg("id")), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Review comment explaining what needs to change", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
body, err := ctx.RequireArg("body", `--body "Looks good"`)
if err != nil {
return err
}
payload := map[string]interface{}{
"state": "changes_requested",
"body": body,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload)
if err != nil {
return fmt.Errorf("请求修改 PR 失败: %w", err)
}
return ctx.Output(env)
},
}
}
func newReviewsShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "reviews",
Description: "List reviews for a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 42")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 PR 评审列表失败: %w", err)
}
return ctx.Output(env)
},
}
}

View File

@ -3,48 +3,135 @@ package shortcuts
import (
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/shortcuts/board"
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/file"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"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/team"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
)
type groupInfo struct {
Short string
Long string
Example string
}
// 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(),
"board": board.Shortcuts(),
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"file": file.Shortcuts(),
"team": team.Shortcuts(),
"wiki": wiki.Shortcuts(),
"webhook": webhook.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",
infos := map[string]groupInfo{
"board": {
Short: "Board (kanban) operations",
Long: "View and manage kanban boards: view board layout, list columns, filter issues, move tasks between columns, assign people, and analyze workload.",
Example: " gitlink-cli board +view\n gitlink-cli board +columns\n gitlink-cli board +issues --column \"In Progress\" --assignee zhangsan\n gitlink-cli board +move --number 42 --status in-progress\n gitlink-cli board +assign --number 42 --assignee lisi\n gitlink-cli board +stats",
},
"repo": {
Short: "Repository operations",
Long: "Manage GitLink repositories: create, list, fork, delete, update settings, and manage members.",
Example: " gitlink-cli repo +list --user myorg\n gitlink-cli repo +create --name new-project\n gitlink-cli repo +info --owner org --repo name",
},
"issue": {
Short: "Issue operations",
Long: "Manage issues: list, create, view, update, close, reopen, comment, labels, batch operations, metadata queries, and comment management.",
Example: " gitlink-cli issue +list --state open\n gitlink-cli issue +create --title \"Bug found\" --body \"Details...\"\n gitlink-cli issue +close --number 42\n gitlink-cli issue +statuses\n gitlink-cli issue +comment-edit --number 42 --comment-id 1 --body \"updated\"",
},
"milestone": {
Short: "Milestone operations",
Long: "Manage milestones: list, create, view, update, delete, and change status.",
Example: " gitlink-cli milestone +list\n gitlink-cli milestone +create --name v1.0 --description \"First release\" --date 2026-12-31\n gitlink-cli milestone +view --id 1\n gitlink-cli milestone +status --id 1 --status closed",
},
"pr": {
Short: "Pull request operations",
Long: "Manage pull requests: list, create, view, merge, close, review, and view changed files.",
Example: " gitlink-cli pr +list --state open\n gitlink-cli pr +create --title \"Fix login\" --head feat-branch\n gitlink-cli pr +merge --id 42",
},
"release": {
Short: "Release operations",
Long: "Manage releases: list, create, view, and delete releases with release notes.",
Example: " gitlink-cli release +list\n gitlink-cli release +create --tag v1.0.0 --name \"First release\"",
},
"branch": {
Short: "Branch operations",
Long: "Manage branches: list, create, delete, and protect branches.",
Example: " gitlink-cli branch +list\n gitlink-cli branch +create --name feature-x\n gitlink-cli branch +protect --name master",
},
"org": {
Short: "Organization operations",
Long: "Manage organizations: list, view info, manage members, create and update.",
Example: " gitlink-cli org +list\n gitlink-cli org +info --org myorg\n gitlink-cli org +members --org myorg",
},
"user": {
Short: "User operations",
Long: "View current user info, profile details, and owned repositories.",
Example: " gitlink-cli user +me\n gitlink-cli user +info --login username",
},
"search": {
Short: "Search operations",
Long: "Search across repositories, users, and issues on GitLink.",
Example: " gitlink-cli search +repos --q \"machine learning\"\n gitlink-cli search +users --q \"developer\"",
},
"ci": {
Short: "CI/CD operations",
Long: "Manage CI/CD pipelines: view build history, read logs, restart and stop builds.",
Example: " gitlink-cli ci +builds\n gitlink-cli ci +logs --id 123\n gitlink-cli ci +restart --id 123",
},
"file": {
Short: "File and code operations",
Long: "Browse directories, read files, create/update/delete files, view commit history and diffs.",
Example: " gitlink-cli file +ls\n gitlink-cli file +read --path README.md\n gitlink-cli file +create --path new.txt --content hello --branch master --message \"add file\"\n gitlink-cli file +commits",
},
"team": {
Short: "Team operations",
Long: "Manage teams within organizations: list, create, delete, and manage members.",
Example: " gitlink-cli team +list --org myorg\n gitlink-cli team +create --org myorg --name dev-team",
},
"wiki": {
Short: "Wiki operations",
Long: "Manage wiki pages: list, create, view, update, delete, lint, fix formatting, and sync.",
Example: " gitlink-cli wiki +list\n gitlink-cli wiki +create --title \"Getting Started\" --content \"# Welcome\"",
},
"webhook": {
Short: "Webhook operations",
Long: "Manage webhooks: list, create, view, update, delete, test, and inspect events.",
Example: " gitlink-cli webhook +list\n gitlink-cli webhook +create --url https://example.com/hook --events push",
},
}
for name, shortcuts := range groups {
info := infos[name]
groupCmd := &cobra.Command{
Use: name,
Short: descriptions[name],
Use: name,
Short: info.Short,
Long: info.Long,
Example: info.Example,
}
common.MountShortcuts(groupCmd, shortcuts)
root.AddCommand(groupCmd)

View File

@ -26,7 +26,7 @@ func Shortcuts() []*common.Shortcut {
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q)
if err != nil {
return err
return fmt.Errorf("获取 Release 列表失败: %w", err)
}
return ctx.Output(env)
},
@ -34,6 +34,12 @@ func Shortcuts() []*common.Shortcut {
{
Name: "create",
Description: "Create a release",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
name := ctx.Arg("name")
tag := ctx.Arg("tag")
return fmt.Sprintf("Create release: %s (tag: %s)", name, tag), nil
},
Flags: []common.Flag{
{Name: "tag", Short: "t", Usage: "Tag name", Required: true},
{Name: "name", Short: "n", Usage: "Release name", Required: true},
@ -45,8 +51,14 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
tag, _ := ctx.RequireArg("tag")
name, _ := ctx.RequireArg("name")
tag, err := ctx.RequireArg("tag", "--tag v1.0.0")
if err != nil {
return err
}
name, err := ctx.RequireArg("name", `--name "Version 1.0.0"`)
if err != nil {
return err
}
payload := map[string]interface{}{
"tag_name": tag,
"name": name,
@ -62,7 +74,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload)
if err != nil {
return err
return fmt.Errorf("创建 Release 失败: %w", err)
}
return ctx.Output(env)
},
@ -77,17 +89,25 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("查看 Release 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a release",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
id := ctx.Arg("id")
return fmt.Sprintf("Delete release #%s", id), nil
},
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
},
@ -95,25 +115,65 @@ func Shortcuts() []*common.Shortcut {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
id, err := ctx.RequireArg("id", "--id 1")
if 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.
// Verify by checking if the release still exists.
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if viewErr != nil {
// Release no longer exists — delete actually succeeded
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功",
}, nil))
}
// Release still exists — delete truly failed
return delErr
return fmt.Errorf("删除 Release 失败: %w", delErr)
}
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功",
}, nil))
},
},
{
Name: "update",
Description: "Update a release",
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 notes"},
{Name: "prerelease", Usage: "Mark as prerelease (true/false)"},
},
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
return fmt.Sprintf("更新 Release #%s", ctx.Arg("id")), nil
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id", "--id 1")
if err != nil {
return err
}
body := map[string]interface{}{}
if n := ctx.Arg("name"); n != "" {
body["name"] = n
}
if b := ctx.Arg("body"); b != "" {
body["body"] = b
}
if p := ctx.Arg("prerelease"); p != "" {
body["prerelease"] = p == "true"
}
if len(body) == 0 {
return fmt.Errorf("at least one of --name, --body, --prerelease is required")
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), body)
if err != nil {
return fmt.Errorf("更新 Release 失败: %w", err)
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,196 @@
package repo
import (
"encoding/csv"
"fmt"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type repoCreateInput struct {
Name string
Description string
Private bool
}
type repoBatchResult struct {
Name string `json:"name" yaml:"name"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
type repoBatchSummary struct {
Owner string `json:"owner" yaml:"owner"`
Action string `json:"action" yaml:"action"`
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 []repoBatchResult `json:"results" yaml:"results"`
}
func newBatchCreateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-create",
Description: "Create multiple repositories from CLI flags or a CSV file",
Flags: []common.Flag{
{Name: "names", Short: "n", Usage: "Comma-separated repository names, e.g. repo-a,repo-b"},
{Name: "from", Usage: "CSV file path"},
{Name: "description", Short: "d", Usage: "Shared description for all repos (inline mode)"},
{Name: "private", Usage: "Make repos private", Bool: true, Default: "false"},
{Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"},
},
Run: runBatchCreate,
}
}
func runBatchCreate(ctx *common.RuntimeContext) error {
var inputs []repoCreateInput
if namesStr := ctx.Arg("names"); namesStr != "" {
for _, name := range strings.Split(namesStr, ",") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
inputs = append(inputs, repoCreateInput{
Name: name,
Description: ctx.Arg("description"),
Private: ctx.Arg("private") == "true",
})
}
}
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readRepoInputsFromCSV(csvPath)
if err != nil {
return err
}
inputs = append(inputs, csvInputs...)
}
if len(inputs) == 0 {
return fmt.Errorf("no repository names provided; use -n repo-a,repo-b or --from repos.csv")
}
dryRun := ctx.Arg("dry-run") == "true"
var login string
var userID int
if !dryRun {
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return fmt.Errorf("failed to get current user: %w", err)
}
userData, _ := userEnv.Data.(map[string]interface{})
login, _ = userData["login"].(string)
if login == "" {
return fmt.Errorf("cannot determine current user login")
}
if uid, ok := userData["user_id"].(float64); ok {
userID = int(uid)
}
}
summary := repoBatchSummary{
Owner: login,
Action: "create",
DryRun: dryRun,
Total: len(inputs),
Results: make([]repoBatchResult, 0, len(inputs)),
}
for _, input := range inputs {
result := repoBatchResult{Name: input.Name}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
body := map[string]interface{}{
"name": input.Name,
"repository_name": input.Name,
"user_id": userID,
}
if input.Description != "" {
body["description"] = input.Description
}
if input.Private {
body["private"] = true
}
if _, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, input.Name), body); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "created"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d repo(s) failed to create", summary.Failed, summary.Total)
}
return nil
}
func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
}
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[strings.ToLower(strings.TrimSpace(h))] = i
}
if _, ok := col["name"]; !ok {
return nil, fmt.Errorf("CSV must have a 'name' column")
}
var inputs []repoCreateInput
for _, record := range records[1:] {
name := getCol(record, col, "name")
if name == "" {
continue
}
private := false
if p := strings.ToLower(getCol(record, col, "private")); p == "true" || p == "1" {
private = true
}
inputs = append(inputs, repoCreateInput{
Name: name,
Description: getCol(record, col, "description"),
Private: private,
})
}
return inputs, nil
}
func getCol(record []string, col map[string]int, name string) string {
if idx, ok := col[name]; ok && idx < len(record) {
return strings.TrimSpace(record[idx])
}
return ""
}

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