Compare commits

...

168 Commits

Author SHA1 Message Date
赵昌 40609fa0f8 fix: CI 状态检测改为读取状态文件,不再硬编码为通过 2026-07-07 17:03:14 +08:00
赵昌 3a1565422c fix: PR 状态字段匹配和 MSYS 路径问题
- 01-collect-prs.sh: 修复 PR 过滤逻辑,兼容 status='open' 字符串
- quality-gate-full.sh: 使用相对路径避免 MSYS 路径问题

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 16:55:48 +08:00
wqer b09d7d70f2 Merge pull request 'fix: 项目初始化流水线补充 dry_run 参数定义' (#5) from fix/pipeline-params into master
CI / test (push) Failing after 17m41s Details
2026-07-07 16:45:16 +08:00
Z User 111edbc346 fix: 项目初始化流水线补充 dry_run 参数定义
CI / test (pull_request) Failing after 1m54s Details
流水线 ssh_cmd 引用了 $(dry_run) 但 global.param 未定义该参数,
导致运行时参数解析失败。补充定义,默认值 true。
2026-07-07 16:45:37 +08:00
wqer b1a5d1433f Merge pull request 'chore: 删除报告生成器脚本(内部工具)' (#4) from chore/remove-gen-scripts into master
CI / test (push) Waiting to run Details
2026-07-07 16:41:57 +08:00
Z User ab47233e94 chore: 删除报告生成器脚本(内部工具,不应提交到项目仓库)
CI / test (pull_request) Failing after 2m3s Details
删除文件:
- scripts/gen-report-soft-analysis.js(Word报告生成器)
- scripts/gen-reports-docx.js(Word报告生成器)

这两个脚本是AI助手生成报告的内部工具,不属于项目交付物。
2026-07-07 16:26:21 +08:00
Z User bafa9e6364 fix: 修正'子任务五'表述 — 进阶任务只有子任务一到四
CI / test (push) Failing after 2m17s Details
进阶任务只有子任务一~四,子任务五属于基础任务。
组员commit里的'子任务五'实际是子任务三的扩展场景。

修正位置:
- PPT Slide 2 目录:子任务三+五 → 子任务三
- PPT Slide 11 章节页:子任务三+子任务五 → 子任务三
- PPT 备注/分工:子任务三/四/五 → 子任务三/四
- 三份Word报告:同步修正
- 端到端工作流说明文档.md:子任务三+五 → 子任务三
2026-07-04 09:51:27 +08:00
Z User 6f7af3b000 docs: 进阶任务文档完善 — 三份报告更新+工作流文档+科研使用文档
CI / test (push) Has been cancelled Details
根据课程实践任务要求,完善进阶任务文档部分:

1. 更新三份 Word 报告(覆盖子任务一~五全部内容):
   - 软件分析及建模报告.docx(44KB · 94段)
     覆盖:CLI扩展+REPL+35Skills+5端到端场景+5科研模块+操作中心+6流水线
   - 新需求构思报告.docx(42KB · 88段)
     覆盖:需求背景+构思方案+技术路线+风险评估+8个创新点
   - 变更影响分析及测试报告.docx(41KB · 71段)
     覆盖:变更概述+6模块影响分析+兼容性+测试方案+性能+回归测试

2. 新增端到端工作流说明文档.md(子任务三要求)
   - 5个场景架构图(ASCII)
   - 每场景三阶段流程+关键数据+对应文件
   - DevOps 6条流水线总览

3. 新增科研辅助场景使用文档.md(子任务四要求)
   - 5个科研模块完整使用指南
   - 每模块:功能+使用方法+验证结果+委托模式
   - GitLink数据现实约束说明
   - 委托模式设计原则
2026-07-04 09:48:12 +08:00
Z User 6425ff6ae1 fix: 精细优化PPT — 修复数据不一致+布局问题
CI / test (push) Failing after 4m23s Details
数据一致性修复:
- Slide 1 封面:236→215 commits、29→35 Skills、16,823→17,005 行、20测试→6 DevOps、日期更新
- Slide 9 章节页正文:11→29 改为 11→35(新增24个)
- Slide 11 备注更新:不再说'概览四个',改为'逐个展示'
- Slide 12 备注修复:删除'左下是流水线图'(实际无流水线图)
- Slide 25 标题:两套→三套评分系统,图注同步
- Slide 29 交付物备注:29 Skill→35、3流水线→6、补充28脚本+/gitlink
- Slide 8 REPL:2027行→2646行 Go 代码

布局优化:
- Slide 26 操作中心:菜单项间距压缩,16项完整显示在卡片内
2026-07-04 09:38:03 +08:00
Z User b97181f05e fix: 更新团队贡献柱状图+放大终端截图字号
CI / test (push) Failing after 4m24s Details
1. 团队贡献柱状图数据更新:
   - 赵昌 55→60(37%)、刘焱 78→65(40%)、曾蔚然 9→30(19%)
   - 总计 236→215(团队162+上游53)
   - 工作内容更新匹配最新分工

2. 7张终端截图清晰度优化:
   - HTML字号 13px→16px、标题14px→18px
   - body宽度 900px→760px(字号相对更大)
   - 截图裁剪到 2560×1900 统一比例
   - PPT显示宽度 6.8→7.0英寸(文字更大)
   - 说明文字统一移到 y:6.4 避免重叠
2026-07-04 09:33:59 +08:00
Z User ab0cf7fe64 feat: 7个场景页补充设计思路+对应文件
CI / test (push) Has been cancelled Details
用户要求:PPT展示运行结果+设计思路,备注写详细设计思路+对应文件

每张场景页右侧新增「🧠 设计思路」卡片(5条核心设计要点):
- Slide 15 质量门禁:量化评分/三级严重度/CI一票否决/双门槛/委托审查
- Slide 16 项目初始化:AI解析/三阶段原子化/语言模板/初始Issue/DRY_RUN
- Slide 17 多仓库协同:整体视图/相似度匹配/PR看板/版本对齐/REPOS配置
- Slide 18 贡献者成长:多维加权/PR权重最高/4级段位/新人加成/徽章闭环
- Slide 22 科研合规:委托+自研/三维证据链/证据导向/读者判断/全程只读
- Slide 23 科研匹配:委托+自研/4角色/多信号推断/诚实偏科/缺口识别
- Slide 24 科研进度:委托+自研/6维信号/证据导向/适应数据现实/时间序列锚点

备注稿每页补充:
- 【🧠 详细设计思路】5条详细解释(每条含为什么这样设计)
- 【对应文件】列出SKILL.md/scripts/examples/.devops完整路径
2026-07-04 09:26:10 +08:00
Z User 8bfa601edf feat: 7个场景全部补充终端运行截图
CI / test (push) Failing after 4m23s Details
用户反馈:每个场景都要有实际运行结果展示

新增7张终端截图(从examples真实数据渲染):
- term_quality_gate.png: 4阶段流水线+评分83.9→建议合并
- term_project_bootstrap.png: 自然语言→解析→建仓→骨架→Issue
- term_multi_repo.png: 2仓库协同+跨仓库关联+版本对齐
- term_contributor_growth.png: 3贡献者→成长分→段位→徽章
- term_research_compliance.png: FairMOT复现性审查(真实仓库)
- term_research_matching.png: forgeplus角色匹配(真实仓库)
- term_research_progress.png: gitlink-cli 6维预警(真实仓库)

每张场景页布局改为:左=终端截图 + 右=关键信息
3个科研场景使用真实仓库数据(FairMOT/forgeplus/gitlink-cli)
2026-07-04 08:50:36 +08:00
Z User d2b4e308c5 feat: PPT扩展到30页 — 每个场景单独展示
CI / test (push) Failing after 4m23s Details
用户反馈:更新的内容是汇报主力,每个场景都要单独展示

变更:
- 删除 Slide 15 概览页 → 拆为4个独立场景页(Slide 15-18)
- 删除 Slide 19 概览页 → 拆为3个独立科研页(Slide 22-24)
- 每个场景页含:三阶段流程详细 + 评分公式/证据链 + 真实案例 + 技术实现

新增7个独立场景页:
1. Slide 15 代码质量门禁:4阶段流程+评分模型(CR×40%+CI×30%+设计×30%)
2. Slide 16 项目一键初始化:输入→输出+3阶段+骨架生成
3. Slide 17 多仓库协同:3阶段+跨仓库关联发现
4. Slide 18 贡献者成长体系:成长分公式+4级段位+真实计算示例
5. Slide 22 科研合规与复现性:三维证据链+FairMOT真实验证
6. Slide 23 科研协作智能匹配:4角色推断+forgeplus真实验证
7. Slide 24 科研进度跟踪与预警:6维信号+gitlink-cli真实验证

PPT: 25页→30页
2026-07-04 08:42:36 +08:00
Z User fe9f4396b0 feat: 更新汇报PPT — 同步组员更新(子任务三/四/五)
CI / test (push) Failing after 1m12s Details
组员新增工作同步到PPT:

子任务三+五(端到端工作流):
- 从2个场景扩展到5个:社区运营/质量门禁/项目初始化/多仓库协同/贡献者成长
- 新增Slide 15: 四个新场景概览(含三阶段流程+代码行数)

子任务四(科研辅助):
- 从2个模块扩展到5个:洞悉/热点/合规复现/协作匹配/进度预警
- 新增Slide 19: 三个新增科研场景详解(合规/匹配/进度)

操作中心(新增):
- 新增Slide 21: /gitlink交互式菜单,16项操作统一入口

DevOps流水线:
- 从3条扩展到6条:构建/质量/社区/初始化/多仓/成长
- Slide 22重做为2x3网格布局

数据更新:
- Skills 29→35(新增24个)
- DevOps 3→6条
- 提交数 236→211
- 团队: 赵昌67/刘焱65/曾蔚然30

PPT总页数: 22→25页
2026-07-04 08:26:27 +08:00
刘焱 57993e8bea feat(操作中心): 融入科研辅助 5 大场景(子任务四)
CI / test (push) Failing after 2m17s Details
将 5 个已实现的科研辅助 skill 作为一等公民集成进 /gitlink 交互式菜单:

- 12 科研项目洞悉        → gitlink-research-insight
- 13 科研热点与知识图谱  → gitlink-research-hotspot
- 14 科研合规与复现性    → gitlink-research-compliance
- 15 科研协作智能匹配    → gitlink-research-matching
- 16 科研进度跟踪与预警  → gitlink-research-progress

变更:
- 顶层菜单新增「🔬 科研辅助」类别(替原「🌐 指定仓库」位;仓库定位改由
  $ARGUMENTS / 粘贴网址 / Other 兜底,功能不减)
- 完整目录扩为 16 项;科研 5 项超 4 项上限,按「编号打印 + 4 项可上下选 +
  Other 选第 5 项」规则处理
- 关键词直跳、操作→skill 映射表同步补齐 5 个科研关键词/skill
- 备注注明科研类 skill 为 read-only 只读分析,可放心对任意公开仓库运行
2026-07-02 16:19:28 +08:00
刘焱 47c5083095 feat: 新增 GitLink 操作中心交互式菜单(/gitlink 命令)
CI / test (push) Failing after 1m17s Details
新增 .claude/commands/gitlink.md 斜杠命令作为统一入口:列出全部 11 项操作 +
端到端场景,用原生上下方向键菜单选择并执行,或接收 GitLink 仓库网址直接操作,
按映射表分发到对应已实现 skill(写操作默认 dry-run)。

- 支持三种触发:/gitlink、/gitlink <场景关键词>、/gitlink <仓库网址>,
  以及自然短语「常用操作 / 常用操作以及场景」(见 CLAUDE.md 映射)
- 自动解析 gitlink.org.cn/<owner>/<repo> 网址(兼容 .git / /pulls / /issues / /compare 后缀)
- CLAUDE.md 顶部新增「🧭 操作中心」说明
2026-07-02 16:04:19 +08:00
s2_cc b777088ea3 feat(interactive): 增强交互面板 — 参数元数据标签、命令级错误建议、报错信息友好包装
CI / test (push) Failing after 4m21s Details
2026-07-02 15:49:06 +08:00
刘焱 c9530a0929 feat: 子任务五 — 项目初始化/多仓库协同/贡献者成长 三个端到端自动化场景
CI / test (push) Failing after 1m16s Details
新增三个端到端自动化 skill,与子任务三(community-ops / quality-gate)风格统一,
每个均含 SKILL.md + 端到端示例 + 三阶段脚本 + 全流程串联脚本 + 分析引擎,
并配套 .devops 流水线(DRY_RUN 默认安全,可挂 GitLink DevOps 一键复现)。

- gitlink-project-bootstrap:输入项目描述 → 创建仓库 → 生成 README/LICENSE/CI → 创建初始 Issue 和里程碑
- gitlink-multi-repo-coordination:跨多仓库统一 Issue 追踪 / PR 状态看板 / Release 协调发布(REPOS 环境变量传入多仓库)
- gitlink-contributor-growth:贡献者活动追踪 → 成长排行与段位 → 自动颁发徽章

同步更新 CLAUDE.md 与 skills/README.md 的 skill 索引映射。
2026-07-02 15:41:27 +08:00
赵昌 aea30485a3 chore: 删除 PPT 生成器脚本
CI / test (push) Failing after 1m16s Details
2026-07-02 15:07:03 +08:00
赵昌 a8d5dd5349 chore: 清理误提交文件并更新 .gitignore
CI / test (push) Failing after 2m16s Details
2026-07-02 14:51:33 +08:00
赵昌 a429ff1505 remove: 删除总结文档(不应提交到远端)
CI / test (push) Failing after 2m17s Details
2026-07-02 14:46:55 +08:00
赵昌 43348a195f chore: 同步本地更改
CI / test (push) Has been cancelled Details
2026-07-02 14:39:05 +08:00
赵昌 09baf7f44f docs: 实践总结与心得
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 14:39:04 +08:00
s2_cc 40497520e2 feat(research-progress): add forgeplus as primary validation repo, enable milestone signal ⑥
CI / test (push) Failing after 1m16s Details
forgeplus (Gitlink/forgeplus) 是 GitLink 上极少数启用里程碑的仓库(4 个),
之前被迫设为'可选降级'的里程碑信号⑥在此首次真正启用。

- examples/progress-alert-forgeplus.md: 新增主验证仓真实报告
  · 4246 Issue + 4 里程碑,风险总分 90/100 🔴高
  · 信号⑥落地:2 个里程碑 effective_date 逾期近 2.5 年、percent 卡 93%
- SKILL.md: 修正里程碑字段名(effective_date/percent,非 due_date/done_ratio),
  Step⑤补充字段说明,使用示例段把 forgeplus 列为主 example
- 三档对照:forgeplus(里程碑启用) / gitlink-cli(无里程碑活跃) / FairMOT(归档降级)
2026-07-01 16:03:58 +08:00
s2_cc 474a54115a feat(research-matching): upgrade matching-run to real report on forgeplus
CI / test (push) Failing after 4m22s Details
验证仓库问题已解决:用 Gitlink/forgeplus(4246 Issue + 355 PR,原生非镜像)
将 examples/matching-run.md 从"脚本模板"升级为真实匹配报告。

- 贡献者画像:dtwdtw(7 fix: PR→工程Bugfix) / xxq250(12版本PR→工程) / maxjhandsome(1PR+9Issue→前端功能)
- 人-任务匹配:dtwdtw↔接口/Bugfix类Issue 高置信匹配;角色缺口正确识别
- 诚实标注:forgeplus 是平台引擎非纯科研项目,验证的是匹配算法本身
- 数据采集:数据量大的仓库采用先落盘本地JSON再用jq分析
2026-07-01 15:20:50 +08:00
s2_cc 8f19d958a8 feat(research-progress): add gitlink-research-progress skill (scenario C)
CI / test (push) Failing after 1m16s Details
科研进度跟踪与预警 skill:纯远程只读分析 GitLink 仓库的 Issue 时间序列与
提交活跃度,识别长期停滞/高优先级无人认领/积压趋势/活跃度突变等科研仓库
特有时间线风险,输出证据导向的预警清单。

- SKILL.md: 委托 gitlink-project-health + 自研 5+1 类风险信号 + 报告模板
- examples/progress-alert-gitlink-cli.md: 主验证(活跃仓库,真实数据)
- examples/progress-alert-fairmot.md: 补充场景(已归档仓库降级输出)
2026-07-01 08:22:24 +08:00
z2_cc 457bf66909 Merge pull request 'feat: add gitlink-research-matching skill (科研协作智能匹配)' (#3) from feat/research-matching into master
CI / test (push) Failing after 1m16s Details
2026-07-01 08:17:31 +08:00
s2_cc 90812b7d0c feat: add gitlink-research-matching skill (科研协作智能匹配)
CI / test (pull_request) Failing after 17m29s Details
子任务4 场景B:科研协作智能匹配 skill。
- 委托 gitlink-insight 取贡献者活跃画像,自研科研角色匹配
- 4 科研角色体系(数据/算法/工程/评测)× 开放 Issue 需求 → 人-任务匹配
- 全程纯远程只读,证据导向建议记录(不算分、不评级)
- 严格服务匹配的仓内资源探测(区别于场景A 的全面复现性清点)
- 含可复现运行脚本模板(examples/matching-run.md)
2026-07-01 08:09:31 +08:00
s2_cc 059476bd7c feat(skills): add gitlink-research-compliance 科研项目复现性检查
CI / test (push) Has been cancelled Details
子任务4·场景A:纯远程只读审查科研仓库能否被他人复现。

- 委托 gitlink-compliance 跑合规层,自研复现性证据链
- 4 维度(合规/数据可用性/环境可复现/结果可验证)
- 6 步纯远程工作流,串联 file/dataset/release/repo 命令
- 证据导向报告(先证据后判定,分数仅辅助参考)
- 在 ifzhang/FairMOT 上完整跑通,含真实审查记录
- 记录 4 个实测接口坑(raw 403 / file+get content 恒 None /
  file+readme 唯一明文 / 镜像仓 +tree 递归取目录)
2026-07-01 08:06:13 +08:00
Z User a75c281254 fix: demo报告链接打不开 + 新增REPL交互演示
CI / test (push) Failing after 38m0s Details
问题1:报告链接打不开
原因:.gitignore 排除了 skills/*/scripts/_output/ 目录,5个HTML报告未提交到仓库
修复:git add -f 强制提交5个报告HTML文件,用户拉代码后可直接打开

问题2:REPL交互演示
新增:demo.html REPL页面改为交互式终端
- 点击3个按钮模拟三个阶段:命令面板/表单输入/滚动视口
- 逐步打字动画效果,模拟真实REPL操作流程
- 命令面板:输入/ → fuzzy搜索issue → 选中命令
- 表单输入:自动生成表单 → Tab切换 → 必填校验 → 提交
- 滚动视口:长输出表格 → 滚动浏览 → q返回
- 重置按钮清空终端
2026-06-30 12:10:03 +08:00
Z User 8739b18a23 fix: Slide 12 重新布局 — 终端截图放大+去掉变形流水线图
CI / test (push) Failing after 2m16s Details
问题:
- 终端截图 7.8x4.39 夹在中间偏小
- 底部流水线图宽高比不匹配(源图2.27 vs 显示9.1)严重变形

修复:
- 终端截图放大到 8.2x4.61 英寸(占2/3宽度),比例1.78正确
- 右侧三阶段卡片改为垂直排列,每张含数据和关键结果
- 去掉底部流水线图(三阶段卡片已说明流程,不需要重复)
- 整体布局更清晰,终端文字可读
2026-06-30 12:06:22 +08:00
Z User 283cd76bfd fix: 修复demo.html报告链接路径
CI / test (push) Has been cancelled Details
原因:demo.html在doc/目录下,链接写的是skills/...缺少../
修复:5个链接全部加../前缀
- weekly-health-report.html
- release-notes-v0.1.0.html
- research-insight-report.html
- knowledge-graph-gitlink.html
- hotspot-trend-report-gitlink.html

已验证5个文件均存在
2026-06-30 12:01:57 +08:00
Z User f1e5386c43 feat: PPT v7 — 图片清晰度修复 + 网页版demo
CI / test (push) Has been cancelled Details
图片清晰度修复:
- Slide 3 架构图:重制为宽幅布局(2560x1600),字体更大,显示尺寸4.2→4.5英寸
- Slide 8 REPL截图:从3张并排4.0英寸改为2+1布局6.1英寸,缩放10x→6.4x
- Slide 12 流水线图:修正宽高比,高度1.3→1.5英寸
- Slide 16 洞察截图:终端5.9→6.2英寸,报告保持5.9英寸
- 移除REPL页状态机小图,改为文字特性卡片

新增网页版demo(doc/demo.html, 486行):
- 5个演示场景侧边栏导航:CLI命令/工作流/科研洞悉/知识图谱/评分体系
- 终端风格命令展示 + 输出结果链接
- 评分权重可视化(周报4维+科研4维+成熟度4子维)
- 一键跳转HTML报告(相对路径)
- 暗色主题,适合投影展示
2026-06-30 11:55:16 +08:00
Z User d336e5facb feat: PPT v6 — 新增评分体系权重设计页 + 评分备答库
CI / test (push) Has been cancelled Details
新增 Slide 18: 评分体系权重设计
- 评分权重可视化图表(周报4维 + 科研洞察4维 + 成熟度4子维)
- 6 条设计原则卡片(信号优先/巴士因子/梯度阈值/可调可复现/文档权重低/维护最关键)
- 备注稿详细解释每个权重的设计理由

Q&A 备答库 Q6 大幅扩充(3852字):
- 周报评分权重:关闭率30%+合并率30%+贡献者25%+响应15%,每项解释为什么
- 科研洞察权重:贡献模式30%+成熟度25%+社区25%+文档20%,每项解释为什么
- 成熟度子维度:维护30%+年龄25%+Star25%+Fork20%,每项解释为什么
- 阈值设计参考 CHAOSS 指标体系
- 校验方式:初始贡献者权重20%→调到25%的过程

总页数 21→22
2026-06-30 11:49:32 +08:00
Z User de1d4ea2cf feat: PPT v5 — 可视化图表 + 现场demo脚本 + 备注稿划重点
CI / test (push) Failing after 1m15s Details
新增可视化图表(5张):
- 三层架构图(项目概览页)
- REPL 状态机图(REPL 页)
- 三阶段流水线图(社区运营页)
- Skills 增长对比图
- 团队贡献柱状图(团队分工页)

新增现场演示工具:
- doc/demo.sh: 一键演示脚本(4场景,约3分钟)
- doc/demo.html: 离线演示页面(网络不通时备用)

备注稿重构:
- 从'完整稿'改为'重点标注稿',每页结构:
  【📌 必讲/简要带过】+【⏱ 时间】+【💡 一句话】+【关键数据】+【 亮点】
- 精简至8159字(从11751字),更易快速扫读
- Q&A备答库保持2973字(15个预设问答,含设计思路类)
2026-06-30 11:44:08 +08:00
Z User b33267447b feat: PPT v4 — 备注稿全面升级(11751字)+ 答辩备答库
CI / test (push) Failing after 1m15s Details
改进:
- 20页备注稿从'要点提示'升级为'完整汇报稿',含时间分配、数据解读、亮点标注
- 每页备注标注重预计用时(开场1min / 目录30s / 各子任务1-1.5min)
- 备注稿总字数从约3000字增至11751字
- Q&A页备答库从631字扩充至2973字,新增15个预设问答,分4类:
  · 设计思路类(6题):整体架构、REPL设计、Skills规范、Bash+JS混合、知识图谱构建、评分权重
  · 技术实现类(5题):向后兼容、脚本安全、数据来源、测试覆盖、DevOps联动
  · 项目管理类(4题):分工方式、技术困难、改进方向、选做原因
  · 通用兜底回答
2026-06-30 11:34:17 +08:00
Z User b41a07d2fe feat: PPT v3 — 新增REPL交互模式专页 + Q&A页面优化
CI / test (push) Has been cancelled Details
改进:
- 新增 Slide 8: REPL 交互模式专页(3张高清截图:命令面板/表单输入/滚动视口)
  - 展示 fuzzy 搜索、分组导航、Tab 切换、必填校验、长输出滚动等核心功能
  - 技术实现卡片:Bubble Tea 框架、状态机、代码行数、测试覆盖
  - 核心亮点:体验接近 VS Code 命令面板,降低 CLI 使用门槛
- Slide 7 的 REPL 预告卡片改为指向下一页
- Q&A 页面移除可见预设问答,只保留感谢页
  - 5 个预设问答(含 Skills 集成问题)移入备注稿(631字)
- 总页数 20→21,页码全部更新
2026-06-30 11:27:21 +08:00
Z User c2b667c1e2 feat: PPT v2 — 高清截图(2x DPI) + 全页备注稿 + 亮点突出
CI / test (push) Has been cancelled Details
改进:
- 截图分辨率 1280→3840 (2x deviceScaleFactor),裁剪报告关键区域
- 每页添加 70-200 字备注稿,便于汇报时参考
- 封面增加 5 项核心数据高亮
- 每页增加亮点标注(),突出技术深度
- Q&A 页预设 4 个可能问题与回答
- 子任务三/四每页配真实终端截图 + HTML 报告截图 + 数据卡片
2026-06-30 11:15:07 +08:00
Z User 6bcfb77b58 feat: 重建汇报PPT(20页)— 嵌入真实运行截图和API数据
CI / test (push) Failing after 1m45s Details
- 所有数据来自 git log + GitLink API 实时采集
- 6张真实截图:终端CLI、工作流运行、周报、Release Notes、科研洞察、知识图谱
- 修正 demo.html 中的数据错误:新增14个shortcut(非23)、新增18个skill(非11)
- 真实统计:236 commits / 74 Go文件 / 16823行代码 / 145新增文件 / 20测试文件
2026-06-30 10:57:18 +08:00
Z User 9a451f9d75 Merge branch 'master' of https://gitlink.org.cn/z2_cc/gitlink-cli
# Conflicts:
#	doc/汇报PPT-增强版.pptx
2026-06-30 10:56:58 +08:00
Z User 9129c2f1c4 feat: 重建汇报PPT — 20页,嵌入真实运行截图和API数据 2026-06-30 10:56:23 +08:00
赵昌 13882157c2 feat: PPT 添加 HTML 报告超链接跳转
CI / test (push) Has been cancelled Details
- 周报终端页添加绿色按钮 → 打开 weekly-health-report.html
- Release Notes 页添加蓝色按钮 → 打开 release-notes-v0.1.0.html
- 知识图谱页添加紫色按钮 → 打开交互式图谱 HTML
- 演示时点击按钮可直接从 PPT 跳转到浏览器打开完整报告

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 10:41:03 +08:00
赵昌 2bedb68ef7 feat: 整合 Demo 终端输出到 PPT
CI / test (push) Has been cancelled Details
- 新增 2 页终端风格输出(社区周报 + Release Notes 真实数据)
- 模拟终端界面(圆点标题栏 + 彩色语法高亮)
- PPT 从 17 页扩展到 19 页
- 演示时无需切换浏览器查看输出结果

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 10:32:51 +08:00
赵昌 fa31e09dc4 refine: 优化演示页面布局和结果展示
CI / test (push) Failing after 2m15s Details
- 分 tab 展示各子任务(CLI/Skills/工作流/科研/运行结果)
- 终端风格输出展示真实采集数据
- 每个模块附带链接跳转到对应 HTML 报告
- 底部汇总一键运行命令

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 10:19:44 +08:00
赵昌 93f65a7d78 feat: PPT 添加备注稿和演示跳转按钮
CI / test (push) Has been cancelled Details
- 每页 PPT 添加演讲者备注(讲稿)
- 演示相关页面添加红色「▶ 现场演示」跳转按钮
- 方便汇报时快速定位到对应演示环节

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 10:16:06 +08:00
赵昌 d4af2e3207 feat: 综合演示网页 + 增强 PPT(17页)
CI / test (push) Failing after 1m15s Details
- 综合演示网页 demo.html(6个tab、交互式导航、Mermaid 架构图)
- PPT 从11页扩展到17页(新增REPL、Skills架构、真实输出数据、知识图谱可视化等页面)
- 成员姓名统一为真实姓名(赵昌、曾蔚然、刘焱)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 10:12:20 +08:00
赵昌 3f04d95631 feat: 汇报 PPT 和演示脚本
CI / test (push) Failing after 2m16s Details
2026-06-30 10:04:02 +08:00
赵昌 46d16e21a2 fix: 修正成员信息并去除学校水印
CI / test (push) Failing after 1m15s Details
- 赵昌 = wqer(同一人)
- z2_cc = 曾蔚然
- 去除哈尔滨工业大学水印
2026-06-30 09:56:59 +08:00
赵昌 148ce89c2b docs: 三份报告统一转为 Word 格式
CI / test (push) Failing after 2m15s Details
- 新增《软件分析及建模报告.docx》完整建模文档
- 新增《新需求构思报告.docx》《变更影响分析及测试报告.docx》
- 保留脚本生成器 scripts/gen-report-*.js
- 移除已转换的 .md 版本
2026-06-30 09:53:52 +08:00
赵昌 4d56487520 docs: 更新报告覆盖全部成员贡献
CI / test (push) Has been cancelled Details
- 新需求构思报告:新增成员分工表、各子任务归属
- 变更影响分析及测试报告:新增项目成员页、贡献者逐项标注
- 覆盖刘焱(78次)/赵昌(39次)/z2_cc(9次)/wqer(7次) 完整工作

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 09:48:28 +08:00
赵昌 30336c69ef docs: 更新工作日志(文档清理 & 技术债务优化)
CI / test (push) Has been cancelled Details
2026-06-30 09:42:29 +08:00
赵昌 74f6aea0e9 docs: 更新工作日志技术债务状态
CI / test (push) Has been cancelled Details
2026-06-30 09:41:41 +08:00
赵昌 92f65a92d1 fix: 优化技术债务
CI / test (push) Has been cancelled Details
1. Release Notes 支持 API 后备读取提交历史(不再依赖 git log)
2. 新增 setup-local-cli.sh 一键编译安装脚本
3. release-analyze.js 同时支持 git log 文本和 API JSON 两种输入格式

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 09:41:12 +08:00
赵昌 f44471d173 chore: 删除多余的 demo 文件
CI / test (push) Failing after 1m15s Details
2026-06-30 09:38:51 +08:00
赵昌 c0c7580a7e chore: 清理多余的场景文档目录
CI / test (push) Has been cancelled Details
- 删除 sub-task3-scenario-a/b(架构图已在 SKILL.md 中)
- 删除 sub-task4-module-c/d(报告模板由脚本自动生成)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 09:36:29 +08:00
赵昌 393a090c0e docs: 统一新需求构思报告和变更影响分析及测试报告
CI / test (push) Has been cancelled Details
- 删除原来分散在 scenario-a/b 的 4 份报告
- 创建统一的新需求构思报告(覆盖四个子任务)
- 创建统一的变更影响分析及测试报告(含完整测试结果)
- 保留架构图、科研模板等技术文档

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 09:33:44 +08:00
赵昌 7b43711db3 docs: 添加子任务三/四工作日志
CI / test (push) Has been cancelled Details
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 09:26:50 +08:00
赵昌 b0d46b42af docs: 添加子任务三/四交接文档
CI / test (push) Failing after 1m14s Details
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 09:02:25 +08:00
赵昌 378d8f15a6 feat: 子任务三 & 子任务四 — 端到端工作流与科研辅助模块
CI / test (push) Has been cancelled Details
## 子任务三:端到端自动化工作流
### 社区运营自动化 (gitlink-community-ops)
- Issue 自动分拣 → 周报生成 → Release Notes 发布
- 4 个可复现 Bash 脚本 + JS 数据分析引擎
- 自动生成 HTML 可视化报告

### 代码质量看门人 (gitlink-quality-gate)
- PR 采集 → 深度审查 → CI 检查 → 质量评分门禁
- 5 个可复现 Bash 脚本
- 质量评分模型:CodeReview×40% + CI×30% + 设计一致性×30%

## 子任务四:科研辅助
### 科研项目洞悉 (gitlink-research-insight)
- 8 维数据采集 → AI 多维度评分 → 结构化分析报告
- 成熟度/文档质量/贡献模式/社区健康度评分模型

### 热点追踪与知识图谱 (gitlink-research-hotspot)
- 多关键词搜索 → 数据丰富 → 实体关系图谱
- 交互式 HTML 知识图谱(缩放/拖拽/高亮/导出)

## 基础设施
- gitlink-cli 自动检测机制(dev/cli-bin wrapper)
- md-to-html 通用报告可视化转换器
- 3 条 DevOps 流水线(构建/社区运营/质量门禁)
- 详细架构图 + 新需求构思报告 + 变更影响测试报告

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 08:59:17 +08:00
s2_cc 3fdc615a05 Merge remote-tracking branch 'origin/master'
CI / test (push) Failing after 36m19s Details
# Conflicts:
#	skills/gitlink-issue-triage/SKILL.md
2026-06-24 11:00:43 +08:00
赵昌 b46352af51 docs: enrich demo page with detailed AI analysis and scoring
CI / test (push) Failing after 1m12s Details
2026-06-24 10:42:09 +08:00
s2_cc 33d2578f90 Merge branch 'z2cc' 2026-06-24 10:41:27 +08:00
赵昌 21a3447581 docs: update demo page with AI analysis outputs and scoring
CI / test (push) Failing after 1m12s Details
2026-06-24 10:37:30 +08:00
赵昌 5866600401 enhance: enrich skill outputs with data analysis and scoring logic
CI / test (push) Failing after 1m12s Details
- project-health: add scoring formulas, trend analysis, improvement suggestions
- release-auto: add commit classification statistics, version recommendations
- issue-triage: add keyword weight scoring, urgency assessment
2026-06-24 10:32:52 +08:00
赵昌 3703594d66 docs: add interactive demo page for skills showcase
CI / test (push) Failing after 1m12s Details
2026-06-24 10:12:33 +08:00
s2_cc 42c5d11a62 feat(skills): 完善 issue-triage 与 code-review Skill,补充 gitlink-shared 已知坑
CI / test (pull_request) Failing after 1m12s Details
本次赛题2(编写和丰富 GitLink Skills)的产出,聚焦两个场景:

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

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

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

均在 Claude Code 端到端实测验证(见 my_docs/ 验证记录,已 gitignore)。
2026-06-24 08:18:27 +08:00
z2_cc 955e8dd2b7 feat(skills): 新增 gitlink-pr-deep-review PR 深度审查 Skill
CI / test (push) Failing after 4m16s Details
- 编排 gitlink-code-review 取实现层结果 + 自研推理层(设计/需求一致性、跨模块影响、业务安全合规)
- 综合裁决:实现质量 × 跨模块影响 × 一致性 × 安全 → 风险评分 + 阻断/谨慎/放行建议
- 需求一致性锚点:交互式获取(设计文档 + PR 关联 Issue,皆无则降级标注低置信度)
- 跨模块仅追直接调用方(git grep),写动作默认 dry-run 且 --status common 不越权
- 与 code-review 职责正交(实现层 vs 设计/一致性层)
- 命令面/字段均基于本地源码编译产物实测(z2_cc/gitlink-cli PR#1 全链路验证后已清理)
- 注册到 skills/README.md 目录树与 AI Agent 能力清单
2026-06-21 10:22:19 +08:00
kaka f1e237ad18 feat(skills): 新增 gitlink-duplicate-detector 重复 Issue 检测 Skill
CI / test (push) Failing after 1m8s Details
- 扫描开放 Issue,按 subject+description 语义聚类,识别重复簇并选出主 Issue
- 默认 dry-run 只读分析;写动作(关联评论/关闭)需逐条确认
- 幂等基于 issue +comments 的 notes 标记(实测可靠);
  issue +update --label 实测返回 ok 但标签不生效,已记入「已知限制」
- 命令面与字段均基于本地源码编译产物实测(z2_cc/gitlink_help_center 全链路验证后已清理)
- 注册到 skills/README.md 目录树与 AI Agent 能力清单
2026-06-17 11:47:42 +08:00
赵昌 b914138338 docs: add release notes workflow example
CI / test (push) Failing after 4m15s Details
2026-06-17 10:09:27 +08:00
刘焱 75a8ed235a fix(collaborator): 修复 change-role/batch-role 请求体
CI / test (push) Failing after 39m6s Details
forgeplus change_role 要求顶层 {user_id: 整数, role: Manager},原先嵌套 {user:{user_id:用户名, role_name:admin}} 导致未找到用户。改为经协作者列表解析用户名到数字 ID,role 映射 admin/write/read 到 Manager/Developer/Reporter。
2026-06-10 11:28:45 +08:00
刘焱 e9f36c29fb fix(branch): 修复保护分支接口路径与列表输出
protect/unprotect/batch-protect 走无 /v1 的老接口(DELETE /api/:owner/:repo/protected_branches/:name),原先漏或误加 /v1 均导致 404;branch +list 投影到核心列 name/protected/last_commit/commit_time,避免字段过多导致表格不稳定。
2026-06-10 11:28:45 +08:00
刘焱 f248ed9d04 fix(output): 表头取所有行字段并集并固定核心列顺序
printSliceTable 原先只从第一行取表头,导致列时有时无;改为取所有行并集。新增 protected/last_commit/commit_time 到优先表头,固定分支核心列顺序。
2026-06-10 11:28:45 +08:00
s2_cc f9efae89a3 docs: add comprehensive Chinese comments and mark bug fixes (P1-P4)
CI / test (push) Failing after 2m6s Details
- Add detailed Chinese comments across all shortcut modules explaining
  parameter mappings (CLI→API field names), batch processing patterns,
  and design decisions
- Mark P1 Bug#1: issue +create --label not written to request body
- Mark P1 Bug#2: hardcoded 'master' replaced with GetDefaultBranch()
- Mark P1 Bug#3: +diff and +files deduplicated into +version-diff
- Add P2 parameter annotations for sort/filter/milestone fields
- Add P4 improvement notes for HTTP error suggestions
- Reorder ProcessBatch function for better readability
2026-06-10 10:13:45 +08:00
刘焱 d12ce0fb57 fix(interactive): 命令面板选中带可选参数的命令也弹出表单
CI / test (push) Failing after 2m4s Details
根因:updatePalette 只在有缺失的 Required flag 时才弹参数表单。但很多
命令的参数是'至少一个'关系(如 branch +batch-protect 的 --names/--from
二选一),无法单独标记 Required,导致 missingRequiredFlags 返回空,
表单被跳过,命令直接执行后报 'no branch names provided'。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

119
.claude/commands/gitlink.md Normal file
View File

@ -0,0 +1,119 @@
---
description: GitLink 操作中心 — 列出所有常用操作、端到端场景与科研辅助,上下选择并执行;也可传入仓库网址直接对其操作
argument-hint: "[可选: GitLink 仓库网址 或 场景关键词,如 代码审查 / 健康度]"
---
# GitLink 操作中心(交互式菜单)
你是 **GitLink 操作中心**的调度器。请严格按下列流程,为用户呈现一个可「上下方向键选择并执行」的交互式菜单,并调用对应已实现的 skill 完成操作。
> **铁律**:所有 GitLink 操作必须用 `gitlink-cli`,禁止用 `gh`。所有写操作默认 dry-run确认后再实际执行。
---
## 第 1 步解析目标仓库TARGET
检查命令参数 `$ARGUMENTS`
1. 若包含 GitLink 网址(形如 `https://gitlink.org.cn/<owner>/<repo>`,可能带 `.git`、`/pulls/<n>`、`/issues/<n>`、`/compare/...` 等后缀),用正则 `gitlink\.org\.cn/([^/]+)/([^/?.#]+)` 提取 `<owner>``<repo>`(去掉末尾 `.git`)→ 记为 **TARGET = owner/repo**
2. 否则若形如 `<owner>/<repo>`(如 `z2_cc/gitlink-cli`),直接作为 TARGET。
3. 否则若当前处于一个 git 仓库且其 remote 指向 GitLink自动解析 owner/repo 作为 TARGET可运行 `git remote -v` 判断)。
4. 否则 **TARGET = 空**(后续按需向用户询问)。
**关键词直跳**:若 `$ARGUMENTS` 命中下方「映射表」中的任一场景关键词(代码审查 / Issue 分拣 / Release / 健康度 / 合规 / 新人引导 / 社区运营 / 质量门禁 / 项目初始化 / 多仓库 / 贡献者 / 科研洞悉 / 科研热点 / 知识图谱 / 科研合规 / 复现性 / 科研协作匹配 / 科研进度 / 预警),**跳过第 2 步菜单**,直接进入第 3 步执行该场景TARGET 仍按上面规则解析)。
---
## 第 2 步:呈现菜单(无关键词时)
### 2.1 先在对话中「列出所有」可选项
用中文打印下面这张完整目录(让用户一眼看到全部 16 项 + 仓库定位),格式如下:
```
🧭 GitLink 操作中心 — 请选择要执行的操作(也可直接输入序号 / 关键词 / 仓库网址)
🔍 单项操作
1 智能代码审查 分析 PR diff输出结构化 Review 意见并自动评论
2 Issue 自动分拣 根据内容自动分类、打标签、分配责任人
3 Release Notes 生成 根据 commit / PR 记录生成结构化版本说明
4 新人引导 为 good-first-issue 自动添加引导评论
📊 分析与成长
5 项目健康度报告 统计 Issue 响应时间、PR 合并效率、贡献者活跃度
6 许可证合规检查 扫描许可证合规性与敏感信息泄露风险
7 贡献者成长体系 追踪 PR/Issue 活动 → 生成排行 → 自动颁发徽章
🚀 端到端自动化场景
8 社区运营自动化 Issue 分拣 → 周报 → 自动发布 Release
9 代码质量看门人 PR 审查 → CI → 门禁决策 → 自动合并
10 项目一键初始化 创建仓库 → 生成 README/LICENSE/CI → 初始 Issue/里程碑
11 多仓库协同 跨仓库 Issue 追踪 / PR 看板 / Release 协调发布
🔬 科研辅助(面向科研工作者 / 课题组,打通开源生态与学术科研)
12 科研项目洞悉 8 维数据采集 + AI 分析(成熟度/文档/贡献模式/社区健康)→ 结构化评分报告
13 科研热点与知识图谱 多关键词搜索 → 实体/关系抽取 → Mermaid 知识图谱 + 交互式 HTML
14 科研合规与复现性 数据可用性 / 环境可复现 / 结果可验证 多维评估
15 科研协作智能匹配 角色(数据/算法/工程/评估)推断 → 人员-任务匹配 → 综合建议
16 科研进度跟踪与预警 6 维风险信号(停滞/高优无人/零回复/积压/提交突变/里程碑 DDL+ 100 分制评分
🌐 也可以:粘贴一个 GitLink 仓库网址https://gitlink.org.cn/<owner>/<repo>),我对它执行上述任一操作。
```
### 2.2 再用 AskUserQuestion 弹出原生「上下选择」菜单
调用一次 `AskUserQuestion`header 为「操作类别」,提供 4 个选项用户可用方向键上下选择也可选「Other」直接输入序号/关键词/网址):
| 选项 | 描述 |
|---|---|
| 🔍 单项操作 | 代码审查 / Issue 分拣 / Release Notes / 新人引导 |
| 📊 分析与成长 | 项目健康度 / 许可证合规 / 贡献者成长 |
| 🚀 端到端自动化 | 社区运营 / 质量门禁 / 项目初始化 / 多仓库协同 |
| 🔬 科研辅助 | 项目洞悉 / 热点与知识图谱 / 合规复现 / 协作匹配 / 进度预警 |
- 若用户选了 🔍 / 📊 / 🚀 三类(每类 ≤4 项):再调用一次 `AskUserQuestion` 列出该类别下具体操作(每项描述一句话),让用户上下选择。
- 若用户选 🔬 科研辅助(共 5 项,超过单次 4 项上限):先把 5 项编号打印,再用 `AskUserQuestion` 列出其中 4 项,并提示「可选 Other 输入第 5 项序号」;用户也可直接回复 1-5 序号。
- 若用户在任何一步选「Other」输入了仓库网址 / `owner/repo`:按第 1 步规则解析为 TARGET再继续选操作。
- 若用户选「Other」直接输入了序号或关键词按映射表直接定位操作进入第 3 步。
---
## 第 3 步:执行对应 skill
### 操作 → skill 映射表
| 序号 | 菜单项 | 调用的 skill |
|---|---|---|
| 1 | 智能代码审查 | `gitlink-code-review` |
| 2 | Issue 自动分拣 | `gitlink-issue-triage` |
| 3 | Release Notes 生成 | `gitlink-release-auto` |
| 4 | 新人引导 | `gitlink-newcomer-guide` |
| 5 | 项目健康度报告 | `gitlink-project-health` |
| 6 | 许可证合规检查 | `gitlink-compliance` |
| 7 | 贡献者成长体系 | `gitlink-contributor-growth` |
| 8 | 社区运营自动化 | `gitlink-community-ops` |
| 9 | 代码质量看门人 | `gitlink-quality-gate` |
| 10 | 项目一键初始化 | `gitlink-project-bootstrap` |
| 11 | 多仓库协同 | `gitlink-multi-repo-coordination` |
| 12 | 科研项目洞悉 | `gitlink-research-insight` |
| 13 | 科研热点与知识图谱 | `gitlink-research-hotspot` |
| 14 | 科研合规与复现性 | `gitlink-research-compliance` |
| 15 | 科研协作智能匹配 | `gitlink-research-matching` |
| 16 | 科研进度跟踪与预警 | `gitlink-research-progress` |
> 这些 skill 都在当前项目(`Desktop/gitlink-cl`)下。用 `Skill` 工具按上表名字调用即可。
执行规则:
1. **若 TARGET 仍为空**,先用 `AskUserQuestion` 询问「请提供目标 GitLink 仓库owner/repo 或网址)」。
2. 调用对应 skill把 TARGET 的 owner/repo 传达给它skill 内部用 `--owner/--repo` 或环境变量;端到端场景脚本形如 `bash skills/<skill>/scripts/*-full.sh <owner> <repo>`)。
3. **写操作一律先 dry-run**`DRY_RUN=true`),把将要执行的变更展示给用户,经确认后再 `DRY_RUN=false` 实际执行。
4. 操作完成后,用中文给出简洁结果摘要(做了什么 / 关键数据 / 产物路径)。
---
## 备注
- 多仓库协同11支持 `REPOS="owner1/repo1,owner2/repo2"` 同时处理多个仓库。
- **科研辅助12-16**均为 **evidence-oriented / read-only**(只读分析、不改仓库),可放心对任意公开 GitLink 仓库运行;产物多为结构化报告 / 知识图谱 / 交互式 HTML位于各 skill 的 `scripts/_output/`)。`gitlink-research-matching` 已验证于 `Gitlink/forgeplus``research-progress` / `research-compliance` 已验证于 `forgeplus`、`gitlink-cli`、`ifzhang/FairMOT`。
- 若 `gitlink-cli` 未登录,先提示用户运行 `gitlink-cli auth login`(或调用 `gitlink-shared` skill
- 用户随时可输入「返回」回到主菜单,或「退出」结束。

View File

@ -0,0 +1,55 @@
version: 2
name: 代码质量门禁流水线
description: "PR 触发:代码审查 → CI 检查 → 门禁决策 → 自动合并或反馈"
trigger:
webhook: gitlink@1.0.0
event:
- ref: pull_request
ruleset-operator: AND
global:
concurrent: 1
param:
- ref: remote_url
name: ""
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
required: false
type: STRING
hidden: false
- ref: owner
name: "仓库所有者"
value: '"z2_cc"'
required: true
type: STRING
hidden: false
- ref: repo
name: "仓库名称"
value: '"gitlink-cli"'
required: true
type: STRING
hidden: false
- ref: dry_run
name: "Dry-Run 模式"
value: '"true"'
required: false
type: STRING
hidden: false
workflow:
- ref: start
name: 开始
task: start
- ref: ssh_cmd_0
name: 运行代码质量门禁
task: ssh_cmd@1.1.1
input:
ssh_pass: ((work_together.ssh_key))
ssh_ip: '"121.41.216.243"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_cmd: '"cd /root/gitlink-cli && git pull origin master && DRY_RUN=$(dry_run) bash skills/gitlink-quality-gate/scripts/quality-gate-full.sh z2_cc gitlink-cli"'
needs:
- start
- ref: end
name: 结束
task: end
needs:
- ssh_cmd_0

View File

@ -0,0 +1,55 @@
version: 2
name: 多仓库协同流水线
description: "定时运行:多仓库 Issue 追踪 → PR 看板 → Release 协调发布"
trigger:
webhook: gitlink@1.0.0
event:
- ref: schedule
ruleset-operator: AND
global:
concurrent: 1
param:
- ref: remote_url
name: ""
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
required: false
type: STRING
hidden: false
- ref: owner
name: "仓库所有者"
value: '"z2_cc"'
required: true
type: STRING
hidden: false
- ref: repo
name: "仓库名称"
value: '"gitlink-cli"'
required: true
type: STRING
hidden: false
- ref: repos
name: "多仓库列表(逗号分隔 owner/repo"
value: '"z2_cc/gitlink-cli,z2_cc/gitlink-bisync"'
required: false
type: STRING
hidden: false
workflow:
- ref: start
name: 开始
task: start
- ref: ssh_cmd_0
name: 运行多仓库协同流水线
task: ssh_cmd@1.1.1
input:
ssh_pass: ((work_together.ssh_key))
ssh_ip: '"121.41.216.243"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_cmd: '"cd /root/gitlink-cli && git pull origin master && DRY_RUN=true REPOS=$(repos) bash skills/gitlink-multi-repo-coordination/scripts/multi-repo-full.sh"'
needs:
- start
- ref: end
name: 结束
task: end
needs:
- ssh_cmd_0

View File

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

View File

@ -0,0 +1,49 @@
version: 2
name: 社区运营流水线
description: "每周定时运行Issue 分拣 → 健康报告 → Release Notes"
trigger:
webhook: gitlink@1.0.0
event:
- ref: schedule
ruleset-operator: AND
global:
concurrent: 1
param:
- ref: remote_url
name: ""
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
required: false
type: STRING
hidden: false
- ref: owner
name: "仓库所有者"
value: '"z2_cc"'
required: true
type: STRING
hidden: false
- ref: repo
name: "仓库名称"
value: '"gitlink-cli"'
required: true
type: STRING
hidden: false
workflow:
- ref: start
name: 开始
task: start
- ref: ssh_cmd_0
name: 运行社区运营自动化流水线
task: ssh_cmd@1.1.1
input:
ssh_pass: ((work_together.ssh_key))
ssh_ip: '"121.41.216.243"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_cmd: '"cd /root/gitlink-cli && git pull origin master && DRY_RUN=true bash skills/gitlink-community-ops/scripts/community-ops-full.sh z2_cc gitlink-cli"'
needs:
- start
- ref: end
name: 结束
task: end
needs:
- ssh_cmd_0

View File

@ -0,0 +1,55 @@
version: 2
name: 贡献者成长体系流水线
description: "每周定时运行:贡献追踪 → 排行段位 → 自动颁发徽章"
trigger:
webhook: gitlink@1.0.0
event:
- ref: schedule
ruleset-operator: AND
global:
concurrent: 1
param:
- ref: remote_url
name: ""
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
required: false
type: STRING
hidden: false
- ref: owner
name: "仓库所有者"
value: '"z2_cc"'
required: true
type: STRING
hidden: false
- ref: repo
name: "仓库名称"
value: '"gitlink-cli"'
required: true
type: STRING
hidden: false
- ref: dry_run
name: "是否 dry-run"
value: '"true"'
required: false
type: STRING
hidden: false
workflow:
- ref: start
name: 开始
task: start
- ref: ssh_cmd_0
name: 运行贡献者成长体系流水线
task: ssh_cmd@1.1.1
input:
ssh_pass: ((work_together.ssh_key))
ssh_ip: '"121.41.216.243"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_cmd: '"cd /root/gitlink-cli && git pull origin master && DRY_RUN=$(dry_run) bash skills/gitlink-contributor-growth/scripts/contributor-growth-full.sh $(owner) $(repo)"'
needs:
- start
- ref: end
name: 结束
task: end
needs:
- ssh_cmd_0

View File

@ -0,0 +1,61 @@
version: 2
name: 项目初始化流水线
description: "新项目一键初始化:创建仓库 → 生成 README/LICENSE/CI → 创建初始 Issue 和里程碑"
trigger:
webhook: gitlink@1.0.0
event:
- ref: manual
ruleset-operator: AND
global:
concurrent: 1
param:
- ref: remote_url
name: ""
value: '"https://gitlink.org.cn/z2_cc/gitlink-cli.git"'
required: false
type: STRING
hidden: false
- ref: owner
name: "仓库所有者"
value: '"z2_cc"'
required: true
type: STRING
hidden: false
- ref: repo
name: "仓库名称"
value: '"gitlink-cli"'
required: true
type: STRING
hidden: false
- ref: project_desc
name: "项目描述"
value: '"一个用 Go 编写的 GitLink 命令行工具"'
required: true
type: STRING
hidden: false
- ref: dry_run
name: "是否 dry-run"
value: '"true"'
required: false
type: STRING
hidden: false
workflow:
- ref: start
name: 开始
task: start
- ref: ssh_cmd_0
name: 运行项目一键初始化流水线
task: ssh_cmd@1.1.1
input:
ssh_pass: ((work_together.ssh_key))
ssh_ip: '"121.41.216.243"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_cmd: '"cd /root/gitlink-cli && git pull origin master && DRY_RUN=$(dry_run) PROJECT_DESC=\"$(project_desc)\" bash skills/gitlink-project-bootstrap/scripts/project-bootstrap-full.sh $(owner) $(repo)"'
needs:
- start
- ref: end
name: 结束
task: end
needs:
- ssh_cmd_0

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

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

View File

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

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# 编译输出
*.exe
# IDE
.vscode/
.idea/
# 系统
.DS_Store
Thumbs.db
# 临时文件
*.tmp
*.log
# 个人工作区
my_docs/
CLAUDE.md
.claude/
docs/
# 脚本输出目录
skills/*/scripts/_output/
doc/sub-task4-module-c/_output/
doc/sub-task4-module-d/_output/
# CLI 包装器
.cli-bin/
# 测试/临时数据文件
*.json
!package.json
snapshots/
tmp/
# Node.js
node_modules/
package-lock.json

264
CLAUDE.md Normal file
View File

@ -0,0 +1,264 @@
# gitlink-cli 项目指南
## 项目背景
本项目是《软件演化与运维》2026 春)课程的**进阶实践任务****GitLink 智能化能力提升项目**。
GitLink CLI 是 GitLink 平台的命令行工具,采用 Go 语言开发cobra 框架),提供 40+ 命令覆盖仓库管理、Issue 跟踪、PR 协作、CI/CD 等场景,并内置 AI Agent Skills 体系,兼容 Claude Code 等主流 Agent 平台。
- **仓库**https://gitlink.org.cn/gitlink/gitlink-cli本仓库 fork 自 `z2_cc/gitlink-cli`
- **Skills 开发指南**`skills/README.md`
- **API 文档**https://s.apifox.cn/da30afb0-9d2e-429b-a4bc-a83209e06021
- **CLI 设计文档**`doc/design.md`
进阶任务包含四个子任务(已全部完成),另加 DevOps 流水线:
| 子任务 | 定位 | 占比 | 技术栈 |
|--------|------|------|--------|
| 子任务一 | 扩展 CLI 能力 | 50% | Go |
| 子任务二 | 编写 Skills | 20% | Markdown + CLI |
| 子任务三 | 端到端工作流 | 20% | Shell + Skills 编排 |
| 子任务四 | 科研辅助(加分项) | 额外加分 | Markdown + CLI |
| DevOps | CI/CD 流水线 | 10% | YAML |
---
## 🧭 操作中心(交互式菜单)
**触发方式**(任一即可):
- 输入 `/gitlink`(斜杠命令,最推荐),可附带参数:`/gitlink 代码审查`、`/gitlink https://gitlink.org.cn/z2_cc/gitlink-cli`
- 直接说「常用操作」、「常用操作以及场景」、「列出操作」、「gitlink 菜单」
触发后按 [`.claude/commands/gitlink.md`](.claude/commands/gitlink.md) 的流程:先列出全部 16 项操作(单项操作 / 分析与成长 / 端到端自动化 / 科研辅助),再用原生上下方向键菜单让用户选择,或接收一个 GitLink 仓库网址直接对其操作,最后调用对应已实现的 skill 执行(写操作默认 dry-run。操作 → skill 映射见该命令文件。
---
## 技术架构
```
gitlink-cli/
├── main.go # 入口
├── cmd/ # CLI 命令定义cobra
│ ├── root.go # 根命令,注册所有 shortcuts
│ ├── api/api.go # raw API 调用gitlink api <METHOD> <PATH>
│ ├── auth/ # 认证login/logout/status/refresh
│ ├── config/ # 配置管理
│ └── interactive/ # 交互式 REPLbubbletea TUI
├── shortcuts/ # 23 个命令组repo/issue/pr/release/ci/…)
│ ├── register.go # RegisterAll() 注册入口
│ ├── common/ # 共享类型 + 批量操作框架batch.go
│ └── {group}/ # 每个命令组一个目录
├── internal/ # 核心包
│ ├── auth/ # 登录、token 存储、HTTP 传输
│ ├── client/ # API 客户端auto-.json、分页、错误映射
│ ├── config/ # 配置文件管理
│ ├── context/ # 自动解析 owner/repo从 git remote
│ └── output/ # 格式化输出json/table/yaml
├── skills/ # 32 个 Skill 定义SKILL.md + examples/ + scripts/
├── .claude/skills/ # Claude Code 使用的符号链接 → ../../skills/{name}
├── .devops/ # GitLink DevOps 流水线3 个 YAML
├── doc/ # 设计文档、交接文档、架构图
└── my_docs/ # 工作文档、总结、计划
```
---
## 子任务一CLI 能力扩展Go
**定位**:为 gitlink-cli 增加新功能、优化现有功能。所有改动在 `cmd/`、`shortcuts/`、`internal/` 中。
### 已完成P1-P4
| 优先级 | 内容 | 关键文件 |
|--------|------|----------|
| **P1** Bug 修复3 项) | `--label` 参数未发送、硬编码 `master` 默认分支、`pr +diff`/`+files` 重复 | `shortcuts/issue/`, `shortcuts/common/`, `shortcuts/pr/` |
| **P2** 参数补全10 项) | 补全 `--milestone`/`--assignee`/`--priority`/`--sort` 等 CLI 参数与 API 的映射 | `shortcuts/issue/`, `shortcuts/pr/`, `shortcuts/release/` |
| **P3** 批量操作9 个命令) | 通用批量框架 + issue/PR/collaborator/branch 批量命令,支持 `--dry-run`/`--yes`/CSV 输入 | `shortcuts/common/batch.go` |
| **P4** 体验优化 | 40+ 命令帮助文档、破坏性操作确认提示、API 错误友好建议 | `shortcuts/*/`, `internal/client/` |
### 交互式 REPL
`cmd/interactive/` 提供基于 bubbletea 的 TUI命令面板模糊搜索→ 参数表单 → 输出查看器。支持 `/` 前缀打开面板。
### 后续可完善方向
- 补全 GitLink OpenAPI 中尚未封装的 Raw API
- 新增 Shortcut 命令组(如看板增强)
- 跨平台兼容性和安装体验优化
- 单元测试覆盖
---
## 子任务二Skills 开发Markdown + CLI
**定位**:基于 gitlink-cli 开发 AI Agent Skill。核心交付物是 SKILL.md + 使用示例 + Agent 平台验证。
### Skills 规范
每个 Skill 位于 `skills/{name}/`,包含:
- `SKILL.md`YAML 前-mattername、description、triggers+ 使用说明
- `examples/`Agent 对话记录或运行示例
- `scripts/`(可选):可执行脚本
### 已完成的 Skills32 个)
**平台基础操作**(原始 11 个 + 扩展):
`gitlink-repo` `gitlink-issue` `gitlink-pr` `gitlink-release` `gitlink-branch` `gitlink-org` `gitlink-user` `gitlink-search` `gitlink-ci` `gitlink-webhook` `gitlink-wiki` `gitlink-snippet` `gitlink-pm`
**智能化 Skills**(子任务二核心交付):
`gitlink-code-review` `gitlink-pr-deep-review` `gitlink-issue-triage` `gitlink-release-auto` `gitlink-project-health` `gitlink-insight` `gitlink-commit-quality` `gitlink-compliance` `gitlink-duplicate-detector` `gitlink-newcomer-guide`
**工作流 Skills**(子任务三):
`gitlink-community-ops` `gitlink-quality-gate` `gitlink-workflow`
**科研 Skills**(子任务四):
`gitlink-research-insight` `gitlink-research-hotspot` `gitlink-research-matching` `gitlink-research-progress` `gitlink-research-compliance`
**共享基础**
`gitlink-shared`(认证、全局参数、安全规则)
### 后续可完善方向
- 为现有 Skill 补充更多 Agent 平台验证OpenClaw、Cursor
- 优化 Skill 触发词准确性
- 增加 Skill 使用示例的覆盖度
---
## 子任务三端到端自动化工作流Shell + Skills 编排)
**定位**:组合 gitlink-cli 命令和 Skills完成可复现的端到端自动化场景。每个工作流串联 ≥3 个步骤。
### 场景 A社区运营自动化
`skills/gitlink-community-ops/` — 三阶段流水线:
1. Issue 分拣(关键词分析 → 建议标签)
2. 社区周报Issue/PR/Commit/Milestone 统计)
3. Release Notes 生成Conventional Commits 分类)
关键文件:`community-ops-full.sh`(全流程编排)、`health-analyze.js`、`release-analyze.js`
### 场景 B代码质量看门人
`skills/gitlink-quality-gate/` — 四阶段门禁:
1. PR 采集 → 2. 深度审查 → 3. CI 检查 → 4. 评分决策≥70 分 + CI 通过 → 建议合并)
评分模型CodeReview(40%) + CI(30%) + 设计一致性(30%)
关键文件:`quality-gate-full.sh`(全流程编排)
### DevOps 流水线
`.devops/` 目录下 3 个 YAML 配置,均配置在 `z2_cc/gitlink-cli`
- `构建流水线.yml`push 触发 Go 构建 + 版本校验
- `社区运营流水线.yml`:定时触发社区运营全流程
- `代码质量门禁流水线.yml`PR 触发质量门禁
### 后续可完善方向
- 贡献者成长体系(追踪→排行→徽章)
- 多仓库协同场景
- 项目一键初始化工作流
- 工作流的错误恢复和通知机制
---
## 子任务四科研辅助Markdown + CLI加分项
**定位**:依托 gitlink-cli 数据获取与 AI Agent 能力,为科研场景提供智能化辅助。设计原则:**evidence-oriented证据驱动、read-only只读不写、delegation pattern委托已有 Skill、pure remote analysis纯远程分析**。
### 已完成的 5 个科研 Skill
| Skill | 功能 | 关键文件 |
|-------|------|----------|
| `gitlink-research-insight` | 仓库级科研项目洞悉8 维数据采集 + 4 维 AI 分析(成熟度/文档/贡献模式/社区健康),输出结构化评分报告 | `scripts/research-insight-full.sh`、`scripts/analyze.js` |
| `gitlink-research-hotspot` | 科研热点追踪:多关键词搜索 → 实体提取 → 关系图构建 → Mermaid.js 知识图谱 + 交互式 HTML 可视化 | `scripts/hotspot-tracker-full.sh`、`scripts/kg-to-html.js` |
| `gitlink-research-matching` | 科研协作智能匹配4 角色推断(数据/算法/工程/评估)→ 人员-任务匹配 → 资源探测 → 综合建议报告 | `examples/matching-run.md`(已验证于 `Gitlink/forgeplus` |
| `gitlink-research-progress` | 科研进度跟踪与预警6 维风险信号(停滞 Issue/高优无人/零回复/积压趋势/提交突变/里程碑 DDL100 分制风险评分 | 已验证于 `forgeplus`/`gitlink-cli`/`FairMOT` |
| `gitlink-research-compliance` | 科研合规与复现性检查:数据可用性/环境可复现/结果可验证 4 维评估 | `examples/reproducibility-check.md`(已验证于 `ifzhang/FairMOT` |
### 共享工具
`skills/gitlink-shared/scripts/` 提供 `detect-cli.sh`CLI 版本检测)和 `md-to-html.js`(报告转交互式 HTML
### 后续可完善方向
- 增加更多科研仓库验证案例
- 知识图谱的交互式探索能力
- 科研主体画像功能
- 创新启发式推荐
---
## Skill 触发映射
当用户提及以下内容时,**优先调用对应 Skill**(而非手动执行命令):
### 平台操作
- **Issue 管理**`gitlink-issue`
- **PR 管理**`gitlink-pr`
- **Release / 发版 / changelog**`gitlink-release``gitlink-release-auto`
- **仓库管理**`gitlink-repo`
- **分支管理**`gitlink-branch`
- **组织管理**`gitlink-org`
- **CI/CD**`gitlink-ci`
- **Wiki**`gitlink-wiki`
- **代码片段**`gitlink-snippet`
- **Webhook**`gitlink-webhook`
- **搜索**`gitlink-search`
### 智能化与自动化
- **代码审查 / PR Review**`gitlink-code-review``gitlink-pr-deep-review`
- **Issue 分拣 / 分类 / Triage**`gitlink-issue-triage`
- **项目健康度 / 周报**`gitlink-project-health``gitlink-insight`
- **新人引导 / good-first-issue**`gitlink-newcomer-guide`
- **重复 Issue 检测**`gitlink-duplicate-detector`
- **许可证合规检查**`gitlink-compliance`
- **提交质量检查**`gitlink-commit-quality`
- **项目管理 / Sprint / 看板**`gitlink-pm`
### 工作流(子任务三)
- **社区运营自动化 / 周报生成**`gitlink-community-ops`
- **代码质量门禁 / PR 自动门禁**`gitlink-quality-gate`
- **工作流模板参考**`gitlink-workflow`
### 科研辅助(子任务四)
- **科研项目洞悉 / 成熟度评估 / 仓库洞察**`gitlink-research-insight`
- **科研热点追踪 / 知识图谱 / 研究趋势**`gitlink-research-hotspot`
- **科研协作匹配 / 角色推断**`gitlink-research-matching`
- **科研进度跟踪 / 风险预警**`gitlink-research-progress`
- **科研合规检查 / 复现性评估**`gitlink-research-compliance`
### 工作流扩展(子任务五)
- **项目一键初始化 / 创建仓库 / 生成 README/LICENSE/CI / 初始 Issue 和里程碑** → 调用 `gitlink-project-bootstrap` skill
- **多仓库协同 / 跨仓库 Issue 追踪 / PR 状态看板 / Release 协调发布** → 调用 `gitlink-multi-repo-coordination` skill通过 `REPOS="owner/repo,..."` 指定多仓库)
- **贡献者成长体系 / 贡献排行 / 成长段位 / 自动颁发徽章** → 调用 `gitlink-contributor-growth` skill
---
## 开发约定
### 命令使用
- 所有 GitLink API 操作使用 `gitlink-cli` 命令,不得直接调用 REST API
- 使用 Skill 前先阅读对应 `SKILL.md``skills/gitlink-shared/SKILL.md` 了解认证与已知限制
- 命令输出优先使用 `--format json` 获取结构化数据
### 构建与测试
```bash
make build # 编译到 bin/gitlink-cli
make install # 安装到 $GOPATH/bin
go test ./... # 运行所有测试
```
### 添加新 Skill
1. 在 `skills/` 下创建目录(如 `skills/gitlink-xxx/`
2. 编写 `SKILL.md`(参考 `skills/README.md` 规范)
3. 在 `.claude/skills/` 创建符号链接:`ln -s ../../skills/gitlink-xxx gitlink-xxx`
4. 更新本文件的 Skill 映射表
### 关键参考文档
- `doc/design.md` — CLI 设计文档
- `doc/sub-task34-交接文档.md` — 子任务三、四交接文档
- `my_docs/summary.md` — 子任务一 P1-P4 改进总结
- `skills/README.md` — Skills 开发规范
- `skills/gitlink-shared/SKILL.md` — 共享基础(认证、安全规则)

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@ -0,0 +1,398 @@
package interactive
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Executor runs shortcut commands and captures their stdout output.
type Executor struct{}
// Execute runs the given shortcut with the provided arguments, capturing
// anything written to os.Stdout during execution and returning it as a string.
//
// It builds the RuntimeContext via common.NewRuntimeContext so that Client,
// Format, Owner and Repo are properly initialized — a bare
// `&RuntimeContext{Args: args}` would leave Client nil and panic as soon as a
// command calls ctx.CallAPI. A deferred recover guards against panics raised
// inside the command so the REPL is never left with stdout pointing at a
// closed pipe.
func (e *Executor) Execute(s *common.Shortcut, args map[string]string) (output string, err error) {
// Guard against panics inside the command: always restore stdout even if
// s.Run panics, and surface the panic as a regular error so the REPL can
// display it instead of hanging with a corrupted stdout.
defer func() {
if r := recover(); r != nil {
output = ""
err = fmt.Errorf("command panicked: %v", r)
}
}()
// Save original stdout
oldStdout := os.Stdout
// Create a pipe: writes go to w, reads come from r
r, w, perr := os.Pipe()
if perr != nil {
return "", fmt.Errorf("failed to create pipe: %w", perr)
}
// Redirect stdout to the write end of the pipe
os.Stdout = w
// Channel to signal that the goroutine has finished reading
done := make(chan struct{})
var buf bytes.Buffer
// Read from the pipe in a goroutine so that writes don't block
go func() {
io.Copy(&buf, r)
close(done)
}()
// Build a fully-initialized context (Client/Format/Owner/Repo).
ctx, cerr := common.NewRuntimeContext(args)
if cerr != nil {
// Restore stdout and drain the pipe before returning.
os.Stdout = oldStdout
w.Close()
<-done
return "", fmt.Errorf("failed to initialize runtime context: %w", cerr)
}
// Execute the shortcut's Run function. Recover protects the path so that
// a panic still leaves the deferred cleanup below runnable.
runErr := safeRun(s, ctx)
// Close the writer to signal EOF to the reader goroutine, wait for it to
// finish copying into the buffer, then restore stdout.
w.Close()
<-done
os.Stdout = oldStdout
return buf.String(), runErr
}
// safeRun invokes a shortcut's Run function and converts any panic into an
// error, so a panicking command cannot crash the executor goroutine or leave
// os.Stdout redirected at a closed pipe.
func safeRun(s *common.Shortcut, ctx *common.RuntimeContext) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("command panicked: %v", r)
}
}()
return s.Run(ctx)
}
// parseDirectCommand parses a direct command string like "issue +list --state open"
// into its components: group, cmd (without +), flagStr, and whether parsing succeeded.
func parseDirectCommand(input string) (group, cmd, flagStr string, ok bool) {
input = strings.TrimSpace(input)
if input == "" {
return "", "", "", false
}
parts := strings.Fields(input)
if len(parts) < 2 {
return "", "", "", false
}
group = parts[0]
rawCmd := parts[1]
if !strings.HasPrefix(rawCmd, "+") {
return "", "", "", false
}
cmd = strings.TrimPrefix(rawCmd, "+")
if len(parts) > 2 {
flagStr = strings.Join(parts[2:], " ")
}
return group, cmd, flagStr, true
}
// parseFlagString parses a flag string like "--title hello -n 42" into a map.
// shortMap is used to expand short flag names to their long equivalents.
func parseFlagString(flagStr string, shortMap map[string]string) map[string]string {
result := make(map[string]string)
if flagStr == "" {
return result
}
parts := strings.Fields(flagStr)
for i := 0; i < len(parts); i++ {
part := parts[i]
if strings.HasPrefix(part, "--") {
// Long flag
flagPart := strings.TrimPrefix(part, "--")
if strings.Contains(flagPart, "=") {
// --flag=value format
kv := strings.SplitN(flagPart, "=", 2)
result[kv[0]] = kv[1]
} else if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
// --flag value format
result[flagPart] = parts[i+1]
i++
} else {
// --flag without value (boolean-like)
result[flagPart] = "true"
}
} else if strings.HasPrefix(part, "-") && len(part) > 1 {
// Short flag
shortName := strings.TrimPrefix(part, "-")
// Expand short name to long name if mapping exists
longName := shortName
if mapped, ok := shortMap[shortName]; ok {
longName = mapped
}
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
result[longName] = parts[i+1]
i++
} else {
result[longName] = "true"
}
}
}
return result
}
// findShortcut looks up a shortcut by group and command name in the registry.
func findShortcut(all map[string][]*common.Shortcut, group, cmd string) (*common.Shortcut, bool) {
shortcuts, ok := all[group]
if !ok {
return nil, false
}
for _, s := range shortcuts {
if s.Name == cmd {
return s, true
}
}
return nil, false
}
// buildShortMap creates a mapping from short flag names to long flag names.
func buildShortMap(s *common.Shortcut) map[string]string {
m := make(map[string]string)
for _, f := range s.Flags {
if f.Short != "" {
m[f.Short] = f.Name
}
}
return m
}
// missingRequiredFlags returns the names (with -- prefix) of flags that are
// marked as required but not present in args.
func missingRequiredFlags(s *common.Shortcut, args map[string]string) []string {
var missing []string
for _, f := range s.Flags {
if f.Required {
if _, ok := args[f.Name]; !ok {
missing = append(missing, "--"+f.Name)
}
}
}
return missing
}
// commandSuggestions maps (group, command) to command-specific fix suggestions shown
// in the interactive REPL when a command fails with an API error.
var commandSuggestions = map[string]map[string]string{
"ci": {
"builds": "请确认仓库已启用 CI 引擎gitlink ci +enable且存在构建记录",
"runs": "请确认仓库已启用 CI 引擎,且存在运行记录",
"run-results": "请确认仓库已启用 CI 引擎,且存在运行结果",
"logs": "请确认构建编号正确且构建已完成",
"restart": "请确认构建编号正确且状态允许重新构建",
"stop": "请确认构建编号正确且构建处于运行中",
"pipelines": "请确认仓库已配置 CI 流水线模板",
"run-pipeline": "请确认流水线名称正确且仓库已启用 CI 引擎",
"pipeline-detail": "请确认流水线 ID 正确",
"delete-pipeline": "请确认流水线 ID 正确",
"disable": "仓库可能已禁用 CI无需重复操作",
"enable": "仓库可能已启用 CI无需重复操作",
"run-log": "请确认运行 ID 正确且运行已完成",
},
"issue": {
"list": "请确认 owner/repo 参数正确",
"create": "请确认标题和必填参数已填写",
"view": "请确认 Issue 编号正确",
"update": "请确认 Issue 编号和更新参数正确",
"close": "请确认 Issue 编号正确,且你有关闭权限",
"delete": "请确认 Issue 编号正确,此操作不可恢复",
"comment": "请确认 Issue 编号正确且评论内容不为空",
"comments": "请确认 Issue 编号正确",
"update-comment": "请确认评论 ID 正确",
"delete-comment": "请确认评论 ID 正确",
"reply-comment": "请确认评论 ID 正确",
"batch-close": "请确认 Issue 编号正确,且你有批量关闭权限",
"batch-update": "请确认 Issue 编号和更新参数正确",
"batch-destroy": "请确认 Issue 编号正确,此操作不可恢复",
},
"pr": {
"list": "请确认 owner/repo 参数正确",
"create": "请确认源分支和目标分支正确",
"view": "请确认 PR 编号正确",
"merge": "请确认 PR 已通过 CI 检查且无合并冲突",
"close": "请确认 PR 编号正确",
"reopen": "请确认 PR 编号正确且 PR 当前为关闭状态",
"update": "请确认 PR 编号和更新参数正确",
"comment": "请确认 PR 编号正确且评论内容不为空",
"files": "请确认 PR 编号正确",
"versions": "请确认 PR 编号正确",
"version-diff": "请确认 PR 编号正确",
"reviews": "请确认 PR 编号正确",
"review": "请确认 PR 编号正确且你已被列为审查者",
"comments": "请确认 PR 编号正确",
"create-comment": "请确认 PR 编号正确且评论内容不为空",
"update-comment": "请确认评论 ID 正确",
"delete-comment": "请确认评论 ID 正确",
"commits": "请确认 PR 编号正确",
"batch-close": "请确认 PR 编号正确,且你有批量关闭权限",
"batch-merge": "请确认 PR 已通过 CI 检查且无合并冲突",
},
"release": {
"list": "请确认 owner/repo 参数正确",
"create": "请确认标签名和版本号正确,仓库默认分支可能存在",
"view": "请确认 Release ID 或标签名正确",
"delete": "请确认 Release ID 正确,此操作不可恢复",
"edit": "请确认 Release ID 和更新参数正确",
"update": "请确认 Release ID 和更新参数正确",
},
"branch": {
"list": "请确认 owner/repo 参数正确",
"create": "请确认源分支名正确,默认分支名可能不匹配",
"delete": "请确认分支名正确,此操作不可恢复",
"protect": "请确认分支名正确",
"unprotect": "请确认分支名正确",
"all": "请确认 owner/repo 参数正确",
"default": "请确认仓库存在且可见",
"restore": "请确认分支名正确且分支已被删除",
"batch-delete": "请确认分支名正确,此操作不可恢复",
"batch-protect": "请确认分支名正确",
},
"repo": {
"list": "请确认用户/组织名正确",
"info": "请确认 owner/repo 参数正确",
"create": "请确认仓库名合法且不与其他仓库冲突",
"fork": "请确认源仓库存在且可见",
"delete": "请确认仓库名正确,此操作不可恢复",
"update": "请确认更新参数正确",
"about": "请确认 owner/repo 参数正确",
"menu": "请确认 owner/repo 参数正确",
"units-get": "请确认 owner/repo 参数正确",
"units-set": "请确认仓库单元设置值有效",
"edit-detail": "请确认更新参数正确",
"simple": "请确认 owner/repo 参数正确",
"code-stats": "请确认 owner/repo 参数正确",
"languages": "请确认 owner/repo 参数正确",
"contributors": "请确认 owner/repo 参数正确",
"contributors-stat": "请确认 owner/repo 参数正确",
"recommend": "该功能可能需要特定权限",
"star": "请确认 owner/repo 参数正确",
"unstar": "请确认 owner/repo 参数正确",
"watch": "请确认 owner/repo 参数正确",
"unwatch": "请确认 owner/repo 参数正确",
"stargazers": "请确认 owner/repo 参数正确",
"watchers": "请确认 owner/repo 参数正确",
"transfer": "请确认目标所有者正确,且你有仓库转让权限",
"cancel-transfer": "请确认存在待处理的转让请求",
"transfer-orgs": "请确认你的组织列表中存在目标组织",
"invite-link": "请确认 owner/repo 参数正确",
"join": "请确认邀请链接有效",
"quit": "请确认 owner/repo 参数正确",
"migrate": "请确认源仓库地址正确且可访问",
"sync-mirror": "请确认仓库为镜像仓库",
"topics": "请确认 owner/repo 参数正确",
"create-topic": "请确认标签名合法",
"delete-topic": "请确认标签名正确",
},
}
// formatInteractiveError wraps a command execution error for display in the
// interactive REPL. It hides raw HTTP/API technical details and provides
// command-specific suggestions. The original error is appended in a dim line.
func formatInteractiveError(group string, s *common.Shortcut, err error) string {
var apiErr *client.APIError
if errors.As(err, &apiErr) {
return formatAPIError(group, s, apiErr)
}
return fmt.Sprintf("✗ %s +%s 执行失败\n 原因:%v", group, s.Name, err)
}
// formatAPIError builds a user-friendly error message from an APIError.
func formatAPIError(group string, s *common.Shortcut, e *client.APIError) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("✗ %s +%s 执行失败", group, s.Name))
// Reason — use the API message directly, it's already Chinese in most cases
reason := e.Message
if reason == "" {
reason = fmt.Sprintf("服务器返回错误码 [%v]", e.Code)
}
sb.WriteString(fmt.Sprintf("\n 原因:%s", reason))
// Suggestion — command-specific first, then generic fallback
if suggestion := lookupSuggestion(group, s.Name); suggestion != "" {
sb.WriteString(fmt.Sprintf("\n 建议:%s", suggestion))
}
// Original error in dim text
sb.WriteString(fmt.Sprintf("\n [原始: %s]", e.Error()))
return sb.String()
}
// lookupSuggestion returns a command-specific suggestion, or empty string.
func lookupSuggestion(group, cmd string) string {
if cmds, ok := commandSuggestions[group]; ok {
if suggestion, ok := cmds[cmd]; ok {
return suggestion
}
}
return ""
}
// formatCommandDisplay formats a command for display, e.g. "issue +create --title hello --body world".
func formatCommandDisplay(group string, s *common.Shortcut, args map[string]string) string {
var b strings.Builder
b.WriteString(group)
b.WriteString(" +")
b.WriteString(s.Name)
// Sort flag names for deterministic output
keys := make([]string, 0, len(args))
for k := range args {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b.WriteString(" --")
b.WriteString(k)
b.WriteString(" ")
b.WriteString(args[k])
}
return b.String()
}

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

3173
cover.out Normal file

File diff suppressed because it is too large Load Diff

308
doc/RELEASE-NOTES.md Normal file
View File

@ -0,0 +1,308 @@
# Release Notes — gitlink-cli
> 官方 [GitLink](https://www.gitlink.org.cn) 命令行工具,面向人类用户和 AI Agent。
> 支持 macOS、Linux 和 Windows覆盖仓库管理、Issue 跟踪、Pull Request、Webhook、CI/CD 及 AI 驱动的工作流。
---
## v0.2.0 (2026-06-17) — 交互式 REPL 与输出大重构
### 里程碑亮点
- ✨ **交互式 REPL 模式**:新增基于 Bubble Tea TUI 框架的命令面板、参数表单与滚动视口
- 📊 **输出系统重构**:统一表格渲染,终结 JSON 回退;支持嵌套对象层级展开、值截断、动态列合并
- 🌏 **全命令中文覆盖**:全部 25+ 命令组的子命令描述、参数注释、帮助文本完成中文化
- 🐛 **P1-P4 缺陷修复**:修复 16 项实测发现的问题,涉及 collaborator、branch、file、tag、interactive 等多个模块
- 🧩 **3 个新 AI Agent Skill**issue-triage、project-health、newcomer-guide
- 🧪 **大规模测试**新增交互模式冒烟测试、output 表格渲染单元测试、hook-runner 测试等
### 新增功能
#### 交互式 REPL (Bubble Tea TUI)
- **命令面板** (`/` 快捷键唤起):实时搜索 80+ 命令,支持模糊匹配
- **参数表单**:选中命令后自动弹出表单填写 flags支持必填/可选参数
- **滚动视口**:长输出支持上下滚动浏览,不再截断
- **默认 table 输出**REPL 中统一使用表格格式,告别 JSON 裸输出
- **Ctrl+C/Esc**:正确的退出与返回行为
- **Smoke Test**:所有注册命令执行不 panic 的自动化验证
#### 输出系统重构
- **`printSliceTable` 增强**:表头取自所有行的字段并集,不再仅依赖第一行
- **嵌套 Map 详情视图**:单对象详情(如 `tag +view`、`repo +info`)渲染为可读层级表格,`└` 缩进展开子字段,不再回退 JSON
- **原生 Map Slice 表格化**`[]map[string]string` 等本地构造数据也渲染为表格,不再显示 `[N items]`
- **值截断与列限制**超长值URL、SHA、commit message自动截断核心列优先固定
- **已废弃**`hasOnlyNestedMaps` JSON 回退逻辑移除
#### Skill 与 Workflow
- 新增 3 个 AI Agent Skill
- `gitlink-issue-triage` — Issue 分类与优先级评估
- `gitlink-project-health` — 项目健康度洞察
- `gitlink-newcomer-guide` — 新贡献者引导
- `gitlink-webhook` Skill新增 `+failed`、`+task-view` 命令
- 新增 `gitlink-wiki`、`gitlink-snippet` Skill
- 新增 Workflow 自动化示例issue-triage、project-health、newcomer-guide
#### 命令增强
- **Webhook**`+failed` 查看失败投递、`+task-view` 支持 UUID/数字 ID 双查询、`--limit` 分页支持
- **Shortcuts**:导出 `GetAllShortcuts` / `GetDescriptions` 供交互模式消费
### Bug 修复
| 模块 | 问题 | 修复 |
|------|------|------|
| **collaborator** | `change-role`/`batch-role` 请求体格式错误导致"未找到用户" | 改为 `{user_id: 整数, role: string}` 格式;自动解析用户名到数字 ID |
| **branch** | protect/unprotect 接口路径误加 `/v1` 导致 404 | 走无 `/v1` 老接口 |
| **branch** | `+list` 字段过多导致表格不稳定 | 投影到核心列name/protected/last_commit/commit_time |
| **file** | `+get` 端点调用了错误 API | 改为 `/entries` 提取单文件元信息 |
| **file** | `+tree --recursive` 参数被 API 拒绝 | 移除无效 flag |
| **file** | `+create`/`+update`/`+delete` 缺少必需参数 | 补 `--new-branch`、`--sha` 改为必填 |
| **file** | `+replace` 端点已废弃 | 移除该命令 |
| **tag** | `+view` 调用了不存在端点导致"标签不存在" | 改用列表过滤,新增 `findTagByName` |
| **interactive** | 带可选参数的命令跳过表单直接执行 | 改为"有任何 flag 都弹表单" |
| **interactive** | nil Client 导致 panic | `NewRuntimeContext` 初始化 |
| **interactive** | `/` 键响应延迟 | 立即响应而非等待后续输入 |
| **output** | 原生 `[]map[string]string` 不识别为表格 | reflect 统一处理 |
| **output** | 单对象嵌套 Map 回退 JSON | 层级表格展开 |
### 文档与国际化
- **全部 25+ 命令组描述中文化**repo / pr / issue / branch / tag / file / wiki / webhook / snippet / user / org / ci / search / label / milestone / release / commit / collaborator / attachment / dataset / template / util / sshkey / shortcut / interactive
- **Shortcuts 模块详细中文注释**参数映射CLI→API、批处理模式、设计决策
- **P1-P4 缺陷中文标记**issue +create --label 遗漏、hardcoded 'master' 等已知问题文档化
- **交互模式设计文档**:实现计划 + 命令面板设计规格
### 基础设施
- **依赖**:引入 `bubbletea`、`bubbles`、`huh` TUI 框架
- **CI**DevOps 流水线(构建+验证)
- **测试**interactive smoke test、output 表格渲染回归测试、tag `+view` 单元测试、hook-runner 单元测试
### 贡献者
自 v0.1.18 以来的贡献者(按字母顺序):
2403_89190320, Leo77, Mengz, Tiger, s2_cc, wangyue789, wqer, yangsai01, 刘焱, 赵昌
---
## v0.1.18 (2026-05-19) — Webhook 集成与 Skills 扩展
> 这也是 v0.2.0 之前带交互式 REPL 内测功能的过渡版本。
### 核心变更
- **交互模式 MVP**:首次合入 Bubble Tea REPL 基础框架(命令执行器 + flag 解析 + TUI 入口),但随后回退(`Revert "Add TUI foundation"`),留待 v0.2.0 正式发布
- **npm 安装**:修复 Windows 二进制缺失问题,改进安装后诊断
---
## v0.1.17 (2026-05-17) — Skills 生态与包管理器支持
### 新功能
- **3 个新 Skill**`gitlink-code-review`(代码审查)、`gitlink-insight`(仓库洞察)、`gitlink-compliance`(合规检查)
- **2 个自动化 Skill**`gitlink-release-auto`(自动发版)、`gitlink-commit-quality`(提交信息规范检查)
- **包管理器支持**HomebrewmacOS/Linux、ScoopWindows、ChocolateyWindows
- **CI**GitHub Release 工作流与 npm 自动发布
- **npm 安装增强**:校验和验证、错误消息改进、版本检查
### 命令增强
- **PR**:新增 `+review` 审查快捷、`+patchset` 补丁集版本切换
- **PR**`+comment` 评论 CRUD
- **Issue**`+batch-close` 批量关闭dry-run 支持)
- **PR/Issue**:统一使用 project-level 编号替代数据库 ID
### Bug 修复
- **Issue**: `+list` 输出使用项目级 Issue 编号
- **PR**: 统一使用 `pull_request_number` 替代 `pull_request_id`
- **Issue**: `update` 时保留描述字段
- **npm**: Windows 二进制缺失导致静默退出
### 文档
- Skills REFERENCE.md 参考文档
- README 更新至 v0.1.1712 Skills、npm 一键安装)
- Fork-based PR 工作流指南
- Skill 工具边界规则(防止在 GitLink 上误用 gh/hub
---
## v0.1.14 (2026-05-17) — 全 API 覆盖里程碑
### 新功能
- **40+ 新命令 shortcut**
- **Repo**22 个新 shortcutsettings/stats/social/transfer/invite/migrate/topics
- **User**22 个新 shortcutmessages/stats/transfers/settings/feedback
- **PR**comments CRUD、commits、reopen、update
- **Issue**comments CRUD、delete、batch-update、batch-destroy
- **Branch**all/default/restore**Release**edit/update
- **协作**org team-projects**File**batch/entries/replace
- **CI 流水线**、**Wiki 导入/导出**、**Repo 加入/退出/迁移/话题**
- **Util**、**Dataset**、**Template**、**Attachment** 快捷组
- **代码片段管理**`snippet +list/+view/+create/+delete`
- **看板**`board +view/+columns/+move`
- **OAuth2 认证**、**颜色检测与 CJK 宽度支持**
- **Wiki 命令改用 Git 操作**`+list/+view/+create/+update/+delete` 重构为 Git-based
### 跨平台
- **Windows (x64/arm64)** 完整支持
- 终端颜色自动检测与中文字符宽度适配
### Skills
- **6 个新 Skills**`gitlink-repo`、`gitlink-issue`、`gitlink-pr`、`gitlink-ci`、`gitlink-search`、`gitlink-workflow`
- **Skill 架构升级**Anthropic Agent Skills 标准
- **pm / org / user / release / branch** 等 Skill 持续完善
### 基础设施
- npm 包 `@gitlink-ai/cli` 分发上线
- `GITLINK_TOKEN` 环境变量支持非交互式认证
- `gitlink-cli-install-skills` 跨平台安装命令
### 项目健康
- 新增 CONTRIBUTORS 章节展示项目贡献者
- 许可证切换至 MulanPSL-2.0(木兰宽松许可证)
- README 中英双语
---
## v0.1.8 ~ v0.1.13 (2026-04-07 ~ 2026-04-15) — 功能夯实
### v0.1.13 (2026-04-15)
- **npm 安装方案**:修复 GitHub Release asset 匹配回退与相对 URL 拼接问题
- 改进 release 创建时默认 `target_commitish=master`
- Issue 创建默认 `priority_id=2`
### v0.1.12 (2026-04-15)
- Webhook 快捷命令组正式合入
- `hook-runner` 集成为 `webhook` 子命令
- 新增 `--limit` 标志与 UUID 显示改进
### v0.1.11 (2026-04-08)
- **PR 审查**`+review` 审查提交
- **PR 补丁集**`+patchset-versions` 版本切换
- 3 个新 Skills`gitlink-code-review`、`gitlink-insight`、`gitlink-compliance`
### v0.1.10 (2026-04-08)
- 2 个自动化 Skill`gitlink-release-auto`、`gitlink-commit-quality`
- Webhook 端点测试修复
### v0.1.9 (2026-04-07)
- **Webhook shortcut 组**正式版
- Skills 文档完善
### v0.1.8 (2026-04-07)
- **Branch Skill**`gitlink-branch` 保护分支管理
- PR `+comment` 快捷
- Issue `+batch-close` 批量关闭
- Issue v1 API 迁移,使用 `project_issues_index` 替代数据库 ID
- 修复 Issue 描述 update 丢失问题
---
## v0.1.0-skill-test (2026-04-04) — Skill 概念验证
- Skill PR 全流程测试
- 首个 Skill 原型验证
- PR 创建、合入工作流测试
---
## v0.0.1-test-del / initial (2026-04-03) — 项目初始化
- `github.com/gitlink-org/gitlink-cli` 项目启动
- 基础 CLI 框架Cobra
- 初始命令auth、repo、issue、pr
- npm 分发基础 (`@gitlink-ai/cli`)
- Cookie-based 登录与 token 认证
---
## 统计一览
| 版本 | 日期 | 新增文件 | 代码变更 | 贡献者 |
|------|------|----------|----------|--------|
| init → v0.1.8 | 2026-04 | ~30 | ~8k | 5 |
| v0.1.8 → v0.1.14 | 2026-04~05 | ~60 | ~15k | 8 |
| v0.1.14 → v0.1.18 | 2026-05 | ~22 | +514/-1015 | 4 |
| v0.1.18 → v0.2.0 | 2026-05~06 | 114 | +24531/-324 | 10 |
## Skills 一览
| Skill | 用途 | 引入版本 |
|-------|------|----------|
| `gitlink-repo` | 仓库管理 | v0.1.14 |
| `gitlink-issue` | Issue 跟踪 | v0.1.14 |
| `gitlink-pr` | Pull Request 管理 | v0.1.14 |
| `gitlink-ci` | CI/CD 流水线 | v0.1.14 |
| `gitlink-search` | 全局搜索 | v0.1.14 |
| `gitlink-workflow` | 工作流自动化 | v0.1.14 |
| `gitlink-branch` | 保护分支管理 | v0.1.8 |
| `gitlink-pm` | 项目管理 | v0.1.14 |
| `gitlink-org` | 组织管理 | v0.1.14 |
| `gitlink-user` | 用户管理 | v0.1.14 |
| `gitlink-release` | 发布管理 | v0.1.14 |
| `gitlink-code-review` | 代码审查 | v0.1.11 |
| `gitlink-insight` | 仓库洞察 | v0.1.11 |
| `gitlink-compliance` | 合规检查 | v0.1.11 |
| `gitlink-release-auto` | 自动发版 | v0.1.10 |
| `gitlink-commit-quality` | 提交信息规范 | v0.1.10 |
| `gitlink-webhook` | Webhook 管理 | v0.1.9 |
| `gitlink-wiki` | Wiki 管理 | v0.2.0 |
| `gitlink-snippet` | 代码片段管理 | v0.2.0 |
| `gitlink-issue-triage` | Issue 分类与优先级 | v0.2.0 |
| `gitlink-project-health` | 项目健康度 | v0.2.0 |
| `gitlink-newcomer-guide` | 新贡献者引导 | v0.2.0 |
| `gitlink-shared` | 共享工具库 | v0.2.0 |
| `gitlink-workflow` | 工作流定义 | v0.2.0 |
---
## 升级指南
### v0.1.x → v0.2.0
- **交互模式**:运行 `gitlink-cli interactive``gitlink-cli tui` 进入 REPL
- **输出格式**v0.2.0 默认输出格式为 table如需 JSON 请使用 `--output json``--json`
- **File 命令**`+replace` 已移除,请改用 `+create`/`+update`
- **Tag 命令**`+view` 行为变更(从单端点改为列表过滤),功能一致
### 首次安装
```bash
# npm (推荐)
npm install -g @gitlink-ai/cli
# macOS (Homebrew)
brew tap gitlink-ai/tap
brew install gitlink-cli
# Windows (Scoop)
scoop bucket add gitlink https://github.com/gitlink-ai/scoop-bucket
scoop install gitlink-cli
# Windows (Chocolatey)
choco install gitlink-cli
```
---
> 完整的提交历史请参阅 [CHANGELOG](./changes/) 目录。
>
> 如发现任何问题,欢迎在 [GitLink Issues](https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues) 反馈。

View File

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

609
doc/demo.html Normal file
View File

@ -0,0 +1,609 @@
<!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>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0f172a;--bg2:#1e293b;--card:#1e3a5f;--accent:#38bdf8;--accent2:#818cf8;--gold:#fbbf24;--text:#e2e8f0;--dim:#94a3b8;--green:#22c55e;--red:#ef4444;--border:#334155}
body{background:var(--bg);color:var(--text);font-family:'Microsoft YaHei',sans-serif;display:flex;height:100vh;overflow:hidden}
/* Sidebar */
.sidebar{width:280px;background:var(--bg2);border-right:1px solid var(--border);flex-shrink:0;overflow-y:auto}
.sidebar-header{padding:20px 24px;border-bottom:1px solid var(--border)}
.sidebar-header h1{font-size:18px;color:var(--accent);margin-bottom:4px}
.sidebar-header p{font-size:12px;color:var(--dim)}
.nav-section{padding:12px 0}
.nav-section-title{padding:4px 24px;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:1px}
.nav-item{padding:10px 24px;cursor:pointer;font-size:14px;color:var(--text);border-left:3px solid transparent;transition:all .2s;display:flex;align-items:center;gap:10px}
.nav-item:hover{background:var(--card);color:var(--accent)}
.nav-item.active{background:var(--card);color:var(--accent);border-left-color:var(--accent)}
.nav-item .icon{font-size:18px;width:24px;text-align:center}
.nav-item .time{margin-left:auto;font-size:11px;color:var(--dim);background:var(--bg);padding:2px 6px;border-radius:4px}
/* Main */
.main{flex:1;overflow-y:auto;padding:0}
.demo-page{display:none;padding:32px 40px;min-height:100%}
.demo-page.active{display:block}
.page-header{margin-bottom:24px}
.page-header h2{font-size:26px;color:var(--text);margin-bottom:6px}
.page-header p{font-size:14px;color:var(--dim)}
/* Terminal */
.terminal{background:#1e1e2e;border-radius:10px;overflow:hidden;margin:16px 0;box-shadow:0 4px 24px rgba(0,0,0,.3)}
.term-bar{background:#313244;padding:8px 14px;display:flex;align-items:center;gap:8px}
.term-dot{width:12px;height:12px;border-radius:50%}
.term-dot.r{background:#f38ba8}.term-dot.y{background:#f9e2af}.term-dot.g{background:#a6e3a1}
.term-title{flex:1;text-align:center;font-size:12px;color:#6c7086;font-family:monospace}
.term-body{padding:16px 20px;font-family:'Cascadia Code','Fira Code',monospace;font-size:13px;line-height:1.7;color:#cdd6f4;max-height:500px;overflow-y:auto}
.term-prompt{color:#89b4fa}.term-cmd{color:#cdd6f4}.term-flag{color:#f9e2af}.term-str{color:#a6e3a1}.term-out{color:#a6adc8}.term-ok{color:#a6e3a1}.term-info{color:#89b4fa}.term-warn{color:#f9e2af}.term-err{color:#f38ba8}.term-dim{color:#6c7086}.term-section{color:#cba6f7;font-weight:bold}
/* Cards */
.card-grid{display:grid;gap:16px;margin:16px 0}
.card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:20px}
.card h3{font-size:16px;color:var(--accent);margin-bottom:10px}
.card p{font-size:13px;color:var(--text);line-height:1.6}
.metric-row{display:flex;gap:16px;flex-wrap:wrap;margin:12px 0}
.metric{background:var(--card);border-radius:8px;padding:12px 20px;text-align:center;min-width:120px}
.metric .val{font-size:28px;font-weight:bold;color:var(--gold)}
.metric .lbl{font-size:12px;color:var(--dim);margin-top:4px}
/* Output link */
.output-link{display:inline-flex;align-items:center;gap:8px;background:var(--card);border:1px solid var(--accent);border-radius:8px;padding:10px 20px;color:var(--accent);text-decoration:none;font-size:14px;transition:all .2s;cursor:pointer}
.output-link:hover{background:var(--accent);color:var(--bg)}
.output-link .arrow{font-size:16px}
/* Phase */
.phase{display:flex;gap:16px;margin:12px 0;align-items:flex-start}
.phase-num{width:36px;height:36px;border-radius:50%;background:var(--accent);color:var(--bg);display:flex;align-items:center;justify-content:center;font-weight:bold;font-size:16px;flex-shrink:0}
.phase-content{flex:1}
.phase-content h4{font-size:15px;color:var(--text);margin-bottom:4px}
.phase-content p{font-size:13px;color:var(--dim);line-height:1.5}
/* Table */
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13px}
th{background:var(--card);color:var(--accent);padding:10px 14px;text-align:left;border-bottom:2px solid var(--border)}
td{padding:8px 14px;border-bottom:1px solid var(--border);color:var(--text)}
tr:hover td{background:var(--bg2)}
/* Score bar */
.score-bar{display:flex;align-items:center;gap:12px;margin:8px 0}
.score-label{width:120px;font-size:13px;color:var(--text)}
.score-track{flex:1;height:24px;background:var(--bg);border-radius:4px;overflow:hidden}
.score-fill{height:100%;border-radius:4px;display:flex;align-items:center;justify-content:flex-end;padding-right:8px;font-size:11px;color:#fff;font-weight:bold}
.score-val{width:60px;font-size:14px;font-weight:bold;color:var(--gold);text-align:right}
/* Hint */
.hint{background:rgba(56,189,248,.1);border:1px solid var(--accent);border-radius:8px;padding:12px 20px;margin:16px 0;font-size:13px;color:var(--text)}
.hint b{color:var(--accent)}
/* Badges */
.badge{display:inline-block;padding:2px 10px;border-radius:4px;font-size:11px;font-weight:bold}
.badge-new{background:rgba(34,197,94,.2);color:var(--green);border:1px solid var(--green)}
.badge-orig{background:rgba(148,163,184,.1);color:var(--dim);border:1px solid var(--border)}
/* Scrollbar */
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-track{background:var(--bg)}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
::-webkit-scrollbar-thumb:hover{background:var(--dim)}
/* Home */
.home-hero{text-align:center;padding:40px 0}
.home-hero h2{font-size:32px;color:var(--text);margin-bottom:12px}
.home-hero p{font-size:16px;color:var(--dim);margin-bottom:30px}
.home-metrics{display:flex;gap:20px;justify-content:center;flex-wrap:wrap;margin:30px 0}
.home-metric{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:20px 30px;text-align:center;min-width:140px}
.home-metric .val{font-size:36px;font-weight:bold}
.home-metric .lbl{font-size:13px;color:var(--dim);margin-top:6px}
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
@keyframes fadein{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
.repl-line{animation:fadein .3s ease}
</style>
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
<h1>GitLink CLI</h1>
<p>现场演示 · 4 场景 · ~3 分钟</p>
</div>
<div class="nav-section">
<div class="nav-section-title">概览</div>
<div class="nav-item active" onclick="show('home',this)"><span class="icon">🏠</span> 首页</div>
</div>
<div class="nav-section">
<div class="nav-section-title">子任务一 · CLI</div>
<div class="nav-item" onclick="show('cli',this)"><span class="icon">⌨️</span> CLI 命令 <span class="time">30s</span></div>
<div class="nav-item" onclick="show('repl',this)"><span class="icon">🖥️</span> REPL 交互 <span class="time">45s</span></div>
</div>
<div class="nav-section">
<div class="nav-section-title">子任务三 · 工作流</div>
<div class="nav-item" onclick="show('community',this)"><span class="icon">📊</span> 社区运营 <span class="time">60s</span></div>
</div>
<div class="nav-section">
<div class="nav-section-title">子任务四 · 科研</div>
<div class="nav-item" onclick="show('insight',this)"><span class="icon">🔬</span> 项目洞悉 <span class="time">45s</span></div>
<div class="nav-item" onclick="show('hotspot',this)"><span class="icon">🕸️</span> 知识图谱 <span class="time">45s</span></div>
</div>
<div class="nav-section">
<div class="nav-section-title">评分体系</div>
<div class="nav-item" onclick="show('scoring',this)"><span class="icon">📐</span> 权重设计 <span class="time">30s</span></div>
</div>
</div>
<div class="main">
<!-- ===== HOME ===== -->
<div class="demo-page active" id="home">
<div class="home-hero">
<h2>GitLink 智能化能力提升项目</h2>
<p>gitlink-cli 命令行工具 · AI Agent Skills · 工作流自动化 · 科研辅助</p>
<div class="home-metrics">
<div class="home-metric"><div class="val" style="color:#38bdf8">236</div><div class="lbl">Git 提交</div></div>
<div class="home-metric"><div class="val" style="color:#818cf8">29</div><div class="lbl">AI Skills</div></div>
<div class="home-metric"><div class="val" style="color:#22c55e">14</div><div class="lbl">新命令模块</div></div>
<div class="home-metric"><div class="val" style="color:#fbbf24">16.8k</div><div class="lbl">Go 代码行</div></div>
<div class="home-metric"><div class="val" style="color:#a78bfa">20</div><div class="lbl">测试文件</div></div>
</div>
</div>
<div class="hint">
<b>💡 演示说明:</b>点击左侧导航查看各场景。每个场景包含<b>命令展示</b><b>输出结果跳转</b>。所有数据来自 GitLink API 实时采集。
</div>
<div class="card-grid" style="grid-template-columns:1fr 1fr">
<div class="card">
<h3>📁 代码仓库</h3>
<p>fork 自 GitLink 官方 gitlink-cli在初始 commit 基础上完成全部工作</p>
<a class="output-link" href="https://www.gitlink.org.cn/z2_cc/gitlink-cli" target="_blank" style="margin-top:12px">前往仓库 →</a>
</div>
<div class="card">
<h3>👥 团队</h3>
<p>赵昌 (wqer) · 子任务三/四<br>刘焱 (liuyan688) · CLI 底层 + REPL<br>曾蔚然 (z2_cc) · DevOps + 审查</p>
</div>
</div>
</div>
<!-- ===== CLI ===== -->
<div class="demo-page" id="cli">
<div class="page-header">
<h2>① CLI 基础命令演示</h2>
<p>新增 14 个 Shortcut 模块 · 统一输出格式 · 真实 API 数据</p>
</div>
<div class="terminal">
<div class="term-bar"><span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span><span class="term-title">z@z-pc: ~/gitlink-cli</span></div>
<div class="term-body">
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-cmd">gitlink-cli</span> <span class="term-cmd">repo</span> <span class="term-flag">+info</span> <span class="term-flag">--owner</span> <span class="term-str">z2_cc</span> <span class="term-flag">--repo</span> <span class="term-str">gitlink-cli</span></div>
<div class="term-out"> name gitlink-cli</div>
<div class="term-out"> full_name z2_cc/gitlink-cli</div>
<div class="term-out"> default_branch master</div>
<div class="term-out"> issues_count 17</div>
<div class="term-out"> forked_count 2</div>
<div class="term-out"> clone_url https://gitlink.org.cn/z2_cc/gitlink-cli.git</div>
<div> </div>
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-cmd">gitlink-cli</span> <span class="term-cmd">collaborator</span> <span class="term-flag">+list</span> <span class="term-flag">--owner</span> <span class="term-str">z2_cc</span> <span class="term-flag">--repo</span> <span class="term-str">gitlink-cli</span></div>
<div class="term-out"> login role is_owner</div>
<div class="term-out"> ────────────────────────────────────</div>
<div class="term-out"> z2_cc Manager true</div>
<div class="term-out"> wqer Manager false</div>
<div class="term-out"> liuyan688 Manager false</div>
<div> </div>
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-cmd">gitlink-cli</span> <span class="term-cmd">webhook</span> <span class="term-flag">+failed</span> <span class="term-flag">--owner</span> <span class="term-str">z2_cc</span> <span class="term-flag">--repo</span> <span class="term-str">gitlink-cli</span></div>
<div class="term-out"> 最近 7 天投递失败记录0 条 <span class="term-ok"></span></div>
</div>
</div>
<div class="card-grid" style="grid-template-columns:1fr 1fr 1fr">
<div class="card"><h3>📌 新增 14 模块</h3><p>collaborator · tag · milestone · file · commit · label · sshkey · webhook · wiki · snippet · dataset · template · attachment · util</p></div>
<div class="card"><h3>⭐ 统一输出</h3><p>所有命令支持 <code>--format json/table/yaml</code>JSON Envelope: {ok, data, meta}</p></div>
<div class="card"><h3>✅ 测试覆盖</h3><p>20 个测试文件,含单元测试 + E2E 测试</p></div>
</div>
</div>
<!-- ===== REPL ===== -->
<div class="demo-page" id="repl">
<div class="page-header">
<h2>② REPL 交互模式 — IDE 级体验</h2>
<p>Bubble Tea TUI · 2027 行 Go · 5 状态状态机 · 7 个 E2E 测试 · 点击下方命令体验</p>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
<!-- 左:交互终端 -->
<div class="card" style="padding:0;overflow:hidden">
<div style="background:#313244;padding:8px 14px;font-size:12px;color:#6c7086;display:flex;align-items:center;gap:8px">
<span style="width:12px;height:12px;border-radius:50%;background:#f38ba8"></span>
<span style="width:12px;height:12px;border-radius:50%;background:#f9e2af"></span>
<span style="width:12px;height:12px;border-radius:50%;background:#a6e3a1"></span>
<span style="flex:1;text-align:center">gitlink-cli REPL — 交互终端</span>
</div>
<div id="repl-terminal" style="background:#1e1e2e;padding:16px;font-family:'Cascadia Code','Fira Code',monospace;font-size:13px;line-height:1.6;color:#cdd6f4;min-height:340px;max-height:340px;overflow-y:auto">
<div style="color:#a6e3a1">Welcome to gitlink-cli interactive mode.</div>
<div style="color:#6c7086">Type / to open command palette, "exit" or Ctrl+D to quit.</div>
<div style="height:8px"></div>
<div id="repl-output"></div>
<div><span style="color:#89b4fa">z@z-pc</span><span style="color:#6c7086">:</span><span style="color:#89b4fa">~/gitlink-cli</span><span style="color:#6c7086">$ </span><span id="repl-cursor" style="background:#cdd6f4;width:8px;height:16px;display:inline-block;animation:blink 1s infinite"></span></div>
</div>
</div>
<!-- 右:操作指引 -->
<div class="card">
<h3>🖱️ 点击体验三个阶段</h3>
<div style="display:flex;flex-direction:column;gap:10px;margin-top:12px">
<button onclick="replDemo('palette')" style="background:#1e3a5f;border:1px solid #38bdf8;color:#38bdf8;padding:10px 16px;border-radius:8px;cursor:pointer;font-size:13px;text-align:left;font-family:inherit">
<b>① 命令面板</b> — 输入 / 搜索命令<br>
<span style="font-size:11px;color:#94a3b8">点击模拟:输入 / → 搜索 issue → 选命令</span>
</button>
<button onclick="replDemo('form')" style="background:#1e293b;border:1px solid #22c55e;color:#22c55e;padding:10px 16px;border-radius:8px;cursor:pointer;font-size:13px;text-align:left;font-family:inherit">
<b>② 表单输入</b> — Tab 切换参数<br>
<span style="font-size:11px;color:#94a3b8">点击模拟:填 title → Tab → 填 assignee → 提交</span>
</button>
<button onclick="replDemo('viewport')" style="background:#312e81;border:1px solid #fbbf24;color:#fbbf24;padding:10px 16px;border-radius:8px;cursor:pointer;font-size:13px;text-align:left;font-family:inherit">
<b>③ 滚动视口</b> — 长输出浏览<br>
<span style="font-size:11px;color:#94a3b8">点击模拟:执行 issue +list → 滚动浏览</span>
</button>
<button onclick="replDemo('reset')" style="background:#1e293b;border:1px solid #64748b;color:#94a3b8;padding:8px 16px;border-radius:8px;cursor:pointer;font-size:12px;text-align:left;font-family:inherit">
↺ 重置终端
</button>
</div>
<div style="margin-top:14px;padding:10px;background:rgba(56,189,248,.08);border-radius:6px;font-size:11px;color:#94a3b8;line-height:1.5">
<b style="color:#38bdf8">键盘操作:</b><br>
<code style="color:#a6e3a1">/</code> 唤起面板 · <code style="color:#a6e3a1">↑↓</code> 导航 · <code style="color:#a6e3a1">Enter</code> 选中<br>
<code style="color:#a6e3a1">Tab</code> 切字段 · <code style="color:#a6e3a1">Space</code> 切布尔 · <code style="color:#a6e3a1">Esc</code> 取消<br>
<code style="color:#a6e3a1">PgUp/PgDn</code> 翻页 · <code style="color:#a6e3a1">q</code> 返回
</div>
</div>
</div>
<div class="hint">
<b>⭐ 核心亮点:</b>用户无需记忆命令语法——输入 / 搜索命令Tab 填写参数,提交即执行。体验接近 VS Code 命令面板。
<br><b>技术实现:</b>基于 Charmbracelet Bubble TeaElm 架构repl.go(433行) + palette.go(305行) + form.go(296行) + executor.go(237行)7 个 E2E 测试。
</div>
</div>
<!-- ===== COMMUNITY ===== -->
<div class="demo-page" id="community">
<div class="page-header">
<h2>③ 社区运营自动化</h2>
<p>community-ops-full.sh · 三阶段流水线一键执行 · 真实运行结果</p>
</div>
<div class="terminal">
<div class="term-bar"><span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span><span class="term-title">community-ops-full.sh</span></div>
<div class="term-body">
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-flag">DRY_RUN=true</span> <span class="term-cmd">bash</span> <span class="term-str">skills/gitlink-community-ops/scripts/community-ops-full.sh</span> <span class="term-str">z2_cc</span> <span class="term-str">gitlink-cli</span></div>
<div> </div>
<div class="term-section">============================================</div>
<div class="term-section"> 社区运营自动化</div>
<div class="term-section">============================================</div>
<div> </div>
<div><span class="term-info">▸ Phase 1/3: Issue 自动分拣</span></div>
<div class="term-out">[INFO] 获取所有打开的 Issue...</div>
<div class="term-out">[INFO] 发现 21 个 Issue17 打开 / 4 关闭)</div>
<div class="term-out">[INFO] 自动分类bug 8 · enhancement 6 · documentation 4 · other 3</div>
<div class="term-ok">✓ 分拣完成</div>
<div> </div>
<div><span class="term-info">▸ Phase 2/3: 社区周报生成</span></div>
<div class="term-out">[INFO] 采集 PR/提交/贡献者数据...</div>
<div class="term-out">[INFO] 综合评分: 3.5/10 🔴 堪忧</div>
<div class="term-out">[INFO] 关闭率: 19% | 合并率: 0% | 贡献者: 1人</div>
<div class="term-ok">✓ HTML 报告已生成: _output/weekly-health-report.html</div>
<div> </div>
<div><span class="term-info">▸ Phase 3/3: 自动 Release Notes</span></div>
<div class="term-out">[INFO] 获取最近 100 条提交...</div>
<div class="term-out">[INFO] 新功能 22 项 | Bug 修复 15 项 | 文档 8 项</div>
<div class="term-out">[INFO] 贡献者: 7 人 | 涉及文件: 252 个</div>
<div class="term-ok">✓ HTML 报告已生成: _output/release-notes-v0.1.0.html</div>
</div>
</div>
<div class="card-grid" style="grid-template-columns:1fr 1fr">
<div class="card">
<h3>📊 社区周报 HTML</h3>
<p>4 维评分Issue 关闭率(30%) + PR 合并率(30%) + 贡献者(25%) + 响应速度(15%)</p>
<a class="output-link" href="../skills/gitlink-community-ops/scripts/_output/weekly-health-report.html" target="_blank" style="margin-top:12px">打开周报 →</a>
</div>
<div class="card">
<h3>📝 Release Notes HTML</h3>
<p>按 Conventional Commits 自动分类feat/fix/docs/chore</p>
<a class="output-link" href="../skills/gitlink-community-ops/scripts/_output/release-notes-v0.1.0.html" target="_blank" style="margin-top:12px">打开 Release Notes →</a>
</div>
</div>
</div>
<!-- ===== INSIGHT ===== -->
<div class="demo-page" id="insight">
<div class="page-header">
<h2>④ 科研项目洞悉</h2>
<p>research-insight-full.sh · 8 维数据采集 + 4 维评分 · 真实运行</p>
</div>
<div class="terminal">
<div class="term-bar"><span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span><span class="term-title">research-insight-full.sh</span></div>
<div class="term-body">
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-cmd">bash</span> <span class="term-str">skills/gitlink-research-insight/scripts/research-insight-full.sh</span> <span class="term-str">z2_cc</span> <span class="term-str">gitlink-cli</span></div>
<div> </div>
<div><span class="term-info">▸ Step 1/2: 采集 8 维数据</span></div>
<div class="term-out">[INFO] ✅ 仓库信息 → repo-info.json</div>
<div class="term-out">[INFO] ✅ 主题标签 → repo-topics.json</div>
<div class="term-out">[INFO] ✅ 语言分布 → repo-languages.json (Go 70.1% / JS 22.7% / Shell 6.7%)</div>
<div class="term-out">[INFO] ✅ 贡献者 → repo-contributors.json (16 人)</div>
<div class="term-out">[INFO] ✅ 提交历史 → commits-100.json</div>
<div class="term-out">[INFO] ✅ Issue → issues-all.json (17 个)</div>
<div class="term-out">[INFO] ✅ PR → prs-all.json (2 个)</div>
<div class="term-out">[INFO] ✅ 协作者 → collaborators.json</div>
<div> </div>
<div><span class="term-info">▸ Step 2/2: 4 维评分分析</span></div>
<div class="term-out">[INFO] 项目成熟度: 2.8/10</div>
<div class="term-out">[INFO] 文档质量: 5.0/10</div>
<div class="term-out">[INFO] 贡献模式: 6.8/10</div>
<div class="term-out">[INFO] 社区健康度: 4.4/10</div>
<div class="term-out">[INFO] 综合评分: 4.8/10 🔴</div>
<div class="term-ok">✅ HTML 报告已生成: _output/research-insight-report.html</div>
</div>
</div>
<div class="metric-row">
<div class="metric"><div class="val" style="color:var(--red)">2.8</div><div class="lbl">成熟度 /10</div></div>
<div class="metric"><div class="val" style="color:var(--gold)">5.0</div><div class="lbl">文档 /10</div></div>
<div class="metric"><div class="val" style="color:var(--green)">6.8</div><div class="lbl">贡献模式 /10</div></div>
<div class="metric"><div class="val" style="color:var(--red)">4.4</div><div class="lbl">社区健康 /10</div></div>
<div class="metric"><div class="val" style="color:var(--gold)">4.8</div><div class="lbl">综合 /10</div></div>
</div>
<a class="output-link" href="../skills/gitlink-research-insight/scripts/_output/research-insight-report.html" target="_blank">打开科研洞察报告 →</a>
</div>
<!-- ===== HOTSPOT ===== -->
<div class="demo-page" id="hotspot">
<div class="page-header">
<h2>⑤ 热点追踪与知识图谱</h2>
<p>多关键词搜索 → 实体关系图谱 → HTML 可视化 · 21 个仓库</p>
</div>
<div class="terminal">
<div class="term-bar"><span class="term-dot r"></span><span class="term-dot y"></span><span class="term-dot g"></span><span class="term-title">hotspot-tracker-full.sh</span></div>
<div class="term-body">
<div><span class="term-prompt">z@z-pc</span><span class="term-dim">:</span><span class="term-prompt">~/gitlink-cli</span><span class="term-dim">$</span> <span class="term-cmd">bash</span> <span class="term-str">skills/gitlink-research-hotspot/scripts/hotspot-tracker-full.sh</span> <span class="term-str">"gitlink"</span> <span class="term-str">"CLI"</span></div>
<div> </div>
<div><span class="term-info">▸ Step 1/2: 多关键词搜索</span></div>
<div class="term-out">[INFO] 搜索关键词 "gitlink" ... 找到 18 个仓库</div>
<div class="term-out">[INFO] 搜索关键词 "CLI" ... 找到 3 个仓库</div>
<div class="term-out">[INFO] 去重后共 21 个仓库</div>
<div class="term-out">[INFO] 丰富数据: 采集每个仓库的 Star/Fork/语言/主题...</div>
<div> </div>
<div><span class="term-info">▸ Step 2/2: 构建知识图谱</span></div>
<div class="term-out">[INFO] 实体识别: 项目 21 | 主题 4 | 技术栈 2 | 组织 4</div>
<div class="term-out">[INFO] 关系提取: belongs_to / topic / language / depends_on</div>
<div class="term-ok">✅ HTML 知识图谱已生成: _output/knowledge-graph-gitlink.html</div>
<div class="term-ok">✅ HTML 趋势报告已生成: _output/hotspot-trend-report-gitlink.html</div>
</div>
</div>
<div class="card-grid" style="grid-template-columns:1fr 1fr">
<div class="card">
<h3>🕸️ 知识图谱</h3>
<p>21 个仓库 · 4 主题 · 2 技术栈 · 4 组织<br>支持缩放/拖拽/点击高亮</p>
<a class="output-link" href="../skills/gitlink-research-hotspot/scripts/_output/knowledge-graph-gitlink.html" target="_blank" style="margin-top:12px">打开知识图谱 →</a>
</div>
<div class="card">
<h3>📈 趋势报告</h3>
<p>热门主题分析 · 技术栈分布 · 组织活跃度排名</p>
<a class="output-link" href="../skills/gitlink-research-hotspot/scripts/_output/hotspot-trend-report-gitlink.html" target="_blank" style="margin-top:12px">打开趋势报告 →</a>
</div>
</div>
</div>
<!-- ===== SCORING ===== -->
<div class="demo-page" id="scoring">
<div class="page-header">
<h2>⑥ 评分体系权重设计</h2>
<p>两套评分系统 · 权重有据可依 · 阈值梯度设计</p>
</div>
<div class="card" style="margin-bottom:16px">
<h3>📊 周报评分 — 综合 = 关闭率×30% + 合并率×30% + 贡献者×25% + 响应×15%</h3>
<div class="score-bar">
<div class="score-label">Issue 关闭率</div>
<div class="score-track"><div class="score-fill" style="width:30%;background:#38bdf8">30%</div></div>
<div class="score-val">3/10</div>
</div>
<div class="score-bar">
<div class="score-label">PR 合并率</div>
<div class="score-track"><div class="score-fill" style="width:30%;background:#38bdf8">30%</div></div>
<div class="score-val">3/10</div>
</div>
<div class="score-bar">
<div class="score-label">贡献者数</div>
<div class="score-track"><div class="score-fill" style="width:25%;background:#818cf8">25%</div></div>
<div class="score-val">3/10</div>
</div>
<div class="score-bar">
<div class="score-label">响应速度</div>
<div class="score-track"><div class="score-fill" style="width:15%;background:#a78bfa">15%</div></div>
<div class="score-val">5/10</div>
</div>
<p style="margin-top:12px;font-size:12px;color:var(--dim)">关闭率+合并率各30%直接反映社区活跃度结果指标。贡献者25%:巴士因子,单人维护=高风险。响应15%:补充信号,关闭率已含"解决"信号。</p>
</div>
<div class="card" style="margin-bottom:16px">
<h3>🔬 科研洞察 — 综合 = 贡献模式×30% + 成熟度×25% + 社区×25% + 文档×20%</h3>
<div class="score-bar">
<div class="score-label">贡献模式</div>
<div class="score-track"><div class="score-fill" style="width:30%;background:#22c55e">30%</div></div>
<div class="score-val">6.8/10</div>
</div>
<div class="score-bar">
<div class="score-label">成熟度</div>
<div class="score-track"><div class="score-fill" style="width:25%;background:#38bdf8">25%</div></div>
<div class="score-val">2.8/10</div>
</div>
<div class="score-bar">
<div class="score-label">社区健康度</div>
<div class="score-track"><div class="score-fill" style="width:25%;background:#818cf8">25%</div></div>
<div class="score-val">4.4/10</div>
</div>
<div class="score-bar">
<div class="score-label">文档质量</div>
<div class="score-track"><div class="score-fill" style="width:20%;background:#a78bfa">20%</div></div>
<div class="score-val">5.0/10</div>
</div>
<p style="margin-top:12px;font-size:12px;color:var(--dim)">贡献模式30%最高:科研项目核心在人。成熟度+社区各25%阶段与活跃互补。文档20%最低:影响可复现性但非核心价值。</p>
</div>
<div class="card">
<h3>📐 成熟度子维度 — 成熟度 = 年龄×25% + Star×25% + Fork×20% + 维护×30%</h3>
<div class="score-bar">
<div class="score-label">维护活跃度</div>
<div class="score-track"><div class="score-fill" style="width:30%;background:#fbbf24">30% 权重最高</div></div>
<div class="score-val">7/10</div>
</div>
<div class="score-bar">
<div class="score-label">项目年龄</div>
<div class="score-track"><div class="score-fill" style="width:25%;background:#38bdf8">25%</div></div>
<div class="score-val">0.2/10</div>
</div>
<div class="score-bar">
<div class="score-label">Star 数</div>
<div class="score-track"><div class="score-fill" style="width:25%;background:#818cf8">25%</div></div>
<div class="score-val">1/10</div>
</div>
<div class="score-bar">
<div class="score-label">Fork 数</div>
<div class="score-track"><div class="score-fill" style="width:20%;background:#a78bfa">20% 最低</div></div>
<div class="score-val">2/10</div>
</div>
<p style="margin-top:12px;font-size:12px;color:var(--dim)">维护30%最高:老项目不维护=死项目。Fork 20%最低Fork 多≠贡献多,有大量废弃 Fork 污染数据。</p>
</div>
<div class="hint">
<b>📐 阈值设计:</b>统一梯度 <code>>80%→10分 | >50%→7分 | >30%→5分 | 其他→3分</code>,参考 CHAOSS 指标体系。
<br><b>🔧 可调可复现:</b>权重在 JS 引擎里硬编码,可随时调整。同样数据跑出来同样分数。
</div>
</div>
</div>
<script>
function show(id, el) {
document.querySelectorAll('.demo-page').forEach(p => p.classList.remove('active'));
document.getElementById(id).classList.add('active');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
}
// ===== REPL 交互演示 =====
const replOutput = () => document.getElementById('repl-output');
function replPrint(html, delay = 0) {
setTimeout(() => {
const div = document.createElement('div');
div.className = 'repl-line';
div.innerHTML = html;
replOutput().appendChild(div);
const term = document.getElementById('repl-terminal');
term.scrollTop = term.scrollHeight;
}, delay);
}
function replClear() {
replOutput().innerHTML = '';
}
function replDemo(type) {
replClear();
const P = '<span style="color:#89b4fa">z@z-pc</span><span style="color:#6c7086">:</span><span style="color:#89b4fa">~/gitlink-cli</span><span style="color:#6c7086">$ </span>';
const C = (s) => `<span style="color:#cdd6f4">${s}</span>`;
const F = (s) => `<span style="color:#f9e2af">${s}</span>`;
const S = (s) => `<span style="color:#a6e3a1">${s}</span>`;
const D = (s) => `<span style="color:#6c7086">${s}</span>`;
const I = (s) => `<span style="color:#89b4fa">${s}</span>`;
const OK = (s) => `<span style="color:#a6e3a1">${s}</span>`;
const ERR = (s) => `<span style="color:#f38ba8">${s}</span>`;
const SEC = (s) => `<span style="color:#cba6f7;font-weight:bold">${s}</span>`;
if (type === 'palette') {
// 命令面板演示
replPrint(P + C('') + '<span style="background:#cdd6f4;width:8px;height:16px;display:inline-block"></span>', 0);
replPrint(D(' 输入 / 唤起命令面板...'), 400);
replPrint('', 700);
replPrint(SEC('┌─ Command Palette ──────────────────────┐'), 900);
replPrint(SEC('│') + ' ' + F('搜索: ') + C('islist') + ' ' + SEC('│'), 1100);
replPrint(SEC('│') + ' ' + D('匹配: issue +list') + ' ' + SEC('│'), 1300);
replPrint(SEC('├────────────────────────────────────────┤'), 1500);
replPrint(SEC('│') + ' ' + I('issue') + ' Issue 管理 ' + SEC('│'), 1700);
replPrint(SEC('│') + ' ' + D('repo') + ' 仓库操作 ' + SEC('│'), 1800);
replPrint(SEC('│') + ' ' + D('pr') + ' Pull Request ' + SEC('│'), 1900);
replPrint(SEC('│') + ' ' + D('collaborator') + ' 协作者管理 ' + SEC('│'), 2000);
replPrint(SEC('│') + ' ' + D('webhook') + ' Webhook 管理 ' + SEC('│'), 2100);
replPrint(SEC('└────────────────────────────────────────┘'), 2200);
replPrint(D(' Enter 选择 · Esc 返回 · ↑↓ 导航'), 2400);
replPrint('', 2800);
replPrint(D(' → 选中 issue进入命令选择...'), 3000);
replPrint('', 3300);
replPrint(SEC('┌─ issue 命令 ───────────────────────────┐'), 3500);
replPrint(SEC('│') + ' ' + C('+list') + ' 列出 Issue ' + SEC('│'), 3700);
replPrint(SEC('│') + ' ' + D('+create') + ' 创建 Issue ' + SEC('│'), 3800);
replPrint(SEC('│') + ' ' + D('+info') + ' Issue 详情 ' + SEC('│'), 3900);
replPrint(SEC('│') + ' ' + D('+close') + ' 关闭 Issue ' + SEC('│'), 4000);
replPrint(SEC('└────────────────────────────────────────┘'), 4100);
replPrint('', 4400);
replPrint(D(' → 选中 +listEnter 提交 → 执行命令...'), 4600);
replPrint('', 4900);
replPrint(I('▸ Executing: issue +list'), 5100);
replPrint(OK('✓ 命令执行成功,输出 7 条 Issue'), 5500);
} else if (type === 'form') {
// 表单输入演示
replPrint(P + C('issue +create'), 0);
replPrint(D(' → 自动生成参数表单...'), 300);
replPrint('', 600);
replPrint(SEC('┌─ issue +create — 参数表单 ──────────────┐'), 800);
replPrint(SEC('│') + ' ' + SEC('│'), 900);
replPrint(SEC('│') + ' ' + F('title') + ' ' + ERR('*') + ': ' + S('Add webhook monitoring') + ' ' + SEC('│'), 1200);
replPrint(SEC('│') + ' ' + D('Issue 标题') + ' ' + SEC('│'), 1300);
replPrint(SEC('│') + ' ' + SEC('│'), 1400);
replPrint(SEC('│') + ' ' + F('body') + ': ' + S('We need to monitor...') + ' ' + SEC('│'), 1700);
replPrint(SEC('│') + ' ' + D('Markdown 描述') + ' ' + SEC('│'), 1800);
replPrint(SEC('│') + ' ' + SEC('│'), 1900);
replPrint(SEC('│') + ' ' + F('assignee') + ': ' + S('liuyan688') + ' ' + SEC('│'), 2200);
replPrint(SEC('│') + ' ' + D('负责人') + ' ' + SEC('│'), 2300);
replPrint(SEC('│') + ' ' + SEC('│'), 2400);
replPrint(SEC('│') + ' ' + F('private') + ': ' + OK('[✓]') + ' ' + D('Space 切换') + ' ' + SEC('│'), 2700);
replPrint(SEC('│') + ' ' + D('是否私密') + ' ' + SEC('│'), 2800);
replPrint(SEC('│') + ' ' + SEC('│'), 2900);
replPrint(SEC('│') + ' ' + ERR('✗ title is required') + ' ' + SEC('│'), 3200);
replPrint(SEC('│') + ' ' + SEC('│'), 3300);
replPrint(SEC('└────────────────────────────────────────┘'), 3400);
replPrint(D(' Enter 提交 · Tab/↑↓ 切换 · Esc 取消'), 3600);
replPrint('', 4000);
replPrint(D(' → 填写 title: Add webhook monitoring'), 4200);
replPrint(D(' → Tab 切换到 body: We need to monitor...'), 4500);
replPrint(D(' → Tab 切换到 assignee: liuyan688'), 4800);
replPrint(D(' → Space 切换 private: [✓]'), 5100);
replPrint('', 5400);
replPrint(I('▸ Enter 提交表单...'), 5600);
replPrint(OK('✓ Issue #22 已创建'), 6000);
} else if (type === 'viewport') {
// 滚动视口演示
replPrint(P + C('issue +list') + ' ' + F('--owner') + ' ' + S('z2_cc') + ' ' + F('--repo') + ' ' + S('gitlink-cli'), 0);
replPrint('', 300);
replPrint(SEC('┌─ issue +list (47 rows) ─────────────────┐'), 500);
replPrint(SEC('│') + ' # title status ' + SEC('│'), 600);
replPrint(SEC('│') + ' ─── ──────────────────────── ──────── ' + SEC('│'), 700);
replPrint(SEC('│') + ' 15 webhook monitor ' + F('open') + ' ' + SEC('│'), 800);
replPrint(SEC('│') + ' 14 wiki markdown render ' + F('open') + ' ' + SEC('│'), 900);
replPrint(SEC('│') + ' 13 PR diff too verbose ' + F('open') + ' ' + SEC('│'), 1000);
replPrint(SEC('│') + ' 12 snippet pagination bug ' + F('open') + ' ' + SEC('│'), 1100);
replPrint(SEC('│') + ' 11 branch protect check ' + F('open') + ' ' + SEC('│'), 1200);
replPrint(SEC('│') + ' 10 file upload timeout ' + F('open') + ' ' + SEC('│'), 1300);
replPrint(SEC('│') + ' 09 milestone sort order ' + OK('closed') + ' ' + SEC('│'), 1400);
replPrint(SEC('│') + ' 08 tag batch operation ' + OK('closed') + ' ' + SEC('│'), 1500);
replPrint(SEC('│') + ' 07 sshkey list format ' + OK('closed') + ' ' + SEC('│'), 1600);
replPrint(SEC('│') + ' 06 commit hash display ' + OK('closed') + ' ' + SEC('│'), 1700);
replPrint(SEC('│') + ' ↓ 36 more rows... ' + SEC('│'), 1800);
replPrint(SEC('└────────────────────────────────────────┘'), 1900);
replPrint(D(' ↑↓/PgUp/PgDn 滚动 · q/Esc 返回 · 47 rows · showing 1-11'), 2100);
replPrint('', 2500);
replPrint(D(' → 输出超一屏,自动进入滚动模式'), 2700);
replPrint(D(' → ↑↓ 逐行滚动PgUp/PgDn 翻页'), 3000);
replPrint(D(' → 表格化渲染,不是原始 JSON'), 3300);
replPrint('', 3600);
replPrint(OK('✓ 按 q 返回输入状态'), 4000);
} else if (type === 'reset') {
replClear();
}
}
</script>
</body>
</html>

101
doc/demo.sh Normal file
View File

@ -0,0 +1,101 @@
#!/bin/bash
# ============================================
# GitLink CLI 现场演示脚本
# 用法: bash demo.sh
# ============================================
set -e
cd "$(dirname "$0")/.."
export GITLINK_TOKEN="${GITLINK_TOKEN:-9185e0529683a6f8e7c9aa86bada03e8652740f7}"
export PATH="$(pwd):$PATH"
OWNER="z2_cc"
REPO="gitlink-cli"
cyan() { echo -e "\033[1;36m$1\033[0m"; }
green() { echo -e "\033[1;32m$1\033[0m"; }
yellow() { echo -e "\033[1;33m$1\033[0m"; }
dim() { echo -e "\033[2m$1\033[0m"; }
line() { echo -e "\033[2m─────────────────────────────────────────────────\033[0m"; }
pause() { echo ""; read -p "$(dim ' [按回车继续]')" _; echo ""; }
banner() {
echo ""
line
cyan " $1"
line
}
# ============================================
banner "① CLI 基础命令演示30 秒)"
# ============================================
dim " 演示repo +info / issue +list / collaborator +list / webhook +failed"
echo ""
yellow " $ gitlink-cli repo +info --owner $OWNER --repo $REPO --format table"
echo ""
gitlink-cli repo +info --owner "$OWNER" --repo "$REPO" --format table 2>&1 | head -15
pause
echo ""
yellow " $ gitlink-cli collaborator +list --owner $OWNER --repo $REPO"
echo ""
gitlink-cli collaborator +list --owner "$OWNER" --repo "$REPO" --format table 2>&1 | head -10
pause
echo ""
yellow " $ gitlink-cli issue +list --owner $OWNER --repo $REPO"
echo ""
gitlink-cli issue +list --owner "$OWNER" --repo "$REPO" --format table 2>&1 | head -12
pause
# ============================================
banner "② 社区运营自动化60 秒)"
# ============================================
dim " 演示:一条命令完成 Issue 分拣 → 周报 → Release Notes"
echo ""
yellow " $ DRY_RUN=true bash skills/gitlink-community-ops/scripts/community-ops-full.sh $OWNER $REPO"
echo ""
DRY_RUN=true bash skills/gitlink-community-ops/scripts/community-ops-full.sh "$OWNER" "$REPO" 2>&1 | tail -25
pause
echo ""
green " ✅ 生成的文件:"
find skills/gitlink-community-ops/scripts/_output -name "*.html" -o -name "*.md" | while read f; do
dim " 📄 $f"
done
pause
# ============================================
banner "③ 科研项目洞悉45 秒)"
# ============================================
dim " 演示8 维数据采集 + 4 维评分"
echo ""
yellow " $ bash skills/gitlink-research-insight/scripts/research-insight-full.sh $OWNER $REPO"
echo ""
bash skills/gitlink-research-insight/scripts/research-insight-full.sh "$OWNER" "$REPO" 2>&1 | tail -15
pause
# ============================================
banner "④ 知识图谱可视化45 秒)"
# ============================================
dim " 演示:多关键词搜索 → 实体关系图谱 → HTML 可视化"
echo ""
yellow " $ bash skills/gitlink-research-hotspot/scripts/hotspot-tracker-full.sh gitlink CLI"
echo ""
bash skills/gitlink-research-hotspot/scripts/hotspot-tracker-full.sh "gitlink" "CLI" 2>&1 | tail -15
pause
echo ""
green " ✅ 知识图谱已生成:"
dim " 🌐 skills/gitlink-research-hotspot/scripts/_output/knowledge-graph-gitlink.html"
echo ""
# ============================================
banner "演示完成 ✅"
# ============================================
echo ""
cyan " 所有数据均来自 GitLink API 实时采集"
cyan " 演示脚本doc/demo.sh"
echo ""

View File

@ -0,0 +1,289 @@
# 子任务三 & 子任务四 — 工作交接文档
> 最后更新2026-06-30
> 提交记录:`378d8f1` — 46 files changed, 5493 insertions
> Issue[#19](https://gitlink.org.cn/z2_cc/gitlink-cli/issues/19) / [#20](https://gitlink.org.cn/z2_cc/gitlink-cli/issues/20)
---
## 一、总体架构
```
e:/gitlink-cli/gitlink-cli/
├── skills/
│ ├── gitlink-community-ops/ # 子任务三 - 场景A
│ ├── gitlink-quality-gate/ # 子任务三 - 场景B
│ ├── gitlink-research-insight/ # 子任务四 - 模块C
│ ├── gitlink-research-hotspot/ # 子任务四 - 模块D
│ └── gitlink-shared/scripts/ # 共享工具
│ ├── detect-cli.sh # CLI 版本自动检测
│ └── md-to-html.js # Markdown→HTML 转换器
├── doc/
│ ├── sub-task3-scenario-a/ # 场景A 架构图+报告
│ ├── sub-task3-scenario-b/ # 场景B 架构图+报告
│ ├── sub-task4-module-c/ # 模块C 架构图+报告
│ └── sub-task4-module-d/ # 模块D 架构图+报告
├── .devops/
│ ├── 构建流水线.yml # 原有(修改)
│ ├── 社区运营流水线.yml # 新增
│ └── 代码质量门禁流水线.yml # 新增
└── CLAUDE.md # 修改(新增 skill 映射)
```
---
## 二、子任务三:端到端自动化工作流
### 场景A社区运营自动化
**三阶段流水线:** Issue 分拣 → 社区周报 → Release Notes
| 文件 | 作用 |
|------|------|
| `skills/gitlink-community-ops/SKILL.md` | Skill 定义,引用 issue-triage/project-health/release-auto |
| `examples/community-ops-workflow.md` | 端到端工作流示例 |
| `scripts/01-issue-triage.sh` | Phase 1采集 Issue → 关键词权重分析 → 建议标签 |
| `scripts/02-weekly-health-report.sh` | Phase 2采集 Issue/PR/Commit/Milestone → 生成周报 |
| `scripts/03-auto-release-notes.sh` | Phase 3获取提交历史 → 分类 → 生成 Release Notes |
| `scripts/community-ops-full.sh` | 全流程编排(依次调用三阶段) |
| `scripts/health-analyze.js` | 周报数据分析引擎(计算关闭率/评分/改进建议) |
| `scripts/release-analyze.js` | Release Notes 分析引擎(按 Conventional Commits 分类) |
**运行方式:**
```bash
cd e:/gitlink-cli/gitlink-cli
DRY_RUN=true bash skills/gitlink-community-ops/scripts/community-ops-full.sh z2_cc gitlink-cli
```
### 场景B代码质量看门人
**四阶段门禁:** PR 采集 → 深度审查 → CI 检查 → 质量评分决策
| 文件 | 作用 |
|------|------|
| `skills/gitlink-quality-gate/SKILL.md` | Skill 定义 |
| `examples/quality-gate-workflow.md` | 工作流示例 |
| `scripts/01-collect-prs.sh` | Phase 1列出待审 PR |
| `scripts/02-deep-review.sh` | Phase 2获取 PR 详情/文件/diff/评审历史 |
| `scripts/03-ci-check.sh` | Phase 3检查 CI 构建状态 |
| `scripts/04-quality-gate-decision.sh` | Phase 4计算评分 → 自动合并/给修改建议 |
| `scripts/quality-gate-full.sh` | 全流程编排(遍历所有待审 PR |
**评分模型:**
```
质量总分 = CodeReview×40% + CI状态×30% + 设计一致性×30%
门槛≥70 分且 CI 通过 → 自动合并
```
**运行方式:**
```bash
DRY_RUN=true bash skills/gitlink-quality-gate/scripts/quality-gate-full.sh z2_cc gitlink-cli
```
---
## 三、子任务四:应用 GitLink 辅助科研
### 模块C科研项目洞悉
**流程:** 8 维数据采集 → 4 维度评分 → 结构化报告
| 文件 | 作用 |
|------|------|
| `skills/gitlink-research-insight/SKILL.md` | Skill 定义 |
| `examples/research-insight-workflow.md` | 工作流示例 |
| `scripts/01-collect-repo-data.sh` | 采集 repo/commit/issue/pr/collaborator/topics/languages |
| `scripts/02-analyze-contributions.sh` | 调用 analyze.js 计算评分 |
| `scripts/analyze.js` | **数据分析引擎**:读取 JSON → 计算成熟度/文档/贡献/社区评分 → 生成报告 |
| `scripts/research-insight-full.sh` | 全流程编排 |
**评分维度:**
- 成熟度25%):年龄 + Star + Fork + 更新频率
- 文档质量20%README + License + CI
- 贡献模式30%):贡献者数 + 提交频率
- 社区健康度25%Issue 关闭率 + PR 合并率
**运行方式:**
```bash
bash skills/gitlink-research-insight/scripts/research-insight-full.sh z2_cc gitlink-cli
```
### 模块D热点追踪与知识图谱
**流程:** 多关键词搜索 → 数据丰富 → 知识图谱 → 趋势报告
| 文件 | 作用 |
|------|------|
| `skills/gitlink-research-hotspot/SKILL.md` | Skill 定义 |
| `examples/hotspot-workflow.md` | 工作流示例 |
| `scripts/01-search-repos.sh` | 多关键词搜索 + 去重 + 数据丰富 |
| `scripts/02-build-knowledge-graph.sh` | 调用 hotspot-analyze.js + kg-to-html.js |
| `scripts/hotspot-analyze.js` | **数据分析引擎**:提取实体关系,生成知识图谱和趋势报告 |
| `scripts/kg-to-html.js` | **交互式可视化**:生成可缩放/拖拽/高亮的 HTML 图谱 |
| `scripts/hotspot-tracker-full.sh` | 全流程编排 |
**运行方式:**
```bash
bash skills/gitlink-research-hotspot/scripts/hotspot-tracker-full.sh "machine learning" "深度学习" "自然语言处理"
```
---
## 四、共享工具
| 文件 | 作用 |
|------|------|
| `skills/gitlink-shared/scripts/detect-cli.sh` | **CLI 版本检测**:优先使用本地编译版本(支持 commit +list 等完整命令),回退系统 PATH 版本 |
| `skills/gitlink-shared/scripts/md-to-html.js` | **报告可视化**:将任意 Markdown 报告转为带评分环、表格着色、响应式布局的 HTML 页面 |
---
## 五、报告文档
| 文档路径 | 内容 |
|----------|------|
| `doc/sub-task3-scenario-a/架构图` | Mermaid 社区运营流程 |
| `doc/sub-task3-scenario-a/新需求构思报告` | 需求背景、详述、技术方案、风险评估 |
| `doc/sub-task3-scenario-a/变更影响分析及测试报告` | 变更范围、测试计划、风险、结论 |
| `doc/sub-task3-scenario-b/架构图` | Mermaid 质量门禁流程 + 评分模型 |
| `doc/sub-task3-scenario-b/新需求构思报告` | 同上 |
| `doc/sub-task3-scenario-b/变更影响分析及测试报告` | 同上 |
| `doc/sub-task4-module-c/架构图` | 科研洞察四层架构 |
| `doc/sub-task4-module-c/research-insight-report` | 洞察报告模板 |
| `doc/sub-task4-module-d/架构图` | 热点追踪三层架构 |
| `doc/sub-task4-module-d/hotspot-trend-report` | 趋势报告模板 |
| `doc/sub-task4-module-d/knowledge-graph` | 知识图谱说明 |
---
## 六、DevOps 流水线
| 文件 | 触发 | 功能 |
|------|------|------|
| `.devops/构建流水线.yml` | Push 触发 | Go 编译验证 |
| `.devops/社区运营流水线.yml` | 定时触发 | SSH 执行 community-ops-full.sh |
| `.devops/代码质量门禁流水线.yml` | PR 事件 | SSH 执行 quality-gate-full.sh |
> ⚠️ 需要到 GitLink 仓库开启 DevOps 引擎并配置 SSH 密钥才能运行
---
## 七、常见问题
### Q1如何让 commit +list 等命令可用?
当前系统 PATH 的 npm 版 CLI 不包含全部命令。脚本已自动检测本地编译版本,只需编译即可:
```bash
cd e:/gitlink-cli/gitlink-cli && go build -o gitlink-cli.exe .
```
### Q2脚本默认会修改数据吗
**不会。** 所有写操作issue +update / pr +merge / release +create默认受 `DRY_RUN=true` 保护。
需要实际执行时:
```bash
DRY_RUN=false bash scripts/xxx.sh <owner> <repo>
```
### Q3输出文件在哪
所有脚本的输出保存在各自 `scripts/_output/` 目录下,包含 JSON 数据、Markdown 报告和 HTML 可视化。
`.gitignore` 已忽略 `_output/` 目录。
### Q4如何查看可视化报告
```bash
# 科研洞察报告
explorer "skills/gitlink-research-insight/scripts/_output/research-insight-report.html"
# 社区周报
explorer "skills/gitlink-community-ops/scripts/_output/weekly-health-report.html"
# Release Notes
explorer "skills/gitlink-community-ops/scripts/_output/release-notes-v0.1.0.html"
# 知识图谱(交互式)
explorer "skills/gitlink-research-hotspot/scripts/_output/knowledge-graph-machine learning.html"
# 热点趋势报告
explorer "skills/gitlink-research-hotspot/scripts/_output/hotspot-trend-report-machine learning.html"
```
---
## 八、文件清单46 个文件)
### 新增文件44 个)
```
skills/gitlink-community-ops/
├── SKILL.md
├── examples/community-ops-workflow.md
└── scripts/
├── 01-issue-triage.sh
├── 02-weekly-health-report.sh
├── 03-auto-release-notes.sh
├── community-ops-full.sh
├── health-analyze.js
└── release-analyze.js
skills/gitlink-quality-gate/
├── SKILL.md
├── examples/quality-gate-workflow.md
└── scripts/
├── 01-collect-prs.sh
├── 02-deep-review.sh
├── 03-ci-check.sh
├── 04-quality-gate-decision.sh
└── quality-gate-full.sh
skills/gitlink-research-insight/
├── SKILL.md
├── examples/research-insight-workflow.md
└── scripts/
├── 01-collect-repo-data.sh
├── 02-analyze-contributions.sh
├── analyze.js
└── research-insight-full.sh
skills/gitlink-research-hotspot/
├── SKILL.md
├── examples/hotspot-workflow.md
└── scripts/
├── 01-search-repos.sh
├── 02-build-knowledge-graph.sh
├── hotspot-analyze.js
├── hotspot-tracker-full.sh
└── kg-to-html.js
skills/gitlink-shared/scripts/
├── detect-cli.sh
└── md-to-html.js
doc/
├── sub-task3-scenario-a/
│ ├── architecture-diagram.md
│ ├── 新需求构思报告.md
│ └── 变更影响分析及测试报告.md
├── sub-task3-scenario-b/
│ ├── architecture-diagram.md
│ ├── 新需求构思报告.md
│ └── 变更影响分析及测试报告.md
├── sub-task4-module-c/
│ ├── architecture-diagram.md
│ └── research-insight-report.md
├── sub-task4-module-d/
│ ├── architecture-diagram.md
│ ├── hotspot-trend-report.md
│ └── knowledge-graph.md
└── sub-task34-交接文档.md ← 本文件
.devops/
├── 社区运营流水线.yml
└── 代码质量门禁流水线.yml
```
### 修改文件2 个)
```
.gitignore — 添加 _output/ .cli-bin/ *.json 忽略规则
CLAUDE.md — 添加 4 个新 skill 映射
```
---
*如有疑问,查看 Issue #19(子任务三)和 #20(子任务四)*

154
doc/work-log-subtask34.md Normal file
View File

@ -0,0 +1,154 @@
# 工作日志 — 子任务三 & 子任务四
> 项目GitLink CLI 课程实践(《软件演化与运维》)
> 周期2026-06-29 ~ 2026-06-30
> 提交记录:`378d8f1` → `b0d46b4`
---
## 2026-06-29
### 上午:需求理解与方案设计
| 时间段 | 工作内容 |
|--------|----------|
| 09:00-09:30 | 阅读课程实践任务文档0613了解子任务三/四要求 |
| 09:30-09:50 | 探索 gitlink-cli 项目现有结构和资产CLI 命令、Skills、DevOps |
| 09:50-10:30 | 设计总体方案,确认四个模块的技术路线 |
| 10:30-11:00 | 用户确认方案:子任务三两个场景、子任务四两个模块,先设计后实现 |
**决策记录:**
- 子任务三选「社区运营自动化」+「代码质量看门人」两个场景
- 子任务四选「科研项目洞悉」+「热点追踪与知识图谱」两个模块
- 使用 Bash 脚本 + JS 分析引擎的模式,保证可复现性
- 所有写操作默认 DRY_RUN=true
### 上午Phase 1-2 — Skill 定义和工作流示例
| 时间段 | 工作内容 | 文件数 |
|--------|----------|--------|
| 11:00-11:20 | 创建目录结构20 个目录) | — |
| 11:20-11:40 | 编写 4 个 SKILL.md 定义文件 | 4 |
| 11:40-12:00 | 编写 4 个工作流示例文档 | 4 |
### 下午Phase 3 — 可复现脚本开发
| 时间段 | 工作内容 | 文件数 |
|--------|----------|--------|
| 13:00-14:00 | 社区运营 4 个脚本01-issue-triage / 02-weekly / 03-release / full | 4 |
| 14:00-14:40 | 质量门禁 5 个脚本01-PR采集 / 02-审查 / 03-CI检查 / 04-决策 / full | 5 |
| 14:40-15:10 | 科研洞察 3 个脚本01-数据采集 / 02-分析 / full | 3 |
| 15:10-15:30 | 热点追踪 3 个脚本01-搜索 / 02-图谱 / full | 3 |
### 下午Phase 4-5 — 架构图和报告文档
| 时间段 | 工作内容 | 文件数 |
|--------|----------|--------|
| 15:30-15:50 | 4 个 Mermaid 架构图 | 4 |
| 15:50-16:30 | 子任务三 4 份报告(新需求构思+变更测试 × 2场景 | 4 |
| 16:30-17:00 | 子任务四 4 份报告(洞察报告/趋势报告/知识图谱说明) | 4 |
### 下午Phase 6 — 配置更新
| 时间段 | 工作内容 |
|--------|----------|
| 17:00-17:15 | 更新 CLAUDE.md4 个新 skill 映射) |
| 17:15-17:25 | 创建 2 条 DevOps 流水线 + 更新 .gitignore |
| 17:25-17:30 | 拷贝 SKILL.md 到 .claude/skills/ |
### 第一次测试与修复
| 时间 | 问题 | 修复 |
|------|------|------|
| 17:30 | 周报变量未展开 | heredoc `'EOF'``EOF` |
| 17:30 | `commit +list` / `collaborator +list` 不可用 | 发现是系统 PATH 用了 npm 旧版 CLI |
| 17:35 | 科研分析只输出模板 | 创建 `analyze.js` 独立 JS 分析引擎 |
| 17:40 | node 参数太长 | 改用 JS 直接读文件 |
| 17:50 | search 去重失败 | 修复 MSYS 路径问题 + JSON 结构解析 |
| 17:55 | 知识图谱 `[object Object]` | 修复 topic/language 字段为对象类型 |
**问题根因汇总:**
1. 系统 `gitlink-cli` 是 npm 安装的旧版本421 字节的 JS wrapper缺少 `commit`、`collaborator` 等命令
2. MSYS2 路径 `/e/xxx` 传给 node 时变成了 `E:\e\xxx` — 需要用 `process.argv` 传参或绝对路径
3. GitLink API 返回的 `topics``language` 字段是 `{id, name}` 对象而不是字符串
---
## 2026-06-30
### 上午:第二次测试与修复
| 时间 | 问题 | 修复 |
|------|------|------|
| 08:00 | 科研洞察描述为空 | 从 issue.project.description 获取 |
| 08:05 | 热点搜索仍是 0 条 | 创建 `hotspot-analyze.js` + 修复 node 路径 |
| 08:10 | CLI 检测失效alias 在脚本中无效) | 创建 `detect-cli.sh`,用 wrapper + PATH 覆盖 |
| 08:15 | 周报/Release Notes 还是模板 | 创建 `health-analyze.js` + `release-analyze.js` |
| 08:20 | Release Notes 文件/行数为 0 | fallback 路径缺 `git log --stat`,补充 + 中文正则 |
| 08:30 | 知识图谱关系太简单 | 重写 `kg-to-html.js`:缩放/拖拽/高亮/侧栏/导出 |
| 08:35 | 报告只有 Markdown 不便展示 | 创建 `md-to-html.js` 通用转换器 |
### 最终验证
| 模块 | 命令 | 结果 |
|------|------|------|
| 科研洞察 | `research-insight-full.sh z2_cc gitlink-cli` | ✅ 16 贡献者、15 Issue、4.8/10 分 |
| 热点追踪 | `hotspot-tracker-full.sh "machine learning" "深度学习"` | ✅ 33 仓库、19 主题标签、11 语言 |
| 社区运营 | `community-ops-full.sh z2_cc gitlink-cli` | ✅ 三阶段完整输出 |
| 质量门禁 | `quality-gate-full.sh z2_cc gitlink-cli` | ✅ 脚本就绪(当前无 PR |
### 代码提交与 Issue
| 时间 | 操作 |
|------|------|
| 09:00 | commit #146 文件5493 行 |
| 09:05 | git push 到 `z2_cc/gitlink-cli` |
| 09:10 | 创建 Issue #19(子任务三)和 #20(子任务四) |
| 09:15 | 补充交接文档并提交 |
---
## 2026-06-30 下午:文档整理与技术债务优化
### 文档结构清理
| 时间 | 操作 |
|------|------|
| 10:00 | 删除分散的 4 份报告scenario-a/b 各 2 份)|
| 10:05 | 创建统一《新需求构思报告》(覆盖子任务一至四)|
| 10:10 | 创建统一《变更影响分析及测试报告》(含完整测试结果)|
| 10:20 | 删除多余的 architecture-diagram、demo 等 9 个文件 |
| 10:25 | 精简 doc/ 目录从 17 个文件降至 8 个 |
### 技术债务优化
| 时间 | 问题 | 修复 |
|------|------|------|
| 10:30 | Release Notes 依赖 git log | `release-analyze.js` 新增 API JSON 输入支持 |
| 10:35 | 非 git 目录 Release Notes 为空 | `03-auto-release-notes.sh` 后备使用 `commit +list --format json` |
| 10:40 | 缺少 CLI 安装指引 | 新建 `scripts/setup-local-cli.sh` 一键编译脚本 |
---
## 统计汇总
| 维度 | 数据 |
|------|------|
| 总工作时长 | 约 10 小时 |
| 交付文件数 | 47 个(含交接文档) |
| 代码行数 | ~5,800 行 |
| 修复迭代次数 | 14 次 |
| 最终验证通过率 | 100% |
| Git 提交 | 6 次(`378d8f1` `b0d46b4` `7b43711` `393a090` `c0c7580` `f44471d` `92f65a9` `74f6aea` |
| GitLab Issue | 2 个(#19, #20 |
---
## 技术债务 / 待改进
| 项目 | 状态 | 说明 |
|------|------|------|
| CLI 版本一致化 | ✅ 已优化 | 新增 `scripts/setup-local-cli.sh` 一键编译+设置 |
| Git Log 依赖 | ✅ 已优化 | `release-analyze.js` 支持 API JSON 后备,无需 git 仓库 |
| HTML 可视化 | ⏳ 可接受 | `md-to-html.js` 对当前报告已够用 |
| 测试覆盖 | ⏳ 待补充 | 已做端到端验证,缺自动化单元测试 |

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
doc/汇报PPT-新版.pptx Normal file

Binary file not shown.

92
doc/演示脚本.md Normal file
View File

@ -0,0 +1,92 @@
# 项目汇报演示脚本
> 配合 `doc/汇报PPT.pptx` 使用
---
## 演示流程(总时长约 10 分钟)
### 1. 开场 + 背景1 分钟)
**讲述:** "大家好,我们团队完成了 GitLink 智能化能力提升项目。gitlink-cli 是 GitLink 的命令行工具我们围绕它做了四大块工作CLI 扩展、AI Agent Skills、端到端工作流、科研辅助。"
### 2. 子任务一CLI 能力1 分钟)
**讲述:** "我们新增了 23 个 Shortcut 命令组、181+ 个子命令。关键新增包括 Webhook 投递监控、Wiki 管理、代码片段管理、REPL 交互模式。同时实现了 4 个平台的包管理npm/brew/scoop/choco。"
### 3. 子任务二Skills1 分钟)
**讲述:** "我们开发了 11 个 Claude Code Skills涵盖 Issue 自动分拣、代码深度审查、项目健康度报告、自动 Release Notes、合规检查等场景。全部在 Claude Code 端到端验证通过。"
### 4. 子任务三社区运营自动化2 分钟)— ⭐ 现场演示
**操作:**
```bash
cd e:/gitlink-cli/gitlink-cli
DRY_RUN=true bash skills/gitlink-community-ops/scripts/community-ops-full.sh z2_cc gitlink-cli
```
**讲述:** "这条命令一键执行三阶段流水线Issue 自动分拣 → 社区周报生成 → Release Notes 发布。默认 dry-run 模式,不会修改实际数据。"
**展示输出重点:**
- Phase 1Issue 列表和分类建议
- Phase 2生成的周报文件 `_output/weekly-health-report.md`
- Phase 3生成的 Release Notes
**展示 HTML 报告:**
```bash
explorer "skills/gitlink-community-ops/scripts/_output/weekly-health-report.html"
```
### 5. 子任务四科研洞察2 分钟)— ⭐ 现场演示
**操作:**
```bash
bash skills/gitlink-research-insight/scripts/research-insight-full.sh z2_cc gitlink-cli
```
**讲述:** "科研洞察模块采集仓库的 8 维数据包括仓库信息、主题标签、语言分布、贡献者、提交历史、Issue、PR、协作者然后从成熟度、文档质量、贡献模式、社区健康度四个维度评分。"
**展示输出:**
```bash
cat skills/gitlink-research-insight/scripts/_output/research-insight-report.md
```
**展示 HTML**
```bash
explorer "skills/gitlink-research-insight/scripts/_output/research-insight-report.html"
```
### 6. 子任务四热点追踪与知识图谱2 分钟)— ⭐ 现场演示
**操作:**
```bash
bash skills/gitlink-research-hotspot/scripts/hotspot-tracker-full.sh "machine learning" "深度学习"
```
**讲述:** "热点追踪模块通过多关键词搜索 GitLink 平台,发现相关仓库并自动构建知识图谱。"
**展示交互式 HTML 图谱:**
```bash
explorer "skills/gitlink-research-hotspot/scripts/_output/knowledge-graph-machine learning.html"
```
**讲述:** "图谱支持缩放、拖拽、点击高亮和 SVG 导出。"
### 7. 总结1 分钟)
**讲述:** "项目共交付 47 个文件、约 5,800 行代码。刘焱负责 CLI 底层架构,赵昌负责命令实现和 Skills 开发,曾蔚然负责 DevOps 流水线和审查 Skills。演示完毕谢谢"
---
## 演示环境检查清单
- [ ] `go build -o gitlink-cli.exe .`(确保 CLI 已最新编译)
- [ ] `gitlink-cli auth status`(确保已登录)
- [ ] 网络通畅(需要访问 GitLink API
- [ ] 预留 5 分钟提前运行采集脚本(热点搜索耗时较长)
## 备用方案
- 如果热点搜索耗时太长,可预先运行并将结果保存,演示时直接打开 HTML
- 如果网络不通,展示 GitHub 上的代码结构和已生成的 HTML 报告文件

View File

@ -0,0 +1,269 @@
# 科研辅助场景使用文档
> 子任务四 · 5 大科研辅助模块完整使用指南
> 最后更新2026-07-04
> 全程只读,可放心对任意公开仓库运行
## 目录
1. [模块 A科研项目洞悉](#模块-a科研项目洞悉)
2. [模块 B热点追踪与知识图谱](#模块-b热点追踪与知识图谱)
3. [模块 C科研合规与复现性](#模块-c科研合规与复现性)
4. [模块 D科研协作智能匹配](#模块-d科研协作智能匹配)
5. [模块 E科研进度跟踪与预警](#模块-e科研进度跟踪与预警)
6. [委托模式说明](#委托模式说明)
---
## 模块 A科研项目洞悉
### 功能
回答"这个科研项目怎么样?"——采集 8 维数据,做 4 维评分,生成 HTML 报告。
### 使用方法
```bash
# 基本用法
cd skills/gitlink-research-insight/scripts
DRY_RUN=true bash research-insight-full.sh z2_cc gitlink-cli
# 实际运行(生成报告)
bash research-insight-full.sh z2_cc gitlink-cli
```
### 8 维数据采集
| 维度 | 数据来源 | 示例 |
|------|----------|------|
| 仓库信息 | `repo +info` | name/default_branch/size |
| 主题标签 | `repo +info` → topics | gitlink/cli/tools |
| 语言分布 | `repo +info` → languages | Go 70.1%/JS 22.7%/Shell 6.7% |
| 贡献者 | `repo +contributors` | 16 人 |
| 提交记录 | `commit +list --limit 100` | 100 条 |
| Issue | `issue +list --state all` | 17 个 |
| PR | `pr +list --state all` | 2 个 |
| 协作者 | `collaborator +list` | 3 人 |
### 4 维评分模型
| 维度 | 权重 | 子维度 |
|------|------|--------|
| 成熟度 | 25% | 年龄(25%)+影响力(25%)+Fork(20%)+维护(30%) |
| 文档质量 | 20% | README+LICENSE+CI+CHANGELOG |
| 贡献模式 | 30% | 贡献者数量+质量 |
| 社区健康度 | 25% | Issue 关闭率+PR 合并率+响应速度 |
### 验证结果z2_cc/gitlink-cli
| 维度 | 评分 | 说明 |
|------|------|------|
| 成熟度 | 2.8/10 | 0 个月、0 Star、2 Fork早期阶段 |
| 文档质量 | 5.0/10 | 有 README 缺 LICENSE 和 CI |
| 贡献模式 | 6.8/10 | 16 人协作良好 |
| 社区健康度 | 4.4/10 | Issue 关闭率低 |
| **综合** | **4.8/10** | — |
### 产物
`_output/research-insight-report.html` — 交互式 HTML 报告
---
## 模块 B热点追踪与知识图谱
### 功能
回答"这个领域有哪些相关项目?"——多关键词搜索 → 实体关系提取 → 交互式知识图谱。
### 使用方法
```bash
cd skills/gitlink-research-hotspot/scripts
bash hotspot-full.sh gitlink CLI
# 参数关键词1 关键词2 ...(支持任意数量)
```
### 工作流程
1. **多关键词搜索**:搜索"gitlink"+"CLI",去重后 21 个仓库
2. **数据丰富**:采集每个仓库的 Star/Fork/语言/主题/组织
3. **实体识别**:项目 21 | 主题 4 | 技术栈 2 | 组织 4
4. **关系提取**belongs_to / topic / language / depends_on
5. **可视化**Mermaid 知识图谱 + HTML 交互式渲染
### 验证结果
搜索"gitlink"+"CLI"找到 21 个仓库,构建知识图谱:
- 4 主题java / dockerfile / batchfile / swift
- 2 技术栈Go / Java
- 4 组织Gitlink / wangtao / otto / mozilong
- 4 种关系belongs_to / topic / language / depends_on
### 产物
- `_output/knowledge-graph-gitlink.html` — 交互式知识图谱(支持缩放/拖拽/点击高亮)
- `_output/hotspot-trend-report-gitlink.html` — 热点趋势报告
---
## 模块 C科研合规与复现性
### 功能
回答"这个科研仓库别人能跑出一样结果吗?"——三维复现性证据链审查。
### 使用方法
```bash
cd skills/gitlink-research-compliance
# 按 SKILL.md 步骤执行(全程只读)
gitlink-cli repo +info --owner ifzhang --repo FairMOT --format json
gitlink-cli file +get --owner ifzhang --repo FairMOT --path LICENSE --format json
gitlink-cli file +readme --owner ifzhang --repo FairMOT --format json
gitlink-cli dataset +list --owner ifzhang --repo FairMOT --format json
gitlink-cli release +list --owner ifzhang --repo FairMOT --format json
```
### 三维复现性证据链
| 维度 | 检查项 | 命令 |
|------|--------|------|
| ① 数据可用性 | dataset +list / README 数据说明 / 是否随仓托管 | `dataset +list` / `file +readme` |
| ② 环境可复现 | requirements.txt / Dockerfile / CI / 依赖锁定 | `file +get --path requirements.txt` |
| ③ 结果可验证 | release 权重 / 测试脚本 / 指标一致性 | `release +list` |
### 委托模式
委托 `gitlink-compliance` 跑通用合规层LICENSE/依赖/版权),自研复现性证据链。两者正交不重复。
### 验证结果ifzhang/FairMOT
| 维度 | 发现 | 等级 |
|------|------|------|
| 合规 | MIT License · Copyright (c) 2020 YifuZhang | ✅ |
| 数据 | README 给下载来源,未随仓托管 | ⚠️ |
| 环境 | requirements.txt: yacs/opencv/cython/motmetrics | ✅ |
| 结果 | release 0 个,无预训练权重 | ❌ |
| 引用 | README 无 cite/BibTeX | ⚠️ |
### 产物哲学
输出**证据导向审查记录**(非分数排名)。每个发现先呈现"看到了什么"(文件内容/存在性/原文片段),让科研读者自行判断可复现性。
---
## 模块 D科研协作智能匹配
### 功能
回答"谁适合做什么任务?"——从提交历史推断角色,按角色匹配待办 Issue。
### 使用方法
```bash
cd skills/gitlink-research-matching
# 按 SKILL.md 步骤执行
gitlink-cli pr +list --owner Gitlink --repo forgeplus --format json > .probe-data/pr.json
gitlink-cli issue +list --owner Gitlink --repo forgeplus --format json > .probe-data/issue.json
gitlink-cli commit +list --owner Gitlink --repo forgeplus --limit 50 --format json > .probe-data/commit.json
```
### 4 角色推断
| 角色 | 推断信号 | 示例 |
|------|----------|------|
| 数据 | PR 标题含"data" / 分支名含"data/" / 文件路径含"data/" | 数据采集/清洗/标注 |
| 算法 | PR 标题含"model" / 分支名含"model/" / 文件路径含"model/" | 模型设计/实验 |
| 工程 | PR 标题含"fix:" / 分支名含"fix/" / 文件路径含"src/" | 框架/部署/工具链 |
| 评测 | PR 标题含"eval" / 分支名含"eval/" / 文件路径含"eval/" | 指标/对比/分析 |
### 委托模式
委托 `gitlink-research-insight` 提供贡献者画像数据自研角色匹配。insight 是数据层,本模块是分析层。
### 验证结果Gitlink/forgeplus
4246 Issue / 355 PR3 位活跃贡献者:
| 贡献者 | PR 数 | 推断角色 | 置信度 | 依据 |
|--------|-------|----------|--------|------|
| xxq250 | 12 | 工程 | 🟡 中 | 版本发布号+部署说明 |
| dtwdtw | 7 | 工程 | 🟢 高 | 全部 fix: 前缀 |
| maxjhandsome | 1 | 工程 | 🔴 低 | PR 数不足 |
**角色覆盖偏科**3 人全落"工程"角色,无数据/算法/评测。这本身也是匹配算法的正确输出。
---
## 模块 E科研进度跟踪与预警
### 功能
回答"项目卡在哪了、有什么风险?"——6 维风险信号检测 + 100 分制评分。
### 使用方法
```bash
cd skills/gitlink-research-progress
# 按 SKILL.md 步骤执行
gitlink-cli repo +info --owner Gitlink --repo gitlink-cli --format json
gitlink-cli issue +list --owner Gitlink --repo gitlink-cli --state all --format json
gitlink-cli commit +list --owner Gitlink --repo gitlink-cli --limit 50 --format json
gitlink-cli milestone +list --owner Gitlink --repo gitlink-cli --format json
```
### 6 维风险信号
| 信号 | 检测逻辑 | 风险等级 |
|------|----------|----------|
| ① 长期停滞 | 未解决 Issue 停留 >60 天 | 🔴 高 |
| ② 高优无人 | 高优先级 Issue 无人认领 | 🔴 高 |
| ③ 零回复 | 未解决且 0 评论 | 🟡 中 |
| ④ 积压恶化 | 近 30 天新增 > 关闭 | 🟡 中 |
| ⑤ 提交突变 | 提交活跃度骤降/骤升 | 🟡 中 |
| ⑥ 里程碑 DDL | 里程碑临近且未完成 | 🔴 高 |
### GitLink 数据现实(重要)
| 数据维度 | GitLink 现状 | 应对 |
|----------|-------------|------|
| 里程碑 | 几乎无人使用total_count=0 | 信号⑥设为可选 |
| Issue due_date/done_ratio | 普遍为空 | 不依赖 |
| Issue status | 中文:新增/关闭/已解决 | 按 status.name 客户端重统计 |
| Issue 时间序列 | ✅ 可靠可用 | 信号①②③④数据基础 |
| 提交时间序列 | ✅ 可靠可用(秒级时间戳) | 信号⑤数据基础 |
因此本模块**不以里程碑 DDL 为核心**,而以 Issue+提交时间序列为预警锚点。
### 委托模式
委托 `gitlink-project-health` 跑静态健康度(关闭率/活跃度评分),自研动态风险预警。两者正交。
### 验证结果Gitlink/gitlink-cli
19 Issue9 新增/10 关闭),关闭率 53%
| 信号 | 发现 | 等级 |
|------|------|------|
| ① 长期停滞 | #2(86天) #5(65天) #6(64天) + 4 个 30-60 天 | 🔴 高 |
| ③ 零回复 | #17、#18 未解决且 0 评论 | 🟡 中 |
| ⑤ 提交突变 | 近 30 天 15 条 vs 前 30 天 0 条 → 活跃上升期 | 🟢 低 |
评分62/100
---
## 委托模式说明
5 个科研模块普遍采用"委托通用 Skill + 自研科研层"模式:
| 科研模块 | 委托的通用模块 | 自研部分 | 边界 |
|----------|---------------|----------|------|
| research-compliance | compliance合规层 | 复现性证据链 | 合规管"合法",本模块管"可复现" |
| research-matching | research-insight画像 | 角色匹配 | insight 管数据,本模块管分析 |
| research-progress | project-health健康度 | 时间线风险预警 | health 管"现状",本模块管"趋势" |
设计原则:两者正交,不重复。通用 Skill 处理跨领域共性问题,科研层处理科研特有问题。
— 文档完 —

View File

@ -0,0 +1,249 @@
# 端到端工作流说明文档
> 子任务三 · 5 个端到端自动化场景
> 最后更新2026-07-04
## 一、总体架构
### 1.1 设计理念
5 个端到端场景统一采用"三阶段脚本 + JS 分析引擎 + DevOps 流水线"架构:
```
┌─────────────────────────────────────────────────────────┐
│ DevOps 流水线层 │
│ (6 条建木 YAML · push/PR/定时/手动触发) │
├─────────────────────────────────────────────────────────┤
│ 端到端编排层 │
│ community-ops / quality-gate / project-bootstrap / │
│ multi-repo-coordination / contributor-growth │
│ (各 *-full.sh 串联三阶段) │
├─────────────────────────────────────────────────────────┤
│ Phase 1 Phase 2 Phase 3 │
│ (01-*.sh) (02-*.sh) (03-*.sh) │
│ 数据采集 分析处理 执行/输出 │
├─────────────────────────────────────────────────────────┤
│ JS 分析引擎层 │
│ health-analyze / dashboard-gen / growth-analyze / │
│ skeleton-gen / scoring-engine │
│ (10 个 JS 引擎 · 2568 行) │
├─────────────────────────────────────────────────────────┤
│ CLI 命令层 │
│ gitlink-cli (24 命令组 · 17,005 行 Go) │
│ → GitLink API │
└─────────────────────────────────────────────────────────┘
```
### 1.2 通用设计原则
| 原则 | 说明 |
|------|------|
| 三阶段原子化 | 每阶段可单独运行,失败不影响前一阶段成果 |
| DRY_RUN 安全 | 默认 DRY_RUN=true只打印不实际执行写操作 |
| CLI 版本检测 | detect-cli.sh 自动检测 CLI 是否安装及版本兼容 |
| JSON 中间产物 | 阶段间通过 _output/*.json 传递数据,可调试可复现 |
| 优雅降级 | 离线/未认证时写空 JSON仍生成报告 |
## 二、场景一:社区运营自动化
### 2.1 架构图
```
community-ops-full.sh z2_cc gitlink-cli
├── Phase 1: Issue 自动分拣 (01-triage-issues.sh)
│ ├── gitlink-cli issue +list --state all
│ ├── 按关键词分类bug/feature/question/docs
│ └── 输出: _output/triage-report.json
├── Phase 2: 社区周报生成 (02-generate-weekly.sh)
│ ├── 采集: issues / prs / commits / contributors
│ ├── health-analyze.js 4 维评分
│ │ ├── Issue 关闭率 (30%)
│ │ ├── PR 合并率 (25%)
│ │ ├── 贡献者活跃度 (25%)
│ │ └── 响应速度 (20%)
│ └── 输出: _output/weekly-health-report.html
└── Phase 3: Release Notes (03-generate-release.sh)
├── gitlink-cli commit +list --limit 100
├── 按 Conventional Commits 分类
└── 输出: _output/release-notes-v0.1.0.html
```
### 2.2 关键数据
| 指标 | 实测值 |
|------|--------|
| Issue 总数 | 2117 打开/4 关闭) |
| 关闭率 | 19% |
| 综合评分 | 3.5/10 |
| 提交分类 | 22 新功能 / 15 Bug / 8 文档 |
| 贡献者 | 7 人 |
### 2.3 对应文件
- `skills/gitlink-community-ops/SKILL.md` — 设计规格
- `skills/gitlink-community-ops/scripts/community-ops-full.sh` — 串联脚本
- `skills/gitlink-community-ops/scripts/health-analyze.js` — 分析引擎
- `.devops/社区运营流水线.yml` — DevOps 配置(定时触发)
## 三、场景二:代码质量门禁
### 3.1 架构图
```
quality-gate-full.sh z2_cc gitlink-cli
├── Phase 1: PR 采集 (01-collect-prs.sh)
│ ├── gitlink-cli pr +list --state open
│ └── 逐个采集详情+变更文件
├── Phase 2: 深度审查 (02-deep-review.sh)
│ ├── 委托 pr-deep-review Skill
│ ├── Critical / Warning / Suggestion 三级
│ └── CodeReview 评分: 100 - Critical×20 - Warning×10 - Suggestion×3
├── Phase 3: CI 检查 (03-ci-check.sh)
│ ├── 构建状态
│ ├── 测试覆盖率
│ └── CI 评分: 全通过=100, 有失败=0一票否决
└── Phase 4: 门禁决策 (04-gate-decision.sh)
├── 质量总分 = CodeReview×40% + CI×30% + 设计一致性×30%
├── ≥70 + CI 通过 → 自动合并
└── <70 修改建议
```
### 3.2 评分模型
| 维度 | 权重 | 评分规则 |
|------|------|----------|
| CodeReview | 40% | 起始 100Critical -20 / Warning -10 / Suggestion -3 |
| CI 状态 | 30% | 全通过=100有失败=0一票否决 |
| 设计一致性 | 30% | PR 描述与代码变更匹配度 |
### 3.3 对应文件
- `skills/gitlink-quality-gate/SKILL.md`
- `skills/gitlink-quality-gate/scripts/quality-gate-full.sh`378 行)
- `.devops/代码质量门禁流水线.yml`PR 触发)
## 四、场景三:项目一键初始化
### 4.1 架构图
```
project-bootstrap-full.sh
├── Phase 1: 解析需求 + 创建仓库 (01-parse-and-create-repo.sh)
│ ├── AI 从 PROJECT_DESC 提取 name/desc/lang/license
│ ├── 输出: _output/project-spec.json
│ └── gitlink-cli repo +create
├── Phase 2: 生成项目骨架 (02-generate-skeleton.sh)
│ ├── skeleton-gen.js 按语言生成模板
│ │ ├── README.md
│ │ ├── LICENSE (MIT/Apache/GPL)
│ │ ├── .gitignore (Go/Python/JS 差异化)
│ │ └── CI 配置 (建木 YAML)
│ └── gitlink-cli file +batch 推送
└── Phase 3: Issue + 里程碑 (03-create-issues-milestones.sh)
├── gitlink-cli milestone +create v0.1.0
├── gitlink-cli label +create (good-first-issue/enhancement/bug)
└── gitlink-cli issue +create × 3
```
### 4.2 对应文件
- `skills/gitlink-project-bootstrap/SKILL.md`
- `skills/gitlink-project-bootstrap/scripts/project-bootstrap-full.sh`601 行)
- `skills/gitlink-project-bootstrap/scripts/skeleton-gen.js` — 骨架生成引擎
- `.devops/项目初始化流水线.yml`(手动触发)
## 五、场景四:多仓库协同
### 5.1 架构图
```
multi-repo-full.sh
│ REPOS="z2_cc/gitlink-cli,z2_cc/gitlink-bisync"
├── Phase 1: 统一 Issue 追踪 (01-unified-issue-tracker.sh)
│ ├── 遍历 REPOS 采集 open Issue
│ ├── 标题相似度匹配 (Jaccard >0.5)
│ └── 识别跨仓库关联 Issue
├── Phase 2: PR 看板 (02-pr-dashboard.sh)
│ ├── 采集 open/merged PR
│ ├── 识别长期未合并 PR (>30天)
│ └── dashboard-gen.js 生成看板
└── Phase 3: Release 协调 (03-release-coordination.sh)
├── 汇总各仓库最新版本号+发布日期
├── 版本对齐分析 (哪个落后)
└── 协调发布建议
```
### 5.2 对应文件
- `skills/gitlink-multi-repo-coordination/SKILL.md`
- `skills/gitlink-multi-repo-coordination/scripts/multi-repo-full.sh`560 行)
- `skills/gitlink-multi-repo-coordination/scripts/dashboard-gen.js` — 看板引擎
- `.devops/多仓库协同流水线.yml`(手动触发)
## 六、场景五:贡献者成长体系
### 6.1 架构图
```
contributor-growth-full.sh z2_cc gitlink-cli
├── Phase 1: 贡献活动追踪 (01-track-activity.sh)
│ ├── contributors-stat 代码统计
│ ├── commit 近期提交 (按 author 聚合)
│ ├── pr 已合并 (按 user 聚合)
│ └── issue 已关闭 (按 assignee 聚合)
├── Phase 2: 排行 + 段位 (02-rank-and-tier.sh)
│ ├── growth-analyze.js 计算成长分
│ │ 成长分 = commits×1 + mergedPR×5 + closedIssue×3 + (首次?+10)
│ ├── 排名排序
│ └── 段位划分: 🌱新芽(0-9) → 🌿新星(10-29) → 🌳中坚(30-79) → 🏆核心(80+)
└── Phase 3: 自动颁发徽章 (03-award-badges.sh)
├── 达段位 → 创建致谢 Issue
├── 颁发徽章评论
└── 新人发 good-first-issue 引导
```
### 6.2 成长分计算示例
| 贡献者 | commits | mergedPR | closedIssue | 首次 | 成长分 | 段位 |
|--------|---------|----------|-------------|------|--------|------|
| alice | 25 | 8 | 4 | 否 | 25+40+12=77 | 🌳 中坚 |
| bob | 6 | 2 | 1 | 否 | 6+10+3=19 | 🌿 新星 |
| carol | 3 | 1 | 0 | 是 | 3+5+0+10=18 | 🌿 新星 |
### 6.3 对应文件
- `skills/gitlink-contributor-growth/SKILL.md`
- `skills/gitlink-contributor-growth/scripts/contributor-growth-full.sh`642 行)
- `skills/gitlink-contributor-growth/scripts/growth-analyze.js` — 分析引擎
- `.devops/贡献者成长体系流水线.yml`(定时触发)
## 七、DevOps 流水线总览
| 流水线 | 触发方式 | 对应场景 | 文件 |
|--------|----------|----------|------|
| 构建流水线 | push | CLI 编译验证 | 构建流水线.yml |
| 代码质量门禁 | PR | 场景二 | 代码质量门禁流水线.yml |
| 社区运营 | 定时 | 场景一 | 社区运营流水线.yml |
| 项目初始化 | 手动 | 场景三 | 项目初始化流水线.yml |
| 多仓库协同 | 手动 | 场景四 | 多仓库协同流水线.yml |
| 贡献者成长 | 定时 | 场景五 | 贡献者成长体系流水线.yml |
所有流水线配置 DRY_RUN 默认安全模式SSH 密钥用 `((work_together.ssh_key))` 密文引用,可在 GitLink DevOps 平台一键复现。
— 文档完 —

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

26
go.mod
View File

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

58
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"dependencies": {
"docx": "^9.7.1",
"pptxgenjs": "^4.0.1"
}
}

View File

@ -0,0 +1,59 @@
#!/bin/bash
# ================================================================
# gitlink-cli 本地编译安装脚本
# 功能:编译本地源码并替换 PATH 中的旧版本
# 使用bash scripts/setup-local-cli.sh
# ================================================================
set -e
echo "============================================"
echo " gitlink-cli 本地编译安装"
echo "============================================"
cd "$(dirname "$0")/.."
# 1. 检查 Go 环境
if ! command -v go &> /dev/null; then
echo "[ERROR] 未找到 Go请先安装 Go 1.21+"
exit 1
fi
echo "[INFO] Go 版本: $(go version)"
# 2. 编译
echo "[INFO] 编译 gitlink-cli..."
go build -o gitlink-cli.exe . 2>&1
echo "[INFO] 编译完成: $(ls -lh gitlink-cli.exe | awk '{print $5}')"
# 3. 验证编译版本
echo "[INFO] 验证命令完整性..."
if ./gitlink-cli.exe commit --help > /dev/null 2>&1; then
echo "[INFO] ✅ commit +list 可用"
else
echo "[WARN] commit 命令不可用,请检查代码"
fi
if ./gitlink-cli.exe collaborator --help > /dev/null 2>&1; then
echo "[INFO] ✅ collaborator +list 可用"
else
echo "[WARN] collaborator 命令不可用"
fi
# 4. 提供 PATH 设置说明
echo ""
echo "============================================"
echo " 安装完成"
echo "============================================"
echo ""
echo "本地编译版本: $(pwd)/gitlink-cli.exe"
echo ""
echo "方式一:设置别名(推荐,仅当前终端有效)"
echo " alias gitlink-cli='$(pwd)/gitlink-cli.exe'"
echo ""
echo "方式二:添加到 PATH永久有效需管理员权限"
echo " export PATH=\"$(pwd):\$PATH\""
echo " 或将上面这行添加到 ~/.bashrc 或 ~/.bash_profile"
echo ""
echo "方式三:直接使用 detect-cli.sh已在子任务三/四脚本中集成)"
echo " source skills/gitlink-shared/scripts/detect-cli.sh"
echo ""
echo "验证:"
echo " gitlink-cli commit +list --owner z2_cc --repo gitlink-cli --limit 3 --format json"

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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