Compare commits

..

91 Commits

Author SHA1 Message Date
yetja a2c1746688 更新赛题三演示视频 2026-07-10 14:42:43 +08:00
chroe 08aa94d4ef docs(子赛题二): 新增 README 明确交付对照赛题要求
新增 提交材料/子赛题二-Skills/README.md,逐条对照子赛题二要求:
- 遵循 gitlink-cli Skills 规范(skills/README.md)
- 每个 Skill 含 SKILL.md + 使用示例(examples/)+ REFERENCE.md
- Claude Code 平台验证通过 + 兼容
- 演示录屏/截图 + 演示输入提示词
并列出 23 个 Skill 位置(skills/ 19 + workflow/skills 4)与智能分析型/命令操作型分类。
2026-07-10 11:41:51 +08:00
chroe 7352b8c765 chore(提交材料): 子赛题三就绪 + setup-skills 覆盖 workflow/skills
- 队友(yetja)提交子赛题三工作流材料:工作流说明、架构图、执行脚本、
  Agent 对话记录、社区运营自动化演示(已在 提交材料/子赛题三-工作流/)。
- 工作流引擎模块化重构(engine/daemon/defs/cli/ai 子包),编译+测试通过。
- 修复 setup-skills.sh:4 个工作流专属 Skill 移至 shortcuts/workflow/skills/
  后,脚本同步扫描该目录,保证 23 个 Skill 仍可一键装入 Claude Code。
- 提交材料索引:子赛题三标记为就绪(四个子赛题全部就绪)。
2026-07-10 11:12:14 +08:00
yetja 6be9766f05 feat(子任务三): 工作流引擎重构 + 提交材料
- 引擎模块化拆分(engine/daemon/defs/cli/ai/state/skills 子包)
- 5 个预置工作流声明式定义,支持 4 种触发模式
- AI/规则引擎双模降级,安全白名单过滤
- 工作流专属 Skill 移至 shortcuts/workflow/skills/
- 子赛题三提交材料:工作流说明、架构图、执行脚本、Agent对话记录、演示视频
2026-07-10 10:39:42 +08:00
chroe 95f8019e33 chore(提交材料): 任务四材料归入统一结构 + 子任务一变更说明 + 索引更新
- 将队友的 顶层'子任务四材料/' 整体移入 提交材料/子赛题四-科研/,
  与子赛题一/二/三 保持统一的提交材料结构(git mv 保留历史)。
- 新增 提交材料/子赛题一-CLI能力/变更说明.md(子任务一 PR 变更说明)。
- 更新 提交材料/README.md 索引:子赛题四标记为就绪。
- .gitignore 补充 showcase 构建产物忽略规则。
2026-07-09 23:50:49 +08:00
chroe 86ec288180 Merge remote-tracking branch 'origin/master' 2026-07-09 23:43:08 +08:00
chroe e8af93c52b chore: 仓库清理 — 删除内部文档与构建产物
- 删除课程内部任务分配文档(任务A/B/C、EXPERIMENT_LOG、PROJECT_STATUS、README_TASKB)
- 删除过时的上游翻译版 README.zh-CN(数字与实际成果冲突)
- 删除误入库的构建产物(gitlink-cli.exe、showcase.exe、showcase-linux、showcase-server-linux)
- .gitignore 已加 showcase 构建产物规则,共释放约 39MB
2026-07-09 23:43:08 +08:00
caoweiqiong 03f310a6cd feat(子任务四): 子任务四材料(双联装 skills+录屏+真实输出+文档)+ showcase 展示
feat(子任务四): 子任务四材料(双联装 skills+录屏+真实输出+文档)+ showcase 展示
2026-07-09 22:26:46 +08:00
Martin 7c1bc9cc4a feat(showcase): 补回子任务四展示(队友覆盖后重新加回)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:20:49 +08:00
Martin a995868a8d docs(子任务四): 新增子任务四材料文件夹(README+skills副本+录屏+真实输出+使用文档+应用报告)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:20:15 +08:00
chroe 08e3ccc063 docs: 比赛提交材料结构 + 完整中文 README
- 新建 提交材料/ 目录(4 个子赛题子文件夹,集中放变更说明+演示)
- 子赛题二演示录屏/截图归位到 提交材料/子赛题二-Skills/demos/
- 重写 README.md:项目简介、四大子赛题成果、快速开始、架构说明、目录导航
- 各子文件夹加占位 README(子一/三/四 待队友补充材料)
2026-07-09 20:19:16 +08:00
chroe 7af9177798 chore: 仓库整理 — 加子任务二演示材料 + 清理垃圾
- 新增 docs/demos/: 子任务二演示视频 + 截图 + 说明
- 删除: pr-test-file.txt(测试垃圾)、wiki模块.zip(残留)、cmd/debug_url(临时调试工具)
2026-07-09 20:09:06 +08:00
yetja c4cd105068 feat: 工作流引擎全面增强 — multi-repo 协同、AI 迁移、规则扩展、showcase 交互优化 2026-07-07 23:00:16 +08:00
chroe e6f9703d24 perf(showcase): 加速流水线构建 — 合并 go build + BuildKit 缓存挂载
Dockerfile:
- 两个 go build 合并成一个 RUN(showcase-server 复用 gitlink-cli 已编译的依赖)
- 加 --mount=type=cache 挂载 Go 编译缓存,之后只编改动的包

流水线 DSL:
- docker build 加 DOCKER_BUILDKIT=1(启用 BuildKit,让缓存挂载生效)
- 加 git config safe.directory(防 dubious ownership 复发)

注意:本次改 Dockerfile 会使下次构建仍全量重编(填缓存),再之后才快。
2026-07-07 21:15:55 +08:00
caoweiqiong 1f5c3b2d8c feat: gitlink-spark + fair v2 科研软件 X 光 + 真科研图谱修复
feat(子任务四): gitlink-spark + fair v2 科研软件 X 光 + 真科研图谱修复
2026-07-07 20:13:25 +08:00
Martin 9c180328bd feat(子任务四): gitlink-spark + fair v2 科研软件 X 光 + 真科研图谱修复
补全 PR#5 之后的工作(PR#5 早先只合并了 fair v1):
- gitlink-spark(文献-代码语义缺口挖掘机)+ scripts/spark.py
- fair v2 科研软件 X 光(重写 SKILL/REFERENCE + fair.py 抽取 + 四维裁决 + 真图谱)
- 真科研图谱富化 + 修 Mermaid 语法(实测渲染通过)
- README/workflow 登记 v2 + spark

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:02:18 +08:00
yetja ee3e4bda86 feat: 工作流引擎全面增强 — multi-repo 协同、AI 迁移、规则扩展
- 新增 multi-repo 多仓库协同工作流:通过 --repos/--from 采集多仓库快照,生成统一 Issue/PR/Release 协调报告
- 新增 workflow-internal: 内部命令协议,支持子进程间数据传递
- 新增规则:gitlink-auto-merge(自动 squash 合并)、gitlink-init-scaffold(项目脚手架初始化)、gitlink-contributor-ranking(贡献者排行)
- 新增 Skill:gitlink-multi-repo、gitlink-auto-merge、gitlink-init-scaffold、gitlink-contributor-ranking、gitlink-research-fair
- AI 客户端迁移至 DeepSeek,JSON 解析支持数字类型参数
- 代码审查规则:每 PR 发布审查评论,无问题 PR 自动 squash 合并,action 执行去重
- Issue 分拣规则增强:closed issue 过滤、good-first-issue 引导评论
- 展示页交互优化:workflow 调度增强、多仓库报告渲染
- daemon 支持 multi-repo 参数透传、多进程防护、--no-detach debug 模式
2026-07-07 17:04:50 +08:00
yetja 9553a30efc chore: add .gitignore 2026-07-07 16:24:41 +08:00
chroe 7faba628d3 fix: 修复测试+跨平台+showcase数据
- repo +clone: 抽取 parseCloneTarget 纯函数,测试改为直接验证解析逻辑
  (原测试用 Unix 专属假 git 脚本,Windows 上必失败)
- issue assign / batch-assign: 改用正确的 assigner_ids 数组字段
  (GitLink 不认 assigned_to_id,原命令静默失效)
- workflow daemon: Setsid 拆分为 proc_unix/proc_windows 构建标签
  (Setsid 是 Linux 专属,Windows 编译失败)
- showcase: 统计数据更新为真实值(19模块/110命令/187测试)

全量 go test ./shortcuts/... 20 包全部通过
2026-07-07 16:27:27 +08:00
caoweiqiong b5f8b299ad feat: 新增 gitlink-research-fair 科研软件 FAIR 体检 Skill(子任务四) (#5)
Release / release (push) Failing after 17m35s Details
Co-authored-by: caoweiqiong <caoweiqiong@example.org>
Co-committed-by: caoweiqiong <caoweiqiong@example.org>
2026-07-02 16:15:10 +08:00
yetja 67a38b48e5 test: code-quality watch 自动检测测试 (#6) 2026-07-02 16:12:40 +08:00
yetja fdb6e115d9 fix(showcase): Docker 容器内创建 gitlink-cli 配置目录 2026-07-02 14:38:33 +08:00
yetja 8c2f843d38 fix(showcase): 恢复 JSON/Table 输出格式切换按钮 2026-07-01 17:06:51 +08:00
yetja 4eba22ed19 fix: 补充 workflow 模块所需的 AnthropicAPIKey 和 AIMode 字段 2026-07-01 17:01:15 +08:00
yetja 32785401c0 fix(shortcuts): 注册 workflow 模块到命令行 2026-07-01 16:12:29 +08:00
yetja 716a4335a9 fix(showcase): Dockerfile build 添加 -buildvcs=false 避免 VCS 卡死 2026-07-01 15:14:51 +08:00
yetja 78c7ad2771 feat(showcase): 重构展示页为四部分项目总览
将原单一 CLI 模块展示页扩展为覆盖全部四个子任务的总览页面:
- 子任务一:保留 11 个 CLI 模块交互卡片
- 子任务二:新增 17 个 AI Skills 分组展示(核心/智能/辅助),智能 Skill 可展开示例
- 子任务三:新增 5 个预置工作流定义卡片 + 11 个 CLI 命令交互运行
- 子任务四:科研辅助占位区域
2026-07-01 14:48:55 +08:00
yetja 4b5d402e89 Merge remote-tracking branch 'origin/master' 2026-07-01 11:48:55 +08:00
yetja c3fc44cbca feat: 实现自动化工作流引擎
工作流引擎支持5个预置场景(社区运营/代码质量/项目初始化/多仓库协同/贡献者成长),
3种步骤类型(command/api/skill),2种执行模式(规则引擎/AI增强),
以及watch/schedule/daemon三种持续运行模式。

- shortcuts/workflow/: 引擎核心(类型定义、步骤执行器、AI客户端、状态管理、守护进程)
- shortcuts/workflow/rules/: 7个确定性规则引擎(triage/health/changelog/review/ci/license/repo)
- skills/gitlink-workflow/: 工作流 Skill 文档与示例
2026-07-01 11:42:30 +08:00
caoweiqiong e6ddc44178 feat: 新增 gitlink-review 智能代码审查 Skill
Release / release (push) Failing after 2m26s Details
新增智能代码审查skill——分析 PR diff,多视角评审 + 对抗式自检,输出结构化 Review 意见并作为评论发布。
2026-06-23 22:52:31 +08:00
Martin c12875ac26 docs(review): REFERENCE 回填 reviews API 实测结论 + 真实走查示例 2026-06-23 22:38:30 +08:00
Martin a95b07a507 docs(review): README 智能表登记 gitlink-review,workflow 链回深度审查 2026-06-20 20:24:00 +08:00
Martin 1af90f192b docs(review): 新增 REFERENCE.md(视角清单+评级+降噪+字段映射;reviews API 结论待 live 验证) 2026-06-20 20:22:40 +08:00
Martin a3a3643369 docs(review): 按 review 反馈补 severity 映射/自审盲区提示/sha 过期行为 2026-06-20 20:20:24 +08:00
Martin 808f6e8d7e feat(review): 新增 gitlink-review SKILL.md(管道+6视角+对抗自检+报告卡) 2026-06-20 20:18:04 +08:00
Martin 758f90a22e docs(review): 添加 gitlink-review 设计 spec 与实现计划 2026-06-20 20:13:40 +08:00
yetja 22e8300768 新增 gitlink-license 许可证合规检查 Skill 2026-06-15 22:24:04 +08:00
chroe 373e79ae5f 修复issue分配优先级,分配责任人,设置标签功能 2026-06-15 11:23:47 +08:00
caoweiqiong b7e310363f 完善Skill + 新增CC启用脚本
完善 issue/pr/search/user/pm 五个 Skill + 新增 Claude Code Skill 工具启用脚本
2026-06-13 16:56:44 +08:00
Martin cd2cb11aad 完善 issue/pr/search/user/pm 五个 Skill,新增 Claude Code 启用脚本
- issue/pr:补充真实数据 examples(PR #1 全流程、6 个 Issue)
- search/user/pm:重写 SKILL.md v2.0.0 + 新增 REFERENCE.md + examples
- 新增 scripts/setup-skills.sh:把 skills/gitlink-* 链接到 ~/.claude/skills/,
  使 Claude Code 可通过 Skill 工具直接调用(Windows junction / Unix symlink)
- README 增补「在 Claude Code 中通过 Skill 工具调用」一节

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 16:38:18 +08:00
caoweiqiong 5bec1e5bf1 Merge pull request 'test: PR workflow demo for skill documentation' (#1) from test/pr-workflow-demo into master
Release / release (push) Failing after 17m30s Details
2026-06-13 10:17:06 +08:00
Martin 60cb293913 test: PR workflow demo for skill documentation 2026-06-13 10:16:39 +08:00
yetja 32783c2086 repo skill: REFERENCE.md 拆为 references/ 目录(与 issue 格式一致) 2026-06-12 19:15:06 +08:00
yetja fb5ebc4d2c 完善 repo/org/ci/workflow 四个 AI Agent Skill(SKILL.md + REFERENCE.md + examples) 2026-06-12 19:09:23 +08:00
chroe 90e51dbadf 三个skill 2026-06-11 17:06:49 +08:00
chroe 3052e0bfa1 fix(skills): gitlink-health 评分算法修复 + 真实数据更新
REFERENCE.md 修复:
- journals API fallback 策略(不可用时按 comment_journals_count 估分)
- 贡献集中度明确排除上游贡献者,仅算本团队成员
- 社区关注度补充评分标准(≥20优秀 / 5-19良好 / <5需改进)

SKILL.md 补充:
- 明确 AI 必须先读 REFERENCE.md 再算分
- PR 维度 N/A 时权重重分配规则
- 贡献集中度计算说明

examples 更新:
- health-report.md: 用 2026-06-08 最新数据,含完整评分计算过程
- weekly-report.md: 反映本周关闭全部 Issue、清理里程碑/分支
2026-06-08 12:45:17 +08:00
chroe 603ab89d31 feat(skills): 新增 3 个 AI Agent Skill — health/changelog/triage
子任务二:编写和丰富 GitLink Skills

新增 Skill:
- gitlink-health: 项目健康度报告(Issue/PR/贡献者/活跃度四维评分)
- gitlink-changelog: Release Notes 自动生成(commit 分类 + 结构化输出)
- gitlink-triage: Issue 智能分拣 + 新人引导(自动分类/打标签/分配责任人)

每个 Skill 包含:
- SKILL.md(操作指南 + 工作流)
- REFERENCE.md(技术参考 + 字段映射)
- examples/(真实数据示例,基于 chroe/gitlink-cli 采集)
- VERIFICATION.md(Claude Code 验证通过)

同步更新:
- skills/README.md 注册新 Skill
- PROJECT_STATUS.md 更新当前阶段为子任务二
2026-06-08 11:02:06 +08:00
chroe 8391c3f3eb fix(showcase): 移除 rebase 残留的 commit message 导致 JS 语法错误白屏 2026-06-04 14:58:54 +08:00
chroe 045c3b3c5f refactor(showcase): 成员管理卡片优化
- 组织ID查找改为 org +info 输入名字返回ID(不再列出全站805个组织)
- 新增仓库选择器(默认 chroe/gitlink-cli),成员操作可选目标仓库
- 移除重复的 org +list(与 member +list 重叠)
- 修复跨模块命令查找逻辑(用 uid 精确匹配)
2026-06-04 11:51:24 +08:00
yetja 9368c89b4d 删除不可用的批量操作(org batch-invite),新增批量操作:issue batch-comment(批量评论)、repo batch-create/batch-fork/batch-delete(批量仓库管理)、member batch-add(批量添加成员)。展示页新增 table 格式支持(formatter 增加 toGeneric 转换),新增批量仓库管理模块,更新批量操作计数。 2026-06-04 11:33:51 +08:00
chroe 21859ae6c1 refactor(showcase): 批量组织邀请合并到成员管理卡片
- 将独立的「批量组织邀请」卡片并入「成员管理」
- 新增「查看组织列表/ID」和「查看组织成员」入口
- 用分隔线区分项目成员和组织成员操作
- 修复跨模块命令 uid 冲突问题
2026-06-04 11:23:55 +08:00
chroe d1965eb098 fix(org): batch-invite 用户名自动解析为数字ID,修正 Showcase 默认值
- batch.go: user_id 字段从传字符串改为传 int64 数字 ID
- 新增 resolveUserIDs() 自动通过 /users/list 搜索解析用户名
- 支持混合输入:用户名自动查 ID,纯数字直接用,找不到的报错
- showcase: 默认组织 ID 从 1 改为 151783,示例用户改为 chroe
2026-06-04 11:01:41 +08:00
chroe ce7bb8ad19 chore: batch-delete默认改用fork后的仓库路径 2026-06-04 10:18:01 +08:00
chroe 831bd74be6 chore: 更新仓库管理默认示例为真实仓库 2026-06-04 10:00:55 +08:00
chroe f6467c5e84 chore: 恢复repo批量管理卡片 2026-06-04 09:53:32 +08:00
chroe 452c87dca7 fix: 移除batch-milestone(v1 PATCH实际不生效) 2026-06-04 09:44:58 +08:00
chroe 7f0652fd79 fix: 移除batch-label(GitLink API不支持issue标签关联) 2026-06-04 09:33:04 +08:00
chroe 73b5a557bc fix: 移除assign/batch-assign(GitLink API不支持) + 添加issue list命令 2026-06-04 09:25:22 +08:00
chroe cb8dfd5537 fix: assign传递数字ID + 保留status_id防止状态被重置 2026-06-04 09:08:53 +08:00
chroe 49c4b2931f Merge branch 'master' of https://gitlink.org.cn/chroe/gitlink-cli 2026-06-04 09:00:24 +08:00
chroe 623e065efa feat: 添加reopen/batch-reopen命令 + 修复assign使用PATCH代替PUT 2026-06-04 09:00:23 +08:00
chroe b1bd9759ce delete test file
Release / release (push) Failing after 56m48s Details
2026-06-03 23:06:17 +08:00
chroe c229513584 add test file 2026-06-03 23:04:03 +08:00
chroe 1004bddc8d fix: watch/star 模块 resolveProjectID 兼容 project_id 字段 2026-06-03 22:57:04 +08:00
chroe ad30d3b233 Merge branch 'master' of https://gitlink.org.cn/chroe/gitlink-cli 2026-06-03 22:45:51 +08:00
chroe 3558a1626c feat: 成员管理卡片添加按用户名搜索ID功能 2026-06-03 22:45:39 +08:00
chroe 72f1a7d6f5 delete test file 2026-06-03 22:38:48 +08:00
chroe 9ca2ab9530 add test file 2026-06-03 22:38:11 +08:00
chroe ac78457f9e fix: 移除index.html多余的花括号导致JS语法错误 2026-06-03 22:37:19 +08:00
chroe cf9f56feb3 Merge branch 'master' of https://gitlink.org.cn/chroe/gitlink-cli 2026-06-03 21:19:38 +08:00
chroe c1155c2262 perf: 移除流水线docker build --no-cache + 更新项目状态 2026-06-03 21:18:23 +08:00
Martin 4e4e3fb7c9 fix: Windows credential path follows system convention, remove duplicate install.js
Release / release (push) Failing after 1h9m56s Details
- ConfigDir() uses %AppData%\gitlink-cli on Windows instead of ~/.config/
- credentialPath() reuses config.ConfigDir() for path consistency
- credential path now respects GITLINK_CONFIG_DIR env override
- Remove stale scripts/install.js (npm/scripts/install.js is canonical)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 20:58:35 +08:00
chroe f627c3d813 merge: 解决 showcase/index.html 合并冲突,保留独立模块卡片+布尔参数修复+null处理优化 2026-06-03 20:24:47 +08:00
yetja bfd8cb0ba5 修复展示页两个bug:错误提示显示null、pflag bool参数解析错误 2026-06-03 20:20:47 +08:00
chroe 1a5dbe9e8e fix: showcase布尔参数用下拉框、移除重复owner/repo参数、改善null输出显示、member模块增加ID查找提示 2026-06-03 20:16:44 +08:00
yetja 37264ea7ec 合并批量操作为一个大类,修复错误提示显示null的bug 2026-06-03 20:01:01 +08:00
yetja fca5e9b821 新增展示页信息 2026-06-03 19:46:16 +08:00
Martin 7cd995dffa feat: add FreeBSD (amd64/arm64) to build and distribution pipeline
- Add freebsd/amd64 and freebsd/arm64 to build-npm.sh PLATFORMS
- Add FreeBSD targets to CI release workflow
- Fix CI Go version mismatch (1.22 -> 1.26 to match go.mod)
- Add freebsd platform mapping in npm postinstall script

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 19:36:58 +08:00
chroe d072d54995 Merge branch 'master' of https://gitlink.org.cn/chroe/gitlink-cli 2026-06-03 16:54:16 +08:00
chroe ad215e292d feat: wiki模块(5命令+sidebar自动清理)、流水线迁移新服务器、showcase参数修复、client.DoRaw
- 新增 wiki 模块:list/view/create/update/delete(含 sidebar 死链接自动清理)
- 新增 client.DoRaw() 方法(不带.json后缀,支持 gateway API)
- 修复 showcase 参数截断问题(支持引号包裹多词参数)
- 流水线迁移至新服务器 118.31.4.168
- Dockerfile 添加 GOPROXY=https://goproxy.cn,direct
- 更新 PROJECT_STATUS.md 项目文档
2026-06-03 09:05:53 +08:00
yetja d6c1a92fe3 增加批量管理仓库、批量邀请成员两个批量操作,优化现有命令的错误提示 2026-06-02 15:39:49 +08:00
Martin 999e721bd8 feat: add file, member, watch, star shortcut modules (15 commands)
- file: +list, +tree, +get, +create, +delete (5 commands, 9 tests)
- member: +list, +add, +remove, +update (4 commands, 6 tests)
- watch: +watch, +unwatch, +watchers (3 commands, 3 tests)
- star: +star, +unstar, +stars (3 commands, 3 tests)

Auto-resolve project-id for watch/star. v1 API paths preferred.
All 21 new tests passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 08:54:18 +08:00
chroe a82717bbf3 refactor: .devops/构建部署Showcase.yml 2026-05-28 16:14:04 +08:00
chroe d63ea089ba feat: .devops/构建部署Showcase.yml 2026-05-28 16:08:44 +08:00
chroe 74f75a55ca feat(showcase): add interactive dashboard with Dockerfile and CI/CD pipeline
Interactive web dashboard that demonstrates new CLI commands with real
API calls. Includes:
- Go web server with embedded HTML (showcase/main.go)
- Dashboard UI with 4 modules, Chinese descriptions, live execution
- Dockerfile for multi-stage build
- Pipeline config for automated deployment on push

Access at http://121.41.210.165:9090 after deployment
2026-05-28 16:01:01 +08:00
yetja e29a02a3d0 新增24个测试用例:issue批量操作/CRUD、org/user/pr全命令、各模块view命令。覆盖率达到74.1% 2026-05-28 15:35:17 +08:00
chroe 2f943d9a53 docs(showcase): add feature showcase page and deploy script
Dark-themed showcase page displaying:
- Core stats (40+ to 70+ commands, 12 shortcut groups, 36+ tests)
- Team member assignments and architecture diagram
- Live command demo outputs and test results
- Deploy script for Nginx container on ECS
2026-05-28 14:42:34 +08:00
chroe 6042a7ea93 feat(commit): add commit shortcut module with list/view/diff/blame commands
Add 4 new commands for commit operations:
- commit list: list commits with sha/page/limit filters
- commit view: view files changed in a commit
- commit diff: show diff for a commit
- commit blame: show line-by-line file blame

Includes 5 unit tests covering all commands.
2026-05-28 14:42:34 +08:00
yetja 60b6ea6f6c 解决issue3:优化 table 输出格式及 batch-close
同时新增batch-close: 新增 Duration 字段记录批量操作耗时
2026-05-26 15:14:57 +08:00
yetja 1276744ee2 解决 issue #2: 新增 issue batch-assign、batch-label、batch-milestone 三个批量操作命令 2026-05-25 11:18:57 +08:00
yetja f024db9878 新增 7 个 Shortcut 命令(search+issues, issue+assign, issue+label, pr+review, release+download, repo+settings, repo+clone)
补全 doc/design.md Phase 3 规划的 7 个缺失命令:
- search +issues:跨仓库搜索 Issue,支持关键词/状态/所有者过滤
- issue +assign:将 Issue 指派给指定用户
- issue +label:给 Issue 添加/移除标签(逗号分隔多 ID)
- pr +review:提交 PR Review(approve/comment/request-changes)
- release +download:下载 Release 附件,不指定文件名则列出全部
- repo +settings:查看/修改仓库设置(可见性/默认分支/描述)
- repo +clone:从 GitLink 克隆仓库(支持 URL 和 owner/repo 格式)

包含 27 个新增单元测试,全部 55 个 shortcut 测试通过。
2026-05-22 20:19:18 +08:00
232 changed files with 31153 additions and 2055 deletions

View File

@ -0,0 +1,31 @@
version: 2
name: 构建部署Showcase
description: "代码提交自动触发在服务器上拉取代码、构建Docker镜像并部署"
global:
concurrent: 1
trigger:
webhook: gitlink@1.0.0
event:
- ref: push
ruleset-operator: AND
workflow:
- ref: start
name: 开始
task: start
- ref: ssh_cmd_0
name: SSH部署到服务器
task: ssh_cmd@1.1.1
input:
ssh_pass: ((deploy_server.server_password))
ssh_ip: '"118.31.4.168"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_cmd: >-
"git config --global --add safe.directory /opt/gitlink-cli && cd /opt/gitlink-cli && git fetch origin && git reset --hard origin/master && DOCKER_BUILDKIT=1 docker build -f showcase/Dockerfile -t gitlink-cli-showcase . && docker stop gitlink-cli-showcase || true && docker rm gitlink-cli-showcase || true && docker run -d -p 9090:9090 --name gitlink-cli-showcase --restart unless-stopped -e GITLINK_TOKEN='cookie:autologin_trustie=56c6d2b4378588465c97d48679eed7bd2495ff1a' gitlink-cli-showcase"
needs:
- start
- ref: end
name: 结束
task: end
needs:
- ssh_cmd_0

View File

@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version: '1.26'
- uses: actions/setup-node@v4
with:
@ -36,7 +36,9 @@ jobs:
"linux amd64" \
"linux arm64" \
"windows amd64" \
"windows arm64"; do
"windows arm64" \
"freebsd amd64" \
"freebsd arm64"; do
GOOS=$(echo "$pair" | cut -d' ' -f1)
GOARCH=$(echo "$pair" | cut -d' ' -f2)
OUT="gitlink-cli"

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
bin/
dist/
# Showcase build outputs由 Dockerfile / deploy 脚本编译,不入库)
showcase/showcase-linux
showcase/showcase-server-linux
showcase/showcase-server
showcase/showcase
# Test binary
*.test
# Output of go coverage
*.out
# Go workspace
go.work
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.local

509
README.md
View File

@ -1,430 +1,183 @@
# gitlink-cli
# GitLink CLI · 智能化能力提升
[![GitLink](https://img.shields.io/badge/GitLink-Gitlink%2Fgitlink--cli-green)](https://www.gitlink.org.cn/Gitlink/gitlink-cli)
[![License](https://img.shields.io/badge/License-MulanPSL--2.0-blue.svg)](https://license.coscl.org.cn/MulanPSL2)
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
> 让 AI 智能体和开发者都能在终端高效操控 GitLink 平台把仓库管理、Issue/PR 协作、CI/CD 变成可自动化、可复现的流程。
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows, with 40+ commands and 12 AI Agent [Skills](./skills/).
**第八届 CCF 开源创新大赛 · track1 GitLink-CLI 贡献赛**参赛作品。
**[中文文档](./README.zh-CN.md)**
---
[Install](#installation--quick-start) · [AI Agent Skills](#ai-agent-skills) · [Auth](#configure--use) · [Commands](#usage-examples) · [Contributing](#related-projects)
## 项目简介
## Why gitlink-cli?
随着 Claude Code、Cursor 等 AI 编程智能体兴起,开发者正从"手动操作平台"转向"Agent 驱动开发"。但 GitLink 平台的能力长期锁在网页 GUI 里——AI 看不到、调不动,开发者也得在终端和浏览器间反复切换。
- **Agent-Native Design** — 12 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Branch, Release, CI, Org, Search, User — all core domains covered
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — Interactive login or `GITLINK_TOKEN` env var, from install to first API call in just 3 steps
- **Secure & Controllable** — OS-native keychain credential storage, `GITLINK_TOKEN` env var for CI/CD & non-interactive environments, auto git remote context resolution
- **Three-Layer Architecture** — Shortcuts (human & AI friendly) → Raw API (full coverage) → Config (configuration management)
本项目围绕 [gitlink-cli](https://gitlink.org.cn/gitlink/gitlink-cli) 做了四件事:
## Features
1. **把平台能力命令化**——新增 19 个命令模块、110 条命令,让每类操作都能在终端一行命令完成
2. **给 AI 写使用说明书**——23 个 Skill让 Claude Code 等智能体直接会操控 GitLink
3. **串成端到端工作流**——自研工作流引擎,一行命令把多个 Skill 串成自动化流水线
4. **延伸到科研场景**——科研项目洞悉、FAIR 合规检查、贡献者画像
| Category | Capabilities |
|----------|-------------|
| 📦 Repo | List, create, fork, delete repositories, view repo info |
| 🐛 Issue | Create, update, close, batch close, comment on issues |
| 🔀 PR | Create, merge, review pull requests, view changed files |
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
| 🏷️ Release | Create, view, delete releases |
| 🏢 Org | Manage organizations, members, teams |
| 🔧 CI | View builds, logs, CI/CD operations |
| 🔍 Search | Search repositories, users |
| 👤 User | View user profiles and info |
| 📋 PM | Sprint management, kanban boards, weekly reports |
| 🤖 Workflow | AI-powered issue triage, PR review, release notes |
| 指标 | 数值 |
|------|------|
| 命令模块 | 19 个110 条命令) |
| 批量操作 | 11 条(全部支持 `--dry-run` 预演) |
| AI Agent Skill | 23 个 |
| 预置工作流 | 5 个 |
| 单元测试 | 187 个 |
| 交叉编译平台 | 8 个Win/Linux/macOS/FreeBSD × amd64/arm64 |
## Installation & Quick Start
---
### Requirements
## 四大子赛题成果
- Node.js 14+ (`npm`/`npx`) — for npm installation
- Supported platforms: macOS, Linux, Windows (x64/arm64)
- Go 1.26+ — only required for building from source
### 子赛题一 · 增加 CLI 能力
新增 19 个 Shortcut 模块(仓库/Issue/PR/Release/CI/Webhook/标签/里程碑/Wiki/工作流…11 条批量操作修复跨平台兼容性Windows/Linux/macOS/FreeBSD。已向主仓库提交 PR。
- 代码:[`shortcuts/`](shortcuts/)、[`internal/`](internal/)、[`cmd/`](cmd/)
- 提交材料:[`提交材料/子赛题一-CLI能力/`](提交材料/子赛题一-CLI能力/)
### Quick Start (Human Users)
### 子赛题二 · 编写 Skills
23 个 Skill命令操作类 + 智能分析型覆盖健康度报告、Release Notes 生成、Issue 智能分拣、智能代码审查、许可证合规、科研辅助等。一键 `setup-skills.sh` 装进 Claude Code 即可用 `/gitlink-xxx` 调用。
- 代码:[`skills/`](skills/)
- 演示录屏:[`提交材料/子赛题二-Skills/demos/`](提交材料/子赛题二-Skills/demos/)
> **Note for AI assistants:** If you are an AI Agent helping the user with installation, jump directly to [Quick Start (AI Agent)](#quick-start-ai-agent), which contains all the steps you need to complete.
### 子赛题三 · 端到端工作流
自研工作流引擎(`shortcuts/workflow/`AI/规则双模式5 个预置工作流(代码质量看门人/社区运营自动化/贡献者成长/多仓库协同/项目一键初始化),支持手动/轮询/定时触发systemd 7×24 自动运行。
- 代码:[`shortcuts/workflow/`](shortcuts/workflow/)
- 提交材料:[`提交材料/子赛题三-工作流/`](提交材料/子赛题三-工作流/)
#### Install
### 子赛题四 · 辅助科研
gitlink-spark科研软件 X 光、gitlink-research-fairFAIR 合规检查、gitlink-contributor-ranking贡献者画像面向科研项目洞悉、合规校验、协作匹配。
- 代码:[`skills/gitlink-spark`](skills/gitlink-spark)、[`skills/gitlink-research-fair`](skills/gitlink-research-fair)
- 提交材料:[`提交材料/子赛题四-科研/`](提交材料/子赛题四-科研/)
**From npm (recommended):**
> 📁 所有变更说明、演示录屏/截图集中在 [`提交材料/`](提交材料/) 目录。
---
## 快速开始
### 安装
```bash
# One command: installs CLI binary + all 12 AI Agent Skills
# 方式一go install需 Go 1.26+
go install github.com/gitlink-org/gitlink-cli@latest
# 方式二npm
npm install -g @gitlink-ai/cli
# 方式三下载预编译包8 平台)
# 见 Release 页面
```
The binary is auto-downloaded for your platform during `postinstall`. No extra steps needed.
**From source:**
Requires Go 1.26+.
### 认证
```bash
git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git
cd gitlink-cli
make install
gitlink-cli auth login # 浏览器登录cookie 自动保存
gitlink-cli auth status # 查看登录状态
```
> **Windows users:** Run `npm install -g @gitlink-ai/cli` in PowerShell or CMD. For building from source, use `go install .` instead of `make install`.
#### Configure & Use
### 使用示例
```bash
# 1. Configure (one-time, interactive guided setup)
gitlink-cli config init
# 列出仓库的 Issue
gitlink-cli issue +list --owner gitlink --repo gitlink-cli
# 2. Log in (choose one)
gitlink-cli auth login # Username/password (recommended)
gitlink-cli auth login --token # Or paste a private token
export GITLINK_TOKEN="your-token" # Or set env var (for CI/CD, non-interactive environments)
# 批量关闭(先预演)
gitlink-cli issue +batch-close --numbers 1,2,3 --dry-run
# 3. Start using
gitlink-cli repo +list
# 在 Claude Code 里一句话调用 Skill
/gitlink-triage 帮我分拣所有未分类的 Issue
```
### Quick Start (AI Agent)
> The following steps are for AI Agents. Some steps require the user to complete actions in a browser.
**Step 1 — Install**
### 一键启用 SkillsClaude Code
```bash
# One command: CLI binary + all Skills auto-installed
npm install -g @gitlink-ai/cli
bash scripts/setup-skills.sh # 把 skills/ 链接到 ~/.claude/skills/
# 之后在 Claude Code 里就能 /gitlink-xxx 调用
```
**Step 2 — Configure**
### 在线演示
```bash
gitlink-cli config init
Showcase Dashboard<http://118.31.4.168:9090> —— 浏览器里点卡片就能跑命令,无需安装。
---
## 架构说明
```
┌──────────────────────────────────────────────────────────┐
│ 入口层 cmd/ main.go → root.go (cobra root) │
│ 4 个基础命令: auth / api / config / version │
│ 全局 flag: --owner/--repo/--format/--debug │
└───────────────┬──────────────────────────────────────────┘
│ shortcuts.RegisterAll(rootCmd)
┌───────────────▼──────────────────────────────────────────┐
│ 业务层 shortcuts/ 19 组 × 100+ 命令 + 工作流引擎 │
│ register.go 命令注册中枢 │
│ common/ RuntimeContext 业务统一上下文 │
│ workflow/ 工作流引擎AI/规则双模式 + 5 预置流) │
└───────────────┬──────────────────────────────────────────┘
│ RuntimeContext.CallAPI / Output
┌───────────────▼──────────────────────────────────────────┐
│ 基础层 internal/ Go 内部包,不对外暴露 │
│ client/ HTTP 客户端 + .json 后缀 + 错误检测 │
│ auth/ Transport 自动注入 Cookie/access_token │
│ config/ YAML 配置 + 跨平台路径 │
│ output/ Envelope(ok/data/meta) + json/yaml/table │
└──────────────────────────────────────────────────────────┘
独立的 AI 层 skills/ 23 个 SKILL.md面向 AI Agent 的自然语言说明书
(非编译期依赖,指导 Agent 调用编译好的二进制)
```
**Step 3 — Login**
**核心设计**:业务命令只依赖 `RuntimeContext`,绝不直接接触 cobra/HTTP 细节——新增模块只挂一个 `shortcuts/` 子包,不动入口与基础层。这是 19 模块 100+ 命令仍保持可测试性的根本。
For interactive environments:
```bash
gitlink-cli auth login
```
---
For non-interactive environments (CI/CD, Trae sandbox, MCP, etc.):
```bash
export GITLINK_TOKEN="your-private-token"
```
> To get a private token, go to GitLink web → Settings → Private Tokens.
**Step 4 — Verify**
```bash
gitlink-cli user +me
```
## Usage Examples
### Repository Operations
```bash
# List repositories
gitlink-cli repo +list
# View repository info
gitlink-cli repo +info --owner Gitlink --repo forgeplus
# Create a repository
gitlink-cli repo +create -n my-project -d "Project description"
# Fork a repository
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### Issue Management
```bash
# List issues
gitlink-cli issue +list --owner Gitlink --repo forgeplus
# Create an issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..."
# View an issue
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
# Close an issue
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
# Preview batch close without changing data
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
# Batch close issues from a CSV file
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
# Add a comment
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
```
### Pull Requests
```bash
# List PRs
gitlink-cli pr +list --owner Gitlink --repo forgeplus
# Create a PR (same-repo branch)
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: Search feature" --head feature/search --base master
# Create a PR (from a fork)
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: New feature" --head your_username/forgeplus:feature/my-feature --base master
# View a PR
gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
# Merge a PR
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
# View changed files
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
```
### Branch Management
```bash
# List branches
gitlink-cli branch +list --owner Gitlink --repo forgeplus
# Create a branch
gitlink-cli branch +create --name feature/new-feature
# Delete a branch
gitlink-cli branch +delete --name feature/old-feature
# Protect a branch
gitlink-cli branch +protect --name main
# Remove branch protection
gitlink-cli branch +unprotect --name main
```
### Release Management
```bash
# List releases
gitlink-cli release +list --owner Gitlink --repo forgeplus
# Create a release
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 Stable" -b "Changelog..."
# View a release
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
```
### CI/CD Operations
```bash
# List builds
gitlink-cli ci +list --owner Gitlink --repo forgeplus
# View build log
gitlink-cli ci +log --owner Gitlink --repo forgeplus -i <build_id>
# Restart a build
gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i <build_id>
```
### Search
```bash
# Search repositories
gitlink-cli search +repos -k "machine learning"
# Search users
gitlink-cli search +users -k "zhangsan"
```
### Raw API
For endpoints not covered by shortcuts, use the Raw API directly:
```bash
# GET request
gitlink-cli api GET /users/me
# POST request
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
# With query parameters
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
```
## Global Parameters
| Parameter | Description | Example |
|-----------|-------------|---------|
| `--owner` | Repository owner | `--owner Gitlink` |
| `--repo` | Repository name | `--repo forgeplus` |
| `--format` | Output format (json/table/yaml) | `--format json` |
| `--debug` | Enable debug output | `--debug` |
**Automatic context resolution:** When running inside a git repository, `--owner` and `--repo` are automatically resolved from `git remote origin`.
## Branch Conventions
gitlink-cli supports bidirectional code sync between GitHub and GitLink:
| Platform | Default Branch |
|----------|---------------|
| GitHub | `main` |
| GitLink | `master` |
**Push to GitLink from local:**
```bash
# Method 1: Use git command directly
git push gitlink main:master
# Method 2: Configure git remote
git config remote.gitlink.push refs/heads/main:refs/heads/master
git push gitlink
```
## AI Agent Skills
The `skills/` directory contains 12 Agent Skill files for AI-automated GitLink operations.
See [skills/README.md](skills/README.md) for details.
| Skill | Description |
|-------|-------------|
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) |
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
| `gitlink-release` | Release management (create, view, delete, etc.) |
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
| `gitlink-search` | Search (repositories, users, etc.) |
| `gitlink-org` | Organization management (members, teams, etc.) |
| `gitlink-user` | User management (profile info, etc.) |
| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) |
| `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) |
## Project Structure
## 目录结构
```
gitlink-cli/
├── cmd/ # Cobra command definitions
│ ├── root.go # Root command + global flags
│ ├── auth/ # Authentication commands
│ ├── api/ # Raw API commands
│ ├── config/ # Configuration commands
│ └── cmdutil/ # Global utilities
├── internal/ # Internal packages
│ ├── auth/ # Login, token storage, transport
│ ├── client/ # HTTP client + pagination
│ ├── config/ # Config file management
│ ├── context/ # Git remote resolution
│ └── output/ # Envelope + formatter
├── shortcuts/ # Shortcut implementations
│ ├── common/ # Framework (types, runner)
│ ├── repo/ # Repository shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── branch/ # Branch shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # Organization shortcuts
│ ├── ci/ # CI shortcuts
│ ├── search/ # Search shortcuts
│ ├── user/ # User shortcuts
│ └── register.go # Registration entry point
├── skills/ # AI Agent Skills
│ ├── README.md # Skills guide
│ ├── gitlink-shared/ # Shared rules
│ ├── gitlink-repo/ # Repository skill
│ ├── gitlink-issue/ # Issue skill
│ ├── gitlink-pr/ # PR skill
│ ├── gitlink-pm/ # Project management skill
│ └── ...
├── doc/ # Design documents
│ ├── Design.md
│ ├── CODE_SYNC_STRATEGY_FINAL.md
│ └── ...
├── main.go
├── Makefile
├── go.mod
└── README.md
├── README.md # 本文件
├── cmd/ # 入口层auth/api/config/version
├── shortcuts/ # 业务层19 命令模块 + workflow 工作流引擎)
├── internal/ # 基础层client/auth/config/output/context/errors
├── skills/ # 23 个 AI Agent Skill
├── showcase/ # 在线演示 DashboardGo 单二进制)
├── scripts/ # 脚本setup-skills.sh 等)
├── docs/ # 设计文档、API 参考
└── 提交材料/ # ★ 比赛提交材料(变更说明 + 演示录屏/截图)
├── 子赛题一-CLI能力/
├── 子赛题二-Skills/
├── 子赛题三-工作流/
└── 子赛题四-科研/
```
## Documentation
---
- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation
- [Design Document](doc/design.md) — Architecture design and development plan
## 技术栈
## FAQ
- **语言**Go 1.26子赛题一Markdown/Shell子赛题二三四
- **CLI 框架**spf13/cobra
- **密钥存储**zalando/go-keyringOS keychain + 文件 fallback
- **配置**gopkg.in/yaml.v3
- **CI/CD**GitLink DevOps建木引擎自动部署 + GitHub Actions 8 平台发版
### Q: How do I use gitlink-cli in scripts?
---
Use the `GITLINK_TOKEN` environment variable + `--format json` for structured output:
## 团队
```bash
export GITLINK_TOKEN="your-private-token"
gitlink-cli repo +list --format json | jq '.data.projects[] | .name'
```
| 成员 | 负责 |
|------|------|
| chroe | CLI 核心模块、Showcase、CI/CD、health/changelog/triage Skill、工作流引擎 |
| yetja | 批量操作、单元测试、license/repo/org Skill、工作流规则引擎 |
| caoweiqiong | file/member/watch/star 模块、review Skill、FreeBSD 支持 |
### Q: How does automatic owner/repo resolution work?
---
When running inside a git repository, the CLI automatically resolves `--owner` and `--repo` from `git remote origin`:
## 相关链接
```bash
cd ~/my-gitlink-project
gitlink-cli issue +list # Automatically uses the current repository
```
### Q: What if my token expires?
Re-authenticate:
```bash
# Username/password login
gitlink-cli auth login
# Or use a private token (generate at GitLink web → Settings → Private Tokens)
gitlink-cli auth login --token
```
### Q: How do I use gitlink-cli in CI/CD or non-interactive environments (e.g. Trae sandbox)?
Set the `GITLINK_TOKEN` environment variable — no `auth login` needed:
```bash
export GITLINK_TOKEN="your-private-token"
gitlink-cli repo +list # Ready to use
gitlink-cli auth status # Shows "✓ Logged in via GITLINK_TOKEN environment variable"
```
Priority: `GITLINK_TOKEN` env var > keyring/file stored token. When the env var is not set, the original interactive login flow works as before.
### Q: What if npm installs successfully but `gitlink-cli` reports a missing binary?
Reinstall first:
```bash
npm install -g @gitlink-ai/cli
```
If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64. You can also download the binary manually from the release page or build from source with `go install .`.
### Q: Where are credentials stored on Windows?
gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`).
### Q: Where can I find the full API reference?
See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md).
## License
[MulanPSL-2.0](https://license.coscl.org.cn/MulanPSL2)
- 上游仓库:<https://gitlink.org.cn/gitlink/gitlink-cli>
- 本项目Fork<https://gitlink.org.cn/chroe/gitlink-cli>
- 在线 Showcase<http://118.31.4.168:9090>
- Skills 开发指南:[`skills/README.md`](skills/README.md)

View File

@ -1,409 +0,0 @@
# gitlink-cli
[![GitLink](https://img.shields.io/badge/GitLink-Gitlink%2Fgitlink--cli-green)](https://www.gitlink.org.cn/Gitlink/gitlink-cli)
[![License](https://img.shields.io/badge/License-MulanPSL--2.0-blue.svg)](https://license.coscl.org.cn/MulanPSL2)
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 11 个 AI Agent [Skills](./skills/)。
**[English](./README.md)**
[安装](#安装与快速上手) · [AI Agent Skills](#ai-agent-skills) · [认证](#配置与使用) · [命令](#使用示例) · [贡献](#相关项目)
## 为什么选择 gitlink-cli
- **Agent-Native 设计** — 开箱即用 11 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
- **开源零门槛** — 木兰宽松许可证第2版MulanPSL-2.0`npm install` 即用
- **3 分钟上手** — 交互式登录或 `GITLINK_TOKEN` 环境变量,从安装到首次 API 调用仅需 3 步
- **安全可控** — OS 原生 keychain 凭证存储,`GITLINK_TOKEN` 环境变量支持 CI/CD 和非交互环境,自动 git remote 上下文解析
- **三层架构** — Shortcuts人+AI友好→ Raw API全覆盖→ Config配置管理
## 功能一览
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息 |
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 🌿 分支 | 创建、删除、保护分支 |
| 🏷️ 发布 | 创建、查看、删除 Release |
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| 🔍 搜索 | 搜索仓库、用户 |
| 👤 用户 | 查看用户资料和信息 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
## 安装与快速上手
### 前置条件
- Node.js 14+`npm`/`npx`)— 用于 npm 安装
- 支持平台macOS、Linux、Windowsx64/arm64
- Go 1.26+ — 仅从源码构建时需要
### 快速上手(人类用户)
> **AI 助手请注意:** 如果你是帮助用户安装的 AI Agent请直接跳到 [快速上手AI Agent](#快速上手ai-agent),其中包含你需要完成的所有步骤。
#### 安装
选择以下**任一**方式:
**方式 1 — 从 npm 安装(推荐):**
```bash
# 安装 CLI
npm install -g @gitlink-ai/cli
# 安装 CLI Skill必须全平台通用
gitlink-cli-install-skills
# 也可使用 npx 安装 Skill
npx skills add ccfos/gitlink-cli/skills -y -g
```
**方式 2 — 从源码构建:**
需要 Go 1.26+。
```bash
git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git
cd gitlink-cli
make install
# 安装 CLI Skill必须
npx skills add ./skills -y -g
```
> **Windows 用户注意:** 请在 PowerShell 或 CMD 中运行 `npm install -g @gitlink-ai/cli`。从源码构建请使用 `go install .` 代替 `make install`
#### 配置与使用
```bash
# 1. 配置(首次使用,交互式引导)
gitlink-cli config init
# 2. 登录(任选其一)
gitlink-cli auth login # 用户名密码(推荐)
gitlink-cli auth login --token # 或粘贴私人令牌
export GITLINK_TOKEN="your-token" # 或设置环境变量(适用于 CI/CD、非交互环境
# 3. 开始使用
gitlink-cli repo +list
```
### 快速上手AI Agent
> 以下步骤面向 AI Agent。部分步骤需要用户在浏览器中完成操作。
**第 1 步 — 安装**
```bash
# 安装 CLI
npm install -g @gitlink-ai/cli
# 安装 CLI Skill必须全平台通用
gitlink-cli-install-skills
```
**第 2 步 — 配置**
```bash
gitlink-cli config init
```
**第 3 步 — 登录**
交互环境:
```bash
gitlink-cli auth login
```
非交互环境CI/CD、Trae 沙箱、MCP 等):
```bash
export GITLINK_TOKEN="your-private-token"
```
> 获取私人令牌GitLink 网页端 → 个人设置 → 私人令牌。
**第 4 步 — 验证**
```bash
gitlink-cli user +me
```
## 使用示例
### 仓库操作
```bash
# 列出仓库
gitlink-cli repo +list
# 查看仓库信息
gitlink-cli repo +info --owner Gitlink --repo forgeplus
# 创建仓库
gitlink-cli repo +create -n my-project -d "项目描述"
# Fork 仓库
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### Issue 管理
```bash
# 列出 Issue
gitlink-cli issue +list --owner Gitlink --repo forgeplus
# 创建 Issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
# 查看 Issue
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
# 关闭 Issue
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
# 预览批量关闭,不修改数据
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
# 从 CSV 文件批量关闭 Issue
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
# 添加评论
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
```
### Pull Request
```bash
# 列出 PR
gitlink-cli pr +list --owner Gitlink --repo forgeplus
# 创建 PR同仓库分支
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 搜索功能" --head feature/search --base master
# 创建 PR从 Fork 仓库)
gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 新功能" --head your_username/forgeplus:feature/my-feature --base master
# 查看 PR
gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42
# 合并 PR
gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42
# 查看 PR 变更文件
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
```
### 发布管理
```bash
# 列出 Release
gitlink-cli release +list --owner Gitlink --repo forgeplus
# 创建 Release
gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..."
# 查看 Release
gitlink-cli release +view --owner Gitlink --repo forgeplus -i <version_id>
```
### 搜索
```bash
# 搜索仓库
gitlink-cli search +repos -k "machine learning"
# 搜索用户
gitlink-cli search +users -k "zhangsan"
```
### Raw API
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:
```bash
# GET 请求
gitlink-cli api GET /users/me
# POST 请求
gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}'
# 带查询参数
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
```
## 全局参数
| 参数 | 说明 | 示例 |
|------|------|------|
| `--owner` | 仓库所有者 | `--owner Gitlink` |
| `--repo` | 仓库名称 | `--repo forgeplus` |
| `--format` | 输出格式json/table/yaml | `--format json` |
| `--debug` | 启用调试输出 | `--debug` |
**自动上下文解析**:在 git 仓库目录下,`--owner` 和 `--repo` 会自动从 `git remote origin` 解析。
## 分支约定
gitlink-cli 支持 GitHub 和 GitLink 的代码双向同步:
| 平台 | 主分支 |
|------|--------|
| GitHub | `main` |
| GitLink | `master` |
**本地 push 到 GitLink**
```bash
# 方式 1使用 git 命令
git push gitlink main:master
# 方式 2配置 git remote
git config remote.gitlink.push refs/heads/main:refs/heads/master
git push gitlink
```
## AI Agent Skills
`skills/` 目录包含 11 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
详见 [skills/README.md](skills/README.md)
| Skill | 说明 |
|-------|------|
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
| `gitlink-repo` | 仓库操作创建、查看、删除、Fork 等) |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
| `gitlink-pr` | Pull Request 操作创建、合并、Review 等) |
| `gitlink-release` | 发布管理(创建、查看、删除等) |
| `gitlink-org` | 组织管理(成员、团队等) |
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
| `gitlink-search` | 搜索功能(仓库、用户等) |
| `gitlink-user` | 用户管理(个人信息等) |
| `gitlink-pm` | 项目管理Sprint、看板、周报等 |
| `gitlink-workflow` | AI 自动化工作流Issue 分类、PR Review、Release Notes 等) |
## 项目结构
```
gitlink-cli/
├── cmd/ # Cobra 命令定义
│ ├── root.go # 根命令 + 全局 flags
│ ├── auth/ # 认证命令
│ ├── api/ # Raw API 命令
│ ├── config/ # 配置命令
│ └── cmdutil/ # 全局工具
├── internal/ # 内部包
│ ├── auth/ # 登录、Token 存储、Transport
│ ├── client/ # HTTP 客户端 + 分页
│ ├── config/ # 配置文件管理
│ ├── context/ # git remote 解析
│ └── output/ # Envelope + Formatter
├── shortcuts/ # Shortcut 实现
│ ├── common/ # 框架types, runner
│ ├── repo/ # 仓库 shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── branch/ # 分支 shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # 组织 shortcuts
│ ├── ci/ # CI shortcuts
│ ├── search/ # 搜索 shortcuts
│ ├── user/ # 用户 shortcuts
│ └── register.go # 注册入口
├── skills/ # AI Agent Skills
│ ├── README.md # Skills 使用指南
│ ├── gitlink-shared/ # 共享规则
│ ├── gitlink-repo/ # 仓库 Skill
│ ├── gitlink-issue/ # Issue Skill
│ ├── gitlink-pr/ # PR Skill
│ ├── gitlink-pm/ # 项目管理 Skill
│ └── ...
├── doc/ # 设计文档
│ ├── Design.md
│ ├── CODE_SYNC_STRATEGY_FINAL.md
│ └── ...
├── main.go
├── Makefile
├── go.mod
└── README.md
```
## 文档
- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明
- [设计文档](doc/design.md) — 架构设计和开发计划
## 常见问题
### Q: 如何在脚本中使用 gitlink-cli
使用 `GITLINK_TOKEN` 环境变量 + `--format json` 获取结构化输出:
```bash
export GITLINK_TOKEN="your-private-token"
gitlink-cli repo +list --format json | jq '.data.projects[] | .name'
```
### Q: 如何自动解析 owner/repo
在 git 仓库目录下运行命令CLI 会自动从 `git remote origin` 解析:
```bash
cd ~/my-gitlink-project
gitlink-cli issue +list # 自动使用当前仓库
```
### Q: Token 过期了怎么办?
重新登录:
```bash
# 用户名密码登录
gitlink-cli auth login
# 或使用私人令牌(在 GitLink 网页端 个人设置 → 私人令牌 中生成)
gitlink-cli auth login --token
```
### Q: 如何在 CI/CD 或非交互环境Trae 沙箱等)中使用?
设置 `GITLINK_TOKEN` 环境变量即可,无需 `auth login`
```bash
export GITLINK_TOKEN="your-private-token"
gitlink-cli repo +list # 直接可用
gitlink-cli auth status # 显示 "✓ Logged in via GITLINK_TOKEN environment variable"
```
Token 优先级:`GITLINK_TOKEN` 环境变量 > keyring/文件存储的 token。不设置环境变量时完全兼容原有交互式登录。
### Q: npm 安装成功但 `gitlink-cli` 提示缺少二进制怎么办?
先尝试重新安装:
```bash
npm install -g @gitlink-ai/cli
```
如果仍然失败,请检查 Release 页面是否包含当前平台的资产,例如 Windows x64 对应 `gitlink-cli_<version>_windows_amd64.zip`。也可以从 Release 页面手动下载二进制,或使用 `go install .` 从源码构建。
### Q: Windows 上凭证存储在哪里?
gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。
### Q: 如何查看完整的 API 参考?
查看 [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md)
## 许可证
[MulanPSL-2.0](https://license.coscl.org.cn/MulanPSL2)

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"regexp"
"strings"
"github.com/spf13/cobra"
@ -13,6 +14,12 @@ import (
"github.com/gitlink-org/gitlink-cli/internal/output"
)
// msysPathRe matches Windows drive paths like "D:/something" that MSYS2
// auto-converts from Unix paths like "/something". When the api command
// receives "/v1/owner/repo", Git Bash on Windows may convert it to
// "C:/Program Files/Git/v1/owner/repo". We detect and revert this.
var msysPathRe = regexp.MustCompile(`^[A-Za-z]:/`)
func NewAPICmd() *cobra.Command {
apiCmd := &cobra.Command{
Use: "api <METHOD> <PATH>",
@ -36,6 +43,20 @@ func runAPI(c *cobra.Command, args []string) error {
method := strings.ToUpper(args[0])
path := args[1]
// Fix MSYS2/Git Bash path auto-conversion on Windows.
// "/v1/owner/repo" gets converted to "C:/Program Files/Git/v1/owner/repo".
// Detect Windows drive letter prefix and strip it, then restore leading "/".
if msysPathRe.MatchString(path) {
// Find where the original API path starts (after the git install dir)
// by looking for common API prefixes like /v1/, /api/, /users/, etc.
for _, prefix := range []string{"/v1/", "/v2/", "/api/", "/users/", "/projects/"} {
if idx := strings.Index(path, prefix); idx >= 0 {
path = path[idx:]
break
}
}
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}

View File

@ -10,6 +10,7 @@ import (
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts"
)
@ -47,7 +48,10 @@ var versionCmd = &cobra.Command{
func Execute() error {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintf(os.Stderr, "错误: %s\n", err)
if s := clierrors.FindSuggestion(err); s != "" {
fmt.Fprintf(os.Stderr, "建议: %s\n", s)
}
return err
}
return nil

View File

@ -0,0 +1,767 @@
# gitlink-review智能代码审查Skill 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 新增一个纯 Markdown skill `gitlink-review`,引导 AI agent 对指定 GitLink PR 做多视角 + 对抗式自检的结构化代码审查,并把报告卡作为评论发布。
**Architecture:** 交付物是 `skills/gitlink-review/` 下的 SKILL.md + REFERENCE.md + examples/,无 Go 代码。智能来自 agent 按管道执行(取 diff → 降噪 → 6 视角 → 对抗自检 → 综合 → 预览 → 发布)。发布主路径用已验证可用的 `pr +comment`;行内评论为"reviews API 可用时增强"。用"植入缺陷测试 PR"做端到端真实验证。
**Tech Stack:** Markdown + gitlink-cli`pr +view` / `pr +files` / `pr +diff` / `pr +comment` / `api POST /pulls/:id/reviews`)。
**Spec:** `docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md`
---
## 前置约束(所有任务适用)
- **`master` 受保护**:所有改动在特性分支 `feat/gitlink-review-skill` 上提交;测试用 fixture 在独立分支 `test/review-fixture` 上(仅供测试,不合并)。
- **平台**Windows + Git Bash。路径用正斜杠。
- **认证**`gitlink-cli` 已登录(`user +me` 可用)。
---
## Task 1建立特性分支并纳入 spec/plan
**Files:**
- Track: `docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md`
- Track: `docs/superpowers/plans/2026-06-18-gitlink-review-skill.md`
- [ ] **Step 1从最新 master 建分支**
```bash
cd "C:/Users/CWQ98/Desktop/演化与运维/gitlink-cli"
git fetch origin
git checkout master
git pull --ff-only origin master
git checkout -b feat/gitlink-review-skill
```
预期:位于 `feat/gitlink-review-skill`,与 master 同步。
- [ ] **Step 2确认 CLI 可用**
```bash
gitlink-cli version
gitlink-cli user +me --format json
```
预期:打印版本;`user +me` 返回 `login: caoweiqiong`
- [ ] **Step 3把 spec 和 plan 纳入版本控制**
```bash
git add docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md \
docs/superpowers/plans/2026-06-18-gitlink-review-skill.md
git commit -m "docs(review): 添加 gitlink-review 设计 spec 与实现计划"
```
---
## Task 2创建"植入缺陷"测试 PR端到端验证载体
**Files:**
- Create`test/review-fixture` 分支上,不合并):`review_fixture.go`
- [ ] **Step 1从 master 建测试分支**
```bash
git checkout master
git checkout -b test/review-fixture
```
- [ ] **Step 2写 fixture 文件(含 4 个植入点)**
创建 `review_fixture.go`
```go
//go:build ignore
// 本文件为 gitlink-review skill 的测试夹具故意植入缺陷。DO NOT MERGE。
package reviewtest
import "os"
var _ = os.Getenv // 仅占位引入,避免 unused 报错build ignore 下不影响)
// User 占位类型
type User struct{ Name string }
// 1. 正确性:空指针未判
func CurrentUser(token string) *User {
if token == "" {
return nil // token 无效返回 nil
}
return &User{Name: "cwq"}
}
func Greet(token string) string {
u := CurrentUser(token)
return "hello " + u.Name // ← BUG: u 可能为 nil解引用会 panic
}
// 2. 安全:硬编码 Token明显伪造字符串避免触发真实密钥扫描
var AdminToken = "glpat-FAKEFAKEFAKE0000000000"
// 3. 测试:新函数 Welcome本 PR 无对应 _test.go
func Welcome(name string) string {
if name == "" {
return "guest"
}
return "hi " + name
}
// 4. 伪阳性:看似除零,但调用方 DoMath 已保证除数非 0
func Divide(a, b int) int {
return a / b
}
func DoMath(x int) int {
if x == 0 {
return 0 // 上游保证 x != 0
}
return Divide(100, x) // 因此 Divide 的除零在真实调用路径上是伪阳性
}
```
- [ ] **Step 3提交并推送**
```bash
git add review_fixture.go
git commit -m "test(review): 植入缺陷夹具(正确性/安全/测试/伪阳性)"
git push origin test/review-fixture
```
- [ ] **Step 4创建 PR**
```bash
gitlink-cli pr +create \
--owner chroe --repo gitlink-cli \
--head test/review-fixture --base master \
--title "test: gitlink-review 植入缺陷夹具(请勿合并)" \
--body "gitlink-review skill 端到端验证用 PR。含 4 个植入点:空指针、硬编码 Token、缺测试、伪阳性。验证完成后将关闭不合并。" \
--format json
```
预期:`pull_request_number` 返回一个数(记为 `<PRN>`,如 3。记录到 `review_fixture_notes.txt`(本地临时):
```
TEST_PR_NUMBER=<PRN>
```
- [ ] **Step 5验证 diff 可取**
```bash
gitlink-cli pr +files --owner chroe --repo gitlink-cli --id <PRN> --format json
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json | head -c 800
```
预期files 含 `review_fixture.go`diff 含上述代码片段。
---
## Task 3实测 reviews API决定行内评论能力
**Files:** 无(结论写入 Task 5 的 REFERENCE.md
- [ ] **Step 1在测试 PR 上探测 POST reviews**
```bash
gitlink-cli api POST /chroe/gitlink-cli/pulls/<PRN>/reviews \
--body '{"body":"gitlink-review probe: 行内评论能力探测","event":"COMMENT"}' 2>&1
```
- [ ] **Step 2判断并记录结论**
- 若返回 `{"ok":true,...}`(或含 review id**行内评论可用**,记录实际请求体格式。
- 若返回 404 / URL 被注入 git exec-path → **不可用(与 triage 记录的 `api POST` bug 一致)**
把结论写入本地 `review_api_probe.txt`
```
REVIEWS_API=<available|unavailable>
NOTES=<观察到的响应或错误>
```
- [ ] **Step 3若可用再试带行号的行内评论**
```bash
gitlink-cli api POST /chroe/gitlink-cli/pulls/<PRN>/reviews \
--body '{"event":"COMMENT","body":"行内探测","line":22,"path":"review_fixture.go","side":"RIGHT"}' 2>&1
```
记录是否支持 `line/path/side`(行内定位)。结果并入 `review_api_probe.txt`
> 此 task 无需 commit结论是数据将在 Task 5 固化进 REFERENCE.md
---
## Task 4写 SKILL.md主管道与方法论
**Files:**
- Create: `skills/gitlink-review/SKILL.md`
- [ ] **Step 1切回特性分支并建目录**
```bash
git checkout feat/gitlink-review-skill
```
- [ ] **Step 2写 SKILL.md**
创建 `skills/gitlink-review/SKILL.md`**完整内容**如下:
````markdown
---
name: gitlink-review
version: 1.0.0
description: "智能代码审查:分析 PR diff多视角评审 + 对抗式自检,输出结构化 Review 意见并作为评论发布。当用户需要审查 GitLink PR、做代码 review、自动生成审查意见时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli pr --help"
---
# gitlink-review智能代码审查
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有写入操作(发布评论)前默认先预览、确认后再执行;`--auto` 跳过确认但仍受置信度门控。**
**CRITICAL — 绝不自动 approve / merge PR。审查只是评论不做合并决策。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md);详细检查清单与字段映射见 [`REFERENCE.md`](REFERENCE.md)。
## 概述
本 Skill 引导 AI 对指定 GitLink PR 做结构化、多视角、低误报的代码审查,并把"报告卡"作为评论发布。核心特点:
1. **多视角评审团**6 个视角并行扫 diff
2. **对抗式自检**:每条候选发现先自我反驳,成立的才留下(直击 AI 审查误报老大难)
3. **降噪**:自动跳过生成代码 / vendor / lock / 纯重命名
4. **可执行**:每条发现带 `file:line` + 修复建议 + 理由
## 命令接口skill 约定参数,非 gitlink-cli 新增 flag
| 参数 | 默认 | 说明 |
|------|------|------|
| `--owner/--repo` | 自动从 cwd 解析 | 目标仓库 |
| `--id` | 必填 | PR 编号(`pull_request_number`,网页 `/pulls/N` |
| `--lenses` | 全 6 视角 | 子集,如 `correctness,security` |
| `--auto` | 关 | 跳过预览确认直接发布(仍受置信度门控) |
| `--inline` | 关 | 尝试附行内评论reviews API 可用时) |
| `--max-findings` | 12 | 报告卡上限 |
| `--refresh` | 关 | 即使存在旧审查哨兵也重审并更新 |
## 管道8 步)
### ① 取上下文
```bash
gitlink-cli pr +view --owner <owner> --repo <repo> --id <N> --format json # 元数据
gitlink-cli pr +files --owner <owner> --repo <repo> --id <N> --format json # 文件 +/-
gitlink-cli pr +diff --owner <owner> --repo <repo> --id <N> --format json # 核心diff 内容
gitlink-cli repo +info --owner <owner> --repo <repo> --format json # 语言校准
```
### ② 降噪过滤
按下述"降噪规则"跳过噪音文件;在报告卡"范围"行注明跳过数量,不静默吞掉。
### ③ 多视角分析
对每个启用的视角扫 diff产出候选发现每条含
```json
{ "lens": "correctness", "severity": "high", "likelihood": "likely",
"file": "auth/login.go", "line": 42, "what": "空指针未判",
"why": "...", "fix": "...", "confidence": "high" }
```
### ④ 对抗式自检(质量门)
逐条自问(任一成立即丢弃或降级):
1. 语境里是否已有保护(外层判空、上游校验、框架机制)?
2. 是真缺陷还是风格偏好?偏好 → 降级 🔵 nit 或丢弃。
3. 引用的 API/签名/语言行为是否真实存在?(不确定则不报)
4. 是否与另一视角重复?
**置信度门控**`confidence=low` 且 `severity≠high` → 丢弃。
### ⑤ 综合
跨视角去重 → 按 `严重度×likelihood` 排序 → 封顶 `--max-findings`
### ⑥ 预览(默认)
展示报告卡给用户;确认后发布。`--auto` 跳过。
### ⑦ 发布
```bash
gitlink-cli pr +comment --owner <owner> --repo <repo> --id <N> \
--body "<报告卡全文含哨兵>"
```
`--inline` 且 reviews API 可用 → 对 🔴/🟡 发现附行内评论(见 REFERENCE.md 的 API 结论)。
### ⑧ 幂等
发布前检查该 PR 是否已有哨兵 `<!-- gitlink-review v1 ... -->`;有则默认提议"更新"`--refresh` 才覆盖)。
## 评审团 6 视角
| 视角 | 盯什么 |
|------|--------|
| 🔴 正确性 (correctness) | 逻辑错、边界、空值、并发竞态、资源泄漏、错误处理、类型转换 |
| 🔒 安全 (security) | 注入SQL/命令/XSS、鉴权越权、密钥/Token 泄露、路径穿越、不安全反序列化、弱加密 |
| ⚡ 性能 (performance) | N+1 查询、无谓拷贝、O(n²)/嵌套循环、热路径分配、缺索引 |
| 🧪 测试 (tests) | 新增/改动代码有无测试、边界用例、断言有效性 |
| 🧹 可维护性 (maintainability) | 命名、重复、圈复杂度、抽象边界、与既有约定一致 |
| 🚨 生产风险 (prod-risk) | "合并后凌晨3点哪会炸"——可观测性/日志、回滚、破坏性变更、配置依赖、降级 |
> 各视角的展开检查清单见 [`REFERENCE.md`](REFERENCE.md)。
## 报告卡格式(作为评论发布)
```markdown
🤖 **gitlink-review 报告**
**结论**<总体LGTM / ️建议修改 / 🛑有阻塞>A 🔴阻塞 / B 🟡建议 / C 🔵nit
**范围**<X> 文件,+<A>/-<D>(跳过 <Y> 噪音文件)|视角:<L>|自检丢弃:<R>
| 严重度 | 视角 | 位置 | 问题 |
|:---:|---|---|---|
| 🔴 | correctness | path/file.go:42 | <一句话> |
### 🔴 阻塞(合并前需处理)
1. **[correctness] path/file.go:42** — <what>
- **为什么**<why>
- **建议**<fix>
### 🟡 建议
### 🔵 nit
### ✅ 未发现问题的方面
- <视角><结论>
---
<!-- gitlink-review v1 | pr:<N> | lenses:<L> | refuted:<R> | sha:<head-sha> -->
*由 gitlink-review skill 生成。*
```
## 降噪规则(自动跳过)
生成代码(`*.gen.go`/`*.pb.go`/`*_generated.*`/`*.min.js`/dist//build//target/、第三方vendor//third_party//node_modules/、锁文件go.sum/package-lock.json/yarn.lock/pnpm-lock.yaml/Cargo.lock、纯重命名/移动、二进制/图片/字体。
## 安全护栏
- 默认预览确认;`--auto` 跳过但仍受置信度门控
- **绝不自动 approve / merge**
- 行内评论仅 `--inline` 且 reviews API 可用时发
- `--max-findings` 封顶防刷屏
- 评论失败 → 把报告卡原文交用户手动粘贴
## 错误处理与降级
| 情况 | 处理 |
|------|------|
| 无 diff | 报"无可审查变更",不发评论 |
| PR 不存在/无权限 | 清晰错误,不发评论 |
| reviews API 404 | 跳过行内,总结评论照发 |
| diff 过大 | 抽样 + 标注"部分审查(仅 X/Y 文件)" |
| 评论发布失败 | 输出报告卡原文供手动粘贴 |
| 全部 low 置信 | 报"未发现高置信问题",列待人工确认项 |
## 最佳实践
- **先预览再发布**(默认),降低对外噪音
- **置信度优先**:宁可少报,不要误报
- **可执行**:每条发现必须带"建议修复"
- **不越权**:只评论,不合并
````
- [ ] **Step 3提交**
```bash
git add skills/gitlink-review/SKILL.md
git commit -m "feat(review): 新增 gitlink-review SKILL.md管道+6视角+对抗自检+报告卡)"
```
---
## Task 5写 REFERENCE.md清单 + 评级 + API 结论)
**Files:**
- Create: `skills/gitlink-review/REFERENCE.md`
- [ ] **Step 1读 Task 3 的探测结论**
```bash
cat review_api_probe.txt
```
`<REVIEWS_API>``<REVIEWS_NOTES>`(下文占位)替换为真实结论。
- [ ] **Step 2写 REFERENCE.md**
创建 `skills/gitlink-review/REFERENCE.md`,完整内容(**把 `<REVIEWS_API>` 等占位替换为 Task 3 实测结果**
````markdown
# gitlink-review 参考手册
> 各视角检查清单、严重度评级、降噪规则、reviews API 实测结论、字段映射。
---
## 一、各视角检查清单
### 🔴 正确性 (correctness)
- 空值/nil 解引用未判
- 边界off-by-one、数组越界、空集合
- 错误未处理或被吞(`err != nil` 缺失、`_ = err`
- 并发:数据竞态、缺锁、死锁
- 资源泄漏:文件/连接/goroutine 未关闭或回收
- 类型转换/断言未检查
- 逻辑分支遗漏、return 路径不全
### 🔒 安全 (security)
- 注入SQL 拼接、命令、XSS、模板未转义
- 鉴权/越权缺权限校验、IDOR
- 密钥/Token 硬编码或写入日志
- 路径穿越(`../`、用户输入拼路径)
- 不安全反序列化、弱加密/弱随机
- 危险默认值debug 开关、CORS *
### ⚡ 性能 (performance)
- N+1 查询、循环内 IO/查询
- 无谓拷贝大对象、字符串反复拼接
- O(n²)/深层嵌套循环
- 热路径分配、缺缓存
- 缺索引/全表扫描
### 🧪 测试 (tests)
- 新增/改动函数有无对应测试
- 边界与异常用例覆盖
- 断言是否有效(非 `assert true`
- mock 是否合理、是否过度
### 🧹 可维护性 (maintainability)
- 命名是否达意
- 重复代码DRY
- 圈复杂度过高、函数过长
- 抽象边界模糊、职责混杂
- 与既有代码风格/约定不一致
### 🚨 生产风险 (prod-risk)
- 破坏性变更API/DB schema/配置格式)
- 可观测性:关键路径有无日志/指标
- 回滚能力:是否可安全回退
- 配置/环境依赖、启动顺序
- 降级与限流缺失
---
## 二、严重度 × likelihood 评级
| severity | 含义 | 处理 |
|----------|------|------|
| 🔴 high | 阻塞:会导致 bug/安全问题/线上故障 | 合并前需处理 |
| 🟡 medium | 建议:应修复但不强制阻塞 | 建议处理 |
| 🔵 low | nit风格/可读性 | 可选 |
| likelihood | 含义 |
|------------|------|
| likely | 真实路径上会发生 |
| possible | 特定条件下发生 |
| unlikely | 罕见但可能 |
排序权重:`high×likely` > `high×possible` > `medium×likely` > …。
置信度 `confidence`high/medium/low。**门控**`confidence=low && severity≠high → 丢弃`。
---
## 三、降噪规则
跳过:`*.gen.go`、`*.pb.go`、`*_generated.*`、`*.min.js`、`dist/`、`build/`、`target/`、`vendor/`、`third_party/`、`node_modules/`、`go.sum`、`package-lock.json`、`yarn.lock`、`pnpm-lock.yaml`、`Cargo.lock`、纯重命名/移动、二进制/图片/字体。跳过时在报告卡"范围"行计数。
---
## 四、reviews API 实测结论(决定行内评论)
**实测命令**(于测试 PR `<PRN>`
```bash
gitlink-cli api POST /<owner>/<repo>/pulls/<N>/reviews \
--body '{"body":"...","event":"COMMENT"}'
```
**结论**<REVIEWS_API>(可用 / 不可用)
<REVIEWS_NOTES>(实测观察到的响应或错误)
**设计影响**
- 若 **不可用**(如返回 404 / git exec-path 注入 URL→ 行内评论关闭,仅用 `pr +comment` 发总结评论。`--inline` 被忽略并提示原因。
- 若 **可用**`--inline` 时对 🔴/🟡 发现调用上述 API 发行内评论;请求体按实测支持的格式(是否含 `line/path/side`)构造。
---
## 五、字段映射
### pr +view 关键字段
| 字段 | 用途 |
|------|------|
| `issue.subject` / `issue.description` | 理解 PR 意图,校准审查重点 |
| `pull_request.base` / `pull_request.head` | 目标/源分支 |
| `author.login` | 作者(自审盲区提示) |
| `files_count` / `commits_count` | 范围概览 |
### pr +diff 关键字段
| 字段 | 用途 |
|------|------|
| `files[].name` | 文件路径 |
| `files[].addition` / `deletion` | 增删行数 |
| `files[].sha` | 文件 sha哨兵可选 |
| diff 正文 | 分析输入 |
**行号映射规则**:报告卡里的 `file:line` 用**文件中的实际行号**(非 diff hunks 的 `+n` 相对行号)。从 diff hunk 头(`@@ -a,b +c,d @@`)推算:实际行号 = `c + (hunk 内相对行)`
---
## 六、幂等哨兵
```
<!-- gitlink-review v1 | pr:<N> | lenses:<L> | refuted:<R> | sha:<head-sha> -->
```
- 发布前用 `pr +view`(或取评论)检查是否已含该哨兵
- `sha` = 审查所基于的 head commitPR 有新提交时提示"审查已过期,建议重审"
- 默认不覆盖;`--refresh` 才重发
````
- [ ] **Step 3提交**
```bash
git add skills/gitlink-review/REFERENCE.md
git commit -m "docs(review): 新增 REFERENCE.md视角清单+评级+API实测结论+字段映射)"
```
---
## Task 6端到端验证——对测试 PR 跑审查
**Files:** 无(验证 + 捕获输出)
- [ ] **Step 1按 SKILL.md 对 `<PRN>` 执行审查**
人工/agent 依 SKILL.md 管道执行:取 view/files/diff → 降噪 → 6 视角 → 对抗自检 → 综合 → 生成报告卡。把生成的报告卡全文存到本地 `review_output_<PRN>.md`
```bash
gitlink-cli pr +view --owner chroe --repo gitlink-cli --id <PRN> --format json
gitlink-cli pr +files --owner chroe --repo gitlink-cli --id <PRN> --format json
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json
```
- [ ] **Step 2验证 4 个植入点**
断言(必须全部满足,否则回改 SKILL.md/REFERENCE.md 后重跑):
| # | 植入点 | 期望 | 校验 |
|---|--------|------|------|
| 1 | `Greet` 空指针 | 🔴 correctness 命中,含 `review_fixture.go:<行>``u.Name` | 报告卡中能找到 |
| 2 | `AdminToken` 硬编码 | 🔒 security 命中 | 报告卡中能找到 |
| 3 | `Welcome` 缺测试 | 🧪 tests 命中(无 _test.go | 报告卡中能找到 |
| 4 | `Divide` 除零DoMath 已保护) | **被对抗自检丢弃**`refuted` 计数 ≥1**不应**出现在发现列表 | 报告卡中无此项 |
- [ ] **Step 3发布到测试 PR 并验证哨兵**
```bash
gitlink-cli pr +comment --owner chroe --repo gitlink-cli --id <PRN> \
--body "$(cat review_output_<PRN>.md)" --format json
```
预期:`ok: true`。再 `pr +view` 确认 `comments_count` 增加。
- [ ] **Step 4若验证不通过回改并重跑**
任何断言失败 → 修正 SKILL.md 的检查清单/自检规则 → 重新执行 Step 1-3直到 4 项全过。
---
## Task 7写 examples/review-workflow.md真实走查
**Files:**
- Create: `skills/gitlink-review/examples/review-workflow.md`
- [ ] **Step 1基于 Task 6 的真实输出写示例**
创建 `skills/gitlink-review/examples/review-workflow.md`
````markdown
# 示例:对植入缺陷 PR 的智能审查(真实数据)
> 基于 `chroe/gitlink-cli` 测试 PR #<PRN>`test/review-fixture`)于 2026-06-18 实际执行。
> 该 PR 故意植入 4 个问题用于验证 gitlink-review skill。
---
## Step 1取上下文
```bash
gitlink-cli pr +view --owner chroe --repo gitlink-cli --id <PRN> --format json
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json
```
**范围**1 文件 `review_fixture.go`+38/-0无噪音文件需跳过。
## Step 26 视角扫描 + 对抗自检(关键过程)
| 候选发现 | 视角 | 自检结果 |
|----------|------|----------|
| `Greet``u.Name` 解引用 nil | correctness | ✅ 成立(无外层判空)→ 保留 🔴 |
| `AdminToken` 硬编码 | security | ✅ 成立 → 保留 🔴 |
| `Welcome` 无对应测试 | tests | ✅ 成立(本 PR 无 _test.go→ 保留 🟡 |
| `Divide(a,b)` 除零 | correctness | ❌ **被反驳**`DoMath` 已保证 `x≠0`,真实调用路径除数非 0 → 丢弃 |
**自检丢弃1 条refuted=1**。
## Step 3发布报告卡真实输出
```bash
gitlink-cli pr +comment --owner chroe --repo gitlink-cli --id <PRN> \
--body "<报告卡全文>"
```
<此处粘贴 Task 6 生成的真实报告卡全文>
**发布结果**`ok: true`PR 评论数 +1。
## Step 4幂等验证
再次运行(不带 `--refresh`)→ 检测到哨兵 `<!-- gitlink-review v1 | pr:<PRN> | ... -->` → 提示"已存在审查,使用 --refresh 更新",未重复发布。
## 关键结论
- 3 个真实缺陷被对应视角命中1 个伪阳性被对抗自检丢弃refuted=1
- 行内评论:<根据 Task 3 结论写"可用已附行内" "API 不可用仅总结评论">
````
- [ ] **Step 2提交**
```bash
git add skills/gitlink-review/examples/review-workflow.md
git commit -m "docs(review): 新增真实走查示例 review-workflow.md"
```
---
## Task 8更新 README 与 workflow 链接
**Files:**
- Modify: `skills/README.md`
- Modify: `skills/gitlink-workflow/SKILL.md`
- [ ] **Step 1README 智能 skill 表加一行**
`skills/README.md` 的"智能 Skills"表追加:
```markdown
| **gitlink-review** | 智能代码审查 | 分析 PR diff多视角评审 + 对抗式自检,结构化 Review 意见自动评论 |
```
并在文档导航/按需处补一行指向 `gitlink-review/SKILL.md`
- [ ] **Step 2workflow 的浅层 Code Review 链回本 skill**
`skills/gitlink-workflow/SKILL.md` 的"AI 在 PR 流程中的角色 → Code Review"处补一句:
```markdown
> 深度代码审查请使用 [`../gitlink-review/SKILL.md`](../gitlink-review/SKILL.md)(多视角 + 对抗式自检 + 自动评论)。
```
- [ ] **Step 3提交**
```bash
git add skills/README.md skills/gitlink-workflow/SKILL.md
git commit -m "docs(review): README 智能表登记 gitlink-reviewworkflow 链回深度审查"
```
---
## Task 9清理测试 PR + 推送特性分支 + 建 PR
**Files:** 无
- [ ] **Step 1关闭测试 PR不合并**
```bash
gitlink-cli pr +close --owner chroe --repo gitlink-cli --id <PRN>
```
预期:`ok: true`PR 状态变 closed未合并植入缺陷不进 master
- [ ] **Step 2删本地/远端测试分支**
```bash
git branch -D test/review-fixture
git push origin --delete test/review-fixture
```
- [ ] **Step 3清理本地临时文件**
```bash
rm -f review_fixture_notes.txt review_api_probe.txt review_output_*.md
```
- [ ] **Step 4推送特性分支**
```bash
git checkout feat/gitlink-review-skill
git push origin feat/gitlink-review-skill
```
- [ ] **Step 5创建合并到 master 的 PR**
```bash
gitlink-cli pr +create \
--owner chroe --repo gitlink-cli \
--head feat/gitlink-review-skill --base master \
--title "feat: 新增 gitlink-review 智能代码审查 Skill" \
--body "## 目的
子任务二核心场景:智能代码审查(分析 PR diff多视角+对抗自检,结构化 Review 自动评论)。
## 交付
- skills/gitlink-review/SKILL.md + REFERENCE.md + examples/review-workflow.md
- skills/README.md、skills/gitlink-workflow/SKILL.md登记与链接
- docs/superpowers/:设计 spec + 实现计划
## 验证(真实数据)
植入缺陷测试 PR 已验证3 个真实缺陷被对应视角命中1 个伪阳性被对抗自检丢弃refuted=1哨兵幂等pr +comment 发布成功。reviews API< Task 3 结论>
## 设计要点
- 多视角评审团(正确性/安全/性能/测试/可维护/生产风险)
- 对抗式自检质量门(直击误报)
- 默认预览确认,绝不自动合并" \
--format json
```
- [ ] **Step 6记录 PR 号并通知用户**
把返回的 `pull_request_number` 报告给用户等待其合并master 受保护)。
---
## 验收(全部满足才算完成)
- [ ] `skills/gitlink-review/` 三件套齐全,结构与 triage/health 一致
- [ ] Task 6 四个植入点断言全过3 命中 + 1 丢弃)
- [ ] REFERENCE.md 的 reviews API 结论为实测结果(非猜测)
- [ ] 报告卡含哨兵,幂等生效
- [ ] README 与 workflow 链接已更新
- [ ] 测试 PR 已关闭未合并,测试分支已删
- [ ] 特性分支已推送PR 已创建

View File

@ -0,0 +1,638 @@
# gitlink-research-fair Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a `gitlink-research-fair` Claude Code Skill that turns any GitLink research repo into a FAIR 体检报告卡 + auto-remediation PR + reproducibility certificate.
**Architecture:** Methodology-type Skill (pure Markdown + gitlink-cli calls, no Go). Report-card-centric: M1 FAIR Checker (5-axis rubric, reuses gitlink-license for license/security) + M2 KG + M3 OpenAlex provenance + M4 Remediation. Validated end-to-end on `songhui18/ICCV2021论文复现`.
**Tech Stack:** Markdown (SKILL.md/REFERENCE.md/examples), gitlink-cli (Go CLI, source build), Mermaid (KG viz), OpenAlex REST (optional enrichment).
**Source of truth:** `docs/superpowers/specs/2026-07-01-gitlink-research-fair-design.md` (read it first).
---
## Environment & Gotchas (engineer must know)
- **Branch:** already on `feat/gitlink-research-fair` (master is protected — never push master). Commit only your own files; leave pre-existing `D README_TASKB.md` / `D gitlink-cli.exe` / `?? dist/` untouched.
- **Binary:** use the go/bin source build. Verify: `gitlink-cli version``gitlink-cli dev`, and `gitlink-cli search --help` shows `+repos`. If commands like `file`/`member` are missing, run `go build -o gitlink-cli.exe .` in the repo root and use `./gitlink-cli.exe`.
- **Encoding (Windows):** gitlink-cli JSON contains Chinese. When parsing with Python, always set `PYTHONUTF8=1 PYTHONIOENCODING=utf-8`. Never inline Chinese inside `python -c "..."` on this shell (GBK-mangles it) — write a `.py` file instead.
- **Known GitLink CLI bug:** `file +tree --recursive true` returns empty on some repos. Workaround: `file +tree` top-level → `file +get --path <subdir>` recursively, or use `file +list`.
- **All examples use `--owner songhui18 --repo ICCV2021论文复现`** unless noted. 处方 (open PR) needs write access → **fork first** (`gitlink-cli repo +fork`); if fork/push fails, fall back to `--no-fork` (write materials to local files).
- **Verification commands** use `grep` (available in Git Bash). Expected outputs are illustrative; the check is "contains the key string".
---
## File Structure
| File | Responsibility |
|------|----------------|
| `skills/gitlink-research-fair/SKILL.md` | Entry doc: frontmatter, CRITICAL headers, command interface, 8-step pipeline, 5-axis rubric, report card template, remediation guardrails, certificate, KG/OpenAlex, error table |
| `skills/gitlink-research-fair/REFERENCE.md` | Deep reference: FAIR4RS per-principle mapping, scoring algorithm, KG schema (triples), OpenAlex fields, SWH-ID notes, **academic citation library**, honesty caveats (incl. 2 banned claims), live-probe findings |
| `skills/gitlink-research-fair/examples/research-fair-workflow.md` | Real walkthrough on songhui18: report card + remediation PR + certificate, with contrast run on a licensed repo |
| `skills/gitlink-research-fair/examples/img/` | Screenshots (GitLink web: opened PR) — optional but recommended for deliverable |
| `skills/README.md` | Add gitlink-research-fair row (科研辅助类) |
| `skills/gitlink-workflow/SKILL.md` | Optional cross-link |
---
## Task 1: Scaffold SKILL.md (frontmatter + CRITICAL headers)
**Files:**
- Create: `skills/gitlink-research-fair/SKILL.md`
- [ ] **Step 1: Create SKILL.md with frontmatter and CRITICAL headers**
Content (exact):
```markdown
---
name: gitlink-research-fair
version: 1.0.0
description: "科研软件 FAIR 体检:分析 GitLink 科研仓库的可发现/可访问/可互操作/可复用/可复现性,输出体检报告卡,自动开 PR 修复缺口并签发 SWH/commit 锚定的可复现证书。当用户需要评估科研仓库的 FAIR 性与可复现性、生成科研软件体检报告时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli repo --help"
---
# gitlink-research-fair科研软件 FAIR 体检)
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有写操作(开修复 PR默认先预览、确认后再执行`--auto` 跳过预览但仍受护栏约束。**
**CRITICAL — 绝不自动 merge绝不 force-push处方只对 fork 开 PR绝不碰原仓库。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)详细检查清单、评分算法、KG schema 与学术引用见 [`REFERENCE.md`](REFERENCE.md)。
```
- [ ] **Step 2: Verify frontmatter + CRITICAL lines**
Run: `head -20 skills/gitlink-research-fair/SKILL.md | grep -c -e "name: gitlink-research-fair" -e "description:" -e "CRITICAL"`
Expected: `6` (1 `name:` + 1 `description:` + 4 `CRITICAL` lines). If `<6`, fix.
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-research-fair/SKILL.md
git commit -m "feat(research-fair): scaffold SKILL.mdfrontmatter + CRITICAL 头)"
```
---
## Task 2: SKILL.md — 命令接口 + 8 步管道
**Files:**
- Modify: `skills/gitlink-research-fair/SKILL.md` (append after the 前置条件 line)
- [ ] **Step 1: Append 概述 + 命令接口 + 8 步管道 sections**
Append (exact content):
```markdown
## 概述
本 Skill 引导 AI 对任意 GitLink 科研仓库做结构化、有学术标准依据、低误报的"FAIR + 可复现性"诊断,产出**体检报告卡**,并对缺口**自动开 PR 修复**、用 **SWH/commit 锚定**签发**可复现证书**。许可证/安全子项**复用 `gitlink-license`**。四幕剧本v1诊断 → 处方 → 证书。
## 命令接口skill 约定参数,非 CLI flag
| 参数 | 默认 | 说明 |
|------|------|------|
| `--owner/--repo` | 自动从 cwd 解析 | 目标科研仓库 |
| `--lenses` | 全 5 轴 | 子集,如 `F,A,Repro` |
| `--auto` | 关 | 跳过预览直接开 PR仍受护栏 |
| `--no-fork` | 关 | 不开 PR处方物料落本地 |
| `--enrich` | 开 | 启用 OpenAlex 溯源(限流自动降级) |
| `--refresh` | 关 | 即使有旧报告哨兵也重评 |
## 管道8 步)
### ① 取上下文
`repo +info`(元数据/license/topics/has_dataset· `file +list`(关键文件清单)
### ② 深采
`file +get` 读 LICENSE/CITATION.cff/codemeta.json/README/requirements/Dockerfile · `release +list`(版本)· `commit +list`SHA· `issue/pr +list`(协作)· `member +list`(作者)
### ③ FAIR 评分M1
逐项评 5 轴(许可证/安全子项委托 `gitlink-license` 流程)→ `✓/✗/⚠/⊥` + 证据 + 置信度
### ④ KG 构建M2
四实体(Repo/Contributor/File/Commit)+Paper/Dataset/License 节点 → Mermaid 小图 + triples JSON
### ⑤ 溯源富集M3
从 README/CITATION 抽论文 → OpenAlex REST 反查作者/机构best-effort
### ⑥ 渲染报告卡hero
等级 + 雷达图 + 5 轴逐项表 + 处方摘要 + 证书栏 + KG 小图 + 溯源栏
### ⑦ 处方M4
`✗/⚠` 项生成 CITATION.cff/codemeta.json/LICENSE/复现章节 → 默认预览
### ⑧ 发布
确认后对 fork 开 PRPR body 带报告卡摘要)+ 出 SWH/commit 证书;内嵌哨兵
```
- [ ] **Step 2: Verify the pipeline + interface landed**
Run: `grep -c -e "管道8 步)" -e "命令接口" -e "### ⑧ 发布" skills/gitlink-research-fair/SKILL.md`
Expected: `3`
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-research-fair/SKILL.md
git commit -m "feat(research-fair): SKILL 命令接口 + 8 步管道"
```
---
## Task 3: SKILL.md — 5 轴 rubric 表
**Files:**
- Modify: `skills/gitlink-research-fair/SKILL.md` (append after 管道)
- [ ] **Step 1: Append the 5-axis rubric**
Append (exact):
```markdown
## 5 轴评分 Rubric
> 对标 **FAIR4RSNature Sci Data 2022+ howfairis 5 维**。诚实声明:这是**适配版**评分(社区尚无认证级自动校验器),不是官方认证。**FAIR ≠ 可复现**,故 Repro 单独成轴。逐条映射与证据见 [`REFERENCE.md`](REFERENCE.md)。
每项判定 `✓满足 / ✗缺失 / ⚠部分 / ⊥GitLink 无该字段(跳过并标注)`,附证据来源 + 置信度。
| 轴 | FAIR4RS 映射 | 检查项(证据) |
|----|--------------|----------------|
| **F 可发现** | F1/F2 | F1 清晰描述(repo desc ≥20 字) · F2 话题/关键词(topics) · F3 标准元数据(codemeta.json/.zenodo.json) |
| **A 可访问** | F1.2/R1.1 | A1 公开(is_public) · A2 有许可证(license_id/LICENSE)[gitlink-license] · A3 有版本发布(release tag) · A4 持久标识(commit SHA/SWH-ID) |
| **I 可互操作** | R1.1/I | I1 依赖清单(requirements/go.mod/package.json/environment.yml) · I2 许可证机器可读(SPDX/license_id) · I3 元数据标准格式(CITATION.cff/codemeta) |
| **R 可复用** | R1.2/R2 | R1 溯源/引用(CITATION.cff/README 引用) · R2 README 质量(安装+使用) · R3 源码声明[gitlink-license] · R4 无敏感泄露[gitlink-license] |
| **Repro 可复现** ⭐差异轴 | (独立于 FAIR | Rep1 环境锁(锁版本/Dockerfile) · Rep2 数据集说明(has_dataset/README 引用) · Rep3 复现步骤(README 章节) · Rep4 版本固定 · Rep5 入口可执行(main/Makefile/CLI) |
**评分**:轴分 = 满足项 / (总项 ⊥项)(⚠ 计 0.5);总评 = 5 轴均分(某轴全 ⊥ 则记 N/A 并排除);等级 **A🟢≥80 / B🟡60-79 / C🔴<60**。`confidence=low` 标"待人工确认",不进总评。
```
- [ ] **Step 2: Verify all 5 axes + scoring present**
Run: `grep -c -e "F 可发现" -e "A 可访问" -e "I 可互操作" -e "R 可复用" -e "Repro 可复现" -e "A🟢≥80" skills/gitlink-research-fair/SKILL.md`
Expected: `6`
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-research-fair/SKILL.md
git commit -m "feat(research-fair): SKILL 5 轴 FAIR rubric 表"
```
---
## Task 4: SKILL.md — 报告卡模板 + 处方护栏 + 证书 + KG/OpenAlex + 错误降级
**Files:**
- Modify: `skills/gitlink-research-fair/SKILL.md` (append after rubric)
- [ ] **Step 1: Append report card template**
Append (exact):
```markdown
## 报告卡格式hero 产出)
\`\`\`markdown
🏥 **gitlink-research-fair 科研软件体检报告**
**总体**<等级> <>/100F · A · I · R · Repro版本锚定commit <sha><release >
**范围**:检查 19 项✓N · ✗N · ⚠N · ⊥N跳过<原因>
### 五轴雷达
ASCII 或 Mermaid 雷达F/A/I/R/Repro
### 逐项
| 轴 | 项 | 状态 | 证据 |
|---|---|:---:|---|
| <> | <id 项名> | ✓/✗/⚠/⊥ | <gitlink-cli 证据> |
### 🔧 处方(可自动修复 N 项)
- [<><id>] <生成物>(从 <来源> 推断)
### 📜 可复现证书
锚定版本git+commit <sha> FAIR: <等级> Repro: <状态>
(完整 SWH-ID 需 swh-identify本次用 commit-SHA 锚定)
### 🔗 溯源OpenAlex
<作者/机构/载体 "未找到关联论文已跳过">
### 🧬 科研关系图KG
\`\`\`mermaid
<RepocontributesContributor · Repolicensed?? · RepohasDataset?>
\`\`\`
---
<!-- gitlink-research-fair v1 | repo:<owner>/<repo> | grade:<G> | sha:<head-sha> -->
*由 gitlink-research-fair skill 生成。*
\`\`\`
哨兵 `<!-- gitlink-research-fair v1 | repo | grade | sha -->` 用于幂等与"基于哪个 commit"标识。重跑检测旧哨兵 → 默认提议更新(`--refresh` 才覆盖);`sha` 不匹配 → 提示"报告已过期,建议重评"。
```
- [ ] **Step 2: Append 处方 + 护栏 + 证书 + KG/OpenAlex + 错误降级**
Append (exact):
```markdown
## 处方M4惊艳闭环
| 缺口 | 生成物 |
|------|--------|
| F3 缺 codemeta.json | 从 repo +info + README 生成 codemeta.json 草稿 |
| R1 缺 CITATION.cff | 从 README/作者生成 CITATION.cff 草稿 |
| A2 缺 license | 给 MIT / 木兰 PSL v2 模板(任选) |
| Rep1 缺依赖锁 | 从 import 扫描建议 requirements.txt |
| Rep3 缺复现章节 | 从入口/README 生成"复现"章节草稿 |
**安全护栏**:默认全量预览 → 确认 → 对 **fork** 开一个 PRPR body 带报告卡摘要);`--auto` 跳过预览但**永不 force-push、永不碰原仓库、永不自动 merge**`--no-fork` 物料落本地。所有写操作需认证。
## 证书SWH/commit 锚定)
用 HEAD commit SHA + release tag 锚定版本。若环境有 Python `swh.model` → 算完整 SWH-ID否则 `git+<commit-SHA>` 锚定,报告卡标注"完整 SWH-ID 需 swh-identify"。证书含仓库、锚定版本、FAIR 等级、Repro 状态、可引用条目(对标 ACM Artifact Badge。嵌报告卡证书栏。
## KG + OpenAlex支撑栏
- **KG**:四实体(Repo/Contributor/File/Commit)+Paper/Dataset/License关系 contributes-to/authored/depends-on/cites/licensed-under/version-at。输出 Mermaid 小图 + triples JSONschema 见 REFERENCE
- **OpenAlex**README/CITATION 抽论文 → REST `/works` 反查作者/机构/载体。降级:限流或抽不到 → 跳过,标注"未找到关联论文"。
## 错误处理与降级
| 情况 | 处理 |
|------|------|
| `file +tree --recursive` 返回空 | 手动递归子目录 |
| 无 LICENSE | A2 ✗,处方建议模板 |
| OpenAlex 限流/无果 | 跳过溯源栏标注 |
| `swh.model` 不可用 | commit-SHA 锚定 + 标注 |
| 仓库过大 | 抽样文件 + "部分审查"标注 |
| fork 失败/无写权限 | 处方物料落本地(`--no-fork` |
| 二进制是 npm 旧版 | 强制 `./gitlink-cli``go build` 重建 |
| 仓库私有/无权限 | 清晰错误,不发报告 |
| 报告/PR 发布失败 | 报告卡原文交用户手动粘贴 |
```
- [ ] **Step 3: Verify sentinel + key sections**
Run: `grep -c -e "gitlink-research-fair v1 | repo" -e "## 处方" -e "## 证书" -e "swh.model" skills/gitlink-research-fair/SKILL.md`
Expected: `4`
- [ ] **Step 4: Commit**
```bash
git add skills/gitlink-research-fair/SKILL.md
git commit -m "feat(research-fair): SKILL 报告卡模板+处方护栏+证书+KG/OpenAlex+错误降级"
```
---
## Task 5: REFERENCE.md — FAIR4RS 映射 + 评分算法 + KG schema + 引用库 + 诚实边界
**Files:**
- Create: `skills/gitlink-research-fair/REFERENCE.md`
- [ ] **Step 1: Create REFERENCE.md with all deep-reference sections**
Content (exact):
```markdown
# gitlink-research-fair 参考文档
> 本文件为 SKILL.md 的深度参考。评分细则、KG schema、OpenAlex/SWH 技术细节、学术引用库与诚实边界均在此。
## 一、FAIR4RS 逐条映射
| 原则 | 含义 | 本 Skill 检查项 | GitLink 证据 | 兜底(无字段时) |
|------|------|----------------|--------------|------------------|
| F1 | 全局唯一持久标识 | A4 持久标识 | repo identifier/URL + commit SHA | commit-SHA 锚定 |
| F1.2 | 版本级唯一标识 | A3 有版本发布 | `release +list` | ⊥(无 release 则 ✗) |
| F2 | 富元数据 | F1 描述/F2 topics/F3 codemeta | `repo +info` desc/topics`file +get` codemeta.json | README 抽取(标注推断) |
| R1.1 | 清晰可访问许可证 | A2/I2 | `repo +info` license_idLICENSE 文件 | gitlink-license 识别 |
| R1.2 | 详细溯源 | R1 | CITATION.cff/README 引用 | LLM 从 README 推断(标注) |
| R2 | 对其他软件的限定引用 | R3 | 源码 SPDX/依赖清单 | gitlink-license 维度4 |
## 二、评分算法
- 每项状态:`✓`(1) / `⚠`(0.5) / `✗`(0) / `⊥`(跳过,不计入分母)
- 轴分 = Σ状态值 / (轴内项数 ⊥项数) × 100
- 总评 = 各轴分均值(全 ⊥ 轴记 N/A 排除)
- 等级A🟢≥80 / B🟡60-79 / C🔴<60
- `confidence=low`:标注"待人工确认",不计入分子分母
## 三、可复现性 checklist独立于 FAIR
Rep1 环境锁requirements 锁版本/Dockerfile/environment.yml· Rep2 数据集说明has_dataset/README 引用/下载脚本)· Rep3 复现步骤README "运行/复现/Quick Start" 章节)· Rep4 版本固定release tag 或 commit 锚定)· Rep5 入口可执行main.py/train.py/Makefile/CLI
## 四、KG schematriples
实体Repo, Contributor, File, Commit, Paper, Dataset, License
关系(主谓宾三元组):
- (Contributor)—contributes-to→(Repo)
- (Contributor)—authored→(Paper)
- (Repo)—depends-on→(File/依赖)
- (Paper)—cites→(Paper)
- (Repo)—licensed-under→(License)
- (Repo)—version-at→(Commit)
- (Repo)—has→(Dataset)
输出:`triples.json`(数组 of {s,p,o}+ Mermaid `graph LR` 小图。
## 五、OpenAlex 字段
- 端点:`https://api.openalex.org/works?search=<title>` 2025-02 起 freemium建议带 `mailto` 参数走 polite pool
- 取字段:`authorships[].author.display_name`、`authorships[].institutions[].display_name`、`host_venue.display_name`(或 `primary_location.source.display_name`)、`concepts[].display_name`、`doi`
- 降级HTTP 429/无结果 → 报告卡溯源栏标"未找到关联论文"
## 六、SWH-ID 说明
- 完整 SWH-ID = `swh:1:dir:<hash>``swh:1:rev:<hash>`,基于 Merkle DAG`swh.model`Python计算或 `swh-identify`
- 环境无 `swh.model` 时:用 `git+<commit-SHA>` 作版本锚定,报告卡标注"完整 SWH-ID 需 swh-identify"
- commit SHA 来自 `commit +list`HEAD
## 七、学术引用库("信服"骨架,每条对抗式核验过)
| 支撑点 | 文献 |
|--------|------|
| 科研软件 FAIR 原则 F1/F1.2/F2/R1.1/R1.2/R2 可机器校验 | Barker et al. 2022, *Nature Scientific Data*, https://www.nature.com/articles/s41597-022-01710-x |
| 可复现性危机2024 ICLR/ICML/NeurIPS 仅 19.5% 提供官方代码 | PaperCoder, arXiv:2504.17192 (Table 9) |
| 结构化对比表/报告卡范式 | ORKG, Jaradeh et al. K-CAP 2019 |
| 仓库→RDF 知识图谱 schema13 实体/47 关系/794 万三元组) | LPWC, ISWC 2023 |
| SWH-ID 版本锚定Merkle DAG, git 兼容) | Di Cosmo et al. ICMS 2020, PMC7340894 |
| 作者/机构溯源用 OpenAlex REST | Priem et al. 2022, arXiv:2205.01833 |
| 仓库级 KG 问答(四实体+SZZ+Cypher, CoT 50%→90% | Repo-KG, arXiv:2412.03815 |
| 科研软件可复现徽章体系 | ACM Artifact Review Badging, https://www.acm.org/publications/policies/artifact-review-badging |
| 直接竞品 howfairis5 维,仅 GitHub | https://github.com/fair-software/howfairis |
| 战略时机PWC 不稳定 | TIB 博客 2025-10 "Papers With Code went offline"单一二手源pitch 前复核) |
## 八、诚实边界(不可过度宣称)
1. FAIR4RS 自称 **aspirational**;本 Skill 是**自建 checker**(社区尚无认证级校验器),不说"套用现成标准工具"。
2. **FAIRness ≠ 可复现性**FAIR 必要非充分)—— Repro 单独成轴。
3. OpenAlex 自 2025-02 起 freemium~$1/day、需 key、100 req/s—— 单仓演示够,批量控量。
4. **禁止使用两条已证伪论点**:①"MSR 六分类法"、②"FAIR 分高→被引更多"因果。
5. 文献多跑在 GitHub"在 GitLink 上复刻"是合理外推,须真机跑通闭环。
## 九、实测结论live probeTask 6 回填)
> 此节由 Task 6 在 `songhui18/ICCV2021论文复现` 真机验证后回填license 状态、README/requirements/releases/commit SHA、各轴实测分、fork→PR 链路是否可用、OpenAlex 是否命中。
```
- [ ] **Step 2: Verify citations + banned claims + sections**
Run: `grep -c -e "s41597-022-01710-x" -e "2504.17192" -e "禁止使用两条已证伪" -e "## 九、实测结论" skills/gitlink-research-fair/REFERENCE.md`
Expected: `4`
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-research-fair/REFERENCE.md
git commit -m "feat(research-fair): REFERENCEFAIR4RS映射+评分+KG schema+引用库+诚实边界)"
```
---
## Task 6: Live probe songhui18 — 回填 REFERENCE 实测结论
**Files:**
- Modify: `skills/gitlink-research-fair/REFERENCE.md` (§九 实测结论)
- [ ] **Step 1: Probe repo metadata + files**
Run (capture outputs):
```bash
gitlink-cli repo +info --owner songhui18 --repo ICCV2021论文复现 --format json > /tmp/rf_info.json
gitlink-cli file +list --owner songhui18 --repo ICCV2021论文复现 --format json > /tmp/rf_files.json
gitlink-cli release +list --owner songhui18 --repo ICCV2021论文复现 --format json > /tmp/rf_rel.json
gitlink-cli commit +list --owner songhui18 --repo ICCV2021论文复现 --page 1 --format json > /tmp/rf_commits.json
```
Expected: each file non-empty, valid JSON with `"ok": true`. If any errors, debug auth/binary first (see Environment & Gotchas).
- [ ] **Step 2: Extract ground truth (license/desc/topics/releases/README/requirements/commit SHA)**
Write `_probe.py` (UTF-8) and run with `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python _probe.py`:
```python
# -*- coding: utf-8 -*-
import json, glob
def load(p):
try:
raw=open(p,encoding='utf-8').read(); i=raw.find('{')
return json.loads(raw[i:]) if i>=0 else {}
except Exception as e:
return {'_err':str(e)}
info=load('/tmp/rf_info.json'); files=load('/tmp/rf_files.json')
rel=load('/tmp/rf_rel.json'); commits=load('/tmp/rf_commits.json')
d=info.get('data',info) if isinstance(info,dict) else {}
print('license_id:', d.get('license_id'))
print('desc_len:', len(d.get('description') or ''))
print('topics:', d.get('topics'))
print('has_dataset:', d.get('has_dataset'))
print('is_public:', d.get('is_public'))
# releases
rd=rel.get('data',rel) if isinstance(rel,dict) else {}
releases=rd.get('releases') or rd.get('project_releases') or []
print('releases:', len(releases) if isinstance(releases,list) else releases)
# files — look for key names
fd=files.get('data',files) if isinstance(files,dict) else {}
entries=fd.get('entries') or fd.get('files') or fd.get('trees') or []
names=[ (e.get('name') or e.get('path') or '') for e in entries ] if isinstance(entries,list) else []
key=['LICENSE','LICENSE.md','COPYING','README.md','README','requirements.txt','environment.yml','Dockerfile','Makefile','CITATION.cff','codemeta.json','.zenodo.json','go.mod','package.json']
print('present:', [k for k in key if any(k==n or n.endswith('/'+k) for n in names)])
print('absent :', [k for k in key if not any(k==n or n.endswith('/'+k) for n in names)])
# HEAD commit sha
cd=commits.get('data',commits) if isinstance(commits,dict) else {}
cl=cd.get('commits') or []
print('head_sha:', (cl[0].get('sha') if cl and isinstance(cl,list) else None))
```
Expected: prints real values (adapt field paths if GitLink nests differently — inspect raw JSON). Record: license state (expect absent), README/requirements present?, releases (expect 0), HEAD sha, has_dataset.
- [ ] **Step 3: Backfill REFERENCE §九 with real findings**
Replace the `> 此节由 Task 6...回填` line with real data: license state (expected: absent), README present?, requirements present?, releases count (expected: 0), HEAD commit SHA, has_dataset, and each axis's real ✓/✗ (preview the grade — expect B/C due to no license).
- [ ] **Step 4: Commit**
```bash
git add skills/gitlink-research-fair/REFERENCE.md
git commit -m "docs(research-fair): REFERENCE 回填 songhui18 实测结论"
```
---
## Task 7: Live run — 报告卡 + 处方 PRfork+ 证书
**Files:**
- (produces real artifacts; outputs captured into Task 8's examples doc)
- [ ] **Step 1: Produce the report card following SKILL.md**
Using Task 6 ground truth, walk the 8-step pipeline on `songhui18/ICCV2021论文复现`: score all 5 axes, build KG (Mermaid), try OpenAlex enrichment (search README for paper title/DOI → query api.openalex.org; if none, mark skipped), render the full report card with real values + sentinel + real commit SHA.
Expected: a complete Markdown report card with a real grade (B/C), real axis scores, real evidence per item.
- [ ] **Step 2: Fork + open remediation PR**
```bash
gitlink-cli repo +fork --owner songhui18 --repo ICCV2021论文复现 --format json
# clone the fork locally, add generated CITATION.cff + LICENSE + 复现章节, commit, push
# then:
gitlink-cli pr +create --owner <你的login> --repo ICCV2021论文复现 \
--head <你的login>:feat/fair-remediation --base master \
--title "Improve research FAIRness & reproducibility (gitlink-research-fair)" \
--body "<报告卡摘要>"
```
Expected: PR created with a numeric id. **Fallback**: if fork/push fails, use `--no-fork` — write CITATION.cff/LICENSE/复现章节 to a local `_remediation/` dir and note "fork 链路不可用,已降级本地物料" in the examples doc.
- [ ] **Step 3: Verify certificate + capture screenshot**
Confirm the certificate line in the report card has the real HEAD commit SHA (from Task 6) and grade. If `swh.model` importable in python, compute full SWH-ID; else keep `git+<sha>`. Capture a screenshot of the opened PR on GitLink web → save to `skills/gitlink-research-fair/examples/img/pr-songhui18.png` (recommended).
- [ ] **Step 4: Commit (artifacts/screenshots only)**
```bash
git add skills/gitlink-research-fair/examples/img/ # if screenshot captured
git commit -m "feat(research-fair): songhui18 实跑报告卡+处方PR+证书(截图)"
```
(No commit if --no-fork fallback and no screenshot — proceed to Task 8.)
---
## Task 8: examples/research-fair-workflow.md — 真实走查
**Files:**
- Create: `skills/gitlink-research-fair/examples/research-fair-workflow.md`
- [ ] **Step 1: Write the real walkthrough using Task 6+7 outputs**
Content skeleton (fill with REAL data from Tasks 6-7):
```markdown
# 示例:科研软件 FAIR 体检(真实数据)
> 基于 `songhui18/ICCV2021论文复现`论文复现合集19⭐于 2026-07-XX 实跑。
## Step 1取上下文真实输出摘要
- license_id: <实测> 描述: <长度> topics: <...> has_dataset: <...>
- 关键文件LICENSE=</> · README=<...> · requirements=<...> · CITATION.cff=<...> · releases=<N>
## Step 25 轴评分(真实)
| 轴 | 分 | 关键依据 |
|---|---|---|
| F | <> | ... |
| A | <> | 无 license → A2 ✗ |
| I | <> | ... |
| R | <> | ... |
| Repro | <> | ... |
**总体:<等级> <>/100**
## Step 3报告卡真实全文
< Task 7 报告卡全文含哨兵>
## Step 4处方 PR
<PR 链接 / --no-fork 降级说明>,含 CITATION.cff + LICENSE + 复现章节
## Step 5证书
锚定 commit <SHA>FAIR <等级>Repro <状态>
## 对照:有 license 仓库
`<对照仓库>`(有 license→ 预期 A 级,证明评分区分度。
## 关键结论
- "论文复现"仓库被"可复现性"工具体检:主题共振
- 无 license = 天然缺口,处方有活干
- 与 howfairis 差异GitLink 原生 + 自动修复 + 证书
```
- [ ] **Step 2: Verify real data (no placeholders)**
Run: `grep -E "TBD|TODO|<等级>|<分>|<实>|<实测>|<SHA>" skills/gitlink-research-fair/examples/research-fair-workflow.md`
Expected: no matches (all `<...>` placeholders filled with real data). If any remain, fill them from Tasks 6-7.
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-research-fair/examples/research-fair-workflow.md
git commit -m "feat(research-fair): examples songhui18 真实走查"
```
---
## Task 9: 对照仓库跑(证明评分区分度)
**Files:**
- Modify: `skills/gitlink-research-fair/examples/research-fair-workflow.md` (对照 section)
- [ ] **Step 1: Pick a licensed repo and run the pipeline**
Use `leejt/图神经网络快速开发测试工具` (has license_id=Y) — or `Edgedev/Edge深度学习框架`. Run `repo +info` + `file +list` to confirm it has a LICENSE, then score the 5 axes.
Expected: A2 = ✓ (has license) → A axis notably higher than songhui18; overall grade A (≥80) or clearly higher than songhui18's B/C.
- [ ] **Step 2: Fill the 对照 section with real contrast numbers**
Update the "对照:有 license 仓库" section with: repo name, its grade (expect A), and a one-line contrast ("songhui18 B/C无 licensevs 对照 A有 license→ 评分有区分度").
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-research-fair/examples/research-fair-workflow.md
git commit -m "docs(research-fair): 对照仓库证明评分区分度"
```
---
## Task 10: 注册 + README + workflow 链接 + 最终验收
**Files:**
- Modify: `skills/README.md`
- Modify: `skills/gitlink-workflow/SKILL.md` (optional cross-link)
- [ ] **Step 1: Register the skill (so Claude Code's Skill tool discovers it)**
Run: `bash scripts/setup-skills.sh` (creates the junction into `~/.claude/skills/`). Then verify discovery: `ls ~/.claude/skills/ | grep gitlink-research-fair`.
Expected: `gitlink-research-fair` listed.
- [ ] **Step 2: Add README row**
In `skills/README.md`, add to the 智能 Skills table (near gitlink-review):
```markdown
| **gitlink-research-fair** | 科研软件 FAIR 体检 | 5 轴 FAIR/可复现评分、报告卡、自动修复 PR、SWH/commit 可复现证书 |
```
Verify: `grep -c "gitlink-research-fair" skills/README.md``≥1`.
- [ ] **Step 3: (Optional) cross-link from gitlink-workflow**
In `skills/gitlink-workflow/SKILL.md`, under a research/repo-health context, add a line linking to `../gitlink-research-fair/SKILL.md`.
- [ ] **Step 4: Final acceptance check (spec §16)**
Verify each (run/read):
- [ ] `skills/gitlink-research-fair/` 三件套齐全SKILL.md + REFERENCE.md + examples/
- [ ] 5 轴 rubric 每项有证据来源 + 状态判定 + 兜底
- [ ] songhui18 真跑通:报告卡 + 处方 PR或 --no-fork 降级)+ 证书,有截图
- [ ] 对照仓库评分有区分度
- [ ] 边界(私有/空/无 README降级正确至少手动核对 SKILL 错误表覆盖)
- [ ] REFERENCE 含引用库 + 2 条禁用论点
- [ ] README 已登记
- [ ] **Step 5: Commit + final state**
```bash
git add skills/README.md skills/gitlink-workflow/SKILL.md
git commit -m "docs(research-fair): README 登记 + workflow 链接 + 注册"
git log --oneline -12
```
Expected: clean commit history on `feat/gitlink-research-fair`, all artifacts present. Then open a PR to master (per protected-branch workflow).

View File

@ -0,0 +1,846 @@
# gitlink-spark Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a `gitlink-spark` Skill + runnable `spark.py` that mines "literature↔code semantic gaps" across arXiv × GitLink × GitHub and outputs an opportunity report with optional fork+issue kickoff.
**Architecture:** `scripts/spark.py` is a standalone data-fusion script (fetches real data → JSON). `SKILL.md` orchestrates an LLM that reads the JSON, semantically matches two gap types (theory-no-impl / demand-no-solution) with evidence triples, renders an opportunity report, and optionally forks+issues to kickoff. Pure functions are unit-tested; the live GNN run is integration validation.
**Tech Stack:** Python 3.11 stdlib only (urllib, subprocess, xml.etree, json, argparse) — no pip deps. gitlink-cli. arXiv Atom API + GitHub Search REST.
**Source of truth:** `docs/superpowers/specs/2026-07-01-gitlink-spark-design.md` (read it first).
---
## Environment & Gotchas (engineer must know)
- **Branch:** on `feat/gitlink-research-fair` (spark is 子任务四第二部分, shares PR #5). Commit only your own files; leave pre-existing `D README_TASKB.md` / `D gitlink-cli.exe` / `?? dist/` / `?? _edge_prescription/` untouched.
- **Encoding:** all python that touches Chinese gitlink-cli output MUST run with `PYTHONUTF8=1 PYTHONIOENCODING=utf-8`. Never inline Chinese in `python -c` — write a `.py` file.
- **arXiv must be HTTPS** (`https://export.arxiv.org`); plain HTTP is sandbox-blocked (returns 0 bytes).
- **GitHub unauthenticated = 10 req/min**`fetch_github_count` sleeps ~7s between calls. Set `GITHUB_TOKEN` env to raise to 5000/h. Cache by query key.
- **`gitlink-cli search +issues` returns HTML (broken)** — never use it. Use per-repo `gitlink-cli issue +list --owner X --repo Y --state open` (returns JSON).
- **`--repo` uses identifier** (ASCII slug), not Chinese display name.
- **No `pip install`** — spark.py uses stdlib only. Tests run via `python test_spark.py` (assert-based, no pytest).
---
## File Structure
| File | Responsibility |
|------|----------------|
| `skills/gitlink-spark/scripts/spark.py` | Standalone data fusion: arXiv + gitlink-cli + GitHub → JSON on stdout. Pure parsers + network fetchers + main() |
| `skills/gitlink-spark/scripts/test_spark.py` | Assert-based unit tests for pure parsers (parse_arxiv_atom, parse_github_search, extract_method_keywords) |
| `skills/gitlink-spark/SKILL.md` | 4-stage orchestration, gap taxonomy, GitHub threshold rule, report template, kickoff guardrails, error table |
| `skills/gitlink-spark/REFERENCE.md` | Gap taxonomy detail, GitHub tiers, LLM prompt template, data-source findings, honesty caveats |
| `skills/gitlink-spark/examples/spark-图神经网络.md` | Real GNN run: 2-3 gap cards + kickoff screenshot |
| `skills/README.md` | Add gitlink-spark row |
| `skills/gitlink-workflow/SKILL.md` | Optional cross-link |
---
## Task 1: Scaffold SKILL.md (frontmatter + CRITICAL + pipeline + command interface)
**Files:**
- Create: `skills/gitlink-spark/SKILL.md`
- [ ] **Step 1: Create SKILL.md with frontmatter, CRITICAL headers, 概述, 命令接口, 4-stage pipeline**
Content (exact):
```markdown
---
name: gitlink-spark
version: 1.0.0
description: "文献-代码语义缺口挖掘机:给一个研究领域,跨 arXiv × GitLink × GitHub 三源挖'有理论无实现/有需求无解答'语义缺口,输出空白学术机会报告,可一键 fork+issue 起跑。当用户需要找研究点、发现论文-代码空白、科研选题启发时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "python skills/gitlink-spark/scripts/spark.py --help"
---
# gitlink-spark文献-代码语义缺口挖掘机)
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 缺口由 LLM 揨断,但每条必须带实证三件套(论文 id / GitLink 查询+命中数 / GitHub total_count无实证的缺口必须丢弃。**
**CRITICAL — 起跑fork+issue默认预览确认绝不自动 merge、绝不 force-push、绝不碰原仓库。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)缺口分类法、GitHub 阈值、LLM prompt 模板见 [`REFERENCE.md`](REFERENCE.md)。
## 概述
给一个研究领域,跨 **arXiv学术× GitLink中文生态× GitHub全球** 三源挖两类语义缺口,输出**空白学术机会报告**。`scripts/spark.py` 抓真实数据JSONLLM 做语义匹配并附实证三件套。与 `gitlink-research-fair`(评估已有)组成"科研辅助双联装"——本 skill 负责**发现空白**。
## 命令接口
数据融合脚本(可独立运行):
```bash
python skills/gitlink-spark/scripts/spark.py --field "图神经网络" [--max-papers 10] [--gap-type both|theory|demand] [--github-token $GITHUB_TOKEN]
# → stdout: 融合 JSON {papers, gitlink_repos, gitlink_issues, github_counts}
```
skill 约定参数(非 CLI flag
| 参数 | 默认 | 说明 |
|------|------|------|
| `--field` | 必填 | 研究领域 |
| `--gap-type` | `both` | `theory` / `demand` / `both` |
| `--max-papers` | 10 | arXiv 抓取上限(控 GitHub 调用) |
| `--auto` | 关 | 跳过预览直接起跑(仍受护栏) |
| `--no-fork` | 关 | 只出报告,不起跑 |
## 管道4 阶段)
### ① 学术采
`spark.py` 调 arXiv HTTPS API 抓领域近 90 天论文(标题/摘要/arxiv id/方法关键词)
### ② GitLink 采
`spark.py``gitlink-cli search +repos` 抓领域仓库;对每个仓库 `issue +list --state open` 抓 open issue**不用 search +issues**,它返回 HTML
### ③ 全球对照
`spark.py` 调 GitHub Search API 对每个论文方法查 `total_count` + Top3 仓库(限流+缓存)
### ④ 缺口匹配LLM+ 报告 + 起跑
读 spark.py 的 JSON → 语义匹配两类缺口(每张带实证三件套)→ 渲染机会报告 → 可选 fork+issue 起跑
```
- [ ] **Step 2: Verify**
Run: `head -30 skills/gitlink-spark/SKILL.md | grep -c -e "name: gitlink-spark" -e "description:" -e "CRITICAL" -e "管道4 阶段)"`
Expected: `6`
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-spark/SKILL.md
git commit -m "feat(spark): scaffold SKILL.mdfrontmatter+CRITICAL+4阶段管道"
```
---
## Task 2: SKILL.md — gap taxonomy + evidence triples + GitHub thresholds + report + guardrails
**Files:**
- Modify: `skills/gitlink-spark/SKILL.md` (append after 管道)
- [ ] **Step 1: Append gap taxonomy, thresholds, report template, kickoff, error table**
Append (exact):
```markdown
## 两类缺口 + 实证三件套(信服核心)
每张缺口卡**必须**带齐三件套,否则丢弃(防 LLM 编造):
### 类型 A有理论无实现paper → code gap
- **三件套**:① 论文 arxiv id + 标题 + 发表日期 ② GitLink 搜索查询串 + 命中数0/极少) ③ GitHub total_count + Top 仓库
- LLM 判定:论文提出方法 MGitLink 实现 0/极少GitHub 按下方阈值分级
### 类型 B有需求无解答open issue → applied gap
- **三件套**:① issue URL + 主题 + 讨论人数/状态 ② GitLink 无现成实现解此痛点 ③ GitHub 是否有成熟开源解
- 降噪LLM 只挑"研究性痛点"(性能/可扩展/新场景),排除"安装报错"等使用问题
## GitHub 全球对照阈值(诚实核心,硬需求)
防止"GitLink 0 ≠ 全球空白"误导。对每个"理论无实现"候选按 GitHub total_count 分级:
| GitHub total_count | 分级 | 报告行为 |
|--------------------|------|----------|
| `< 10` | 全球稀缺(真空白) | 报为高价值缺口 |
| `1050` | 新兴(部分空白) | 报为中等缺口("GitLink 空白,全球新兴" |
| `≥ 50` | 全球已成熟 | **不报为空白**,列入"✅ 已诚实排除" |
宁可少报,不误报机会。
## 机会报告格式hero
````markdown
⚡ **gitlink-spark 机会报告:<field>**
学术采arXiv 近 90 天 N 篇 GitLink 仓库 M 个 GitHub 全球基线已对照
生成时间YYYY-MM-DD
### 🧩 缺口 1 · 有理论无实现 [全球稀缺·高价值]
**论文**[arxiv:<id>] "<title>" (<date>)
**方法关键词**<...>
**GitLink**search "<query>" → **0 命中**(查询串留底)
**GitHub 全球**total_count = **N**Top: <repo> <stars>⭐)→ 稀缺
**机会建议**<LLM 一句话>
**起跑**[按钮] fork 基准 <repo> → 创建 issue 粘论文伪代码
### 🧩 缺口 2 · 有需求无解答 [应用机会]
**Issue**<repo>#<n> "<subject>"N 人讨论, open
**痛点**<LLM 归纳>
**GitLink / GitHub**:均无成熟解
**机会建议**<LLM 一句话>
### ✅ 已诚实排除(非空白)
- 论文 YGitLink 虽 0但 GitHub 已 N 个 → 全球已成熟,不报
---
<!-- gitlink-spark v1 | field:<field> | gaps:<N> | date:<YYYY-MM-DD> -->
*由 gitlink-spark skill 生成。*
````
## 起跑动作 + 护栏
选定一张"理论无实现"缺口卡 → 确认 →
1. `gitlink-cli repo +fork` 最近基准GitHub Top 仓库或 GitLink 最近实现)
2. LLM 从 arXiv 论文抓 Algorithm/Pseudocode 节
3. `gitlink-cli issue +create` 在 fork 建复现 todo issuebody 粘伪代码 + 报告卡摘要)
**护栏**:默认预览;`--auto` 跳过但**永不 force-push、永不碰原仓库、永不自动 merge**`--no-fork` 只出报告。
## 错误处理与降级
| 情况 | 处理 |
|------|------|
| arXiv 空/超时 | HTTPS 重试;仍空降级用既有论文 |
| `search +issues` 返回 HTML | 不用,改逐仓库 `issue +list` |
| GitHub 未认证限流(10/min) | spark.py sleep ~7s建议设 `GITHUB_TOKEN` |
| GitHub 查询失败 | 该论文标"对照失败",不进缺口判定 |
| OpenAlex 503 | 跳过引用富集 |
| LLM 缺口无三件套 | 置信度门控丢弃 |
| fork/issue 起跑失败 | 输出 fork 目标 + 伪代码文本供手动起跑 |
```
- [ ] **Step 2: Verify**
Run: `grep -c -e "三件套" -e "GitHub total_count" -e "已诚实排除" -e "起跑动作" skills/gitlink-spark/SKILL.md`
Expected: `4`
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-spark/SKILL.md
git commit -m "feat(spark): SKILL 缺口分类法+GitHub阈值+报告模板+起跑护栏+降级"
```
---
## Task 3: spark.py — parse_arxiv_atom + test (TDD)
**Files:**
- Create: `skills/gitlink-spark/scripts/spark.py`
- Create: `skills/gitlink-spark/scripts/test_spark.py`
- [ ] **Step 1: Write the failing test**
`test_spark.py` (exact):
```python
# -*- coding: utf-8 -*-
"""Assert-based unit tests for spark.py pure parsers. Run: python test_spark.py"""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from spark import parse_arxiv_atom, parse_github_search, extract_method_keywords
SAMPLE_ARXIV = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<id>http://arxiv.org/abs/2403.12345v1</id>
<title>Graph Attention Networks with Sparse Transformers</title>
<summary>We propose a new graph attention mechanism using sparse attention.</summary>
<published>2024-03-15T00:00:00Z</published>
</entry>
<entry>
<id>http://arxiv.org/abs/2404.99999v2</id>
<title>Federated Learning on Heterogeneous Graphs</title>
<summary>A federated approach for heterogeneous graph neural networks.</summary>
<published>2024-04-20T00:00:00Z</published>
</entry>
</feed>"""
def test_parse_arxiv_atom():
papers = parse_arxiv_atom(SAMPLE_ARXIV)
assert len(papers) == 2, f"expected 2 papers, got {len(papers)}"
assert papers[0]["arxiv_id"] == "2403.12345v1", papers[0]["arxiv_id"]
assert "Graph Attention" in papers[0]["title"]
assert papers[0]["published"] == "2024-03-15"
assert "sparse" in papers[0]["abstract"].lower()
print("test_parse_arxiv_atom OK")
def test_parse_github_search():
import json as _j
sample = _j.dumps({"total_count": 1543, "items": [{"full_name": "a/b", "stargazers_count": 3534}]})
res = parse_github_search(sample)
assert res["total_count"] == 1543
assert res["top"][0]["full_name"] == "a/b"
assert res["top"][0]["stars"] == 3534
print("test_parse_github_search OK")
def test_extract_method_keywords():
kws = extract_method_keywords("Graph Attention Networks", "We propose a sparse attention mechanism for graphs.", max_k=5)
assert "graph" in kws and "attention" in kws
assert "propose" not in kws # 'propose' is in the stop set, filtered out
print("test_extract_method_keywords OK")
if __name__ == "__main__":
test_parse_arxiv_atom()
test_parse_github_search()
test_extract_method_keywords()
print("ALL TESTS PASSED")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd skills/gitlink-spark/scripts && python test_spark.py`
Expected: FAIL with `ModuleNotFoundError: No module named 'spark'`
- [ ] **Step 3: Write minimal spark.py with parse_arxiv_atom (+ stubs for the other two so import works)**
`spark.py` (exact):
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""gitlink-spark data fusion: arXiv x GitLink x GitHub -> JSON on stdout. Stdlib only."""
import argparse, json, os, sys, time, subprocess, urllib.request, urllib.parse, re
from xml.etree import ElementTree as ET
ARXIV_ENDPOINT = "https://export.arxiv.org/api/query"
GITHUB_ENDPOINT = "https://api.github.com/search/repositories"
_NS = {"a": "http://www.w3.org/2005/Atom"}
def parse_arxiv_atom(xml_text):
"""Parse arXiv Atom feed -> list of {arxiv_id, title, abstract, published}."""
root = ET.fromstring(xml_text)
papers = []
for e in root.findall("a:entry", _NS):
aid = (e.find("a:id", _NS).text or "").strip().split("/")[-1]
title = re.sub(r"\s+", " ", (e.find("a:title", _NS).text or "").strip())
summary = re.sub(r"\s+", " ", (e.find("a:summary", _NS).text or "").strip())
pub = (e.find("a:published", _NS).text or "")[:10]
papers.append({"arxiv_id": aid, "title": title, "abstract": summary, "published": pub})
return papers
def parse_github_search(json_text):
return {"total_count": 0, "top": []} # stub — implemented in Task 4
def extract_method_keywords(title, abstract, max_k=5):
return [] # stub — implemented in Task 4
def main():
pass # implemented in Task 7
if __name__ == "__main__":
main()
```
- [ ] **Step 4: Run test — arxiv test passes, others fail on stubs**
Run: `cd skills/gitlink-spark/scripts && python test_spark.py`
Expected: `test_parse_arxiv_atom OK`, then FAIL on `test_parse_github_search` (top[0] index error on empty). This confirms arxiv parser works; stubs next.
- [ ] **Step 5: Commit**
```bash
git add skills/gitlink-spark/scripts/spark.py skills/gitlink-spark/scripts/test_spark.py
git commit -m "feat(spark): spark.py parse_arxiv_atom + testTDD"
```
---
## Task 4: spark.py — parse_github_search + extract_method_keywords (real impl)
**Files:**
- Modify: `skills/gitlink-spark/scripts/spark.py` (replace the two stubs)
- [ ] **Step 1: Replace the two stub functions with real implementations**
Replace `def parse_github_search(json_text): ...` and `def extract_method_keywords(...): ...` with:
```python
def parse_github_search(json_text):
"""Parse GitHub search JSON -> {total_count, top:[{full_name, stars}]}."""
d = json.loads(json_text)
return {
"total_count": d.get("total_count", 0),
"top": [{"full_name": r.get("full_name"), "stars": r.get("stargazers_count")}
for r in (d.get("items") or [])[:3]],
}
def extract_method_keywords(title, abstract, max_k=5):
"""Crude keyword extraction for GitHub/arXiv query."""
text = (title + " " + abstract).lower()
stop = {"the", "a", "an", "of", "for", "and", "to", "in", "on", "with", "via",
"based", "using", "by", "from", "as", "is", "are", "we", "our", "this",
"that", "propose", "proposed", "paper", "method", "approach", "novel", "new"}
tokens = re.findall(r"[a-z][a-z0-9-]+", text)
seen = set(); out = []
for t in tokens:
if t in stop or len(t) < 3 or t in seen:
continue
seen.add(t); out.append(t)
if len(out) >= max_k:
break
return out
```
- [ ] **Step 2: Run all tests — expect ALL PASS**
Run: `cd skills/gitlink-spark/scripts && python test_spark.py`
Expected: `ALL TESTS PASSED` (all 3 tests)
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-spark/scripts/spark.py
git commit -m "feat(spark): parse_github_search + extract_method_keywords 实现3 测试全过)"
```
---
## Task 5: spark.py — fetch_arxiv (network)
**Files:**
- Modify: `skills/gitlink-spark/scripts/spark.py` (add fetch_arxiv before main)
- [ ] **Step 1: Add fetch_arxiv**
Insert before `def main()`:
```python
def fetch_arxiv(field, max_papers=10):
"""Search arXiv (HTTPS) for recent papers in field. Returns list of paper dicts."""
q = urllib.parse.quote(f'abs:"{field}"')
url = (f"{ARXIV_ENDPOINT}?search_query={q}&max_results={max_papers}"
f"&sortBy=submittedDate&sortOrder=descending")
with urllib.request.urlopen(url, timeout=30) as r:
papers = parse_arxiv_atom(r.read().decode("utf-8", "replace"))
for p in papers:
p["method_keywords"] = extract_method_keywords(p["title"], p["abstract"])
return papers
```
- [ ] **Step 2: Smoke test fetch_arxiv (live network)**
Run:
```bash
cd skills/gitlink-spark/scripts && PYTHONUTF8=1 python -c "
from spark import fetch_arxiv
ps = fetch_arxiv('graph neural network', max_papers=2)
assert len(ps) >= 1, 'no papers'
p = ps[0]
assert p['arxiv_id'] and p['title'] and p['published']
assert isinstance(p['method_keywords'], list)
print('OK', p['arxiv_id'], '|', p['title'][:50])
"
```
Expected: `OK 2504.xxxxx | <recent GNN paper title>` (a real recent arxiv id). If 0 bytes, confirm HTTPS (not HTTP).
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-spark/scripts/spark.py
git commit -m "feat(spark): fetch_arxivarXiv HTTPS 网络层)"
```
---
## Task 6: spark.py — fetch_gitlink_repos + fetch_gitlink_issues
**Files:**
- Modify: `skills/gitlink-spark/scripts/spark.py`
- [ ] **Step 1: Add _gitlink helper + the two fetchers**
Insert before `def main()`:
```python
def _gitlink(*args):
"""Run gitlink-cli with json output; return parsed dict (UTF-8 safe)."""
r = subprocess.run(["gitlink-cli"] + list(args) + ["--format", "json"],
capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=60)
raw = r.stdout
i = raw.find("{")
return json.loads(raw[i:]) if i >= 0 else {}
def fetch_gitlink_repos(field):
"""gitlink-cli search +repos -> list of {owner, repo(identifier), name, desc, topics}."""
d = _gitlink("search", "+repos", "-k", field)
projs = d.get("data", {}).get("projects", []) or []
out = []
for p in projs:
out.append({
"owner": (p.get("author") or {}).get("login"),
"repo": p.get("identifier"),
"name": p.get("name"),
"desc": p.get("description"),
"topics": [t.get("name") if isinstance(t, dict) else t for t in (p.get("topics") or [])],
})
return out
def fetch_gitlink_issues(repos, max_per_repo=10):
"""Per-repo issue +list (open) -> list of {repo, number, subject, status, participants}.
Works around search +issues returning HTML."""
out = []
for r in repos:
if not (r.get("owner") and r.get("repo")):
continue
d = _gitlink("issue", "+list", "--owner", r["owner"], "--repo", r["repo"], "--state", "open")
data = d.get("data", {}) or {}
issues = data.get("issues") or []
for it in issues[:max_per_repo]:
st = (it.get("status") or {})
if st.get("name") == "关闭":
continue
out.append({
"repo": f'{r["owner"]}/{r["repo"]}',
"number": it.get("project_issues_index") or it.get("number"),
"subject": it.get("subject"),
"status": st.get("name"),
"participants": it.get("participants_count") or 0,
})
return out
```
- [ ] **Step 2: Smoke test (live)**
Run:
```bash
cd skills/gitlink-spark/scripts && PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python -c "
from spark import fetch_gitlink_repos, fetch_gitlink_issues
repos = fetch_gitlink_repos('图神经网络')
print('repos:', len(repos))
if repos: print(' sample:', repos[0]['owner'], '/', repos[0]['repo'])
iss = fetch_gitlink_issues(repos[:2])
print('issues(from first 2 repos):', len(iss))
"
```
Expected: `repos: N` (N≥1, includes GraphGallery-class), `issues: M`. If `repos: 0`, the field keyword missed — retry with `'graph neural'`. If issues JSON parse fails, inspect gitlink-cli `issue +list` structure and adapt field names.
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-spark/scripts/spark.py
git commit -m "feat(spark): fetch_gitlink_repos + fetch_gitlink_issues逐仓库绕开 search+issues HTML"
```
---
## Task 7: spark.py — fetch_github_count + main() + end-to-end smoke
**Files:**
- Modify: `skills/gitlink-spark/scripts/spark.py`
- [ ] **Step 1: Add fetch_github_count (cache+throttle) and implement main()**
Replace `def main(): pass` and add fetch_github_count before it:
```python
_GH_CACHE = {}
def fetch_github_count(query, token=None, throttle=True):
"""GitHub search total_count + top3 for a query. Caches + throttles (10/min unauth)."""
if query in _GH_CACHE:
return _GH_CACHE[query]
url = f"{GITHUB_ENDPOINT}?q={urllib.parse.quote(query)}&per_page=3&sort=stars"
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json",
"User-Agent": "gitlink-spark/1.0"})
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=25) as r:
res = parse_github_search(r.read().decode("utf-8", "replace"))
except Exception as e:
res = {"total_count": None, "top": [], "error": str(e)[:80]}
if throttle and not token:
time.sleep(7) # unauthenticated = 10 req/min
_GH_CACHE[query] = res
return res
def main():
ap = argparse.ArgumentParser(description="gitlink-spark data fusion")
ap.add_argument("--field", required=True)
ap.add_argument("--max-papers", type=int, default=10)
ap.add_argument("--gap-type", default="both", choices=["both", "theory", "demand"])
ap.add_argument("--github-token", default=os.environ.get("GITHUB_TOKEN"))
args = ap.parse_args()
papers = fetch_arxiv(args.field, args.max_papers)
grepos = fetch_gitlink_repos(args.field)
gissues = fetch_gitlink_issues(grepos) if args.gap_type in ("both", "demand") else []
gh_counts = {}
if args.gap_type in ("both", "theory"):
for p in papers:
q = p["title"][:60] # primary query = paper title (truncated)
gh_counts[q] = fetch_github_count(q, args.github_token)
out = {
"field": args.field,
"papers": papers,
"gitlink_repos": grepos,
"gitlink_issues": gissues,
"github_counts": gh_counts,
}
json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
```
- [ ] **Step 2: End-to-end smoke (tiny, live)**
Run:
```bash
cd skills/gitlink-spark/scripts && PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python spark.py --field "graph neural network" --max-papers 3 --gap-type theory > _smoke.json 2>&1
PYTHONUTF8=1 python -c "
import json
d=json.load(open('_smoke.json',encoding='utf-8'))
print('field:', d['field'])
print('papers:', len(d['papers']), '| gitlink_repos:', len(d['gitlink_repos']))
print('github_counts keys:', len(d['github_counts']))
g=d['github_counts']
for k,v in list(g.items())[:1]: print(' sample gh:', k[:30], '-> total', v.get('total_count'))
"
rm -f _smoke.json
```
Expected: papers=3, gitlink_repos≥1, github_counts has 3 entries with real total_count ints. Takes ~25s (3 GitHub calls × 7s throttle). If GitHub 403 rate-limit, set `GITHUB_TOKEN` env or wait 60s.
- [ ] **Step 3: Re-run unit tests (regression)**
Run: `cd skills/gitlink-spark/scripts && python test_spark.py`
Expected: `ALL TESTS PASSED`
- [ ] **Step 4: Commit**
```bash
git add skills/gitlink-spark/scripts/spark.py
git commit -m "feat(spark): fetch_github_count + main() 编排 → JSON端到端 smoke 通过)"
```
---
## Task 8: REFERENCE.md — gap taxonomy detail + GitHub tiers + LLM prompt + data sources
**Files:**
- Create: `skills/gitlink-spark/REFERENCE.md`
- [ ] **Step 1: Create REFERENCE.md**
Content (exact):
```markdown
# gitlink-spark 参考文档
> SKILL.md 的深度参考缺口分类细则、GitHub 阈值、LLM prompt 模板、数据源实测、诚实边界。
## 一、缺口分类法细则
### 类型 A 有理论无实现
- 输入arXiv 论文方法 Mtitle + method_keywords
- GitLink 侧:`search +repos -k <M>` 命中数0 或极少,如 ≤2
- GitHub 侧:`total_count`(按 §二阈值分级)
- 判定为"缺口"条件GitLink ≤2 **且** GitHub < 50全球稀缺或新兴
### 类型 B 有需求无解答
- 输入:领域仓库的 open issue`issue +list`,排除关闭)
- LLM 筛"研究性痛点":含性能/可扩展性/新场景/新数据集,排除安装报错/使用咨询
- 判定GitLink 无现成实现解此痛点 **且** GitHub 无成熟开源方案
## 二、GitHub 全球对照阈值
| total_count | 分级 | 报告 |
|---|---|---|
| < 10 | 全球稀缺 | 高价值缺口 |
| 1050 | 新兴 | 中等缺口 |
| ≥ 50 | 已成熟 | **不报为空白**,列入"已诚实排除" |
spark.py 缓存 GitHub 结果(按 query key避免重复调用。
## 三、LLM 缺口匹配 prompt 模板
```
你是科研机会发现助手。下面是 spark.py 抓取的真实数据JSON
请跨"arXiv 论文 × GitLink 仓库/issues × GitHub 全球计数"找出语义缺口,输出机会报告。
规则:
1. 只输出可溯源到下列数据的缺口;每张缺口卡带"实证三件套"。
2. 类型A理论无实现论文 M 的 GitLink 命中≤2 且 GitHub total_count<50 才报
GitHub ≥50 的论文列入"已诚实排除",不报为空白。
3. 类型B需求无解答只挑研究性痛点 issue排除使用/安装类。
4. 每张卡给一句"机会建议"(主观),但证据必须客观可查。
5. 宁可少报,不误报。
数据:
{spark.py 的 JSON}
```
## 四、数据源实测结论2026-07-01
| 源 | 状态 | 备注 |
|---|---|---|
| arXiv API | ✅ 必须 HTTPS | HTTP 被沙箱阻断返回 0 字节 |
| gitlink-cli search +repos | ✅ | 用 identifier/关键词 |
| gitlink-cli issue +list | ✅ | 逐仓库,绕开 search+issues |
| gitlink-cli search +issues | ❌ 返回 HTML | 不可用,勿用 |
| GitHub Search API | ✅ | 未认证 10/minGITHUB_TOKEN 提额 |
| OpenAlex | ⚠ 间歇 503 | best-effort 富集,降级跳过 |
## 五、诚实边界
1. **GitLink 覆盖薄**:缺口卡明确标 "GitLink 0 / GitHub N"GitHub ≥50 不报为空白。
2. LLM 缺口必须可溯源实证三件套,否则丢弃。
3. arXiv 仅覆盖 CS/物理等,报告标注学科范围。
4. GitHub 未认证 10/minspark.py sleep 7s + 缓存;建议 demo 设 GITHUB_TOKEN。
5. "机会建议"为主观启发,标注"需研究者自行判断"。
```
- [ ] **Step 2: Verify**
Run: `grep -c -e "类型 A 有理论无实现" -e "GitHub ≥50 不报为空白" -e "search +issues.*HTML" -e "LLM 缺口匹配 prompt" skills/gitlink-spark/REFERENCE.md`
Expected: `4`
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-spark/REFERENCE.md
git commit -m "feat(spark): REFERENCE缺口分类法+GitHub阈值+LLM prompt+数据源实测)"
```
---
## Task 9: Live GNN run — produce 2-3 gap cards + ≥1 kickoff
**Files:**
- (no committed code; produces real data feeding Task 10's examples doc)
- [ ] **Step 1: Run spark.py on GNN (full)**
```bash
cd skills/gitlink-spark/scripts
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python spark.py --field "graph neural network" --max-papers 8 --gap-type both > _gnn.json 2>&1
PYTHONUTF8=1 python -c "
import json
d=json.load(open('_gnn.json',encoding='utf-8'))
print('papers:',len(d['papers']),'| repos:',len(d['gitlink_repos']),'| issues:',len(d['gitlink_issues']))
# 列出 GitHub 稀缺(<10) 的论文 = 理论缺口候选
for p in d['papers']:
q=p['title'][:60]; gh=d['github_counts'].get(q,{})
tc=gh.get('total_count')
if tc is not None and tc < 10:
print(' THEORY gap cand:', p['arxiv_id'], '|', p['title'][:45], '| GitHub', tc)
"
```
Expected: papers=8, repos≥1, and ≥1 THEORY gap candidate (GitHub <10). Note the candidate arxiv_ids + GitHub counts. Keep `_gnn.json` for the examples doc.
- [ ] **Step 2: LLM-match gap cards from _gnn.json**
Following REFERENCE §三 prompt, produce the opportunity report from `_gnn.json`:
- ≥1 "理论无实现" gap card (from Step 1 candidates) — with evidence triple
- ≥1 "需求无解答" gap card (from gitlink_issues, if any research-pain issue; if issues empty/none research-y, note honestly and lean on theory gaps + lower the demand bar OR widen field keyword)
- ≥1 "已诚实排除" entry (a paper with GitHub ≥50)
Save the report text (will go into Task 10 examples). If no demand-side issue exists, be honest: report 2 theory gaps + 1 排除, note demand-side sparse for GNN on GitLink.
- [ ] **Step 3: Kickoff (fork + issue) on ONE theory gap**
Pick the best theory gap (GitHub <10). Fork its GitHub top repo's nearest GitLink equivalent OR the GitHub top repo isn't forkable via gitlink-cli (cross-platform) instead: if a GitLink baseline exists, `gitlink-cli repo +fork` it; else create a todo issue on an existing GitLink GNN repo (e.g. GraphGallery) describing the reproduction plan with the paper's pseudocode.
```bash
# 若有 GitLink 基准仓库fork 它;否则在 leejt/GraphGallery 开个复现 todo issue
gitlink-cli issue +create --owner leejt --repo GraphGallery \
--title "Reproduction todo: <paper title> (gitlink-spark 机会)" \
--body "<论文 arxiv 链接 + Algorithm 伪代码摘要 + 机会报告卡>"
```
Capture the issue URL/number. If write fails (no permission on leejt/GraphGallery), fall back: create the issue on your own fork (fork first) OR output the todo text for manual creation. Note the actual outcome.
- [ ] **Step 4: No commit (data-gathering)** — proceed to Task 10.
---
## Task 10: examples/spark-图神经网络.md — real walkthrough
**Files:**
- Create: `skills/gitlink-spark/examples/spark-图神经网络.md`
- [ ] **Step 1: Write the real walkthrough using Task 9 outputs**
Content skeleton (fill with REAL data from `_gnn.json` + Task 9 report/kickoff — no placeholders):
```markdown
# 示例gitlink-spark GNN 缺口挖掘(真实数据)
> 基于 `spark.py --field "graph neural network" --max-papers 8` 于 2026-07-XX 实跑。
## 数据采集(真实)
- arXiv 论文N 篇(近 90 天)
- GitLink 仓库M 个(含 <列举>
- GitLink open issuesK 条
- GitHub 全球对照8 个查询
## 机会报告(真实全文)
< Task 9 §2 的报告全文 1 理论缺口 + 1 排除每张带实证三件套>
## 起跑(真实)
<fork/issue 链接或降级说明>
## 关键结论
- 三源融合真实可跑
- GitHub 阈值生效≥50 不报为空白)
- 每条缺口可溯源到 spark.py JSON
```
- [ ] **Step 2: Verify no placeholders**
Run: `grep -E "TBD|TODO|<论文|<列举|<贴 Task" skills/gitlink-spark/examples/spark-图神经网络.md`
Expected: no matches. If any, fill from Task 9 outputs.
- [ ] **Step 3: Clean temp + commit**
```bash
rm -f skills/gitlink-spark/scripts/_gnn.json
git add skills/gitlink-spark/examples/spark-图神经网络.md
git commit -m "feat(spark): examples GNN 真实走查2-3缺口卡+起跑)"
```
---
## Task 11: README + workflow link + register + acceptance
**Files:**
- Modify: `skills/README.md`
- Modify: `skills/gitlink-workflow/SKILL.md`
- [ ] **Step 1: Register the skill**
Run: `bash scripts/setup-skills.sh` then `ls ~/.claude/skills/ | grep gitlink-spark`
Expected: `gitlink-spark` listed.
- [ ] **Step 2: Add README row**
In `skills/README.md` 智能 Skills table, after the gitlink-research-fair row add:
```markdown
| **gitlink-spark** | 文献-代码语义缺口挖掘机 | arXiv×GitLink×GitHub 三源挖"理论无实现/需求无解答"缺口,出机会报告,一键 fork+issue 起跑 |
```
Verify: `grep -c gitlink-spark skills/README.md``≥1`.
- [ ] **Step 3: workflow cross-link**
In `skills/gitlink-workflow/SKILL.md` 专项 Skill list, add:
```markdown
> - 科研机会发现(缺口挖掘) → [`../gitlink-spark/SKILL.md`](../gitlink-spark/SKILL.md)
```
- [ ] **Step 4: Acceptance (spec §15)**
Verify:
- [ ] 四件套齐全SKILL.md + spark.py + REFERENCE.md + examples/
- [ ] `python spark.py --field 图神经网络` 输出合法 JSON
- [ ] GNN 跑出 ≥1 理论 + ≥1 demand或诚实标注 demand 稀疏)缺口卡,三件套可查
- [ ] GitHub 阈值生效≥1 "已诚实排除"
- [ ] ≥1 理论缺口走完起跑
- [ ] 每条缺口可溯源 spark.py JSON
- [ ] REFERENCE 含分类法+数据源实测+诚实边界
- [ ] README 登记
- [ ] **Step 5: Commit + push (updates PR #5)**
```bash
git add skills/README.md skills/gitlink-workflow/SKILL.md
git commit -m "docs(spark): README 登记 + workflow 链接 + 注册"
git push myfork feat/gitlink-research-fair
```

View File

@ -0,0 +1,759 @@
# gitlink-research-fair v2 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Upgrade `gitlink-research-fair` from "5-axis FAIR card" to **科研软件 X 光 (Research Software X-Ray)** — a runnable `fair.py` extracts a real research profile (paper/datasets/repro/citation), LLM renders a varied per-repo report (verdict line + real Mermaid knowledge graph + repo-specific findings) with optional prescription.
**Architecture:** `scripts/fair.py` = stdlib-only deterministic extractor (README regex + file scan → research-profile JSON). `SKILL.md` orchestrates the LLM: 4-dimension verdicts (each citing fair.py evidence) → real knowledge graph → hybrid report → optional fork+PR prescription. Pure extractors are unit-tested; Feature_Critic + Edge live runs are integration validation.
**Tech Stack:** Python 3.11 stdlib only (argparse/json/subprocess/re). gitlink-cli (file +get/+list, repo +info, commit +list). Mermaid for the graph.
**Source of truth:** `docs/superpowers/specs/2026-07-07-gitlink-research-fair-v2-design.md` (read it first).
---
## Environment & Gotchas (engineer must know)
- **Branch:** `feat/gitlink-research-fair` (v2 升级与 v1/spark 同 PR #5). Commit only your own files; leave pre-existing `D README_TASKB.md` / `D gitlink-cli.exe` / `?? dist/` / `?? _edge_prescription/` / `?? report-cards/` untouched.
- **Encoding:** python touching gitlink-cli output MUST run `PYTHONUTF8=1 PYTHONIOENCODING=utf-8`. Never inline Chinese in `python -c` — write a `.py` file.
- **`file +list` returns `data` as a stringified JSON** → `json.loads` it (known structure, see Task 3).
- **`file +get` content lives at `data.entries.content`** (plain text, not base64).
- **`--repo` uses identifier** (ASCII slug, e.g. `Feature_Critic`), not Chinese display name.
- **stdlib only** — no pip. Tests run via `python test_fair.py` (assert-based, no pytest).
- **v1 to remove:** the old `examples/research-fair-workflow.md` (songhui18 v1 report card) is replaced by v2 examples (Task 8 `git rm`s it).
---
## File Structure
| File | Responsibility |
|------|----------------|
| `skills/gitlink-research-fair/scripts/fair.py` | Stdlib extractor: README regex + file scan → research-profile JSON. Pure extractors + gitlink-cli fetchers + main() |
| `skills/gitlink-research-fair/scripts/test_fair.py` | Assert-based unit tests for pure extractors (extract_paper, extract_datasets, assess_repro, assess_citation, extract_methods_frameworks) |
| `skills/gitlink-research-fair/SKILL.md` | REWRITE: X-ray pipeline, 4-dim verdict table, hybrid report template, real KG section, prescription guardrails, degradation table |
| `skills/gitlink-research-fair/REFERENCE.md` | REWRITE: 4-dim verdict rules, real KG schema, extraction rules, data-source findings, FAIR4RS anchor |
| `skills/gitlink-research-fair/examples/feature-critic-xray.md` | Real Feature_Critic X-ray (live-demo script) |
| `skills/gitlink-research-fair/examples/edge-xray.md` | Real Edge X-ray (engine-class contrast: license-conflict finding) |
| `skills/README.md` + `skills/gitlink-workflow/SKILL.md` | Update fair description → "科研软件 X 光" |
---
## Task 1: fair.py — extract_paper + extract_datasets + tests (TDD)
**Files:**
- Create: `skills/gitlink-research-fair/scripts/fair.py`
- Create: `skills/gitlink-research-fair/scripts/test_fair.py`
- [ ] **Step 1: Write the failing test**
`test_fair.py` (exact):
```python
# -*- coding: utf-8 -*-
"""Assert-based unit tests for fair.py pure extractors. Run: python test_fair.py"""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from fair import extract_paper, extract_datasets, assess_repro, assess_citation, extract_methods_frameworks
SAMPLE_README = """# Feature_Critic
Demo code for 'Feature-Critic Networks for Heterogeneous Domain Generalisation'.
This paper is located at https://arxiv.org/abs/1901.11448 and will appear in ICML 2019.
Evaluated on PACS and Visual Decathlon.
@inproceedings{li2019feature,
title={Feature-Critic Networks},
booktitle={ICML}}
"""
SAMPLE_FILES = ["README.md", "main_Feature_Critic.py", "main_baseline.py", "model_PACS.py",
"alexnet.py", "resnet.py", "vggnet.py", "data_gen_PACS.py", "get_model_dataset.sh", "utils.py"]
def test_extract_paper():
p = extract_paper(SAMPLE_README)
assert p["arxiv_id"] == "1901.11448", p["arxiv_id"]
assert p["arxiv_url"] == "https://arxiv.org/abs/1901.11448"
assert p["venue"] == "ICML"
assert p["in_readme"] is True
print("test_extract_paper OK")
def test_extract_paper_none():
p = extract_paper("# Hello\nA normal project with no paper.")
assert p["in_readme"] in (False, True) # title-only may set in_readme; arxiv must be None
assert p["arxiv_id"] is None
print("test_extract_paper_none OK")
def test_extract_datasets():
ds = extract_datasets(SAMPLE_README, SAMPLE_FILES)
names = [d["name"] for d in ds]
assert "PACS" in names and "Visual Decathlon" in names
pacs = [d for d in ds if d["name"] == "PACS"][0]
assert pacs["download_script"] and "data_gen_PACS.py" in pacs["download_script"]
print("test_extract_datasets OK")
if __name__ == "__main__":
test_extract_paper(); test_extract_paper_none(); test_extract_datasets()
print("PART 1 OK (run all after Task 2)")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd skills/gitlink-research-fair/scripts && python test_fair.py`
Expected: FAIL with `ModuleNotFoundError: No module named 'fair'`
- [ ] **Step 3: Write fair.py with extract_paper + extract_datasets (+ stubs for Task 2 functions so import works)**
`fair.py` (exact):
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""gitlink-research-fair v2: research software X-ray. Extract a research profile from a GitLink repo. Stdlib only."""
import argparse, json, os, sys, subprocess, re
_ARXIV_PATS = [
r'https?://arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})',
r'arXiv:(\d{4}\.\d{4,5})',
r'\b(\d{4}\.\d{4,5})\b',
]
_DOI_PAT = r'10\.\d{4,9}/\S+'
_VENUES = ["ICML", "NeurIPS", "NIPS", "ICLR", "CVPR", "ICCV", "ECCV", "ACL", "EMNLP",
"NAACL", "KDD", "WWW", "AAAI", "IJCAI", "SIGGRAPH", "Nature", "Science"]
_KNOWN_DATASETS = ["Visual Decathlon", "PACS", "ImageNet", "CIFAR-10", "CIFAR-100", "CIFAR",
"Cora", "Citeseer", "Pubmed", "MNIST", "COCO", "QM9", "ZINC", "OGB", "ogbn",
"Wikipedia", "PPI", "Reddit", "Amazon", "Yelp", "MUTAG"]
def extract_paper(readme):
"""Extract paper provenance (arxiv/doi/title/venue) from README text."""
if not readme:
return {"in_readme": False, "arxiv_id": None, "arxiv_url": None, "doi": None,
"title": None, "authors": [], "venue": None}
arxiv_id = arxiv_url = None
for pat in _ARXIV_PATS:
m = re.search(pat, readme)
if m:
arxiv_id = m.group(1); arxiv_url = f"https://arxiv.org/abs/{m.group(1)}"; break
doi = None
m = re.search(_DOI_PAT, readme)
if m:
doi = m.group(0).rstrip(").,;]")
venue = None
for v in _VENUES:
if re.search(rf"\b{re.escape(v)}\b", readme):
venue = v; break
title = None
m2 = (re.search(r"[Cc]ode (?:for|of)\s+'([^']+)'", readme)
or re.search(r'[Cc]ode (?:for|of)\s+"([^"]+)"', readme)
or re.search(r"^\s*#\s+(.+)$", readme, re.M))
if m2:
title = m2.group(1).strip()
return {"in_readme": bool(arxiv_id or doi or title), "arxiv_id": arxiv_id,
"arxiv_url": arxiv_url, "doi": doi, "title": title, "authors": [], "venue": venue}
def extract_datasets(readme, files):
"""Identify referenced datasets (known-name match + data scripts)."""
text = readme or ""
found = []
for ds in _KNOWN_DATASETS:
if re.search(rf"\b{re.escape(ds)}\b", text, re.I):
found.append(ds)
scripts = [f for f in files if any(k in (f or "").lower()
for k in ["data_gen", "get_data", "download", "prepare_data", "data_load"])]
return [{"name": ds, "evidence": "mentioned in README",
"download_script": scripts[:2] or None, "license": None} for ds in found]
def assess_repro(files, readme): # implemented in Task 2
return {"deps_files": [], "deps_pinned": False, "entry_points": [], "expected_results": False, "env_spec": False}
def assess_citation(files, readme): # implemented in Task 2
return {"cff": False, "codemeta": False, "zenodo": False, "readme_bibtex": None}
def extract_methods_frameworks(files, readme): # implemented in Task 2
return {"methods": [], "frameworks": []}
def main(): # implemented in Task 3
pass
if __name__ == "__main__":
main()
```
- [ ] **Step 4: Run test to verify Task-1 tests pass**
Run: `cd skills/gitlink-research-fair/scripts && python test_fair.py`
Expected: `test_extract_paper OK` / `test_extract_paper_none OK` / `test_extract_datasets OK` / `PART 1 OK`
- [ ] **Step 5: Commit**
```bash
git add skills/gitlink-research-fair/scripts/fair.py skills/gitlink-research-fair/scripts/test_fair.py
git commit -m "feat(fair-v2): fair.py extract_paper + extract_datasets + testTDD"
```
---
## Task 2: fair.py — assess_repro + assess_citation + extract_methods_frameworks (real impl + tests)
**Files:**
- Modify: `skills/gitlink-research-fair/scripts/fair.py` (replace the 3 stubs)
- Modify: `skills/gitlink-research-fair/scripts/test_fair.py` (add tests)
- [ ] **Step 1: Add tests for the 3 functions**
Append to `test_fair.py` (before the `if __name__` block):
```python
def test_assess_repro():
files = ["README.md", "main_Feature_Critic.py", "requirements.txt", "model_PACS.py"]
r = assess_repro(files, SAMPLE_README)
assert "requirements.txt" in r["deps_files"]
assert r["deps_pinned"] is True
assert "main_Feature_Critic.py" in r["entry_points"]
assert r["expected_results"] is False # SAMPLE_README has no accuracy/results table
print("test_assess_repro OK")
def test_assess_citation():
c = assess_citation(SAMPLE_FILES, SAMPLE_README)
assert c["cff"] is False and c["codemeta"] is False
assert c["readme_bibtex"] and "@inproceedings" in c["readme_bibtex"]
print("test_assess_citation OK")
def test_extract_methods_frameworks():
mf = extract_methods_frameworks(SAMPLE_FILES, SAMPLE_README)
assert "domain generalisation" in mf["methods"], mf["methods"] # SAMPLE_README 提到 Domain Generalisation
assert isinstance(mf["frameworks"], list)
print("test_extract_methods_frameworks OK")
```
And replace the `if __name__ == "__main__":` block with:
```python
if __name__ == "__main__":
test_extract_paper(); test_extract_paper_none(); test_extract_datasets()
test_assess_repro(); test_assess_citation(); test_extract_methods_frameworks()
print("ALL TESTS PASSED")
```
- [ ] **Step 2: Run test to verify the 3 new ones fail**
Run: `cd skills/gitlink-research-fair/scripts && python test_fair.py`
Expected: `test_assess_repro OK` may print then FAIL on assert (stubs return empty deps_files → `"requirements.txt" in []` is False → assert fails). Confirms stubs need replacing.
- [ ] **Step 3: Replace the 3 stubs with real implementations**
Replace `def assess_repro(...) ...` / `def assess_citation(...) ...` / `def extract_methods_frameworks(...) ...` with:
```python
def assess_repro(files, readme):
"""Static reproducibility readiness: deps + entry + env + expected results."""
name_set = {(f or "") for f in files}
deps_candidates = ["requirements.txt", "environment.yml", "go.mod", "package.json",
"Dockerfile", "setup.py", "pyproject.toml"]
deps_files = [f for f in deps_candidates if f in name_set]
entry_points = sorted([f for f in name_set if re.match(r"(main|train|run|demo)_?\w*\.py$", f, re.I)])
env_spec = any(f in ("Dockerfile", "environment.yml") for f in deps_files)
expected = bool(re.search(r"(accuracy|f1\b|bleu|rouge|results?\s*(table|in section)|table\s*\d)",
readme or "", re.I))
return {"deps_files": deps_files, "deps_pinned": bool(deps_files),
"entry_points": entry_points[:5], "expected_results": expected, "env_spec": env_spec}
def assess_citation(files, readme):
"""Citation readiness: CITATION.cff / codemeta / zenodo + README bibtex."""
name_set = {(f or "") for f in files}
m = re.search(r"@(inproceedings|article|misc|book)\{[^}]+\}", readme or "", re.S | re.I)
return {"cff": "CITATION.cff" in name_set,
"codemeta": "codemeta.json" in name_set,
"zenodo": ".zenodo.json" in name_set,
"readme_bibtex": (m.group(0)[:200] if m else None)}
def extract_methods_frameworks(files, readme):
"""Infer methods + frameworks from filenames + README."""
text = " ".join(files) + " " + (readme or "")
frameworks = []
if re.search(r"\b(torch|pytorch|nn\.module)\b", text, re.I): frameworks.append("PyTorch")
if re.search(r"\b(tensorflow|tf\.|keras)\b", text, re.I): frameworks.append("TensorFlow")
if re.search(r"\b(jax|flax|haiku)\b", text, re.I): frameworks.append("JAX")
if re.search(r"\b(sklearn|scikit-learn)\b", text, re.I): frameworks.append("scikit-learn")
methods = []
for kw in ["attention", "transformer", "contrastive", "meta-learning", "federated",
"graph", "convolution", "resnet", "gan", "diffusion", "reinforcement",
"domain generalisation", "domain generalization"]:
if re.search(rf"\b{kw}", text, re.I):
methods.append(kw)
return {"methods": methods[:6],
"frameworks": frameworks or ["unknown (infer from filenames; verify imports)"]}
```
- [ ] **Step 4: Run all tests — expect ALL PASS**
Run: `cd skills/gitlink-research-fair/scripts && python test_fair.py`
Expected: `ALL TESTS PASSED` (6 tests)
- [ ] **Step 5: Commit**
```bash
git add skills/gitlink-research-fair/scripts/fair.py skills/gitlink-research-fair/scripts/test_fair.py
git commit -m "feat(fair-v2): assess_repro + assess_citation + extract_methods_frameworks6 测试全过)"
```
---
## Task 3: fair.py — gitlink-cli fetchers + main + end-to-end smoke
**Files:**
- Modify: `skills/gitlink-research-fair/scripts/fair.py` (add network layer + replace main stub)
- [ ] **Step 1: Add gitlink-cli fetchers + implement main()**
Insert before `def main():` and replace the `def main(): pass` stub:
```python
def _gitlink(*args):
"""Run gitlink-cli with json output; return parsed dict (UTF-8 safe)."""
r = subprocess.run(["gitlink-cli"] + list(args) + ["--format", "json"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60)
raw = r.stdout
i = raw.find("{")
return json.loads(raw[i:]) if i >= 0 else {}
def fetch_readme(owner, repo):
d = _gitlink("file", "+get", "--owner", owner, "--repo", repo, "--path", "README.md")
ent = (d.get("data", {}) or {}).get("entries", {}) or {}
return ent.get("content", "") if isinstance(ent, dict) else ""
def fetch_file_list(owner, repo):
d = _gitlink("file", "+list", "--owner", owner, "--repo", repo)
fd = d.get("data", "[]")
if isinstance(fd, str):
fd = json.loads(fd)
return [f.get("name") for f in fd if isinstance(f, dict)] if isinstance(fd, list) else []
def fetch_repo_meta(owner, repo):
info = _gitlink("repo", "+info", "--owner", owner, "--repo", repo)
comm = _gitlink("commit", "+list", "--owner", owner, "--repo", repo, "--page", "1")
cd = comm.get("data", {})
cl = cd.get("commits") if isinstance(cd, dict) else None
head = (cl[0].get("sha") if cl and isinstance(cl, list) and cl else None)
d = info.get("data", {}) or {}
return {"identifier": d.get("identifier"), "license_id": d.get("license_id"),
"has_dataset": d.get("has_dataset"), "head_sha": head}
def main():
ap = argparse.ArgumentParser(description="gitlink-research-fair v2: research software X-ray")
ap.add_argument("--owner", required=True)
ap.add_argument("--repo", required=True)
args = ap.parse_args()
readme = fetch_readme(args.owner, args.repo)
files = fetch_file_list(args.owner, args.repo)
meta = fetch_repo_meta(args.owner, args.repo)
mf = extract_methods_frameworks(files, readme)
profile = {
"repo": f"{args.owner}/{args.repo}",
"head_sha": meta.get("head_sha"),
"paper": extract_paper(readme),
"datasets": extract_datasets(readme, files),
"repro": assess_repro(files, readme),
"citation": assess_citation(files, readme),
"methods": mf["methods"],
"frameworks": mf["frameworks"],
"license": {"file": any("LICENSE" in (f or "") for f in files),
"license_id": meta.get("license_id")},
"files_count": len(files),
}
json.dump(profile, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
```
- [ ] **Step 2: End-to-end smoke (live, Feature_Critic)**
Run:
```bash
cd skills/gitlink-research-fair/scripts
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python fair.py --owner liyiying10 --repo Feature_Critic > _xray.json 2>&1
PYTHONUTF8=1 python -c "
import json
d=json.load(open('_xray.json',encoding='utf-8'))
print('repo:', d['repo'], '| head:', (d.get('head_sha') or '')[:7])
print('paper arxiv:', d['paper']['arxiv_id'], '| venue:', d['paper']['venue'], '| in_readme:', d['paper']['in_readme'])
print('datasets:', [x['name'] for x in d['datasets']])
print('repro deps:', d['repro']['deps_files'], '| entry:', d['repro']['entry_points'], '| env:', d['repro']['env_spec'])
print('citation cff:', d['citation']['cff'], '| bibtex?', bool(d['citation']['readme_bibtex']))
print('files:', d['files_count'])
"
rm -f _xray.json
```
Expected: arxiv `1901.11448` + venue `ICML` + datasets incl `PACS`/`Visual Decathlon` + entry `main_Feature_Critic.py` + cff False + bibtex True + files 15. If arxiv None, inspect README (the repo may have changed; adapt regex). If `file +list` parse fails, confirm data is stringified-JSON and json.loads handles it.
- [ ] **Step 3: Re-run unit tests (regression)**
Run: `cd skills/gitlink-research-fair/scripts && python test_fair.py`
Expected: `ALL TESTS PASSED`
- [ ] **Step 4: Commit**
```bash
git add skills/gitlink-research-fair/scripts/fair.py
git commit -m "feat(fair-v2): gitlink-cli fetchers + main() 编排 → 科研画像 JSONFeature_Critic smoke 通过)"
```
---
## Task 4: Rewrite SKILL.md (X-ray pipeline + 4-dim + hybrid report + real KG + prescription + degradation)
**Files:**
- Modify (overwrite): `skills/gitlink-research-fair/SKILL.md`
- [ ] **Step 1: Overwrite SKILL.md with the v2 content**
New `SKILL.md` (exact — this replaces the v1 5-axis content entirely):
```markdown
---
name: gitlink-research-fair
version: 2.0.0
description: "科研软件 X 光:用 fair.py 真抽取 GitLink 科研仓库的论文/数据/复现/引用画像LLM 四维裁决,输出含真科研图谱与特有关键发现的洞察报告,可选处方 PR。当用户需要深挖科研仓库的科研产物、评估可复现/可引用性时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "python skills/gitlink-research-fair/scripts/fair.py --help"
---
# gitlink-research-fair v2科研软件 X 光)
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 裁决由 LLM 做出,但每条必须引用 `fair.py` 抽到的实证arxiv id / 文件名 / deps 状态);无实证的判断丢弃。**
**CRITICAL — 处方(开 PR默认预览确认绝不自动 merge、绝不 force-push、绝不碰原仓库。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md);四维裁决细则、真 KG schema、抽取规则见 [`REFERENCE.md`](REFERENCE.md)。
## 概述
**科研软件 X 光**`scripts/fair.py` 真抽取仓库内容README/文件/依赖)→ 科研画像 JSONLLM 对 4 个科研专属维度裁决(论文溯源/数据链/复现就绪/引用就绪),渲染**每 repo 特有的洞察报告**(裁决总览 + 真科研图谱 + 关键发现),可选处方 PR。与 `gitlink-health`(项目过程健康)正交,与 `gitlink-spark`(跨仓挖缺口)互补——本 skill **单仓深挖科研产物**
## 命令接口
```bash
python skills/gitlink-research-fair/scripts/fair.py --owner <owner> --repo <identifier>
# → stdout: 科研画像 JSON {paper, datasets, repro, citation, methods, frameworks, license, head_sha, files_count}
```
skill 约定参数(非 CLI flag
| 参数 | 默认 | 说明 |
|------|------|------|
| `--owner/--repo` | 自动从 cwd 解析 | 目标科研仓库(用 identifier |
| `--auto` | 关 | 跳过预览直接开处方 PR仍受护栏 |
| `--no-fork` | 关 | 只出 X 光报告,不开 PR |
| `--refresh` | 关 | 即使有旧哨兵也重评 |
## 管道
### ① 抽取fair.py确定性
`fetch_readme`file +get+ `fetch_file_list`file +list+ `fetch_repo_meta`repo +info + commit +list 取 HEAD sha`extract_paper` / `extract_datasets` / `assess_repro` / `assess_citation` / `extract_methods_frameworks` → 科研画像 JSON
### ② 四维裁决LLM读 JSON
论文溯源 / 数据链 / 复现就绪 / 引用就绪。每维 `✅/⚠️/❌` + **引用画像字段的具体证据**。规则见 [`REFERENCE.md`](REFERENCE.md)。
### ③ 真科研图谱(从画像生成 Mermaid
节点 `Paper↔Method↔Code↔Dataset↔Framework↔Citation`,带状态色(✅绿/⚠️黄/❌红。schema 见 REFERENCE。
### ④ 渲染 X 光报告hero并落盘
裁决一行 + 真图谱 + **本 repo 特有关键发现** + 处方摘要 + 双裁决证书。**始终保存** `report-cards/<owner>-<repo>-xray.md`(绝不只在终端)。
### ⑤ 处方(可选)
对 ❌/⚠️ 项生成 CITATION.cff从 README 抽的引用)/ requirements.txt从 import 扫)/ Dockerfile → fork → PR默认预览
## 四维裁决(速览,细则见 REFERENCE
| 维度 | ✅ | ⚠️ | ❌ |
|------|---|----|----|
| 论文溯源 | arxiv/DOI + 元数据全 | 仅 README 文字,无稳定链接 | 无论文线索 |
| 数据链 | 命名 + 下载脚本 + license | 命名但无脚本/无 license | 未提及数据集 |
| 复现就绪 | 依赖锁+入口+环境+期望结果齐全 | 有入口但缺依赖锁/环境/期望结果 | 无入口/无依赖 |
| 引用就绪 | CITATION.cff/codemeta + DOI + 版本 | 仅 README 引用文本 | 无引用信息 |
## 报告格式混合主视觉hero
````markdown
🔬 **科研软件 X 光 — <owner>/<repo>**
═══════════════════════════════════════
论文溯源 <V> 数据链 <V> 复现就绪 <V> 引用就绪 <V>
═══════════════════════════════════════
### 🧬 真科研图谱
```mermaid
graph LR
P[<paper venue+arxiv>]:::ok -->|proposes| M[<method>]
M -->|implements| C[<entry file>]:::ok
C -->|uses| D[<datasets>]:::warn
C -->|depends| F[<framework>]:::warn
P -->|cited-via| Ci[<CITATION? or >]:::bad
classDef ok fill:#cfe,stroke:#3a3; classDef warn fill:#ffe,stroke:#cc3; classDef bad fill:#fee,stroke:#c33;
```
### 🔍 关键发现(本 repo 特有)
- <LLM 从画像抽出的 3 条具体发现每条引用 fair.py 字段>
### 🔧 处方(可选)
- < / 项的修复建议>
### 📜 双裁决证书
复现就绪 <V> 引用就绪 <V> 锚定 commit `<sha>`
---
<!-- gitlink-research-fair v2 | repo:<owner>/<repo> | paper:<✅/⚠️/❌> | repro:<V> | cite:<V> | sha:<head> -->
*由 gitlink-research-fair v2科研软件 X 光)生成。*
````
## 处方闭环 + 护栏
对 ❌/⚠️ 项生成修复:`CITATION.cff`(从 README 抽的引用文本构造)+ `requirements.txt`(从代码 import 扫)+ `Dockerfile`(模板)→ `repo +fork``pr +create`PR body 带报告摘要)。**护栏**:默认预览;`--auto` 跳过但**永不 force-push、永不碰原仓库、永不自动 merge**`--no-fork` 报告已落盘(④),不开 PR。
## 错误处理与降级
| 情况 | 处理 |
|------|------|
| README 读失败 | 降级用 file list + 元数据,标注"README 不可读,结论受限" |
| `file +list` data 为字符串 | json.loads 解套 |
| arxiv/DOI 抽不到 | 论文溯源判 ⚠️/❌,据实 |
| 框架无法推断 | frameworks 标"未知",不编造 |
| `--repo` 用中文显示名 404 | 提示用 identifier |
| fork/PR 失败 | 处方物料落本地,告知路径 |
| 报告/PR 发布失败 | 报告已在 ④ 落盘,告知路径 |
```
- [ ] **Step 2: Verify the rewrite removed v1 content**
Run: `grep -c -e "5 轴评分 Rubric" -e "Repro 可复现" -e "五轴雷达" skills/gitlink-research-fair/SKILL.md`
Expected: `0` (v1 5-axis content gone). Then `grep -c -e "科研软件 X 光" -e "四维裁决" -e "真科研图谱" skills/gitlink-research-fair/SKILL.md` → expect `≥3`.
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-research-fair/SKILL.md
git commit -m "feat(fair-v2): 重写 SKILL.mdX光管道+四维裁决+混合报告+真图谱+处方移除v1五轴"
```
---
## Task 5: Rewrite REFERENCE.md (4-dim rules + KG schema + extraction rules + FAIR4RS anchor)
**Files:**
- Modify (overwrite): `skills/gitlink-research-fair/REFERENCE.md`
- [ ] **Step 1: Overwrite REFERENCE.md with v2 content**
New `REFERENCE.md` (exact — replaces v1):
```markdown
# gitlink-research-fair v2 参考文档(科研软件 X 光)
> SKILL.md 的深度参考:四维裁决细则、真 KG schema、fair.py 抽取规则、数据源实测、学术锚。
## 一、四维裁决细则
每维 `✅/⚠️/❌`**必须引用 fair.py 画像字段作证据**。
### 论文溯源F2/R1.2
- ✅ `paper.arxiv_id``paper.doi` 非空 + `paper.title`/`venue` 抽到
- ⚠️ 仅 `paper.title` 抽到("Code for..." 句式),无 arxiv/DOI
- ❌ `paper.in_readme` = False
### 数据链FAIR 数据维度)
- ✅ `datasets` 非空 + 至少一个有 `download_script` + 数据 license 可考
- ⚠️ `datasets` 非空(命名)但无 download_script 或无 license
- ❌ `datasets` 为空
### 复现就绪(独立于 FAIR
- ✅ `repro.deps_pinned`=True + `entry_points` 非空 + `env_spec`=True + `expected_results`=True四件齐全
- ⚠️ 有 `entry_points` 但缺依赖锁/环境/期望结果中任一
- ❌ 无 `entry_points` 或无 `deps_files`
### 引用就绪R1.1/R2
- ✅ `citation.cff``citation.codemeta` 为 True + 有版本/DOI
- ⚠️ 仅 `citation.readme_bibtex` 非空README 有引用文本,无机器可读文件)
- ❌ 三者皆 False
## 二、真科研图谱 schemaMermaid
节点:`Paper(venue+arxiv)` / `Method` / `Code(entry file)` / `Dataset` / `Framework` / `Citation`
边:`Paper —proposes→ Method`、`Method —implements→ Code`、`Code —uses→ Dataset`、`Code —depends→ Framework`、`Paper —cited-via→ Citation`
状态色(按对应维度裁决):✅ `classDef ok fill:#cfe` / ⚠️ `classDef warn fill:#ffe` / ❌ `classDef bad fill:#fee`
## 三、fair.py 抽取规则
- **arxiv**三路正则arxiv URL / `arXiv:id` / 裸 `\d{4}.\d{4,5}`),取首个命中
- **venue**白名单ICML/NeurIPS/ICLR/CVPR/ACL/...)正则
- **datasets**已知名白名单PACS/Visual Decathlon/Cora/ImageNet/...+ 数据脚本data_gen/get_data/download
- **repro**依赖文件名匹配requirements/go.mod/environment.yml/Dockerfile/setup.py+ 入口main/train/run*.py+ 期望结果accuracy/f1/results table 正则)
- **citation**CITATION.cff/codemeta.json/.zenodo.json 文件存在 + README `@inproceedings/@article` bibtex
- **frameworks**torch/tensorflow/jax/sklearn 关键词(文件名+README无则标"未知verify imports"
## 四、数据源实测2026-07
| 源 | 状态 | 备注 |
|---|---|---|
| `gitlink-cli file +get` | ✅ | content 在 `data.entries.content`(纯文本) |
| `gitlink-cli file +list` | ✅ | `data` 是字符串化 JSON需 json.loads |
| `gitlink-cli repo +info` | ✅ | license_id/identifier/has_dataset |
| `gitlink-cli commit +list` | ✅ | HEAD sha |
| OpenAlex | ❌ 已砍 | v1 弱环节(间歇 503v2 不依赖 |
## 五、学术锚FAIR4RS
四维裁决对标 **FAIR4RS**Barker et al. 2022, Nature Sci Data论文溯源→F2/R1.2、数据链→FAIR-Data、复现就绪→独立轴FAIR 必要非充分、引用就绪→R1.1/R2。诚实声明这是适配版评分社区尚无认证级自动校验器非官方认证。
## 六、诚实边界
1. fair.py 抽取覆盖度受 README 写法影响;非标准 README 可能漏(同时匹配多句式兜底)。
2. 框架/方法为推断,标"推断"/"未知",不肯定。
3. 复现就绪是**静态**判断(依赖/入口/环境/期望结果四件套),不实际跑代码。
4. v1 的 OpenAlex 溯源已砍(避免 503 弱环节)。
```
- [ ] **Step 2: Verify**
Run: `grep -c -e "四维裁决细则" -e "真科研图谱 schema" -e "fair.py 抽取规则" -e "FAIR4RS" skills/gitlink-research-fair/REFERENCE.md`
Expected: `4`. And `grep -c -e "5 轴" -e "OpenAlex 字段" skills/gitlink-research-fair/REFERENCE.md``0`.
- [ ] **Step 3: Commit**
```bash
git add skills/gitlink-research-fair/REFERENCE.md
git commit -m "feat(fair-v2): 重写 REFERENCE四维裁决细则+真KG schema+抽取规则+FAIR4RS锚"
```
---
## Task 6: Live run — Feature_Critic X-ray (live-demo) + 落盘
**Files:**
- (produces `report-cards/liyiying10-Feature_Critic-xray.md`; data feeds Task 8 example)
- [ ] **Step 1: Run fair.py on Feature_Critic**
```bash
cd skills/gitlink-research-fair/scripts
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python fair.py --owner liyiying10 --repo Feature_Critic > _fc.json 2>&1
PYTHONUTF8=1 python -c "
import json
d=json.load(open('_fc.json',encoding='utf-8'))
assert d['paper']['arxiv_id']=='1901.11448', 'arxiv 抽取失败'
assert d['paper']['venue']=='ICML'
assert any(x['name']=='PACS' for x in d['datasets'])
assert 'main_Feature_Critic.py' in d['repro']['entry_points']
assert d['citation']['cff'] is False
print('Feature_Critic 画像 OK | head:', (d.get('head_sha') or '')[:7])
"
```
Expected: assertions pass. Keep `_fc.json` for the report.
- [ ] **Step 2: LLM-render the X-ray report (controller)**
Read `_fc.json`, apply REFERENCE §一 verdict rules, render the full hybrid report (verdict line + Mermaid graph + ≥3 repo-specific findings + prescription + dual-verdict certificate) per SKILL.md §报告格式. Expected verdicts: 论文溯源 ✅ / 数据链 ⚠️ / 复现就绪 ⚠️ / 引用就绪 ❌.
- [ ] **Step 3: Save report to report-cards/**
Save the rendered report to `report-cards/liyiying10-Feature_Critic-xray.md` (with sentinel). This is the live-demo artifact.
- [ ] **Step 4: No commit (data-gathering)** — proceed to Task 8.
---
## Task 7: Live run — Edge X-ray (contrast) + 落盘
**Files:**
- (produces `report-cards/Edgedev-Edge-Computing-Engine-xray.md`; data feeds Task 8 example)
- [ ] **Step 1: Run fair.py on Edge**
```bash
cd skills/gitlink-research-fair/scripts
PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python fair.py --owner Edgedev --repo Edge-Computing-Engine > _edge.json 2>&1
PYTHONUTF8=1 python -c "
import json
d=json.load(open('_edge.json',encoding='utf-8'))
print('Edge | paper:', d['paper'].get('arxiv_id'), '| methods:', d['methods'][:3], '| frameworks:', d['frameworks'][:2])
print('license file:', d['license']['file'], '| files:', d['files_count'])
"
```
Expected: paper arxiv likely None (Edge has no arxiv), methods incl autodiff/CNN-ish, license file True. Keep `_edge.json`.
- [ ] **Step 2: LLM-render Edge X-ray + save**
Render the Edge X-ray (controller). The **license-conflict finding** (README "禁止闭源商用" vs Apache LICENSE — note: fair.py detects `license.file=True`; the conflict is read from README content during rendering) must appear as a key finding. Verdicts will differ from Feature_Critic (e.g., 论文溯源 ❌, no paper). Save to `report-cards/Edgedev-Edge-Computing-Engine-xray.md`. The two reports must look visibly different (proves not 千篇一律).
- [ ] **Step 3: No commit** — proceed to Task 8.
---
## Task 8: examples (2 docs) + remove v1 example
**Files:**
- Create: `skills/gitlink-research-fair/examples/feature-critic-xray.md`
- Create: `skills/gitlink-research-fair/examples/edge-xray.md`
- Remove: `skills/gitlink-research-fair/examples/research-fair-workflow.md` (v1 songhui18)
- [ ] **Step 1: Write feature-critic-xray.md (using Task 6 outputs)**
Real walkthrough: the fair.py command + the画像摘要 + the full X-ray report (from `report-cards/liyiying10-Feature_Critic-xray.md`) + a "答辩演示脚本" section (what to say when demoing live). No placeholders — all real values (arxiv 1901.11448, PACS/Visual Decathlon, etc.).
- [ ] **Step 2: Write edge-xray.md (using Task 7 outputs)**
Real walkthrough: fair.py command + 画像 + X-ray report (license-conflict finding). Emphasize the contrast with Feature_Critic (different profile → different report).
- [ ] **Step 3: Remove v1 example + clean temp + commit**
```bash
cd "C:\Users\CWQ98\Desktop\演化与运维\gitlink-cli"
git rm skills/gitlink-research-fair/examples/research-fair-workflow.md
rm -f skills/gitlink-research-fair/scripts/_fc.json skills/gitlink-research-fair/scripts/_edge.json
rm -rf skills/gitlink-research-fair/scripts/__pycache__
git add skills/gitlink-research-fair/examples/feature-critic-xray.md skills/gitlink-research-fair/examples/edge-xray.md
git commit -m "feat(fair-v2): examples Feature_Critic+Edge 真实X光走查移除 v1 songhui18 示例"
```
---
## Task 9: README + workflow update + register + acceptance + push
**Files:**
- Modify: `skills/README.md`
- Modify: `skills/gitlink-workflow/SKILL.md`
- [ ] **Step 1: Update README fair row**
In `skills/README.md`, change the gitlink-research-fair row to:
```markdown
| **gitlink-research-fair** | 科研软件 X 光 | fair.py 抽论文/数据/复现/引用画像,四维裁决 + 真科研图谱 + 特有关键发现报告,可选处方 PR |
```
- [ ] **Step 2: Update workflow link text**
In `skills/gitlink-workflow/SKILL.md`, change "科研软件 FAIR 体检" → "科研软件 X 光fair v2".
- [ ] **Step 3: Re-register + acceptance (spec §16)**
```bash
bash scripts/setup-skills.sh # re-link (no-op if already linked)
ls ~/.claude/skills/ | grep gitlink-research-fair
```
Verify spec §16:
- [ ] SKILL.md重写+ fair.py + test_fair.py + REFERENCE.md重写+ 2 examples 齐全
- [ ] `python fair.py --owner liyiying10 --repo Feature_Critic` 出合法画像 JSONarxiv 1901.11448
- [ ] test_fair.py 6 测试全过
- [ ] Feature_Critic X 光:论文✅/数据⚠️/复现⚠️/引用❌ + 真图谱 ≥6 节点 + 关键发现 ≥3
- [ ] Edge X 光license 冲突作为发现;与 Feature_Critic 报告明显不同
- [ ] 报告落盘 report-cards/
- [ ] v1 五轴雷达/假 KG 已从 SKILL.md 移除grep 0
- [ ] README/workflow 描述更新
- [ ] **Step 4: Commit + push (updates PR #5)**
```bash
git add skills/README.md skills/gitlink-workflow/SKILL.md
git commit -m "docs(fair-v2): README/workflow 更新为「科研软件 X 光」+ 注册"
git push myfork feat/gitlink-research-fair
```

View File

@ -0,0 +1,216 @@
# 智能代码审查 Skillgitlink-review设计
- **日期**2026-06-18
- **状态**:已批准,待编写实现计划
- **作者**CWQ + Claude
- **定位**:子任务二核心场景之一——智能代码审查;纯 Markdown skillSKILL.md + REFERENCE.md + examples/),无需 Go 代码
---
## 1. 背景与目标
gitlink-cli 现有 16 个 skill 中**没有专门的代码审查 skill**`gitlink-workflow` 的"PR 全流程"仅在 Step 5 浅层提及(`pr +files` + "给出审查意见"。README 列的智能 skill 只有 health / changelog / triage 三件套。
本 skill 填补该缺口:引导 AI agent 对指定 GitLink PR 执行**结构化、多视角、低误报**的代码审查,并把结构化审查意见作为评论发布到 PR。
**实用性目标**:信噪比高、误报少、每条发现可执行(带 `file:line` + 修复建议 + 理由)、能稳定落地到 GitLink`pr +comment` 已验证可用)。
**创意性目标**:多视角"评审团" + 对抗式自检(自带质量门,直击 AI code review 的误报老大难)+ 生产风险oncall视角。
## 2. 非目标YAGNI
- 不做 CI / webhook 自动触发——按需由 agent 调用
- **绝不自动 approve / merge**
- 不做跨仓库批量(单 PR 为主;批量留作可选入口,不在首版实现)
- 不修改 gitlink-cli 的 Go 代码——纯 skill 交付物
## 3. 关键决策(用户确认)
| 决策点 | 选择 |
|--------|------|
| 评论形式 | 总结评论为主(`pr +comment`,可靠)+ 行内评论增强reviews API 可用时附加,否则降级) |
| 发表策略 | 默认预览、确认后发;`--auto` 跳过确认(仍受置信度门控) |
| 审查引擎 | 多视角评审团6 视角全量)+ 对抗式自检 |
## 4. 文件清单与改动范围
| 文件 | 动作 | 内容 |
|------|------|------|
| `skills/gitlink-review/SKILL.md` | 新增 | 主管道、6 视角、对抗自检、降噪、输出格式、安全护栏、命令接口 |
| `skills/gitlink-review/REFERENCE.md` | 新增 | 各视角检查清单、严重度评级表、字段映射、reviews API 实测结论与降级、噪音文件规则 |
| `skills/gitlink-review/examples/review-workflow.md` | 新增 | 真实 PR 走查(含植入缺陷验证、真实输出) |
| `skills/README.md` | 修改 | 智能技能表新增 gitlink-review |
| `skills/gitlink-workflow/SKILL.md` | 修改 | 浅层 Code Review 步骤链回本 skill |
## 5. 命令接口
skill 由 agent 调用,参数(约定,非 CLI 子命令):
| 参数 | 默认 | 说明 |
|------|------|------|
| `--owner/--repo` | 自动从 cwd 解析 | 目标仓库 |
| `--id` | 必填 | PR 编号(`pull_request_number`,网页 URL `/pulls/N` |
| `--lenses` | 全 6 视角 | 指定子集,如 `correctness,security` |
| `--auto` | 关 | 跳过预览确认,直接发布(仍受置信度门控) |
| `--inline` | 关 | 尝试附行内评论reviews API 可用时) |
| `--max-findings` | 12 | 报告卡上限,防刷屏 |
| `--refresh` | 关 | 即使检测到旧审查哨兵也重审并更新 |
## 6. 数据流8 步管道)
```
① 取上下文 pr +view / pr +files / pr +diff核心输入/ repo +info语言校准、约定
② 降噪过滤 跳过生成代码 / vendor / lock / 纯重命名 / 二进制 / minified
③ 多视角分析 6 视角并行扫 diff → 候选发现
④ 对抗式自检 逐条尝试反驳 → 丢弃不成立/低置信
⑤ 综合 跨视角去重、按 严重度×likelihood 排序、封顶 → 报告卡
⑥ 预览 默认展示给用户,确认后发(--auto 跳过)
⑦ 发布 pr +comment 发总结评论(可靠);--inline 且 reviews API 可用 → 附行内评论,否则降级
⑧ 幂等 评论内嵌哨兵 <!-- gitlink-review v1 -->,重跑检测旧审查 → 提议更新而非刷屏
```
每条候选发现的数据结构(内部):
```json
{
"lens": "correctness",
"severity": "high", // high(🔴) | medium(🟡) | low(🔵)
"likelihood": "likely", // likely | possible | unlikely
"file": "auth/login.go",
"line": 42,
"what": "空指针未判",
"why": "GetUser 在 token 无效时返回 nil此处直接解引用",
"fix": "if u := GetUser(t); u != nil { ... }",
"confidence": "high" // high | medium | low
}
```
## 7. 评审团 6 视角
| 视角 | 盯什么 |
|------|--------|
| 🔴 正确性 (correctness) | 逻辑错、边界、空值、并发竞态、资源泄漏、错误处理、类型转换 |
| 🔒 安全 (security) | 注入SQL/命令/XSS、鉴权越权、密钥/Token 泄露、路径穿越、不安全反序列化、弱加密 |
| ⚡ 性能 (performance) | N+1 查询、无谓拷贝、O(n²)/嵌套循环、热路径分配、缺索引、大对象常驻 |
| 🧪 测试 (tests) | 新增/改动代码有无对应测试、边界用例、断言是否有效、mock 是否合理 |
| 🧹 可维护性 (maintainability) | 命名、重复代码、圈复杂度、抽象边界、与既有约定一致性 |
| 🚨 生产风险 (prod-risk) | "合并后凌晨3点哪会炸"——可观测性/日志、回滚能力、破坏性变更API/DB/schema、配置依赖、降级路径 |
各视角的展开检查清单写入 `REFERENCE.md`
## 8. 对抗式自检(质量门,创意核心)
对每条候选发现agent 自我问以下问题,任一成立即丢弃或降级:
1. **语境已处理**:完整上下文里是否已有保护(外层判空、上游校验、框架机制)?
2. **风格冒充 bug**:这是真缺陷还是个人风格偏好?若是偏好,降级为 🔵 nit 或丢弃。
3. **幻觉检查**:我引用的 API/函数签名/语言行为是否真实存在?(不确定则不报,或标注"待确认"
4. **重复/已被覆盖**:是否与另一视角的发现其实是同一问题?
**置信度门控**`confidence=low` 且 `severity≠high` → 丢弃。这保证只发高信号发现。
## 9. 降噪规则(自动跳过,不审查)
- 生成代码:`*.gen.go`、`*.pb.go`、`*_generated.*`、`*.min.js`、dist/、build/、target/
- 第三方:`vendor/`、`third_party/`、`node_modules/`
- 锁文件:`go.sum`、`package-lock.json`、`yarn.lock`、`pnpm-lock.yaml`、`Cargo.lock`
- 纯重命名/移动(无内容变更)
- 二进制文件、图片、字体
跳过时在报告卡"范围"行注明跳过数量,不静默吞掉。
## 10. 输出格式(报告卡,作为 PR 评论发布)
```markdown
🤖 **gitlink-review 报告**
**结论**:⚠️ 建议修改2 🔴阻塞 / 4 🟡建议 / 3 🔵nit
**范围**5 文件,+120/-30跳过 2 噪音文件视角6自检丢弃3
| 严重度 | 视角 | 位置 | 问题 |
|:---:|---|---|---|
| 🔴 | 正确性 | auth/login.go:42 | 空指针未判 |
| 🔴 | 安全 | config.go:8 | 硬编码 Token |
| 🟡 | 性能 | list.go:88 | 循环内重复查询 |
### 🔴 阻塞(合并前需处理)
1. **[正确性] auth/login.go:42** — `GetUser(token)` 在 token 无效时返回 nil此处直接解引用会 panic。
- **建议**`if u := GetUser(t); u != nil { ... }`
2. **[安全] config.go:8** — 硬编码 Token存在泄露风险。
- **建议**:改从环境变量读取 `os.Getenv("GITLINK_TOKEN")`
### 🟡 建议
### 🔵 nit
### ✅ 未发现问题的方面
- 安全:未发现注入点
- 生产风险:无破坏性 API 变更
---
<!-- gitlink-review v1 | pr:5 | lenses:6 | refuted:3 | sha:<head-sha> -->
*由 gitlink-review skill 生成;本评论为预览确认后发布。*
```
哨兵 `<!-- gitlink-review v1 | pr:N | lenses | refuted | sha -->` 用于幂等与"针对哪个 commit"标识。
## 11. 安全护栏
- 默认预览确认;`--auto` 跳过确认但**仍受置信度门控**
- **绝不自动 approve / merge**(即使 `--auto`
- 行内评论仅在显式 `--inline` 且 reviews API 实测可用时发,否则不发
- `--max-findings` 封顶,防刷屏
- 所有写操作需认证(遵循 `gitlink-shared`
- 跳过自己作者本人的 PR 时可提示(避免自审盲区),但不强制阻断
## 12. 错误处理与降级
| 情况 | 处理 |
|------|------|
| `pr +diff` 无差异 | 报告"无可审查变更",不发评论 |
| PR 不存在/无权限 | 清晰错误信息,不发评论 |
| reviews API 404已知 `api POST` bug | 记录、跳过行内、总结评论照发 |
| diff 过大(> 阈值) | 抽样审查 + 标注"部分审查(仅 X/Y 文件)" |
| `pr +comment` 发布失败 | 把报告卡原文输出给用户手动粘贴 |
| 置信度全部 low | 报告"未发现高置信问题",列出待人工确认项 |
## 13. 幂等与去重
- 评论内嵌哨兵;重跑时先 `pr +view`/取评论检测哨兵
- 已存在旧审查:默认提议"更新"`--refresh` 才覆盖),避免重复发
- `sha` 字段记录审查所基于的 head commitPR 有新提交时提示"审查已过期,建议重审"
## 14. 验证计划(真实数据,沿用其他 skill 惯例)
建一个**植入已知缺陷的小测试 PR**,包含:
1. 一处空指针/越界(验证 🔴 正确性视角命中)
2. 一处硬编码密钥(验证 🔒 安全视角命中)
3. 一处缺测试的新函数(验证 🧪 测试视角命中)
4. 一处"看起来像 bug 但语境已处理"的伪阳性(验证对抗自检丢弃)
验证项:
- [ ] 每个植入缺陷被对应视角命中
- [ ] 伪阳性被对抗自检丢弃refuted 计数 +1
- [ ] 哨兵正确内嵌,重跑不刷屏
- [ ] 总结评论通过 `pr +comment` 成功发布
- [ ] reviews API 实测:记录可用/不可用结论写入 REFERENCE.md
- [ ] `--auto` 与预览两种模式均验证
结果(真实输出)写入 `examples/review-workflow.md`
## 15. 风险与未决
- **reviews API 可用性**`gitlink-triage` 记录 `api POST` 有 URL 注入 bug 导致 404。本 skill 的行内评论依赖 `POST /:owner/:repo/pulls/:id/reviews`,实现时**必须实测**;不可用则设计已内建降级(仅总结评论)。结论写入 REFERENCE.md。
- **token 消耗**6 视角全量较重;通过 `--lenses` 子集和 `--max-findings` 控制。
- **行号偏移**diff 行号 vs 文件行号的映射需在 REFERENCE.md 给出规则,保证 `file:line` 准确。
## 16. 验收标准
1. `skills/gitlink-review/` 三件套齐全,结构与 triage/health 等智能 skill 一致
2. 测试 PR 的 4 个植入点验证全部通过3 命中 + 1 丢弃)
3. 总结评论在真实 PR 成功发布,含哨兵
4. reviews API 可用性有明确实测结论
5. README 与 workflow 链接更新

View File

@ -0,0 +1,249 @@
# 科研软件 FAIR 体检 Skillgitlink-research-fair设计
- **日期**2026-07-01
- **状态**:已批准,待编写实现计划
- **作者**CWQ + Claude
- **定位**:子任务四(应用 GitLink 辅助科研,加分项)——把 GitLink 科研仓库转化为"FAIR 体检报告卡 + 自动修复 + 可复现证书";纯 Markdown skillSKILL.md + REFERENCE.md + examples/),无需 Go 代码
- **架构中心**报告卡中心FAIR 体检报告为 hero 产出KG/处方/证书/OpenAlex 为其栏目)
---
## 1. 背景与目标
子任务四要求把 gitlink-cli 与学术科研结合,做一个"科研分析层"skill。gitlink-cli 现有 17 个 skill 都是**数据采集/运维型**issue/pr/search/user/pm/review/health/license/changelog/ci/repo/branch/release/triage/workflow/org/shared缺少把仓库数据**升维成科研洞察**的能力。
本 skill 填补该缺口:对任意 GitLink 科研仓库执行**结构化、有学术标准依据、低误报**的"FAIR + 可复现性"诊断,产出一张**体检报告卡**,并对发现的缺口**自动开 PR 修复**、用 **SWH/commit 锚定版本**签发**可复现证书**
**实用性目标**:评分有据(对标 FAIR4RS + howfairis、每项检查可追溯证据哪个 gitlink-cli 字段/文件)、缺口可执行修复(生成 CITATION.cff/codemeta.json/LICENSE/复现章节)、能在真实 GitLink 科研仓库跑通。
**创意性目标**:四幕剧本(**诊断 → 处方 → 证书**v1 不含排行)+ 中国代码托管平台**原生**的科研软件诊断(现有 howfairis/Codefair 只跑 GitHub+ 知识图谱 + OpenAlex 溯源。
## 2. 非目标YAGNI
- **不做多仓库 FAIR 排行榜**(第四幕"排行"留 v2
- 不做 CI/webhook 自动触发——按需由 agent 调用
- **绝不自动 merge** 修复 PR处方默认预览确认`--auto` 仅跳过预览
- **绝不 force-push、绝不碰原仓库**——处方只对 fork 开 PR
- 不修改 gitlink-cli 的 Go 代码——纯 skill 交付物
- 不重复 `gitlink-license` 已有能力——许可证/安全子项**委托复用**它
## 3. 关键决策(用户确认)
| 决策点 | 选择 | 理由 |
|--------|------|------|
| v1 范围 | 诊断 + 处方 + 证书(不做排行) | 保留最惊艳的"自动修复闭环",避开多仓库验证成本 |
| 验证仓库 | `songhui18/ICCV2021论文复现`(处方走 fork | "论文复现"主题与"可复现性"内核共振19⭐真实活跃无 license=现成缺口 |
| 架构中心 | 报告卡中心A 方案) | 最强"信服"载体FAIR4RS 逐项可核对);避免评委认知过载 |
| 许可证/安全支柱 | 复用 `gitlink-license` | 已有 4 维评分+模式库,不重复造 |
| 发表策略 | 默认预览、确认后开 PR`--auto` 跳过预览 | 沿用既有"先预览再执行"偏好 |
## 4. 学术依据与差异化("信服"骨架,每条对抗式核验过)
| 支撑点 | 文献(已核验) | 在 skill 中的用途 |
|--------|----------------|------------------|
| 科研软件 FAIR 原则有国际标准F1/F1.2/F2/R1.1/R1.2/R2 可逐条机器校验 | Barker et al. 2022, *Nature Sci Data* [s41597-022-01710-x]3-0 ✓×3 | 评分骨架 |
| 可复现性危机铁证2024 ICLR/ICML/NeurIPS 论文仅 19.5% 提供官方代码 | PaperCoder [arXiv:2504.17192] Table 92-0 ✓) | 开场"为什么重要"硬数据 |
| 结构化对比表/报告卡是有学术先例的成熟范式 | ORKG (K-CAP 2019)3-0 ✓) | 报告卡范式出处 |
| 仓库数据→RDF 知识图谱的实体/关系 schema 可借鉴13 实体/47 关系/794 万三元组) | LPWC, ISWC 20233-0 ✓) | KG 本体设计 |
| 版本锚定用 SWH-IDMerkle DAG + git 兼容哈希,本地可生成) | Di Cosmo et al. ICMS 2020 [PMC7340894]3-0 ✓) | 证书版本锚定 |
| 作者/机构溯源直接调 OpenAlex REST 反查(无需自建索引) | Priem et al. 2022 [arXiv:2205.01833]3-0 ✓) | 溯源富集 |
| 仓库级 KG 问答在真实仓库跑通(四实体+SZZ+CypherCoT 50%→90% | Repo-KG [arXiv:2412.03815]3-0 ✓) | KG+推理可行性基线 |
| 科研软件"可用+可复现"有现成徽章体系可对标 | ACM Artifact Badging [acm.org]5 核验) | 证书/徽章锚 |
| 直接竞品 howfairis5 维repository/license/registry/citation/checklist只跑 GitHub | fair-software/howfairis | 差异化GitLink 原生 |
**差异化叙事(必须咬死)***第一个原生跑在中国代码托管平台上、面向任意科研仓库的实时 FAIR+可复现性诊断 → 自动修复 → 证书闭环,纯 Agent Skill 形态。* 与 Software Heritage重存档、Papers With Code重列表、OpenAlex重文献、ASCL重天体软件正交。
**战略时机**:据 TIB 2025-10 博客 *Papers With Code went offline*PWC 出现下线/不稳定,社区在找替代——中国平台原生科研软件诊断工具是真空(⚠️ 单一二手源pitch 前自行复核 PWC 状态)。
**诚实边界(不可过度宣称)**
1. FAIR4RS 原文自称 **aspirational**F1.2/R1.1/R1.2/R2 可逐条校验,但"全自动合规验证器"社区仍在建SOMEF/FAIR EVA——本 skill 是**自建 checker**,这本身是贡献,不说"套用现成标准工具"。
2. **FAIRness ≠ 可复现性**FAIR 是必要非充分)——报告卡里 Repro **单独成轴**,不混为一谈。
3. OpenAlex 自 2025-02 起 freemium免费档 ~$1/day、需 API key、100 req/s——批量控量单仓演示够。
4. **两条已被证伪的论点禁止使用**:①"MSR 六分类法"、②"FAIR 分高→被引更多"的因果(研究已剔除)。
5. 文献多跑在 GitHub——"在 GitLink 上复刻"是合理外推,必须真机跑通闭环。
## 5. 文件清单与改动范围
| 文件 | 动作 | 内容 |
|------|------|------|
| `skills/gitlink-research-fair/SKILL.md` | 新增 | 主管道、5 轴 rubric、报告卡模板、处方护栏、证书、KG/OpenAlex、错误降级、命令接口 |
| `skills/gitlink-research-fair/REFERENCE.md` | 新增 | FAIR4RS 逐条映射表、评分算法、KG schema(triples)、OpenAlex 字段、SWH-ID 说明、**学术引用库**、诚实边界 |
| `skills/gitlink-research-fair/examples/research-fair-workflow.md` | 新增 | `songhui18/ICCV2021论文复现` 真实走查(报告卡+处方 PR+证书+截图) |
| `skills/README.md` | 修改 | 新增 gitlink-research-fair科研辅助类 |
| `skills/gitlink-workflow/SKILL.md` | 修改(可选) | 在合适处链回本 skill |
## 6. 命令接口
skill 由 agent 调用,参数(约定,非 CLI 子命令):
| 参数 | 默认 | 说明 |
|------|------|------|
| `--owner/--repo` | 自动从 cwd 解析 | 目标科研仓库 |
| `--lenses` | 全 5 轴 | 子集,如 `F,A,Repro` |
| `--auto` | 关 | 跳过预览直接开 PR仍受护栏不 force-push/不碰原仓/不 merge |
| `--no-fork` | 关 | 不开 PR处方物料落本地文件供手动处理 |
| `--enrich` | 开 | 启用 OpenAlex 溯源(限流则自动降级) |
| `--refresh` | 关 | 即使存在旧报告哨兵也重评 |
## 7. 数据流8 步管道)
```
① 取上下文 repo +info元数据/license/topics/has_dataset· file +list关键文件清单
② 深采 file +get 读 LICENSE/CITATION.cff/codemeta.json/README/requirements/Dockerfile ·
release +list版本· commit +listSHA· issue/pr +list协作· member +list作者
③ FAIR 评分 M1 逐项评 5 轴(许可证/安全子项委托 gitlink-license 流程)→ ✓/✗/⚠/⊥ + 证据 + 置信度
④ KG 构建 M2 四实体(Repo/Contributor/File/Commit)+Paper/Dataset/License 节点 → Mermaid 小图 + triples
⑤ 溯源富集 M3 从 README/CITATION 抽论文 → OpenAlex REST 反查作者/机构best-effort
⑥ 渲染报告卡 hero等级+雷达图+5 轴逐项表+处方摘要+证书栏+KG 小图+溯源栏
⑦ 处方 M4 对 ✗/⚠ 项生成 CITATION.cff/codemeta.json/LICENSE/复现章节 → 默认预览
⑧ 发布 确认后对 fork 开 PRPR body 带报告卡摘要)+ 出 SWH/commit 锚定证书;内嵌哨兵
```
每项检查的内部数据结构:
```json
{
"axis": "A", "id": "A2", "title": "有明确许可证",
"status": "✗", // ✓满足 | ✗缺失 | ⚠部分 | ⊥无字段(跳过)
"evidence": "repo +info.license_id 为空;根目录无 LICENSE 文件",
"confidence": "high", // high | medium | low
"fix": "建议添加 MIT 或木兰 PSL v2 LICENSE处方可自动生成"
}
```
## 8. 评分 Rubric核心
> 对标 **FAIR4RSNature Sci Data 2022+ howfairis 5 维**。诚实声明:这是**适配版**评分(社区尚无认证级自动校验器),不是官方认证。**FAIR ≠ 可复现**,故 Repro 单独成轴。
每项判定 `✓满足 / ✗缺失 / ⚠部分 / ⊥GitLink 无该字段(跳过并标注)`,附**证据来源** + 置信度。
| 轴 | FAIR4RS 映射 | 检查项(证据) |
|----|--------------|----------------|
| **F 可发现** | F1/F2 | F1 清晰描述(repo desc 非空且≥20 字) · F2 话题/关键词(topics) · F3 标准元数据(codemeta.json/.zenodo.json) |
| **A 可访问** | F1.2/R1.1 | A1 公开(is_public) · A2 有许可证(license_id/LICENSE)[gitlink-license] · A3 有版本发布(release tag) · A4 持久标识(commit SHA/SWH-ID) |
| **I 可互操作** | R1.1/I | I1 依赖清单(requirements/go.mod/package.json/environment.yml) · I2 许可证机器可读(SPDX/license_id) · I3 元数据标准格式(CITATION.cff/codemeta) |
| **R 可复用** | R1.2/R2 | R1 溯源/引用(CITATION.cff/README 引用) · R2 README 质量(安装+使用) · R3 源码声明[gitlink-license] · R4 无敏感泄露[gitlink-license] |
| **Repro 可复现** ⭐差异轴 | (独立于 FAIR | Rep1 环境锁(锁版本/Dockerfile) · Rep2 数据集说明(has_dataset/README 引用) · Rep3 复现步骤(README 章节) · Rep4 版本固定 · Rep5 入口可执行(main/Makefile/CLI) |
**评分算法**:轴分 = 满足项 / (总项 ⊥项)(⚠ 计 0.5);总评 = 5 轴均分 → 等级 **A🟢≥80 / B🟡60-79 / C🔴<60**。若某轴全部为 ⊥GitLink 无任何可用字段),该轴记 N/A 并从总评均分中排除。`⊥` 项在报告卡"范围"行注明"GitLink 无 X 字段,已跳过 N 项",不静默吞掉。`confidence=low` 的项标注"待人工确认",不进总评分计算。
## 9. 报告卡格式hero 产出)
```markdown
🏥 **gitlink-research-fair 科研软件体检报告**
**总体**B 🟡 64/100F 70 · A 50 · I 40 · R 80 · Repro 80版本锚定commit a1b2c3d无 release
**范围**:检查 19 项✓11 · ✗4 · ⚠2 · ⊥2跳过GitLink 无 codemeta 字段)
### 五轴雷达
Repro 80
/ \
R 80 F 70
\ /
I 40 —— A 50
### 逐项(节选)
| 轴 | 项 | 状态 | 证据 |
|---|---|:---:|---|
| A | A2 有许可证 | ✗ | license_id 空,根目录无 LICENSE |
| Repro | Rep3 复现步骤 | ⚠ | README 有"使用"但无完整复现流程 |
| F | F1 清晰描述 | ✓ | repo desc 详述 CV 顶会论文复现合集 |
### 🔧 处方(可自动修复 3 项)
- [R1] 生成 CITATION.cff从 README/作者推断)
- [A2] 添加 LICENSEMIT / 木兰 PSL v2 任选)
- [Rep3] 补"复现"章节(从入口推断)
### 📜 可复现证书
锚定版本git+commit a1b2c3d FAIR: B 🟡 Repro: ⚠ 部分可复现
(完整 SWH-ID 需 swh-identify本次用 commit-SHA 锚定)
### 🔗 溯源OpenAlex
未找到关联论文README 未含可识别 DOI/标题)— 已跳过
### 🧬 科研关系图KG
[Mermaid: Repo—contributes→Contributor×3 · Repo—licensed?—? · Repo—has→Dataset? ]
---
<!-- gitlink-research-fair v1 | repo:songhui18/ICCV2021论文复现 | grade:B | sha:a1b2c3d -->
*由 gitlink-research-fair skill 生成。*
```
哨兵 `<!-- gitlink-research-fair v1 | repo | grade | sha -->` 用于幂等与"基于哪个 commit"标识。
## 10. 处方模块M4惊艳闭环与安全护栏
对可自动修复的 ✗/⚠ 项生成物料:
| 缺口 | 生成物 |
|------|--------|
| F3 缺 codemeta.json | 从 repo +info + README 生成 codemeta.json 草稿 |
| R1 缺 CITATION.cff | 从 README/作者生成 CITATION.cff 草稿 |
| A2 缺 license | 给 MIT / 木兰 PSL v2 模板(任选) |
| Rep1 缺依赖锁 | 从 import 扫描建议 requirements.txt |
| Rep3 缺复现章节 | 从入口/README 生成"复现"章节草稿 |
**安全护栏**(沿用既有偏好):
- 默认全量预览 → 用户确认 → 对 **fork**`caoweiqiong/ICCV2021论文复现`)开一个 PRPR body 带报告卡摘要
- `--auto` 跳过预览,但**永不 force-push、永不碰原仓库、永不自动 merge**
- `--no-fork`:不开 PR物料落本地文件供手动处理
- 所有写操作需认证(遵循 `gitlink-shared`
## 11. 证书SWH/commit 锚定)
- 版本锚定HEAD commit SHA + release tag若有
- 若环境有 Python `swh.model` → 算完整 SWH-ID否则用 `git+<commit-SHA>` 锚定,报告卡标注"完整 SWH-ID 需 swh-identify"
- 证书内容仓库、锚定版本、FAIR 等级、Repro 状态、生成时间、可引用条目(对标 ACM Artifact Badge 的 Available/Functional 风格)
- 嵌入报告卡"证书栏";亦可单独输出可复制引用文本
## 12. KG + OpenAlex支撑栏非主角
- **KG**四实体Repo / Contributor / File / Commit+ Paper / Dataset / License 节点;关系 contributes-to / authored / depends-on / cites / licensed-under / version-at。输出 Mermaid 小图(报告卡内嵌)+ triples JSONREFERENCE 给 schema。借鉴 LPWC 实体/关系划分与 Repo-KG 四实体。
- **OpenAlex**:从 README/CITATION 抽论文标题/作者/DOI → REST `/works` 反查作者机构/发表载体/概念。降级freemium 限流或抽不到 → 跳过,标注"未找到关联论文",不影响主报告卡。
## 13. 错误处理与降级
| 情况 | 处理 |
|------|------|
| `file +tree --recursive` 返回空 | 手动递归子目录gitlink-license 已知坑) |
| 无 LICENSE | A2 ✗,处方建议模板 |
| OpenAlex 限流/无果 | 跳过溯源栏标注 |
| `swh.model` 不可用 | commit-SHA 锚定 + 标注 |
| 仓库过大 | 抽样文件 + "部分审查"标注 |
| fork 失败/无写权限 | 处方物料落本地文件供手动 PR |
| 二进制是 npm 旧版 | 强制 `./gitlink-cli` 或提示 `go build` 重建 |
| 仓库私有/无权限 | 清晰错误,不发报告 |
| 报告/PR 发布失败 | 报告卡原文交用户手动粘贴 |
## 14. 验证计划(真实数据)
主目标:**`songhui18/ICCV2021论文复现`**论文复现合集19⭐/17forkPython/CUDA/Jupyter无 license
验证项:
- [ ] 报告卡正确给出 B/C 级(因无 licenseA2 ✗5 轴评分有区分度
- [ ] 处方fork 后成功开 1 个 PRCITATION.cff + LICENSE + 复现章节PR body 含报告卡摘要
- [ ] 证书commit-SHA 锚定正确(若有 swh.model 则给完整 SWH-ID
- [ ] **对照**:跑一个有 license 的仓库(`leejt/图神经网络快速开发测试工具` 或 `Edgedev/Edge深度学习框架`)→ 预期 A 级,证明评分有区分度
- [ ] **边界**:私有仓 / 空仓 / 无 README → 降级正确,不崩溃
- [ ] OpenAlex 溯源:能抽到论文则富集,抽不到则标注降级
- [ ] KGMermaid 图 + triples JSON 正确生成
结果(真实输出 + 截图)写入 `examples/research-fair-workflow.md`
## 15. 风险与未决
- **GitLink 元数据粒度**FAIR4RS 某些条目(如 R2 限定引用、标准 codemetaGitLink 无专用字段 → 用 ⊥ 跳过 + 文件探测 + LLM 推断兜底,明确标注"推断 vs 已验证"。实现时需在真实仓库上确认哪些项可机器读取。
- **OpenAlex freemium 限额**2025-02 起;单仓演示够,批量需控量。
- **SWH-ID 完整性**:纯 Markdown+CLI 环境无 `swh.model` 时只能 commit-SHA 锚定;实现时探测依赖,降级已内建。
- **GitLink GraphQL/REST 粒度**Repo-KG 的 Cypher QA 需图数据库,本 skill **不引入图数据库**(纯 Mermaid+triplesKG 仅作可视化/溯源不做复杂问答YAGNI
- **处方 PR 真实性**:对 fork 开 PR 需 `gitlink-cli repo +fork` + 本地改文件 + push + `pr +create`;需实测 fork→PR 链路是否顺畅,不通则降级 `--no-fork`
## 16. 验收标准
1. `skills/gitlink-research-fair/` 三件套齐全,结构与 health/changelog/triage/review 等智能 skill 一致
2. 5 轴 rubric 每项有明确证据来源 + 状态判定 + 兜底规则
3. `songhui18/ICCV2021论文复现` 真实跑通:报告卡 + 处方 PR对 fork+ 证书,有截图
4. 对照仓库评分有区分度(有 license → A无 → B/C
5. 边界情况(私有/空/无 README降级正确
6. REFERENCE.md 含完整学术引用库 + 诚实边界(含 2 条禁用论点)
7. README 登记 gitlink-research-fair

View File

@ -0,0 +1,212 @@
# 文献-代码语义缺口挖掘机 Skillgitlink-spark设计
- **日期**2026-07-01
- **状态**:已批准,待编写实现计划
- **作者**CWQ + Claude
- **定位**:子任务四(应用 GitLink 辅助科研)的**第二部分**——与 `gitlink-research-fair`(评估已有)组成"科研辅助双联装";本 skill 负责"**发现空白**"。
- **形态**skill + **可运行 python 脚本**(非纯 Markdown不写 Go。
---
## 1. 背景与目标
`gitlink-research-fair` 解决"已有科研软件合不合格"。研究者还有个更核心的痛点:**找新的研究点**。他们读大量论文找灵感,却很难发现"**学术界的最新理论**"与"**开源社区的实际落地**"之间有哪些未被填补的空白——而这往往是低成本发论文/出成果的黄金地带。
本 skill 填补该缺口:给一个研究领域,跨 **arXiv学术× GitLink中文生态× GitHub全球** 三源,挖出两类语义缺口,输出**空白学术机会报告**,并可一键 **fork+issue 起跑**
**实用性目标**demo 级,非生产级全场景通用):在 GNN 领域跑出 **2-3 个漂亮真实例子**供答辩展示;每条缺口带可查实证。
**创意性目标**:从"paper→code gap"角度启发创新,区别于 Papers With Code只链接、不挖缺口/ OpenAlex只文献/ GitHub Archive只存档
## 2. 非目标YAGNI
- **不做生产级全领域通用**——demo 级GNN 跑通 2-3 例即可,不追求任意领域鲁棒
- 不做实时订阅/webhook——按需 agent 调用
- 不自动撰写论文/实现代码——只"提议机会 + 起跑 scaffold"
- **绝不自动 merge**起跑fork+issue默认预览`--auto` 跳过预览
- 不修改 gitlink-cli 的 Go 代码——skill + python 脚本
- 不重复 `gitlink-research-fair`——fair 评估单仓 FAIRspark 跨仓跨源挖缺口
## 3. 关键决策(用户确认)
| 决策点 | 选择 | 理由 |
|--------|------|------|
| v1 缺口类型 | **两种都做**(有理论无实现 + 有需求无解答) | 完整愿景demo 级 2-3 例即可 |
| 验证领域 | **图神经网络 GNN** | 三源最均衡arXiv 海量、GitLink 少量缺口真实、GitHub 海量(对照鲜明) |
| GitHub 全球对照 | **硬需求**,做 | 避免"GitLink 0 ≠ 全球空白"误导;含阈值过滤 |
| 缺口匹配引擎 | **A. LLM 语义匹配为主** | 脚本抓真实数据LLM 提缺口假设+附实证三件套demo 级最轻最灵活 |
| 起跑动作 | fork 最近基准 + issue 粘伪代码 todo | 对称 fair 处方 PR强 demo 闭环 |
| 交付方式 | 与 fair 同 PR #5(子任务四双联装) | 统一叙事 |
## 4. 数据源与可行性(实测 2026-07-01
| 数据源 | 状态 | 用途 | 备注 |
|--------|------|------|------|
| arXiv APIHTTPS | ✅ HTTP 200返回论文条目 | ① 学术采:领域近 N 天论文 | 必须 HTTPSHTTP 被沙箱阻断) |
| `gitlink-cli search +repos` | ✅ | ② GitLink 仓库 | 用 identifier/关键词 |
| `gitlink-cli issue +list`(逐仓库) | ✅ | ② GitLink open issue | 绕开 `search +issues`(返回 HTML 的坑) |
| GitHub Search API | ✅ HTTP 200total_count + items | ③ 全球对照 | 未认证 10 req/min`GITHUB_TOKEN` 提至 5000/h |
| OpenAlex | ⚠ 间歇 503 | 引用计数(可选富集) | best-effort降级跳过 |
## 5. 文件清单
| 文件 | 动作 | 内容 |
|------|------|------|
| `skills/gitlink-spark/SKILL.md` | 新增 | 4 阶段管道编排、缺口分类法、报告模板、GitHub 阈值规则、起跑护栏、错误降级、命令接口 |
| `skills/gitlink-spark/scripts/spark.py` | 新增 | **可独立运行的数据融合脚本**arXiv + gitlink-cli + GitHub API → 输出 JSON 给 LLM含缓存与限流 |
| `skills/gitlink-spark/REFERENCE.md` | 新增 | 缺口分类法细则、GitHub 阈值与分级、LLM prompt 模板、数据源实测结论、诚实边界 |
| `skills/gitlink-spark/examples/spark-图神经网络.md` | 新增 | GNN 真实跑出的 2-3 缺口卡(含起跑截图) |
| `skills/README.md` | 修改 | 智能技能表加 gitlink-spark |
| `skills/gitlink-workflow/SKILL.md` | 修改(可选) | 专项 skill 链表加 gitlink-spark |
## 6. 命令接口
### spark.py独立可运行
```bash
python skills/gitlink-spark/scripts/spark.py \
--field "图神经网络" \
[--max-papers 10] \
[--gap-type both|theory|demand] \
[--github-token $GITHUB_TOKEN] # 可选,未设则按 10/min 限流
# → stdout 输出融合 JSON{papers, gitlink_repos, gitlink_issues, github_counts}
```
### skill 约定参数SKILL.md非 CLI flag
| 参数 | 默认 | 说明 |
|------|------|------|
| `--field` | 必填 | 研究领域(如 `图神经网络`、`federated learning` |
| `--gap-type` | `both` | `theory` / `demand` / `both` |
| `--max-papers` | 10 | arXiv 抓取论文上限(控制 GitHub 调用) |
| `--auto` | 关 | 跳过预览直接起跑(仍受护栏) |
| `--no-fork` | 关 | 只出报告,不起跑 |
## 7. 数据流4 阶段管道)
```
① 学术采 spark.py: arXiv HTTPS 抓领域近 90 天论文(标题/摘要/arxiv id/方法关键词)
② GitLink spark.py: gitlink-cli search +repos 抓领域仓库;
对每个仓库 issue +list 抓 open issue绕开 search+issues HTML
③ 全球对照 spark.py: GitHub Search API 对每个论文方法查 total_count + Top3 仓库
④ 缺口匹配LLMSKILL.md 编排)
读 spark.py 输出的 JSON → 语义匹配两类缺口 → 每张带实证三件套 → 渲染机会报告
⑤ 起跑(可选) 选定缺口 → repo +fork 基准 → issue +create 粘论文伪代码 todo
```
**职责切分**`spark.py` 只抓**真实数据**(确定性、可复现);**缺口发现**交给 LLM语义判断但必须附实证受置信度门控。
spark.py 输出 JSON schema
```json
{
"field": "图神经网络",
"papers": [{"arxiv_id":"2403.xxxxx","title":"...","abstract":"...","published":"2024-03-15","method_keywords":[...]}],
"gitlink_repos": [{"owner":"leejt","repo":"GraphGallery","desc":"...","topics":[...]}],
"gitlink_issues": [{"repo":"leejt/GraphGallery","number":12,"subject":"...","status":"open","participants":3}],
"github_counts": [{"method":"graph attention XXX","total_count":2,"top":[{"full_name":"...","stars":3534}]}]
}
```
## 8. 两类缺口分类法 + 实证三件套
每张缺口卡**必须**带齐三件套,否则被置信度门控丢弃(防 LLM 编造):
### 类型 A有理论无实现paper → code gap
- **三件套**:① 论文 arxiv id + 标题 + 发表日期 ② GitLink 搜索查询串 + 命中数0 或极少) ③ GitHub total_count + Top 仓库(参考基准)
- **判定**:论文提出方法 MGitLink 实现 0/极少GitHub 按下面阈值分级。
### 类型 B有需求无解答open issue → applied research gap
- **三件套**:① issue URL + 主题 + 讨论人数/状态 ② GitLink 无现成实现解此痛点 ③ GitHub 是否有成熟开源解(对照)
- **判定**:领域仓库中存在"研究性痛点"open issue排除"安装报错"类使用问题);学术/GitHub 无现成解。
- **降噪**LLM 筛"研究性痛点"(含性能/可扩展性/新场景),排除使用/部署类。
## 9. GitHub 全球对照与阈值(诚实核心,硬需求落地)
防止"GitLink 0 ≠ 全球空白"误导。对每个"理论无实现"候选,按 GitHub total_count 分级:
| GitHub total_count | 分级 | 报告行为 |
|--------------------|------|----------|
| `< 10` | **全球稀缺(真空白)** | 报为高价值缺口:"GitLink 生态空白 × 全球稀缺 → 复现并开源到 GitLink易成本平台标杆" |
| `1050` | **新兴(部分空白)** | 报为中等缺口:"GitLink 空白全球新兴N 个),可做中文生态首个完整实现" |
| `≥ 50` | **全球已成熟** | **不报为空白**,列入"✅ 已诚实排除"区:"GitLink 虽 0但 GitHub 已 N 个(含官方)→ 全球已成熟,非空白" |
> 这个分级是本 skill 的诚实命门宁可少报不误报机会。GitHub 计数缓存spark.py 按 method key 缓存去重)。
## 10. 机会报告格式hero
````markdown
⚡ **gitlink-spark 机会报告:<field>**
学术采arXiv 近 90 天 N 篇 GitLink 仓库 M 个 GitHub 全球基线已对照
生成时间YYYY-MM-DD
### 🧩 缺口 1 · 有理论无实现 [全球稀缺·高价值]
**论文**[arxiv:<id>] "<title>" (<venue/date>)
**方法关键词**<...>
**GitLink**search "<query>" → **0 命中**(查询串留底可复现)
**GitHub 全球**total_count = **2**Top: <repo> <stars>⭐)→ 全球稀缺
**机会建议**<LLM 一句话为何值得复现 + 开源到 GitLink>
**起跑**[按钮] fork 基准 <repo> → 创建 issue 粘论文 Algorithm 1 伪代码
### 🧩 缺口 2 · 有需求无解答 [应用机会]
**Issue**<repo>#<n> "<subject>"N 人讨论, open, <date>
**痛点**<LLM 一句话研究性痛点归纳>
**GitLink / GitHub**:均无成熟开源解
**机会建议**<LLM 一句话可写应用级论文 + GitLink 落地>
### ✅ 已诚实排除(非空白)
- 论文 YGitLink 虽 0但 GitHub 已 47 个实现(含官方)→ 全球已成熟,不报
---
<!-- gitlink-spark v1 | field:<field> | gaps:<N> | date:<YYYY-MM-DD> -->
*由 gitlink-spark skill 生成。*
````
## 11. 起跑动作M5+ 安全护栏
选定一张"理论无实现"缺口卡 → 用户确认 →
1. `gitlink-cli repo +fork` 最近基准GitHub Top 仓库 或 GitLink 最近实现)
2. LLM 从 arXiv 论文抓取 Algorithm/Pseudocode 节
3. `gitlink-cli issue +create` 在 fork 上建一个复现 todo issuebody 粘入论文伪代码 + 报告卡摘要
**护栏**(沿用既有偏好):默认预览确认;`--auto` 跳过预览但**永不 force-push、永不碰原仓库、永不自动 merge**`--no-fork` 只出报告。
## 12. 错误处理与降级
| 情况 | 处理 |
|------|------|
| arXiv 返回空/超时 | 改 HTTPS 重试;仍空则报告"学术采失败",降级用既有论文 |
| `search +issues` 返回 HTML已知坑 | 不用它;用 `issue +list` 逐仓库枚举 |
| GitHub 未认证限流10/min | spark.py sleep ~7s 限速;建议设 `GITHUB_TOKEN` |
| GitHub total_count 查询失败 | 该论文标注"GitHub 对照失败",不进缺口判定 |
| OpenAlex 503 | 跳过引用富集,不影响主报告 |
| LLM 缺口无实证三件套 | 置信度门控丢弃 |
| fork/issue 起跑失败 | 输出 fork 目标 + 伪代码文本供手动起跑 |
| 二进制是 npm 旧版 | 强制 `./gitlink-cli``go build` |
## 13. 验证计划demo 2-3 例GNN 领域)
实跑 `spark.py --field "图神经网络"` + LLM 匹配,产出:
- [ ] **≥1 张"理论无实现"缺口卡**GitHub total_count < 10全球稀缺三件套可查
- [ ] **≥1 张"需求无解答"缺口卡**(真实 open issue + 研究性痛点),三件套可查
- [ ] **≥1 张"理论无实现"缺口卡走完 fork+issue 起跑**(截图/链接demand 卡为写论文方向、无 fork 起跑)
- [ ] "✅ 已诚实排除"区至少 1 条GitHub ≥50 的非空白),证明阈值生效
- [ ] 全报告无 LLM 编造(每条可溯源到 spark.py JSON
结果写入 `examples/spark-图神经网络.md`
## 14. 风险与未决
- **arXiv 方法抽取**:从论文摘要自动抽"方法关键词"供 GitHub 查询LLM 抽取有噪声 → spark.py 同时用论文标题关键词 + LLM 抽取双路查询 GitHub取 total_count。
- **GitLink GNN 仓库数量**:可能很少(之前扫到 GraphGallery 等需求侧缺口issue依赖仓库数若太少则 demand 缺口样本不足 → 必要时放宽领域关键词(如含 `图`/`GNN`/`graph neural`)。
- **GitHub 限流**demo 一次 10 篇论文 × 1 查询 = 10 次,刚好未认证上限;建议跑 demo 时设 `GITHUB_TOKEN`
- **缺口"机会建议"主观**:靠实证三件套兜底;建议标注"机会仅为启发,需研究者自行判断"。
## 15. 验收标准
1. `skills/gitlink-spark/` 四件套齐全SKILL.md + scripts/spark.py + REFERENCE.md + examples/
2. `spark.py` 可独立运行:`python spark.py --field 图神经网络` 输出合法融合 JSON
3. GNN 跑出 2-3 张真实缺口卡≥1 theory + ≥1 demand每张三件套可查
4. GitHub 阈值生效:"已诚实排除"区至少 1 条
5. ≥1 张缺口卡走完 fork+issue 起跑,有截图/链接
6. 全报告每条缺口可溯源到 spark.py JSON无 LLM 编造)
7. REFERENCE.md 含缺口分类法 + 数据源实测结论 + 诚实边界
8. README 登记 gitlink-spark

View File

@ -0,0 +1,234 @@
# gitlink-research-fair v2 设计(科研软件 X 光 / Research Software X-Ray
- **日期**2026-07-07
- **状态**:已批准,待编写实现计划
- **作者**CWQ + Claude
- **定位**:对 v15 轴 FAIR 体检卡)的**重大升级**,回应"与 health 重合 / 报告千篇一律 / 假科研图谱 / 纯 Markdown 无脚本"四条批评。同一 skill 演进(保留 fair 名 + FAIR4RS 学术锚 + PR #5),重构内部。
- **形态**skill + **可运行 `scripts/fair.py`**stdlib only内容感知的科研分析工作流。
---
## 1. 背景与目标
v1 的 fair 是"5 轴 FAIR 打分 + 报告卡",存在四个真问题:
1. 与 `gitlink-health` 形态重合(都是"聚合 gitlink-cli → AI → 打分报告"),且不够科研专属。
2. 报告千篇一律(每仓库都是同一组 ✓/✗);五轴 ASCII 雷达呈现烂;"科研关系图"是假的(只画作者/license/dataset?)。
3. 纯 Markdown无脚本分析靠 LLM"看一眼",不可复现。
4. 缺真实科研洞察。
v2 升级为**科研软件 X 光**`fair.py` **真抽取** repo 内容README/文件/依赖)→ 科研画像 JSON → LLM 对 4 个科研专属维度做裁决(每条带实证)→ **每 repo 特有的洞察报告**(裁决总览 + 真科研图谱 + 关键发现)+ 可选处方闭环。
**目标**:内容感知(每份报告说本 repo 特有的东西)、科研专属(论文/数据/复现/引用非通用软件质量、可执行fair.py 脚本、demo 级1-2 示例 + 1 答辩演示,非全场景覆盖)。
## 2. 非目标YAGNI
- 不做"实际运行代码验证复现"(静态复现就绪度检查即可,跑代码超 demo 范围)
- 不做全场景/全领域覆盖——Feature_Critic + Edge 两个场景跑通即可
- 不重复 sparkspark 跨仓跨源挖缺口fair v2 单仓深挖科研产物)
- 不自动 merge处方默认预览
- 不写 Go——skill + python 脚本
- **砍掉 v1 的**5 轴雷达、假 KG、OpenAlex 溯源(弱环节)、与 health 重合的通用质量味
## 3. 关键决策(用户确认)
| 决策点 | 选择 | 理由 |
|--------|------|------|
| 主角定位 | **科研软件 X 光**(深挖科研产物 + 4 维裁决) | 替代 5 轴打分,科研专属,每 repo 特有 |
| 报告主视觉 | **混合**(裁决一行 + 真 Mermaid 图谱 + 关键发现) | 扫读 + 视觉兼顾,回应雷达烂 + 假图谱 + 千篇一律三连批 |
| 改造方式 | **演进式**(保留 fair 名/FAIR4RS 锚/PR #5 | 不浪费 v1 资产,重构内部 |
| 演示仓库 | **liyiying10/Feature_Critic**ICML2019 paper-code | README 明含 arxiv 链接X 光论文溯源能真抽4 维有区分度 |
| 第二示例 | **Edgedev/Edge-Computing-Engine**(引擎类) | 与 Feature_Critic 不同 profilelicense 冲突发现),证明不千篇一律 |
| 脚本 | **fair.py**stdlib onlyfair.py 抽数据 + LLM 裁决) | 内容感知、可复现,取代 LLM"看一眼" |
## 4. 与 v1 的差异 + 取舍
| 维度 | v1 | v2 |
|------|----|----|
| 评分对象 | 5 轴 FAIRF/A/I/R/Repro通用软件质量 | 4 维科研专属(论文溯源/数据链/复现就绪/引用就绪) |
| 数据来源 | LLM 看 gitlink-cli 元数据 | fair.py 真抽取 README/文件/依赖 |
| 图谱 | 假 KG作者/license/dataset? | 真 Mermaidpaper↔method↔code↔dataset↔framework↔citation |
| 报告 | 千篇一律 ✓/✗ + ASCII 雷达 | 裁决一行 + 真图谱 + **本 repo 特有关键发现** |
| 脚本 | 无(纯 Markdown | fair.py可独立运行 |
| 处方 | 有(保留) | 保留升级CITATION.cff 从 README 抽 / requirements 从 import 扫) |
| OpenAlex 溯源 | 有(弱、易 503 | **砍**(去掉更自洽) |
| 落盘 | 有v1 已补) | 保留report-cards/<owner>-<repo>-xray.md |
## 5. 文件清单
| 文件 | 动作 | 内容 |
|------|------|------|
| `skills/gitlink-research-fair/SKILL.md` | 重写 | X 光管道抽数→裁决→图谱→报告→处方、4 维裁决表、报告模板、真图谱说明、处方护栏、降级 |
| `skills/gitlink-research-fair/scripts/fair.py` | 新增 | 真抽取README/文件/依赖)→ 科研画像 JSONstdlib only |
| `skills/gitlink-research-fair/scripts/test_fair.py` | 新增 | 抽取函数单测arxiv 正则 / 依赖检测 / 数据集识别 / 引用检测) |
| `skills/gitlink-research-fair/REFERENCE.md` | 重写 | 4 维裁决细则 + 真 KG schema + 抽取规则 + 数据源实测 + 学术锚FAIR4RS |
| `skills/gitlink-research-fair/examples/feature-critic-xray.md` | 新增 | Feature_Critic 真实 X 光走查(答辩演示脚本) |
| `skills/gitlink-research-fair/examples/edge-xray.md` | 新增 | Edge 引擎类对照license 冲突发现) |
| `skills/README.md` / `skills/gitlink-workflow/SKILL.md` | 微调 | fair 描述改为"科研软件 X 光" |
> v1 的旧 examplessonghui18 报告卡)替换为 v2 X 光示例。
## 6. 命令接口
### fair.py可独立运行
```bash
python skills/gitlink-research-fair/scripts/fair.py --owner liyiying10 --repo Feature_Critic
# → stdout: 科研画像 JSON {paper, datasets, repro, citation, methods, frameworks, license, files, head_sha}
```
### skill 约定参数SKILL.md
| 参数 | 默认 | 说明 |
|------|------|------|
| `--owner/--repo` | 自动从 cwd 解析 | 目标科研仓库(用 identifier |
| `--auto` | 关 | 跳过预览直接开处方 PR仍受护栏 |
| `--no-fork` | 关 | 只出 X 光报告,不开 PR |
| `--refresh` | 关 | 即使有旧哨兵也重评 |
## 7. 数据流(管道)
```
① 抽取fair.py确定性
fetch readme (file +get) + file list (file +list) + repo +info + commit +list(HEAD sha)
→ extract_paper / extract_datasets / assess_repro / assess_citation / extract_methods_frameworks
→ 科研画像 JSON
② 裁决LLM读 JSON4 维 × (✅/⚠️/❌ + 具体证据)
③ 真科研图谱(从画像生成 Mermaid
④ 渲染 X 光报告hero裁决一行 + 真图谱 + 关键发现 + 处方摘要 + 双裁决证书)→ 落盘
⑤ 处方可选fork → 建 CITATION.cff/requirements/Dockerfile → PR默认预览
```
**职责切分**fair.py 只做**确定性抽取**(可复现);**裁决与"关键发现"叙述**交给 LLM语义但每条必须引用 fair.py 抽到的实证。
## 8. fair.py 抽取目标 + 科研画像 JSON schema
抽取函数(纯函数单测 + gitlink-cli 网络层):
- `extract_paper(readme)` → 正则抽 arxiv id/URL、DOI、OpenReview/aclanthology、标题/作者/会议、"Code for the paper" 句式
- `extract_datasets(readme, files)` → 已知数据集名PACS/Cora/ImageNet/Visual Decathlon/MNIST/CIFAR…+ 数据脚本data_gen/get_data/download.sh+ 数据 license/DOI
- `assess_repro(files, readme)` → 依赖文件requirements/go.mod/environment.yml/Dockerfile+ 锁版本入口main/train/run/*.py期望结果results.md/accuracy/表);环境说明
- `assess_citation(files, readme)` → CITATION.cff / codemeta.json / .zenodo.json 存在?+ README BibTeX/引用文本
- `extract_methods_frameworks(files, readme)` → 方法词 + 框架torch/tensorflow/jax从 import/文件名推断)
科研画像 JSON
```json
{
"repo": "liyiying10/Feature_Critic", "head_sha": "<sha>",
"paper": {"arxiv_id":"1901.11448","arxiv_url":"https://arxiv.org/abs/1901.11448","doi":null,
"title":"Feature-Critic Networks for Heterogeneous Domain Generalisation",
"authors":["Yiying Li","Yongxin Yang","Wei Zhou","Timothy M. Hospedales"],"venue":"ICML 2019","in_readme":true},
"datasets": [{"name":"PACS","evidence":"data_gen_PACS.py + README","download_script":"get_model_dataset.sh","license":null},
{"name":"Visual Decathlon","evidence":"data_gen_VD.py","download_script":null,"license":null}],
"repro": {"deps_files":[],"deps_pinned":false,"entry_points":["main_Feature_Critic.py","main_baseline.py"],
"expected_results":false,"env_spec":null},
"citation": {"cff":false,"codemeta":false,"zenodo":false,
"readme_bibtex":"Li, Yang, Zhou, Hospedales. Feature-Critic Networks... ICML 2019"},
"methods": ["feature-critic","meta-learning","domain generalisation"],
"frameworks": ["torch (inferred: alexnet/resnet/vggnet)"],
"license": {"file":false,"type":null,"conflict":null},
"files_count": 15
}
```
## 9. 4 维裁决规则LLM 读画像,每维 ✅/⚠️/❌ + 证据)
| 维度 | ✅ | ⚠️ | ❌ |
|------|---|----|----|
| 论文溯源 | 有 arxiv/DOI + 元数据全 | 仅 README 文字提及,无稳定链接 | 无任何论文线索 |
| 数据链 | 数据集命名 + 下载脚本 + license | 命名但无脚本/无 license | 未提及数据集 |
| 复现就绪 | 依赖锁 + 入口 + 环境 + 期望结果四件齐全 | 有入口但缺依赖锁/环境/期望结果 | 无入口/无依赖 |
| 引用就绪 | CITATION.cff/codemeta + DOI + 版本 | 仅 README 引用文本 | 无任何引用信息 |
每维裁决**必须引用画像里的具体字段**(如"arxiv 1901.11448 已抽""requirements.txt 缺失"),无实证的判断丢弃。
## 10. 真科研图谱 schemaMermaid从画像生成
节点(带状态色):`Paper(arxiv+venue)` / `Method` / `Code(file)` / `Dataset` / `Framework` / `Citation`
边:`Paper —proposes→ Method`、`Method —implements→ Code`、`Code —uses→ Dataset`、`Code —depends→ Framework`、`Paper —cited-via→ Citation`
状态色:✅绿 / ⚠️黄 / ❌红(缺失项)。
Feature_Critic 实例化:`ICML2019(1901.11448)✅ —proposes→ Feature-Critic方法 —implements→ main_Feature_Critic.py✅ —uses→ PACS⚠/Visual Decathlon⚠ —depends→ PyTorch(推断)⚠Paper —cited-via→ 无CITATION❌`。真有科研含义,非 v1 假图。
## 11. 报告格式混合主视觉hero
````markdown
🔬 **科研软件 X 光 — liyiying10/Feature_Critic**
═══════════════════════════════════════
论文溯源 ✅ 数据链 ⚠️ 复现就绪 ⚠️ 引用就绪 ❌
═══════════════════════════════════════
### 🧬 真科研图谱
```mermaid
graph LR
P[ICML2019 arxiv:1901.11448]:::ok -->|proposes| M[Feature-Critic 方法]
M -->|implements| C[main_Feature_Critic.py]:::ok
C -->|uses| D1[PACS]:::warn
C -->|uses| D2[Visual Decathlon]:::warn
C -->|depends| F[PyTorch 推断]:::warn
P -->|cited-via| Ci[无 CITATION.cff]:::bad
classDef ok fill:#cfe,stroke:#3a3; classDef warn fill:#ffe,stroke:#cc3; classDef bad fill:#fee,stroke:#c33;
```
### 🔍 关键发现(本 repo 特有)
- 论文 ICML2019 arxiv:1901.11448 已溯源 ✓,但**无 CITATION.cff** → 机器不可引用
- 数据集 PACS/Visual Decathlon 命名 + `get_model_dataset.sh`,但**无数据 license**
- 入口 `main_Feature_Critic.py` 在,但**无 requirements.txt** → 依赖未锁,复现风险
- 框架alexnet/resnet/vggnet疑似 PyTorch**依赖未声明**
### 🔧 处方(可选)
`CITATION.cff`(从 README 抽的引用)+ `requirements.txt`(从 import 扫)+ `Dockerfile`
### 📜 双裁决证书
复现就绪 ⚠️ 部分 引用就绪 ❌ 锚定 commit `<sha>`
---
<!-- gitlink-research-fair v2 | repo:<owner>/<repo> | paper:? | repro:? | cite:? | sha:<head> -->
*由 gitlink-research-fair v2科研软件 X 光)生成。*
````
报告**始终落盘** `report-cards/<owner>-<repo>-xray.md`(沿用 v1 落盘原则)。
## 12. 处方闭环 + 护栏(保留 v1 升级)
对 ❌/⚠️ 项生成修复:`CITATION.cff`(从 README 抽的引用文本构造)+ `requirements.txt`(从代码 import 扫出依赖)+ `Dockerfile`(模板)。→ fork → PR默认预览`--auto` 跳过;永不 force-push/碰原仓库/自动 merge`--no-fork` 报告已落盘)。
## 13. 错误处理与降级
| 情况 | 处理 |
|------|------|
| README 读取失败 | 降级用 file list + 元数据,标注"README 不可读,结论受限" |
| `file +list` data 为字符串 | json.loads 解套(已知结构) |
| arxiv/DOI 抽不到 | 论文溯源维度判 ⚠️ 或 ❌,据实 |
| 框架无法推断 | frameworks 标 "未知",不编造 |
| `--repo` 用了中文显示名 404 | 提示用 identifier从 search 取) |
| fork/PR 失败 | 处方物料落本地,告知路径 |
| 报告/PR 发布失败 | 报告已在 ④ 落盘,告知路径 |
## 14. 验证计划1-2 示例 + 答辩演示)
- **答辩现场演示 + 示例1**`liyiying10/Feature_Critic`
- [ ] fair.py 抽出 arxiv 1901.11448 + ICML2019 + PACS/VD 数据集 + 入口 + 无 CITATION/requirements
- [ ] 4 维裁决:论文 ✅ / 数据 ⚠️ / 复现 ⚠️ / 引用 ❌
- [ ] 真图谱画出 6 类节点 + 状态色
- [ ] 关键发现 ≥3 条本 repo 特有
- **示例2**`Edgedev/Edge-Computing-Engine`
- [ ] X 光发现 license 冲突Apache vs README"禁商用")作为关键发现
- [ ] 与 Feature_Critic 报告内容明显不同(证明不千篇一律)
- 两示例均落盘 report-cards/ + 写入 examples/。
## 15. 风险与未决
- **README 抽取覆盖度**:正则抽 arxiv/DOI 依赖 README 写法;对非标准 README 可能漏。fair.py 同时匹配多种句式arxiv URL / arXiv:id / 裸 id兜底。
- **框架推断**:从文件名/import 推断 PyTorch/TF 有噪声 → 标"推断",不肯定。
- **数据集识别**:已知数据集名白名单有限;未知名标"unnamed dataset (mentioned)"。
- **GitLink file +list data 字符串化**fair.py 需 json.loads 解套(已知)。
- **演示仓库稳定性**Feature_Critic 是 1⭐ 小仓但 ICML2019 真实学术代码,内容稳定;演示前复跑一次确认 README/文件未变。
## 16. 验收标准
1. `skills/gitlink-research-fair/` 含 SKILL.md重写+ scripts/fair.py + test_fair.py + REFERENCE.md重写+ 2 个 examples
2. `python fair.py --owner liyiying10 --repo Feature_Critic` 输出合法科研画像 JSON含真实 arxiv 1901.11448
3. test_fair.py 抽取函数单测全过
4. Feature_Critic X 光4 维裁决正确(论文✅/数据⚠️/复现⚠️/引用❌)+ 真图谱 ≥6 节点 + 关键发现 ≥3 条
5. Edge X 光license 冲突作为关键发现,报告与 Feature_Critic 明显不同
6. 报告落盘 report-cards/
7. v1 的 5 轴雷达/假 KG 已从 SKILL.md 移除
8. README/workflow 描述更新为"科研软件 X 光"

View File

@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/zalando/go-keyring"
)
@ -41,8 +42,7 @@ func DeleteToken() error {
// File-based fallback
func credentialPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
return filepath.Join(config.ConfigDir(), "credentials")
}
func storeTokenFile(token string) error {

View File

@ -24,12 +24,17 @@ type APIError struct {
StatusCode int
Code interface{}
Message string
Suggestion string
}
func (e *APIError) Error() string {
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
}
func (e *APIError) Suggest() string {
return e.Suggestion
}
func New() (*Client, error) {
cfg, err := config.Load()
if err != nil {
@ -77,6 +82,10 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Debug {
fmt.Printf("→ %s %s\n", method, fullURL)
}
@ -98,10 +107,12 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
// Check HTTP-level errors
if resp.StatusCode >= 400 {
suggestion := suggestFix(resp.StatusCode)
return nil, &APIError{
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
Suggestion: suggestion,
}
}
@ -128,6 +139,7 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
Suggestion: suggestion,
}
}
}
@ -174,16 +186,100 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
return c.Do("DELETE", path, nil, query)
}
// DoRaw sends a request without appending the .json suffix.
func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
fullURL := c.BaseURL + path
if query != nil && len(query) > 0 {
fullURL += "?" + query.Encode()
}
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, fullURL, bodyReader)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Debug {
fmt.Printf("→ %s %s\n", method, fullURL)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if c.Debug {
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
}
if resp.StatusCode >= 400 {
return nil, &APIError{
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
}
}
var raw map[string]interface{}
if err := json.Unmarshal(respData, &raw); err != nil {
return output.SuccessEnvelope(string(respData), nil), nil
}
// Check error-in-body pattern
if status, ok := raw["status"]; ok {
var statusCode float64
switch v := status.(type) {
case float64:
statusCode = v
case int:
statusCode = float64(v)
}
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
msg, _ := raw["message"].(string)
return output.ErrorEnvelope(int(statusCode), msg, ""), &APIError{
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
}
}
}
return output.SuccessEnvelope(raw, nil), nil
}
func suggestFix(code int) string {
switch code {
case 400:
return "请求参数格式错误,请检查参数值是否正确"
case 401:
return "请先运行 gitlink-cli auth login 登录"
case 403:
return "权限不足,请确认账户权限或联系项目管理员"
case 404:
return "资源不存在,请检查 owner/repo/id 是否正确"
case 409:
return "资源冲突,可能存在同名资源"
case 422:
return "参数校验失败,请检查请求参数"
case 500, 502, 503:
return "服务器内部错误,请稍后重试或联系平台管理员"
default:
return ""
}

View File

@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"runtime"
"gopkg.in/yaml.v3"
)
@ -13,10 +14,11 @@ const (
)
type Config struct {
BaseURL string `yaml:"base_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
BaseURL string `yaml:"base_url"`
Format string `yaml:"default_format"`
Editor string `yaml:"editor,omitempty"`
Pager string `yaml:"pager,omitempty"`
DeepSeekAPIKey string `yaml:"deepseek_api_key,omitempty"`
}
func DefaultConfig() *Config {
@ -30,6 +32,11 @@ func ConfigDir() string {
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
return dir
}
if runtime.GOOS == "windows" {
if appData := os.Getenv("AppData"); appData != "" {
return filepath.Join(appData, "gitlink-cli")
}
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli")
}
@ -85,6 +92,8 @@ func Get(key string) (string, error) {
return cfg.Editor, nil
case "pager":
return cfg.Pager, nil
case "deepseek_api_key":
return cfg.DeepSeekAPIKey, nil
default:
return "", nil
}
@ -104,6 +113,8 @@ func Set(key, value string) error {
cfg.Editor = value
case "pager":
cfg.Pager = value
case "deepseek_api_key":
cfg.DeepSeekAPIKey = value
}
return Save(cfg)
}

View File

@ -17,7 +17,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) {
owner, repo, err := fromGitRemote()
if err != nil {
if flagOwner == "" || flagRepo == "" {
return "", "", fmt.Errorf("cannot detect owner/repo from git remote: %w\nUse --owner and --repo flags to specify explicitly", err)
return "", "", fmt.Errorf("无法从 git remote 自动检测 owner/repo: %w\n请使用 --owner 和 --repo 参数手动指定", err)
}
}
@ -34,7 +34,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) {
func fromGitRemote() (string, string, error) {
out, err := exec.Command("git", "remote", "get-url", "origin").Output()
if err != nil {
return "", "", fmt.Errorf("not a git repository or no remote 'origin'")
return "", "", fmt.Errorf("当前目录不是 git 仓库或未配置 remote 'origin'")
}
remote := strings.TrimSpace(string(out))
return parseRemoteURL(remote)
@ -45,7 +45,7 @@ func parseRemoteURL(remote string) (string, string, error) {
if strings.HasPrefix(remote, "git@") {
parts := strings.SplitN(remote, ":", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("cannot parse SSH remote: %s", remote)
return "", "", fmt.Errorf("无法解析 SSH 远程地址: %s", remote)
}
return parsePathSegments(parts[1])
}
@ -53,7 +53,7 @@ func parseRemoteURL(remote string) (string, string, error) {
// HTTPS format: https://www.gitlink.org.cn/owner/repo.git
u, err := url.Parse(remote)
if err != nil {
return "", "", fmt.Errorf("cannot parse remote URL: %s", remote)
return "", "", fmt.Errorf("无法解析远程地址 URL: %s", remote)
}
return parsePathSegments(u.Path)
}
@ -63,7 +63,7 @@ func parsePathSegments(path string) (string, string, error) {
path = strings.TrimSuffix(path, ".git")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 2 {
return "", "", fmt.Errorf("cannot extract owner/repo from path: %s", path)
return "", "", fmt.Errorf("无法从路径提取 owner/repo: %s", path)
}
return parts[0], parts[1], nil
}

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

@ -0,0 +1,61 @@
package errors
import (
"errors"
"fmt"
)
// Suggester is implemented by errors that carry a user-facing suggestion.
type Suggester interface {
Suggest() string
}
// Error is a CLI error with an optional suggestion for the user.
type Error struct {
Message string
Suggestion string
Err error
}
func (e *Error) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *Error) Unwrap() error {
return e.Err
}
func (e *Error) Suggest() string {
return e.Suggestion
}
// New creates a new Error with an optional suggestion.
func New(message, suggestion string) *Error {
return &Error{Message: message, Suggestion: suggestion}
}
// Wrapf wraps an error with a formatted message and optional suggestion.
func Wrapf(err error, suggestion, format string, args ...interface{}) *Error {
return &Error{
Message: fmt.Sprintf(format, args...),
Suggestion: suggestion,
Err: err,
}
}
// Wrap wraps an error with a message and optional suggestion.
func Wrap(err error, message, suggestion string) *Error {
return &Error{Message: message, Suggestion: suggestion, Err: err}
}
// FindSuggestion walks the error chain and returns the first suggestion found.
func FindSuggestion(err error) string {
var s Suggester
if errors.As(err, &s) {
return s.Suggest()
}
return ""
}

View File

@ -66,22 +66,68 @@ func printTable(w io.Writer, envelope *Envelope) error {
return nil
}
// Try to render as table if data is a slice of maps
switch data := envelope.Data.(type) {
// Convert struct types to generic map/slice via JSON round-trip
data := envelope.Data
if _, ok := data.(map[string]interface{}); !ok {
if _, ok := data.([]interface{}); !ok {
generic, err := toGeneric(data)
if err != nil {
return printJSON(w, envelope)
}
data = generic
}
}
switch data := data.(type) {
case []interface{}:
return printSliceTable(w, data)
case map[string]interface{}:
// For maps with nested structures, prefer JSON
if slice := findSliceInMap(data); slice != nil {
return printSliceTable(w, slice)
}
if hasComplexValues(data) {
return printJSON(w, envelope)
}
return printMapTable(w, data)
default:
// Fallback to JSON
return printJSON(w, envelope)
}
}
func toGeneric(v interface{}) (interface{}, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
var result interface{}
if err := json.Unmarshal(b, &result); err != nil {
return nil, err
}
return result, nil
}
func findSliceInMap(m map[string]interface{}) []interface{} {
for _, key := range []string{
"issues", "pull_requests", "milestones", "webhooks", "issue_tags",
"commits", "files", "members", "collaborators", "users", "branches",
"releases", "entries", "tags", "watchers", "results",
} {
if v, ok := m[key]; ok {
if slice, ok := v.([]interface{}); ok && len(slice) > 0 {
return slice
}
}
}
for _, v := range m {
if slice, ok := v.([]interface{}); ok && len(slice) > 0 {
if _, isMap := slice[0].(map[string]interface{}); isMap {
return slice
}
}
}
return nil
}
func hasComplexValues(m map[string]interface{}) bool {
for _, v := range m {
switch v.(type) {
@ -98,7 +144,6 @@ func printSliceTable(w io.Writer, items []interface{}) error {
return nil
}
// Collect headers from first item
first, ok := items[0].(map[string]interface{})
if !ok {
data, _ := json.MarshalIndent(items, "", " ")
@ -109,7 +154,6 @@ func printSliceTable(w io.Writer, items []interface{}) error {
headers := collectKeys(first)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
// Print headers
fmt.Fprintln(tw, strings.Join(headers, "\t"))
dashes := make([]string, len(headers))
for i, h := range headers {
@ -117,7 +161,6 @@ func printSliceTable(w io.Writer, items []interface{}) error {
}
fmt.Fprintln(tw, strings.Join(dashes, "\t"))
// Print rows
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
@ -144,7 +187,6 @@ func printMapTable(w io.Writer, m map[string]interface{}) error {
func collectKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
// Prefer common keys first
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
seen := map[string]bool{}
for _, k := range priority {
@ -170,10 +212,21 @@ func formatValue(v interface{}) string {
case reflect.Map, reflect.Slice:
data, _ := json.Marshal(v)
s := string(data)
if len(s) > 60 {
return s[:57] + "..."
if len(s) > 50 {
return s[:47] + "..."
}
return s
case reflect.Bool:
if v.(bool) {
return "yes"
}
return "no"
case reflect.Float64:
f := v.(float64)
if f == float64(int64(f)) {
return fmt.Sprintf("%d", int64(f))
}
return fmt.Sprintf("%v", v)
default:
return fmt.Sprintf("%v", v)
}

View File

@ -11,3 +11,4 @@ func main() {
os.Exit(1)
}
}
// test: code-quality watch trigger test 1782976608

View File

@ -25,6 +25,7 @@ function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
darwin: "darwin",
linux: "linux",
win32: "windows",
freebsd: "freebsd",
};
const archMap = {

View File

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

View File

@ -27,6 +27,8 @@ PLATFORMS=(
"linux/arm64"
"windows/amd64"
"windows/arm64"
"freebsd/amd64"
"freebsd/arm64"
)
cd "$PROJECT_DIR"

View File

@ -1,150 +0,0 @@
#!/usr/bin/env node
"use strict";
const os = require("os");
const path = require("path");
const fs = require("fs");
const https = require("https");
const http = require("http");
const { execSync } = require("child_process");
const PACKAGE = require("../package.json");
const VERSION = PACKAGE.version;
const BINARY_NAME = "gitlink-cli";
const RELEASE_BASE = "https://www.gitlink.org.cn";
const REPO_OWNER = "Gitlink";
const REPO_NAME = "gitlink-cli";
function getPlatformInfo() {
const platform = os.platform();
const arch = os.arch();
const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };
const archMap = { x64: "amd64", arm64: "arm64" };
const goPlatform = platformMap[platform];
const goArch = archMap[arch];
if (!goPlatform || !goArch) {
throw new Error(`Unsupported platform: ${platform}-${arch}`);
}
return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" };
}
function fetch(url, options = {}) {
return new Promise((resolve, reject) => {
const maxRedirects = options.maxRedirects || 5;
let redirectCount = 0;
function doRequest(currentUrl) {
const mod = currentUrl.startsWith("https") ? https : http;
const req = mod.get(currentUrl, (res) => {
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
redirectCount++;
if (redirectCount > maxRedirects) { reject(new Error("Too many redirects")); return; }
let redirectUrl = res.headers.location;
if (redirectUrl.startsWith("/")) {
const parsed = new URL(currentUrl);
redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`;
}
doRequest(redirectUrl);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
if (options.json) {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(new Error("JSON parse failed")); }
});
} else {
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => resolve(Buffer.concat(chunks)));
}
});
req.on("error", reject);
req.setTimeout(30000, () => { req.destroy(); reject(new Error("Timeout")); });
}
doRequest(url);
});
}
async function findReleaseAsset(platform, arch) {
const archiveName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`;
const tagName = `v${VERSION}`;
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
try {
const releases = await fetch(apiUrl, { json: true });
const rlist = Array.isArray(releases) ? releases : (releases && releases.releases ? releases.releases : []);
let release = rlist.find(r => r.tag_name === tagName || r.tag_name === VERSION);
if (!release && rlist.length > 0) release = rlist[0];
if (release && release.attachments) {
let asset = release.attachments.find(a => a.title === archiveName || a.filename === archiveName);
if (!asset) {
const pattern = `_${platform}_${arch}.tar.gz`;
asset = release.attachments.find(a => (a.title || a.filename || "").endsWith(pattern));
}
if (asset) {
let url = asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
if (url.startsWith("/")) url = RELEASE_BASE + url;
return url;
}
}
} catch (e) {}
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
}
async function downloadAndExtract(url, destDir, platform) {
const data = await fetch(url);
const archivePath = path.join(destDir, "download.tar.gz");
fs.writeFileSync(archivePath, data);
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
fs.unlinkSync(archivePath);
const binaryPath = path.join(destDir, BINARY_NAME);
if (!fs.existsSync(binaryPath)) {
const files = fs.readdirSync(destDir);
for (const file of files) {
const subPath = path.join(destDir, file, BINARY_NAME);
if (fs.existsSync(subPath)) { fs.renameSync(subPath, binaryPath); break; }
}
}
if (!fs.existsSync(binaryPath)) throw new Error("Binary not found after extraction");
fs.chmodSync(binaryPath, 0o755);
}
async function main() {
const { platform, arch } = getPlatformInfo();
const binDir = path.join(__dirname, "..", "bin");
if (!fs.existsSync(binDir)) { fs.mkdirSync(binDir, { recursive: true }); }
const binaryPath = path.join(binDir, BINARY_NAME);
if (fs.existsSync(binaryPath)) {
try {
const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 });
if (output.includes(VERSION)) {
console.log(`${BINARY_NAME} v${VERSION} already installed.`);
return;
}
} catch (e) {}
fs.unlinkSync(binaryPath);
}
try {
const downloadUrl = await findReleaseAsset(platform, arch);
await downloadAndExtract(downloadUrl, binDir, platform);
console.log(`${BINARY_NAME} v${VERSION} installed.`);
} catch (err) {
// Don't fail npm install — binary can be installed later
console.warn(`${BINARY_NAME} binary download failed: ${err.message}`);
console.warn(` Skills are installed. You can install the binary manually later:`);
console.warn(` npm run postinstall`);
}
}
main();

79
scripts/setup-skills.sh Executable file
View File

@ -0,0 +1,79 @@
#!/bin/bash
# Link skills/gitlink-* into ~/.claude/skills/ so Claude Code can load and
# invoke them through the Skill tool (e.g. `Skill gitlink-issue`).
#
# Cross-platform:
# - Windows (Git Bash / MSYS): directory junction (mklink /J, no admin needed)
# - macOS / Linux: symlink
#
# Links point at the in-repo skills/ directory, so updating the repo keeps the
# Skill content in sync. The script is idempotent and safe to re-run.
#
# CRITICAL (Windows): an existing link is removed with `rmdir` (no /s), never
# `rm -rf` — `rm -rf` would follow the junction and DELETE THE SOURCE FILES.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SRC="$PROJECT_DIR/skills"
DST="$HOME/.claude/skills"
# --- collect gitlink-* skills (top-level skills/ + workflow-bundled skills) ---
shopt -s nullglob
SKILLS=("$SRC"/gitlink-*/ "$PROJECT_DIR/shortcuts/workflow/skills"/gitlink-*/)
shopt -u nullglob
if [ ${#SKILLS[@]} -eq 0 ]; then
echo "No gitlink-* skills found under $SRC" >&2
exit 1
fi
mkdir -p "$DST"
# --- detect platform ---
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;;
*) PLATFORM=unix ;;
esac
OK=0
FAIL=0
for skill in "${SKILLS[@]}"; do
skill="${skill%/}" # strip trailing slash for clean paths
name="$(basename "$skill")"
link="$DST/$name"
if [ "$PLATFORM" = "windows" ]; then
win_link="$(cygpath -w "$link")"
win_src="$(cygpath -w "$skill")"
if [ -e "$link" ] || [ -L "$link" ]; then
# rmdir (no /s) removes only the junction/symlink, never the target.
if ! cmd //c rmdir "$win_link" >/dev/null 2>&1; then
echo "SKIP $name (existing path is not a link — left untouched)"
FAIL=$((FAIL+1)); continue
fi
fi
if powershell -NoProfile -Command \
"New-Item -ItemType Junction -Path '$win_link' -Target '$win_src' -ErrorAction Stop" \
>/dev/null 2>&1; then
echo "OK $name"; OK=$((OK+1))
else
echo "FAIL $name"; FAIL=$((FAIL+1))
fi
else
# ln -sfn replaces an existing symlink safely (does not follow it).
if ln -sfn "$skill" "$link"; then
echo "OK $name"; OK=$((OK+1))
else
echo "FAIL $name"; FAIL=$((FAIL+1))
fi
fi
done
echo ""
echo "Done: $OK linked, $FAIL failed."
echo "Target: $DST"
echo "Skills are now invocable via the Claude Code Skill tool."

View File

@ -18,14 +18,14 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
if err != nil {
return err
return fmt.Errorf("获取分支列表失败: %w", err)
}
return ctx.Output(env)
},
@ -39,9 +39,12 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
name, _ := ctx.RequireArg("name")
from := ctx.Arg("from")
if from == "" {
from = "master"
@ -52,7 +55,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload)
if err != nil {
return err
return fmt.Errorf("创建分支失败: %w", err)
}
return ctx.Output(env)
},
@ -65,15 +68,18 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{
"branch_name": name,
}
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
if err != nil {
return err
return fmt.Errorf("删除分支失败: %w", err)
}
return ctx.Output(env)
},
@ -86,15 +92,18 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{
"branch_name": name,
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
if err != nil {
return err
return fmt.Errorf("设置分支保护失败: %w", err)
}
return ctx.Output(env)
},
@ -107,13 +116,16 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
name, _ := ctx.RequireArg("name")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
if err != nil {
return fmt.Errorf("取消分支保护失败: %w", err)
}
return ctx.Output(env)
},
},

140
shortcuts/branch/branch_test.go Executable file
View File

@ -0,0 +1,140 @@
package branch
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestBranchList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/branches.json" {
writeJSON(t, w, []map[string]interface{}{
{"name": "master", "protected": true},
{"name": "develop", "protected": false},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBranchShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("branch list failed: %v", err)
}
}
func TestBranchCreate(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/branches.json" {
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"name": "feature-x"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBranchShortcut(t, server, "create", map[string]string{
"name": "feature-x",
"from": "master",
})
if err != nil {
t.Fatalf("branch create failed: %v", err)
}
if body["new_branch_name"] != "feature-x" {
t.Fatalf("expected new_branch_name=feature-x, got %v", body["new_branch_name"])
}
if body["old_branch_name"] != "master" {
t.Fatalf("expected old_branch_name=master, got %v", body["old_branch_name"])
}
}
func TestBranchDelete(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/branches/delete.json" {
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBranchShortcut(t, server, "delete", map[string]string{"name": "feature-x"})
if err != nil {
t.Fatalf("branch delete failed: %v", err)
}
if body["branch_name"] != "feature-x" {
t.Fatalf("expected branch_name=feature-x, got %v", body["branch_name"])
}
}
func TestBranchProtect(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/protected_branches.json" {
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "protected"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBranchShortcut(t, server, "protect", map[string]string{"name": "master"})
if err != nil {
t.Fatalf("branch protect failed: %v", err)
}
if body["branch_name"] != "master" {
t.Fatalf("expected branch_name=master, got %v", body["branch_name"])
}
}
func runBranchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findBranchShortcut(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 findBranchShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func 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)
}
}

View File

@ -18,14 +18,14 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
if err != nil {
return err
return fmt.Errorf("获取构建列表失败: %w", err)
}
return ctx.Output(env)
},
@ -40,9 +40,12 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
build, err := ctx.RequireArg("build")
if err != nil {
return err
}
build, _ := ctx.RequireArg("build")
stage := ctx.Arg("stage")
step := ctx.Arg("step")
if stage == "" {
@ -53,7 +56,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil)
if err != nil {
return err
return fmt.Errorf("获取构建日志失败: %w", err)
}
return ctx.Output(env)
},
@ -66,13 +69,16 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
build, _ := ctx.RequireArg("build")
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
build, err := ctx.RequireArg("build")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
if err != nil {
return fmt.Errorf("重启构建失败: %w", err)
}
return ctx.Output(env)
},
},
@ -84,13 +90,16 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
build, _ := ctx.RequireArg("build")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
build, err := ctx.RequireArg("build")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
if err != nil {
return fmt.Errorf("停止构建失败: %w", err)
}
return ctx.Output(env)
},
},

View File

@ -19,7 +19,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
@ -29,7 +29,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo), q)
if err != nil {
return err
return fmt.Errorf("获取提交列表失败: %w", err)
}
return ctx.Output(env)
},
@ -44,7 +44,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
sha, err := ctx.RequireArg("sha")
if err != nil {
@ -55,7 +55,7 @@ func Shortcuts() []*common.Shortcut {
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), q)
if err != nil {
return err
return fmt.Errorf("查看提交详情失败: %w", err)
}
return ctx.Output(env)
},
@ -68,7 +68,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
sha, err := ctx.RequireArg("sha")
if err != nil {
@ -76,7 +76,7 @@ func Shortcuts() []*common.Shortcut {
}
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 fmt.Errorf("获取提交 Diff 失败: %w", err)
}
return ctx.Output(env)
},
@ -90,7 +90,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
filePath, err := ctx.RequireArg("path")
if err != nil {
@ -101,7 +101,7 @@ func Shortcuts() []*common.Shortcut {
q.Set("sha", ctx.Arg("sha"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo), q)
if err != nil {
return err
return fmt.Errorf("获取 Blame 信息失败: %w", err)
}
return ctx.Output(env)
},

View File

@ -36,6 +36,7 @@ type RuntimeContext struct {
Repo string
Format string
Args map[string]string
AIMode string
}
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.

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

@ -0,0 +1,179 @@
package file
import (
"encoding/base64"
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List repository files",
Flags: []common.Flag{
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
{Name: "search", Short: "s", Usage: "Search keyword"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
if search := ctx.Arg("search"); search != "" {
q.Set("search", search)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "tree",
Description: "List file tree for a branch or commit",
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"},
{Name: "recursive", Usage: "Recursively list all files", Bool: true, Default: "false"},
{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
}
sha := ctx.Arg("sha")
if sha == "" {
sha = "master"
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if ctx.Arg("recursive") == "true" {
q.Set("recursive", "true")
}
env, err := ctx.CallAPIWithQuery("GET",
fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "get",
Description: "Get file or directory contents",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File or directory path", Required: true},
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
q := url.Values{}
q.Set("filepath", filePath)
q.Set("ref", ctx.Arg("ref"))
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a new file in the repository",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "content", Short: "c", Usage: "File content (plain text, auto Base64 encoded)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
},
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
}
branch := ctx.Arg("branch")
if branch == "" {
branch = "master"
}
body := map[string]interface{}{
"filepath": filePath,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
"branch": branch,
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a file from the repository",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "sha", Short: "s", Usage: "File blob SHA (from file +list)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
branch := ctx.Arg("branch")
if branch == "" {
branch = "master"
}
body := map[string]interface{}{
"filepath": filePath,
"sha": sha,
"message": message,
"branch": branch,
}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

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

@ -0,0 +1,202 @@
package file
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestFileList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/files.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, []map[string]interface{}{
{"name": "README.md", "path": "README.md", "type": "file"},
{"name": "src", "path": "src", "type": "dir"},
})
}))
defer server.Close()
if err := runFileShortcut(t, server, "list", map[string]string{}); err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestFileListWithRef(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("ref") != "dev" {
t.Fatalf("expected ref=dev, got %s", r.URL.Query().Get("ref"))
}
writeJSON(t, w, []map[string]interface{}{})
}))
defer server.Close()
if err := runFileShortcut(t, server, "list", map[string]string{"ref": "dev"}); err != nil {
t.Fatalf("list with ref failed: %v", err)
}
}
func TestFileTree(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/git/trees/master.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"entries": []map[string]interface{}{{"name": "main.go", "type": "file"}},
})
}))
defer server.Close()
if err := runFileShortcut(t, server, "tree", map[string]string{}); err != nil {
t.Fatalf("tree failed: %v", err)
}
}
func TestFileTreeRecursive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("recursive") != "true" {
t.Fatalf("expected recursive=true")
}
writeJSON(t, w, map[string]interface{}{"entries": []map[string]interface{}{}})
}))
defer server.Close()
if err := runFileShortcut(t, server, "tree", map[string]string{"recursive": "true"}); err != nil {
t.Fatalf("tree recursive failed: %v", err)
}
}
func TestFileGet(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/sub_entries.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.URL.Query().Get("filepath") != "README.md" {
t.Fatalf("expected filepath=README.md, got %s", r.URL.Query().Get("filepath"))
}
writeJSON(t, w, map[string]interface{}{"name": "README.md", "type": "file"})
}))
defer server.Close()
err := runFileShortcut(t, server, "get", map[string]string{"path": "README.md"})
if err != nil {
t.Fatalf("get failed: %v", err)
}
}
func TestFileGetRequiresPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --path")
}))
defer server.Close()
err := runFileShortcut(t, server, "get", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --path")
}
}
func TestFileCreate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/create_file.json" {
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["filepath"] != "test.txt" {
t.Fatalf("expected filepath=test.txt, got %v", payload["filepath"])
}
if payload["message"] != "add test" {
t.Fatalf("expected message=add test, got %v", payload["message"])
}
if payload["branch"] != "master" {
t.Fatalf("expected branch=master, got %v", payload["branch"])
}
if _, ok := payload["content"].(string); !ok || payload["content"] == "" {
t.Fatal("content should be a non-empty Base64 string")
}
writeJSON(t, w, map[string]interface{}{"name": "test.txt", "sha": "abc123"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runFileShortcut(t, server, "create", map[string]string{
"path": "test.txt", "content": "hello world", "message": "add test",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
}
func TestFileDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/delete_file.json" {
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["filepath"] != "old.txt" {
t.Fatalf("expected filepath=old.txt, got %v", payload["filepath"])
}
if payload["sha"] != "def456" {
t.Fatalf("expected sha=def456, got %v", payload["sha"])
}
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runFileShortcut(t, server, "delete", map[string]string{
"path": "old.txt", "sha": "def456", "message": "remove old",
})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
}
func TestFileDeleteRequiresPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --path")
}))
defer server.Close()
err := runFileShortcut(t, server, "delete", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --path")
}
}
// === helpers ===
func runFileShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findFileShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner", Repo: "repo", Format: "json", Args: args,
}
return shortcut.Run(ctx)
}
func findFileShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}

View File

@ -6,26 +6,29 @@ import (
"os"
"strconv"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const closedIssueStatusID = 5
const openIssueStatusID = 1
type batchCloseResult struct {
type batchResult struct {
Number string `json:"number" yaml:"number"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
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"`
type batchSummary 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"`
Duration string `json:"duration" yaml:"duration"`
Results []batchResult `json:"results" yaml:"results"`
}
func newBatchCloseShortcut() *common.Shortcut {
@ -43,27 +46,29 @@ func newBatchCloseShortcut() *common.Shortcut {
func runBatchClose(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
start := time.Now()
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchCloseSummary{
summary := batchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchCloseResult, 0, len(numbers)),
Results: make([]batchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchCloseResult{Number: number, Action: "close"}
result := batchResult{Number: number, Action: "close"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
@ -82,11 +87,13 @@ func runBatchClose(ctx *common.RuntimeContext) error {
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total)
return fmt.Errorf("%d / %d 个 Issue 关闭失败", summary.Failed, summary.Total)
}
return nil
}
@ -94,7 +101,7 @@ func runBatchClose(ctx *common.RuntimeContext) error {
func closeIssue(ctx *common.RuntimeContext, number string) error {
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("fetch issue: %w", err)
return fmt.Errorf("获取 Issue 详情: %w", err)
}
body := map[string]interface{}{
@ -103,11 +110,466 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
"status_id": closedIssueStatusID,
}
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("close issue: %w", err)
return fmt.Errorf("关闭 Issue: %w", err)
}
return nil
}
func newBatchReopenShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-reopen",
Description: "Reopen multiple closed issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "dry-run", Usage: "Preview the issues that would be reopened without changing them", Bool: true, Default: "false"},
},
Run: runBatchReopen,
}
}
func runBatchReopen(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
start := time.Now()
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchResult{Number: number, Action: "reopen"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := reopenIssue(ctx, number); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "reopened"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个 Issue 重新打开失败", summary.Failed, summary.Total)
}
return nil
}
func reopenIssue(ctx *common.RuntimeContext, number string) error {
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("获取 Issue 详情: %w", err)
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": openIssueStatusID,
}
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("重新打开 Issue: %w", err)
}
return nil
}
func newBatchAssignShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-assign",
Description: "Assign multiple issues to a user by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "user", Short: "u", Usage: "Assignee user ID (numeric)", Required: true},
{Name: "dry-run", Usage: "Preview the issues that would be assigned without changing them", Bool: true, Default: "false"},
},
Run: runBatchAssign,
}
}
func runBatchAssign(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
user, err := ctx.RequireArg("user")
if err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchResult{Number: number, Action: "assign"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := assignIssue(ctx, number, user); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "assigned"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个 Issue 分配失败", summary.Failed, summary.Total)
}
return nil
}
func assignIssue(ctx *common.RuntimeContext, number, user string) error {
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("获取 Issue 详情: %w", err)
}
userID, err := strconv.Atoi(user)
if err != nil {
return fmt.Errorf("user 参数必须是数字 ID而不是用户名")
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"assigner_ids": []int64{int64(userID)},
}
if current.StatusID != nil {
body["status_id"] = current.StatusID
}
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("分配 Issue: %w", err)
}
return nil
}
func newBatchLabelShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-label",
Description: "Add or remove labels on multiple issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "add", Short: "a", Usage: "Comma-separated label IDs to add"},
{Name: "remove", Short: "r", Usage: "Comma-separated label IDs to remove"},
{Name: "dry-run", Usage: "Preview the issues that would be labeled without changing them", Bool: true, Default: "false"},
},
Run: runBatchLabel,
}
}
func runBatchLabel(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
addIDs := ctx.Arg("add")
removeIDs := ctx.Arg("remove")
if addIDs == "" && removeIDs == "" {
return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个")
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchResult, 0, len(numbers)),
}
addLabels := parseCommaSeparated(addIDs)
removeLabels := parseCommaSeparated(removeIDs)
for _, number := range numbers {
result := batchResult{Number: number, Action: "label"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := labelIssue(ctx, number, addLabels, removeLabels); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "labeled"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个 Issue 标签操作失败", summary.Failed, summary.Total)
}
return nil
}
func labelIssue(ctx *common.RuntimeContext, number string, addLabels, removeLabels []string) error {
var errs []string
for _, labelID := range addLabels {
body := map[string]interface{}{
"tag_id": labelID,
}
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil {
errs = append(errs, fmt.Sprintf("添加标签 %s: %v", labelID, err))
}
}
for _, labelID := range removeLabels {
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil {
errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err))
}
}
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, "\n"))
}
return nil
}
func newBatchMilestoneShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-milestone",
Description: "Set milestone on multiple issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "milestone", Short: "m", Usage: "Milestone ID", Required: true},
{Name: "dry-run", Usage: "Preview the issues that would be updated without changing them", Bool: true, Default: "false"},
},
Run: runBatchMilestone,
}
}
func runBatchMilestone(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
milestoneID, err := ctx.RequireArg("milestone")
if err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchResult{Number: number, Action: "milestone"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := setMilestone(ctx, number, milestoneID); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "milestoned"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个 Issue 设置里程碑失败", summary.Failed, summary.Total)
}
return nil
}
func setMilestone(ctx *common.RuntimeContext, number, milestoneID string) error {
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("获取 Issue 详情: %w", err)
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"fixed_version_id": milestoneID,
}
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("设置里程碑: %w", err)
}
return nil
}
func newBatchCommentShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-comment",
Description: "Add a comment to multiple issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
{Name: "dry-run", Usage: "Preview the issues that would be commented on without changing them", Bool: true, Default: "false"},
},
Run: runBatchComment,
}
}
func runBatchComment(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
start := time.Now()
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchResult{Number: number, Action: "comment"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := commentIssue(ctx, number, body); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "commented"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个 Issue 评论失败", summary.Failed, summary.Total)
}
return nil
}
func commentIssue(ctx *common.RuntimeContext, number, body string) error {
payload := map[string]interface{}{
"notes": body,
}
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload); err != nil {
return fmt.Errorf("添加评论: %w", err)
}
return nil
}
func parseCommaSeparated(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
var result []string
for _, item := range strings.Split(value, ",") {
if trimmed := strings.TrimSpace(item); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := parseIssueNumbers(numbersValue)
if err != nil {
@ -134,7 +596,7 @@ func parseIssueNumbers(value string) ([]string, error) {
func readIssueNumbersFromCSV(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read issue numbers from CSV: %w", err)
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
}
defer file.Close()
@ -142,7 +604,7 @@ func readIssueNumbersFromCSV(path string) ([]string, error) {
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err)
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
}
if len(records) == 0 {
return nil, nil
@ -180,7 +642,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) {
continue
}
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number)
return nil, fmt.Errorf("无效的 Issue 编号 %q: Issue 编号必须是整数", number)
}
if seen[number] {
continue

View File

@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -17,11 +18,17 @@ func v1RepoPath(ctx *common.RuntimeContext) string {
type existingIssue struct {
Subject string
Description string
StatusID interface{}
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchReopenShortcut(),
newBatchAssignShortcut(),
newBatchLabelShortcut(),
newBatchMilestoneShortcut(),
newBatchCommentShortcut(),
{
Name: "list",
Description: "List issues",
@ -32,7 +39,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
@ -42,7 +49,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return err
return fmt.Errorf("获取 Issue 列表失败: %w", err)
}
return ctx.Output(env)
},
@ -59,7 +66,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
title, err := ctx.RequireArg("title")
if err != nil {
@ -82,7 +89,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
return err
return fmt.Errorf("创建 Issue 失败: %w", err)
}
return ctx.Output(env)
},
@ -95,7 +102,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
number, err := ctx.RequireArg("number")
if err != nil {
@ -103,7 +110,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return err
return fmt.Errorf("查看 Issue 详情失败: %w", err)
}
return ctx.Output(env)
},
@ -116,7 +123,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
number, err := ctx.RequireArg("number")
if err != nil {
@ -124,7 +131,7 @@ func Shortcuts() []*common.Shortcut {
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return err
return fmt.Errorf("关闭 Issue 失败: %w", err)
}
body := map[string]interface{}{
@ -133,9 +140,40 @@ func Shortcuts() []*common.Shortcut {
"status_id": 5, // 5 = closed
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return fmt.Errorf("关闭 Issue 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "reopen",
Description: "Reopen a closed issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("重新打开 Issue 失败: %w", err)
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": 1, // 1 = open
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return fmt.Errorf("重新打开 Issue 失败: %w", err)
}
return ctx.Output(env)
},
},
@ -150,7 +188,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
number, err := ctx.RequireArg("number")
if err != nil {
@ -160,12 +198,12 @@ func Shortcuts() []*common.Shortcut {
description := ctx.Arg("body")
state := ctx.Arg("state")
if title == "" && description == "" && state == "" {
return fmt.Errorf("at least one of --title, --body, or --state is required")
return fmt.Errorf("至少需要指定 --title、--body 或 --state 中的一个")
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return err
return fmt.Errorf("更新 Issue 失败: %w", err)
}
body := map[string]interface{}{
@ -187,11 +225,109 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
return fmt.Errorf("更新 Issue 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "assign",
Description: "Assign an issue to a user",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "user", Short: "u", Usage: "Assignee user ID (numeric)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
user, err := ctx.RequireArg("user")
if err != nil {
return err
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("分配 Issue 失败: %w", err)
}
userID, err := strconv.Atoi(user)
if err != nil {
return fmt.Errorf("user 参数必须是数字 ID而不是用户名")
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"assigner_ids": []int64{int64(userID)},
}
if current.StatusID != nil {
body["status_id"] = current.StatusID
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return fmt.Errorf("分配 Issue 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "label",
Description: "Add or remove labels on an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "add", Short: "a", Usage: "Comma-separated label IDs to add"},
{Name: "remove", Short: "r", Usage: "Comma-separated label IDs to remove"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
addIDs := ctx.Arg("add")
removeIDs := ctx.Arg("remove")
if addIDs == "" && removeIDs == "" {
return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个")
}
var errs []string
if addIDs != "" {
for _, labelID := range strings.Split(addIDs, ",") {
labelID = strings.TrimSpace(labelID)
if labelID == "" {
continue
}
body := map[string]interface{}{
"tag_id": labelID,
}
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil {
errs = append(errs, fmt.Sprintf("添加标签 %s: %v", labelID, err))
}
}
}
if removeIDs != "" {
for _, labelID := range strings.Split(removeIDs, ",") {
labelID = strings.TrimSpace(labelID)
if labelID == "" {
continue
}
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil {
errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err))
}
}
}
if len(errs) > 0 {
return fmt.Errorf("标签操作失败:\n%s", strings.Join(errs, "\n"))
}
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "标签操作成功",
}, nil))
},
},
{
Name: "comment",
Description: "Add a comment to an issue",
@ -201,7 +337,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
number, err := ctx.RequireArg("number")
if err != nil {
@ -216,7 +352,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
if err != nil {
return err
return fmt.Errorf("添加评论失败: %w", err)
}
return ctx.Output(env)
},
@ -227,20 +363,28 @@ func Shortcuts() []*common.Shortcut {
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return nil, err
return nil, fmt.Errorf("获取 Issue 详情: %w", err)
}
issueData, ok := getEnv.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("failed to parse issue data")
return nil, fmt.Errorf("解析 Issue 数据失败")
}
subject, _ := issueData["subject"].(string)
if subject == "" {
return nil, fmt.Errorf("failed to parse issue subject")
return nil, fmt.Errorf("解析 Issue 标题失败")
}
description, _ := issueData["description"].(string)
statusID := issueData["status_id"]
if statusID == nil {
// Try nested status object
if status, ok := issueData["status"].(map[string]interface{}); ok {
statusID = status["id"]
}
}
return &existingIssue{
Subject: subject,
Description: description,
StatusID: statusID,
}, nil
}
@ -254,6 +398,6 @@ func normalizeIssueStatus(state string) (interface{}, error) {
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
return nil, fmt.Errorf("无效的 --state 值 %q: 请使用 open、closed 或数字 status_id", state)
}
}

358
shortcuts/issue/issue_test.go Normal file → Executable file
View File

@ -186,3 +186,361 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func TestIssueList(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json" {
writeJSON(t, w, map[string]interface{}{
"issues": []map[string]interface{}{
{"id": float64(1), "subject": "bug", "status_id": float64(1)},
{"id": float64(2), "subject": "feature", "status_id": float64(5)},
},
"total_count": float64(2),
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runIssueShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("issue list failed: %v", err)
}
}
func TestIssueCreate(t *testing.T) {
var createPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues.json" {
createPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(99), "subject": "new bug",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runIssueShortcut(t, server, "create", map[string]string{
"title": "new bug",
"body": "steps to reproduce",
"assignee": "42",
})
if err != nil {
t.Fatalf("issue create failed: %v", err)
}
assertEqual(t, createPayload["subject"], "new bug")
assertEqual(t, createPayload["description"], "steps to reproduce")
assertEqual(t, createPayload["assigned_to_id"], "42")
}
func TestIssueView(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "subject": "test issue", "status_id": float64(1),
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runIssueShortcut(t, server, "view", map[string]string{"number": "1"})
if err != nil {
t.Fatalf("issue view failed: %v", err)
}
}
func TestIssueComment(t *testing.T) {
var commentPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/1/journals.json" {
commentPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "notes": "looks good",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runIssueShortcut(t, server, "comment", map[string]string{
"number": "1",
"body": "looks good",
})
if err != nil {
t.Fatalf("issue comment failed: %v", err)
}
assertEqual(t, commentPayload["notes"], "looks good")
}
func TestIssueAssignSendsCorrectUser(t *testing.T) {
var assignPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
assignPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "指派成功"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "assign", map[string]string{
"number": "42",
"user": "42",
})
if err != nil {
t.Fatalf("assign shortcut failed: %v", err)
}
// GitLink 责任人字段是 assigner_ids数组不是 assigned_to_id
got, ok := assignPayload["assigner_ids"].([]interface{})
if !ok || len(got) != 1 {
t.Fatalf("expected assigner_ids [42], got %#v", assignPayload["assigner_ids"])
}
assertEqual(t, got[0], float64(42))
}
func TestIssueAssignRequiresNumberAndUser(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, map[string]interface{}{
"message": "ok",
})
})
defer server.Close()
err := runIssueShortcut(t, server, "assign", map[string]string{"number": ""})
if err == nil {
t.Fatal("assign shortcut should error when required flags are missing")
}
}
func TestIssueLabelAddSendsCorrectTagIDs(t *testing.T) {
var capturedPaths []string
var capturedPayloads []map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/42/tags.json":
capturedPaths = append(capturedPaths, r.URL.Path)
capturedPayloads = append(capturedPayloads, decodeJSON(t, r))
writeJSON(t, w, map[string]interface{}{
"message": "标签添加成功",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "label", map[string]string{
"number": "42",
"add": "5,8",
})
if err != nil {
t.Fatalf("label shortcut failed: %v", err)
}
if len(capturedPaths) != 2 {
t.Fatalf("expected 2 tag add calls, got %d", len(capturedPaths))
}
assertEqual(t, capturedPayloads[0]["tag_id"], "5")
assertEqual(t, capturedPayloads[1]["tag_id"], "8")
}
func TestIssueLabelRemoveSendsDeleteRequests(t *testing.T) {
var capturedPaths []string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42/tags/3.json":
capturedPaths = append(capturedPaths, r.URL.Path)
writeJSON(t, w, map[string]interface{}{
"message": "标签删除成功",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "label", map[string]string{
"number": "42",
"remove": "3",
})
if err != nil {
t.Fatalf("label shortcut failed: %v", err)
}
if len(capturedPaths) != 1 {
t.Fatalf("expected 1 tag delete call, got %d", len(capturedPaths))
}
}
func TestBatchAssign(t *testing.T) {
var assignPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && (r.URL.Path == "/v1/owner/repo/issues/1.json" || r.URL.Path == "/v1/owner/repo/issues/2.json"):
writeJSON(t, w, map[string]interface{}{"subject": "title", "description": "desc"})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json":
assignPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "指派成功"})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/2.json":
writeJSON(t, w, map[string]interface{}{"message": "指派成功"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-assign", map[string]string{
"numbers": "1,2",
"user": "42",
"dry-run": "false",
})
if err != nil {
t.Fatalf("batch-assign failed: %v", err)
}
got, ok := assignPayload["assigner_ids"].([]interface{})
if !ok || len(got) != 1 {
t.Fatalf("expected assigner_ids [42], got %#v", assignPayload["assigner_ids"])
}
assertEqual(t, got[0], float64(42))
}
func TestBatchAssignDryRun(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made in dry-run mode")
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-assign", map[string]string{
"numbers": "1,2,3",
"user": "zhangsan",
"dry-run": "true",
})
if err != nil {
t.Fatalf("batch-assign dry-run failed: %v", err)
}
}
func TestBatchLabelAddAndRemove(t *testing.T) {
var capturedPaths []string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/1/tags.json":
capturedPaths = append(capturedPaths, r.URL.Path)
writeJSON(t, w, map[string]interface{}{"message": "标签添加成功"})
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/1/tags/3.json":
capturedPaths = append(capturedPaths, r.URL.Path)
writeJSON(t, w, map[string]interface{}{"message": "标签删除成功"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-label", map[string]string{
"numbers": "1",
"add": "5",
"remove": "3",
"dry-run": "false",
})
if err != nil {
t.Fatalf("batch-label failed: %v", err)
}
if len(capturedPaths) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(capturedPaths))
}
}
func TestBatchLabelDryRun(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made in dry-run mode")
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-label", map[string]string{
"numbers": "1,2",
"add": "5,8",
"dry-run": "true",
})
if err != nil {
t.Fatalf("batch-label dry-run failed: %v", err)
}
}
func TestBatchMilestone(t *testing.T) {
var patchPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json":
writeJSON(t, w, map[string]interface{}{
"subject": "Test issue", "description": "desc",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json":
patchPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "success"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-milestone", map[string]string{
"numbers": "1",
"milestone": "5",
"dry-run": "false",
})
if err != nil {
t.Fatalf("batch-milestone failed: %v", err)
}
assertEqual(t, patchPayload["fixed_version_id"], "5")
assertEqual(t, patchPayload["subject"], "Test issue")
assertEqual(t, patchPayload["description"], "desc")
}
func TestBatchMilestoneDryRun(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made in dry-run mode")
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-milestone", map[string]string{
"numbers": "1,2,3",
"milestone": "5",
"dry-run": "true",
})
if err != nil {
t.Fatalf("batch-milestone dry-run failed: %v", err)
}
}
func TestIssueLabelRequiresAddOrRemove(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("should not make any API calls")
})
defer server.Close()
err := runIssueShortcut(t, server, "label", map[string]string{
"number": "42",
})
if err == nil {
t.Fatal("expected error when neither --add nor --remove is provided")
}
}

View File

@ -23,7 +23,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
@ -33,7 +33,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_tags", q)
if err != nil {
return err
return fmt.Errorf("获取标签列表失败: %w", err)
}
return ctx.Output(env)
},
@ -48,7 +48,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
name, err := ctx.RequireArg("name")
if err != nil {
@ -63,7 +63,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issue_tags", body)
if err != nil {
return err
return fmt.Errorf("创建标签失败: %w", err)
}
return ctx.Output(env)
},
@ -79,7 +79,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
@ -89,7 +89,7 @@ func Shortcuts() []*common.Shortcut {
color := ctx.Arg("color")
desc := ctx.Arg("description")
if name == "" && color == "" && desc == "" {
return fmt.Errorf("at least one of --name, --color, or --description is required")
return fmt.Errorf("至少需要指定 --name、--color 或 --description 中的一个")
}
body := map[string]interface{}{}
if name != "" {
@ -103,7 +103,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), body)
if err != nil {
return err
return fmt.Errorf("更新标签失败: %w", err)
}
return ctx.Output(env)
},
@ -116,7 +116,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
@ -124,7 +124,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return err
return fmt.Errorf("删除标签失败: %w", err)
}
return ctx.Output(env)
},

204
shortcuts/member/batch.go Normal file
View File

@ -0,0 +1,204 @@
package member
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type batchAddResult struct {
User string `json:"user" yaml:"user"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
type batchAddSummary struct {
Owner string `json:"owner" yaml:"owner"`
Repo string `json:"repo" yaml:"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"`
Duration string `json:"duration" yaml:"duration"`
Results []batchAddResult `json:"results" yaml:"results"`
}
func batchAddShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-add",
Description: "批量添加成员到项目,支持逗号分隔列表或 CSV 文件",
Flags: []common.Flag{
{Name: "users", Short: "u", Usage: "逗号分隔的用户数字 ID例如: 42,99,105"},
{Name: "from", Usage: "从 CSV 文件读取用户 ID。支持 user_id/id/user 列名或无表头首列"},
{Name: "dry-run", Usage: "仅预览将要添加的成员,不实际执行", Bool: true, Default: "false"},
},
Run: runBatchAdd,
}
}
func runBatchAdd(ctx *common.RuntimeContext) error {
start := time.Now()
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
userIDs, err := collectUserIDs(ctx.Arg("users"), ctx.Arg("from"))
if err != nil {
return err
}
if len(userIDs) == 0 {
return fmt.Errorf("未提供用户 ID请使用 --users 42,99 或 --from users.csv")
}
dryRun := parseMemberBool(ctx.Arg("dry-run"))
summary := batchAddSummary{
Owner: ctx.Owner,
Repo: ctx.Repo,
DryRun: dryRun,
Total: len(userIDs),
Results: make([]batchAddResult, 0, len(userIDs)),
}
for _, uid := range userIDs {
result := batchAddResult{User: uid, Action: "add"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
id, _ := strconv.ParseInt(uid, 10, 64)
body := map[string]interface{}{"user_id": id}
if _, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "added"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个成员添加失败", summary.Failed, summary.Total)
}
return nil
}
func collectUserIDs(usersValue, csvPath string) ([]string, error) {
ids, err := parseUserIDList(usersValue)
if err != nil {
return nil, err
}
if csvPath == "" {
return ids, nil
}
csvIDs, err := readUserIDsFromCSV(csvPath)
if err != nil {
return nil, err
}
return mergeUserIDLists(ids, csvIDs), nil
}
func parseUserIDList(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return normalizeUserIDs(strings.Split(value, ","))
}
func readUserIDsFromCSV(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
}
if len(records) == 0 {
return nil, nil
}
idCol := -1
startRow := 0
for i, cell := range records[0] {
switch strings.ToLower(strings.TrimSpace(cell)) {
case "user_id", "id", "user", "uid":
idCol = i
startRow = 1
}
}
if idCol == -1 {
idCol = 0
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if idCol >= len(record) {
continue
}
values = append(values, record[idCol])
}
return normalizeUserIDs(values)
}
func normalizeUserIDs(values []string) ([]string, error) {
ids := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
id := strings.TrimSpace(value)
if id == "" {
continue
}
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
return nil, fmt.Errorf("无效的用户 ID %q: 必须是整数", id)
}
if seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
return ids, nil
}
func mergeUserIDLists(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
for _, ids := range values {
for _, id := range ids {
if seen[id] {
continue
}
seen[id] = true
merged = append(merged, id)
}
}
return merged
}
func parseMemberBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
}

116
shortcuts/member/member.go Normal file
View File

@ -0,0 +1,116 @@
package member
import (
"fmt"
"net/url"
"strconv"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
batchAddShortcut(),
{
Name: "list",
Description: "List project members",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k)
}
env, err := ctx.CallAPIWithQuery("GET",
fmt.Sprintf("/v1/%s/%s/collaborators", ctx.Owner, ctx.Repo), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "add",
Description: "Add a member to the project",
Flags: []common.Flag{
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
userIDStr, _ := ctx.RequireArg("user-id")
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
}
body := map[string]interface{}{"user_id": userID}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "remove",
Description: "Remove a member from the project",
Flags: []common.Flag{
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
userIDStr, _ := ctx.RequireArg("user-id")
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
}
body := map[string]interface{}{"user_id": userID}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Change a member's role",
Flags: []common.Flag{
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
{Name: "role", Short: "r", Usage: "Role: Manager, Developer, Reporter", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
userIDStr, _ := ctx.RequireArg("user-id")
role, _ := ctx.RequireArg("role")
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
}
switch role {
case "Manager", "Developer", "Reporter":
default:
return fmt.Errorf("invalid role %q: must be Manager, Developer, or Reporter", role)
}
body := map[string]interface{}{
"user_id": userID,
"role": role,
}
env, err := ctx.CallAPI("PUT", ctx.RepoPath()+"/collaborators/change_role", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,152 @@
package member
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestMemberList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/collaborators.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 2,
"collaborators": []map[string]interface{}{
{"id": 1, "login": "alice", "role_name": "Manager"},
{"id": 2, "login": "bob", "role_name": "Developer"},
},
})
}))
defer server.Close()
if err := runMemberShortcut(t, server, "list", map[string]string{}); err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestMemberAdd(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/collaborators.json" {
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["user_id"].(float64) != 42 {
t.Fatalf("expected user_id=42, got %v", payload["user_id"])
}
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
if err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "42"}); err != nil {
t.Fatalf("add failed: %v", err)
}
}
func TestMemberAddRejectsInvalidID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made with invalid ID")
}))
defer server.Close()
err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "abc"})
if err == nil {
t.Fatal("expected error for invalid user-id")
}
}
func TestMemberRemove(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/collaborators/remove.json" {
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["user_id"].(float64) != 42 {
t.Fatalf("expected user_id=42")
}
writeJSON(t, w, map[string]interface{}{})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
if err := runMemberShortcut(t, server, "remove", map[string]string{"user-id": "42"}); err != nil {
t.Fatalf("remove failed: %v", err)
}
}
func TestMemberUpdate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PUT" && r.URL.Path == "/owner/repo/collaborators/change_role.json" {
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["user_id"].(float64) != 42 {
t.Fatalf("expected user_id=42")
}
if payload["role"] != "Developer" {
t.Fatalf("expected role=Developer, got %v", payload["role"])
}
writeJSON(t, w, map[string]interface{}{})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMemberShortcut(t, server, "update", map[string]string{
"user-id": "42", "role": "Developer",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
}
func TestMemberUpdateRejectsInvalidRole(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made with invalid role")
}))
defer server.Close()
err := runMemberShortcut(t, server, "update", map[string]string{
"user-id": "42", "role": "Admin",
})
if err == nil {
t.Fatal("expected error for invalid role")
}
}
// === helpers ===
func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findMemberShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner", Repo: "repo", Format: "json", Args: args,
}
return shortcut.Run(ctx)
}
func findMemberShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}

View File

@ -26,7 +26,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
@ -45,7 +45,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/milestones", q)
if err != nil {
return err
return fmt.Errorf("获取里程碑列表失败: %w", err)
}
return ctx.Output(env)
},
@ -58,7 +58,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
@ -66,7 +66,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return err
return fmt.Errorf("查看里程碑详情失败: %w", err)
}
return ctx.Output(env)
},
@ -81,7 +81,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
name, err := ctx.RequireArg("name")
if err != nil {
@ -98,7 +98,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/milestones", body)
if err != nil {
return err
return fmt.Errorf("创建里程碑失败: %w", err)
}
return ctx.Output(env)
},
@ -114,7 +114,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
@ -124,7 +124,7 @@ func Shortcuts() []*common.Shortcut {
desc := ctx.Arg("description")
due := ctx.Arg("due")
if name == "" && desc == "" && due == "" {
return fmt.Errorf("at least one of --name, --description, or --due is required")
return fmt.Errorf("至少需要指定 --name、--description 或 --due 中的一个")
}
body := map[string]interface{}{}
if name != "" {
@ -138,7 +138,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), body)
if err != nil {
return err
return fmt.Errorf("更新里程碑失败: %w", err)
}
return ctx.Output(env)
},
@ -151,7 +151,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
@ -162,7 +162,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/milestones/%s/update_status", ctx.Owner, ctx.Repo, id), body)
if err != nil {
return err
return fmt.Errorf("关闭里程碑失败: %w", err)
}
return ctx.Output(env)
},
@ -175,7 +175,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
@ -183,7 +183,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return err
return fmt.Errorf("删除里程碑失败: %w", err)
}
return ctx.Output(env)
},

18
shortcuts/milestone/milestone_test.go Normal file → Executable file
View File

@ -137,6 +137,24 @@ func TestMilestoneDelete(t *testing.T) {
}
}
func TestMilestoneView(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/milestones/1.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "name": "v1.0", "status": "open",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "view", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("milestone view failed: %v", err)
}
}
func TestMilestoneUpdateRequiresAtLeastOneField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made when no fields are provided")

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

@ -0,0 +1,336 @@
package org
import (
"encoding/csv"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type batchInviteResult struct {
User string `json:"user" yaml:"user"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
type batchInviteSummary struct {
Org string `json:"org" yaml:"org"`
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"`
Duration string `json:"duration" yaml:"duration"`
Results []batchInviteResult `json:"results" yaml:"results"`
}
func newBatchInviteShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-invite",
Description: "批量邀请成员加入组织,支持逗号分隔列表或 CSV 文件",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "组织 ID 或 login", Required: true},
{Name: "users", Short: "u", Usage: "逗号分隔的用户名或 ID例如: alice,bob,charlie"},
{Name: "from", Usage: "从 CSV 文件读取用户名。支持 user/login/user_id 列名或无表头首列"},
{Name: "role", Short: "r", Usage: "成员角色: member 或 admin", Default: "member"},
{Name: "dry-run", Usage: "仅预览将要邀请的成员,不实际执行", Bool: true, Default: "false"},
},
Run: runBatchInvite,
}
}
func runBatchInvite(ctx *common.RuntimeContext) error {
start := time.Now()
orgID, err := ctx.RequireArg("id")
if err != nil {
return err
}
role := ctx.Arg("role")
if role == "" {
role = "member"
}
if role != "member" && role != "admin" {
return fmt.Errorf("无效的角色 %q: 必须为 member 或 admin", role)
}
userInputs, err := collectUsers(ctx.Arg("users"), ctx.Arg("from"))
if err != nil {
return err
}
if len(userInputs) == 0 {
return fmt.Errorf("未提供用户名,请使用 --users alice,bob 或 --from users.csv")
}
// 将用户名解析为数字 ID如果传入的已经是数字则直接使用
resolvedUsers, resolveErrors := resolveUserIDs(ctx, userInputs)
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchInviteSummary{
Org: orgID,
DryRun: dryRun,
Total: len(userInputs),
Results: make([]batchInviteResult, 0, len(userInputs)),
}
// 先记录解析失败的
for input, errMsg := range resolveErrors {
summary.Results = append(summary.Results, batchInviteResult{
User: input,
Action: "invite",
Status: "failed",
Error: errMsg,
})
summary.Failed++
}
for _, ru := range resolvedUsers {
displayName := ru.Input
if ru.Input != ru.UserID {
displayName = fmt.Sprintf("%s (ID:%s)", ru.Input, ru.UserID)
}
result := batchInviteResult{User: displayName, Action: "invite"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
userIDInt, _ := strconv.ParseInt(ru.UserID, 10, 64)
body := map[string]interface{}{
"user_id": userIDInt,
"role": role,
}
path := fmt.Sprintf("/organizations/%s/organization_users", orgID)
if _, err := ctx.CallAPI("POST", path, body); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "invited"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个成员邀请失败", summary.Failed, summary.Total)
}
return nil
}
func collectUsers(usersValue, csvPath string) ([]string, error) {
users, err := parseUserList(usersValue)
if err != nil {
return nil, err
}
if csvPath == "" {
return users, nil
}
csvUsers, err := readUsersFromCSV(csvPath)
if err != nil {
return nil, err
}
return mergeUserLists(users, csvUsers), nil
}
func parseUserList(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return normalizeUserIDs(strings.Split(value, ","))
}
func readUsersFromCSV(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
}
if len(records) == 0 {
return nil, nil
}
userCol := -1
startRow := 0
for i, cell := range records[0] {
switch strings.ToLower(strings.TrimSpace(cell)) {
case "user", "login", "user_id", "username":
userCol = i
startRow = 1
}
}
if userCol == -1 {
userCol = 0
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if userCol >= len(record) {
continue
}
values = append(values, record[userCol])
}
return normalizeUserIDs(values)
}
func normalizeUserIDs(values []string) ([]string, error) {
users := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
user := strings.TrimSpace(value)
if user == "" {
continue
}
if seen[user] {
continue
}
seen[user] = true
users = append(users, user)
}
return users, nil
}
func parseBool(value string) bool {
if strings.EqualFold(strings.TrimSpace(value), "true") {
return true
}
return false
}
func mergeUserLists(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
for _, users := range values {
for _, u := range users {
if seen[u] {
continue
}
seen[u] = true
merged = append(merged, u)
}
}
return merged
}
// resolvedUser holds the mapping from user input to numeric ID.
type resolvedUser struct {
Input string // original input (username or numeric string)
UserID string // resolved numeric user ID
}
// resolveUserIDs converts usernames to numeric user IDs via the search API.
// If an input is already numeric, it is used directly.
func resolveUserIDs(ctx *common.RuntimeContext, inputs []string) ([]resolvedUser, map[string]string) {
results := make([]resolvedUser, 0, len(inputs))
errors := make(map[string]string)
for _, input := range inputs {
// 如果已经是纯数字,直接使用
if _, err := strconv.Atoi(input); err == nil {
results = append(results, resolvedUser{Input: input, UserID: input})
continue
}
// 通过搜索 API 查找用户名对应的数字 ID
q := url.Values{}
q.Set("search", input)
q.Set("limit", "5")
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
if err != nil {
errors[input] = fmt.Sprintf("查找用户失败: %v", err)
continue
}
// env.Data 是 {"total_count":N, "users":[...]} 的 map 结构
users := extractUsers(env.Data)
if len(users) == 0 {
errors[input] = fmt.Sprintf("未找到用户 %q", input)
continue
}
// 精确匹配用户名
matched := users[0]
for _, u := range users {
if u.Login == input {
matched = u
break
}
}
results = append(results, resolvedUser{
Input: input,
UserID: strconv.Itoa(matched.UserID),
})
}
return results, errors
}
// searchUser holds a parsed user from search results.
type searchUser struct {
Login string
UserID int
}
// extractUsers extracts the user list from the search API response data.
// data is expected to be map[string]interface{} with a "users" key containing a slice.
func extractUsers(data interface{}) []searchUser {
if data == nil {
return nil
}
m, ok := data.(map[string]interface{})
if !ok {
return nil
}
rawUsers, ok := m["users"]
if !ok {
return nil
}
usersSlice, ok := rawUsers.([]interface{})
if !ok {
return nil
}
var result []searchUser
for _, item := range usersSlice {
um, ok := item.(map[string]interface{})
if !ok {
continue
}
login, _ := um["login"].(string)
userID := 0
switch v := um["user_id"].(type) {
case float64:
userID = int(v)
case int:
userID = v
case string:
userID, _ = strconv.Atoi(v)
}
if login != "" && userID > 0 {
result = append(result, searchUser{Login: login, UserID: userID})
}
}
return result
}

View File

@ -9,6 +9,7 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchInviteShortcut(),
{
Name: "list",
Description: "List organizations",
@ -22,7 +23,7 @@ func Shortcuts() []*common.Shortcut {
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", "/organizations", q)
if err != nil {
return err
return fmt.Errorf("获取组织列表失败: %w", err)
}
return ctx.Output(env)
},
@ -34,11 +35,14 @@ func Shortcuts() []*common.Shortcut {
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
if err != nil {
return fmt.Errorf("查看组织详情失败: %w", err)
}
return ctx.Output(env)
},
},
@ -51,13 +55,16 @@ func Shortcuts() []*common.Shortcut {
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
id, _ := ctx.RequireArg("id")
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q)
if err != nil {
return err
return fmt.Errorf("获取组织成员列表失败: %w", err)
}
return ctx.Output(env)
},
@ -70,7 +77,10 @@ func Shortcuts() []*common.Shortcut {
{Name: "description", Short: "d", Usage: "Description"},
},
Run: func(ctx *common.RuntimeContext) error {
name, _ := ctx.RequireArg("name")
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
payload := map[string]interface{}{
"name": name,
}
@ -79,7 +89,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", "/organizations", payload)
if err != nil {
return err
return fmt.Errorf("创建组织失败: %w", err)
}
return ctx.Output(env)
},

138
shortcuts/org/org_test.go Executable file
View File

@ -0,0 +1,138 @@
package org
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestOrgList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/organizations.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "name": "org1"},
{"id": float64(2), "name": "org2"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runOrgShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("org list failed: %v", err)
}
}
func TestOrgInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/organizations/1.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "name": "myorg", "description": "A test org",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runOrgShortcut(t, server, "info", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("org info failed: %v", err)
}
}
func TestOrgMembers(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/organizations/1/organization_users.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "login": "user1", "role": "admin"},
{"id": float64(2), "login": "user2", "role": "member"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runOrgShortcut(t, server, "members", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("org members failed: %v", err)
}
}
func TestOrgCreate(t *testing.T) {
var createPayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/organizations.json" {
createPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(99), "name": "new-org",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runOrgShortcut(t, server, "create", map[string]string{
"name": "new-org",
"description": "My new organization",
})
if err != nil {
t.Fatalf("org create failed: %v", err)
}
if createPayload["name"] != "new-org" {
t.Fatalf("expected name=new-org, got %v", createPayload["name"])
}
if createPayload["description"] != "My new organization" {
t.Fatalf("expected description='My new organization', got %v", createPayload["description"])
}
}
func runOrgShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findOrgShortcut(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 findOrgShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func 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)
}
}

View File

@ -20,7 +20,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
@ -30,7 +30,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if err != nil {
return err
return fmt.Errorf("获取 PR 列表失败: %w", err)
}
return ctx.Output(env)
},
@ -46,10 +46,16 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
title, err := ctx.RequireArg("title")
if err != nil {
return err
}
head, err := ctx.RequireArg("head")
if err != nil {
return err
}
title, _ := ctx.RequireArg("title")
head, _ := ctx.RequireArg("head")
base := ctx.Arg("base")
if base == "" {
base = "master"
@ -64,7 +70,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
if err != nil {
return err
return fmt.Errorf("创建 PR 失败: %w", err)
}
return ctx.Output(env)
},
@ -77,13 +83,16 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("查看 PR 详情失败: %w", err)
}
return ctx.Output(env)
},
},
@ -96,9 +105,12 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
id, _ := ctx.RequireArg("id")
method := ctx.Arg("method")
if method == "" {
method = "merge"
@ -108,7 +120,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
if err != nil {
return err
return fmt.Errorf("合并 PR 失败: %w", err)
}
return ctx.Output(env)
},
@ -121,13 +133,16 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("关闭 PR 失败: %w", err)
}
return ctx.Output(env)
},
},
@ -139,13 +154,16 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 PR 变更文件失败: %w", err)
}
return ctx.Output(env)
},
},
@ -157,13 +175,53 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 PR Diff 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "review",
Description: "Submit a review on a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Review comment body", Required: true},
{Name: "action", Short: "a", Usage: "Review action: approve, comment, request-changes", Default: "comment"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
action := ctx.Arg("action")
if action == "" {
action = "comment"
}
reviewAction := mapAction(action)
payload := map[string]interface{}{
"body": body,
"action": reviewAction,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload)
if err != nil {
return fmt.Errorf("提交 PR Review 失败: %w", err)
}
return ctx.Output(env)
},
},
@ -176,14 +234,20 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
id, _ := ctx.RequireArg("id")
body, _ := ctx.RequireArg("body")
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("fetch PR: %w", err)
return fmt.Errorf("获取 PR 详情: %w", err)
}
issueID, err := extractIssueID(prEnv)
if err != nil {
@ -195,7 +259,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload)
if err != nil {
return err
return fmt.Errorf("添加 PR 评论失败: %w", err)
}
return ctx.Output(env)
},
@ -203,18 +267,29 @@ func Shortcuts() []*common.Shortcut {
}
}
func mapAction(action string) string {
switch action {
case "approve":
return "approved"
case "request-changes":
return "changes_requested"
default:
return "commented"
}
}
func extractIssueID(env *output.Envelope) (int64, error) {
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected PR response format")
return 0, fmt.Errorf("PR 响应格式异常")
}
issue, ok := data["issue"].(map[string]interface{})
if !ok {
return 0, fmt.Errorf("PR response missing issue field")
return 0, fmt.Errorf("PR 响应缺少 issue 字段")
}
idFloat, ok := issue["id"].(float64)
if !ok {
return 0, fmt.Errorf("PR response missing issue.id field")
return 0, fmt.Errorf("PR 响应缺少 issue.id 字段")
}
return int64(idFloat), nil
}

235
shortcuts/pr/pr_test.go Normal file → Executable file
View File

@ -141,3 +141,238 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func TestPRList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "title": "fix bug", "state": "open"},
{"id": float64(2), "title": "add feature", "state": "merged"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("pr list failed: %v", err)
}
}
func TestPRCreate(t *testing.T) {
var createPayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls.json" {
createPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "title": "new feature",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "create", map[string]string{
"title": "new feature",
"head": "feature-branch",
"base": "master",
"body": "This adds a new feature",
})
if err != nil {
t.Fatalf("pr create failed: %v", err)
}
assertEqual(t, createPayload["title"], "new feature")
assertEqual(t, createPayload["head"], "feature-branch")
assertEqual(t, createPayload["base"], "master")
assertEqual(t, createPayload["body"], "This adds a new feature")
}
func TestPRView(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/1.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "title": "fix bug", "state": "open",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "view", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("pr view failed: %v", err)
}
}
func TestPRMerge(t *testing.T) {
var mergePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/1/pr_merge.json" {
mergePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "merged"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "merge", map[string]string{
"id": "1",
"method": "squash",
})
if err != nil {
t.Fatalf("pr merge failed: %v", err)
}
assertEqual(t, mergePayload["do"], "squash")
}
func TestPRClose(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/1/refuse_merge.json" {
writeJSON(t, w, map[string]interface{}{"message": "closed"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "close", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("pr close failed: %v", err)
}
}
func TestPRFiles(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/1/files.json" {
writeJSON(t, w, []map[string]interface{}{
{"filename": "src/main.go", "status": "modified"},
{"filename": "src/test.go", "status": "added"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "files", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("pr files failed: %v", err)
}
}
func TestPRDiff(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/1/files.json" {
writeJSON(t, w, map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"filename": "main.go", "patch": "@@ -1,3 +1,4 @@"},
},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "diff", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("pr diff failed: %v", err)
}
}
func TestPRReviewSubmitsApproveAction(t *testing.T) {
var reviewPath string
var reviewPayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
reviewPath = r.URL.Path
reviewPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"state": "approved",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runPRShortcut(t, server, "review", map[string]string{
"id": "13",
"body": "LGTM!",
"action": "approve",
})
if err != nil {
t.Fatalf("review shortcut failed: %v", err)
}
if reviewPath == "" {
t.Fatal("review endpoint was not called")
}
assertEqual(t, reviewPayload["body"], "LGTM!")
assertEqual(t, reviewPayload["action"], "approved")
}
func TestPRReviewDefaultsToCommentAction(t *testing.T) {
var reviewPayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
reviewPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(2),
"state": "commented",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runPRShortcut(t, server, "review", map[string]string{
"id": "13",
"body": "Looks good",
})
if err != nil {
t.Fatalf("review shortcut failed: %v", err)
}
assertEqual(t, reviewPayload["action"], "commented")
}
func TestPRReviewSubmitsRequestChangesAction(t *testing.T) {
var reviewPayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
reviewPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(3),
"state": "changes_requested",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runPRShortcut(t, server, "review", map[string]string{
"id": "13",
"body": "Please fix the null check",
"action": "request-changes",
})
if err != nil {
t.Fatalf("review shortcut failed: %v", err)
}
assertEqual(t, reviewPayload["action"], "changes_requested")
assertEqual(t, reviewPayload["body"], "Please fix the null check")
}

View File

@ -7,16 +7,24 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/file"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
"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/star"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/watch"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/cli"
_ "github.com/gitlink-org/gitlink-cli/shortcuts/workflow/defs"
_ "github.com/gitlink-org/gitlink-cli/shortcuts/workflow/rules"
)
// RegisterAll mounts all shortcut groups onto the root command.
@ -35,6 +43,12 @@ func RegisterAll(root *cobra.Command) {
"milestone": milestone.Shortcuts(),
"webhook": webhook.Shortcuts(),
"label": label.Shortcuts(),
"file": file.Shortcuts(),
"member": member.Shortcuts(),
"watch": watch.Shortcuts(),
"star": star.Shortcuts(),
"wiki": wiki.Shortcuts(),
"workflow": cli.Shortcuts(),
}
descriptions := map[string]string{
@ -51,6 +65,12 @@ func RegisterAll(root *cobra.Command) {
"milestone": "Milestone operations",
"webhook": "Webhook operations",
"label": "Issue label operations",
"file": "Repository file operations",
"member": "Project member management",
"watch": "Watch repository operations",
"star": "Star repository operations",
"wiki": "Wiki page operations",
"workflow": "Automated workflow operations",
}
for name, shortcuts := range groups {

View File

@ -2,7 +2,10 @@ package release
import (
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -19,14 +22,14 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q)
if err != nil {
return err
return fmt.Errorf("获取 Release 列表失败: %w", err)
}
return ctx.Output(env)
},
@ -43,10 +46,16 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
tag, err := ctx.RequireArg("tag")
if err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
tag, _ := ctx.RequireArg("tag")
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{
"tag_name": tag,
"name": name,
@ -62,7 +71,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload)
if err != nil {
return err
return fmt.Errorf("创建 Release 失败: %w", err)
}
return ctx.Output(env)
},
@ -75,16 +84,122 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("查看 Release 详情失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "download",
Description: "Download release assets",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID or tag", Required: true},
{Name: "asset", Short: "a", Usage: "Asset file name (omit to list all assets)"},
{Name: "dir", Short: "d", Usage: "Download directory", Default: "."},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
assetName := ctx.Arg("asset")
dlDir := ctx.Arg("dir")
if dlDir == "" {
dlDir = "."
}
releaseEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 Release 信息失败: %w", err)
}
releaseData, ok := releaseEnv.Data.(map[string]interface{})
if !ok {
return fmt.Errorf("Release 响应格式异常")
}
assets, ok := releaseData["assets"].([]interface{})
if !ok || len(assets) == 0 {
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "该 Release 没有附件资源",
}, nil))
}
if assetName == "" {
var assetList []map[string]interface{}
for _, a := range assets {
asset, _ := a.(map[string]interface{})
assetList = append(assetList, map[string]interface{}{
"name": asset["name"],
"size": asset["size"],
})
}
return ctx.OutputData(map[string]interface{}{
"assets": assetList,
})
}
var downloadURL string
for _, a := range assets {
asset, _ := a.(map[string]interface{})
name, _ := asset["name"].(string)
if name == assetName {
downloadURL, _ = asset["download_url"].(string)
if downloadURL == "" {
downloadURL, _ = asset["url"].(string)
}
break
}
}
if downloadURL == "" {
return fmt.Errorf("Release 中未找到附件 %q", assetName)
}
if err := os.MkdirAll(dlDir, 0755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %w", dlDir, err)
}
resp, err := ctx.Client.HTTP.Get(downloadURL)
if err != nil {
return fmt.Errorf("下载失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
}
destPath := filepath.Join(dlDir, assetName)
file, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("创建文件 %s 失败: %w", destPath, err)
}
defer file.Close()
written, err := io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "下载完成",
"path": destPath,
"size": written,
}, nil))
},
},
{
Name: "delete",
Description: "Delete a release",
@ -93,22 +208,21 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
id, _ := ctx.RequireArg("id")
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if delErr != nil {
// GitLink API bug: delete succeeds but returns error status.
// Verify by checking if the release still exists.
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if viewErr != nil {
// Release no longer exists — delete actually succeeded
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功",
}, nil))
}
// Release still exists — delete truly failed
return delErr
return fmt.Errorf("删除 Release 失败: %w", delErr)
}
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功",

277
shortcuts/release/release_test.go Executable file
View File

@ -0,0 +1,277 @@
package release
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestReleaseDownloadListsAssetsWhenNoAssetFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"tag": "v1.0",
"assets": []interface{}{
map[string]interface{}{
"name": "app-linux-amd64",
"size": float64(1048576),
"download_url": "https://example.com/dl/app-linux-amd64",
},
map[string]interface{}{
"name": "app-darwin-amd64",
"size": float64(2097152),
"download_url": "https://example.com/dl/app-darwin-amd64",
},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runReleaseShortcut(t, server, "download", map[string]string{
"id": "v1.0",
})
if err != nil {
t.Fatalf("download shortcut failed: %v", err)
}
}
func TestReleaseDownloadNoAssetsReturnsMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"tag": "v1.0",
"assets": []interface{}{},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runReleaseShortcut(t, server, "download", map[string]string{
"id": "v1.0",
})
if err != nil {
t.Fatalf("download shortcut failed: %v", err)
}
}
func TestReleaseDownloadFetchesAssetURL(t *testing.T) {
assetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte("fake-binary-content"))
}))
defer assetServer.Close()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"tag": "v1.0",
"assets": []interface{}{
map[string]interface{}{
"name": "app-linux-amd64",
"size": float64(1048576),
"download_url": assetServer.URL + "/dl/app-linux-amd64",
},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
tmpDir := t.TempDir()
err := runReleaseShortcut(t, server, "download", map[string]string{
"id": "v1.0",
"asset": "app-linux-amd64",
"dir": tmpDir,
})
if err != nil {
t.Fatalf("download shortcut failed: %v", err)
}
destPath := filepath.Join(tmpDir, "app-linux-amd64")
data, err := os.ReadFile(destPath)
if err != nil {
t.Fatalf("failed to read downloaded file: %v", err)
}
if string(data) != "fake-binary-content" {
t.Fatalf("got %q, want %q", string(data), "fake-binary-content")
}
}
func TestReleaseDownloadAssetNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"tag": "v1.0",
"assets": []interface{}{
map[string]interface{}{
"name": "app-linux-amd64",
"size": float64(1048576),
},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runReleaseShortcut(t, server, "download", map[string]string{
"id": "v1.0",
"asset": "nonexistent.tar.gz",
})
if err == nil {
t.Fatal("expected error for non-existent asset, got nil")
}
}
func TestReleaseList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "tag_name": "v1.0", "name": "First release"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runReleaseShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("release list failed: %v", err)
}
}
func TestReleaseCreate(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/releases.json" {
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "tag_name": "v2.0",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runReleaseShortcut(t, server, "create", map[string]string{
"tag": "v2.0",
"name": "Version 2.0",
"body": "Release notes here",
})
if err != nil {
t.Fatalf("release create failed: %v", err)
}
assertEqual(t, body["tag_name"], "v2.0")
assertEqual(t, body["name"], "Version 2.0")
assertEqual(t, body["body"], "Release notes here")
}
func TestReleaseView(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "tag_name": "v1.0", "name": "First Release",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runReleaseShortcut(t, server, "view", map[string]string{"id": "v1.0"})
if err != nil {
t.Fatalf("release view failed: %v", err)
}
}
func TestReleaseDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/releases/1.json" {
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("release delete failed: %v", err)
}
}
func runReleaseShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findReleaseShortcut(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 findReleaseShortcut(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)
}
}

378
shortcuts/repo/batch.go Normal file
View File

@ -0,0 +1,378 @@
package repo
import (
"encoding/csv"
"fmt"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type batchRepoResult struct {
Repo string `json:"repo" yaml:"repo"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
type batchRepoSummary struct {
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Duration string `json:"duration" yaml:"duration"`
Results []batchRepoResult `json:"results" yaml:"results"`
}
func newBatchCreateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-create",
Description: "批量创建仓库,支持逗号分隔列表或 CSV 文件",
Flags: []common.Flag{
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库名称,例如: repo1,repo2,repo3"},
{Name: "from", Usage: "从 CSV 文件读取仓库名称。支持 name/repo/repository 列名或无表头首列"},
{Name: "description", Short: "d", Usage: "仓库描述(所有仓库共用一个描述)"},
{Name: "private", Usage: "设为私有仓库 (true/false)", Default: "false"},
{Name: "dry-run", Usage: "仅预览将要创建的仓库,不实际执行", Bool: true, Default: "false"},
},
Run: runBatchCreate,
}
}
func runBatchCreate(ctx *common.RuntimeContext) error {
start := time.Now()
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
if err != nil {
return err
}
if len(repos) == 0 {
return fmt.Errorf("未提供仓库名称,请使用 --repos repo1,repo2 或 --from repos.csv")
}
// 仅取仓库名,不需要 owner/repo 格式
names := make([]string, len(repos))
for i, r := range repos {
parts := strings.SplitN(r, "/", 2)
names[i] = parts[len(parts)-1]
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchRepoSummary{
Total: len(names),
Results: make([]batchRepoResult, 0, len(names)),
}
var userLogin string
var userID int
if !dryRun {
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return fmt.Errorf("获取当前用户信息失败: %w", err)
}
userData, _ := userEnv.Data.(map[string]interface{})
login, _ := userData["login"].(string)
if login == "" {
return fmt.Errorf("无法获取当前用户名")
}
userLogin = login
uid, _ := userData["user_id"].(float64)
userID = int(uid)
}
private := ctx.Arg("private") == "true"
desc := ctx.Arg("description")
for _, name := range names {
result := batchRepoResult{Repo: name, Action: "create"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
body := map[string]interface{}{
"name": name,
"repository_name": name,
"user_id": userID,
}
if desc != "" {
body["description"] = desc
}
if private {
body["private"] = true
}
path := fmt.Sprintf("/%s/%s", userLogin, name)
if _, err := ctx.CallAPI("POST", path, body); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "created"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个仓库创建失败", summary.Failed, summary.Total)
}
return nil
}
func newBatchForkShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-fork",
Description: "批量 Fork 仓库,支持逗号分隔列表或 CSV 文件",
Flags: []common.Flag{
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo例如: alice/proj1,bob/proj2"},
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo 单列或 owner、repo 双列格式"},
{Name: "dry-run", Usage: "仅预览将要 Fork 的仓库,不实际执行", Bool: true, Default: "false"},
},
Run: runBatchFork,
}
}
func runBatchFork(ctx *common.RuntimeContext) error {
start := time.Now()
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
if err != nil {
return err
}
if len(repos) == 0 {
return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchRepoSummary{
Total: len(repos),
Results: make([]batchRepoResult, 0, len(repos)),
}
for _, repoID := range repos {
parts := strings.SplitN(repoID, "/", 2)
result := batchRepoResult{Repo: repoID, Action: "fork"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
path := fmt.Sprintf("/%s/%s/forks", parts[0], parts[1])
if _, err := ctx.CallAPI("POST", path, nil); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "forked"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个仓库 Fork 失败", summary.Failed, summary.Total)
}
return nil
}
func newBatchDeleteShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-delete",
Description: "批量删除仓库,支持逗号分隔列表或 CSV 文件",
Flags: []common.Flag{
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo例如: alice/proj1,bob/proj2"},
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo 单列或 owner、repo 双列格式"},
{Name: "dry-run", Usage: "仅预览将要删除的仓库,不实际执行", Bool: true, Default: "false"},
},
Run: runBatchDelete,
}
}
func runBatchDelete(ctx *common.RuntimeContext) error {
start := time.Now()
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
if err != nil {
return err
}
if len(repos) == 0 {
return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchRepoSummary{
Total: len(repos),
Results: make([]batchRepoResult, 0, len(repos)),
}
for _, repoID := range repos {
parts := strings.SplitN(repoID, "/", 2)
result := batchRepoResult{Repo: repoID, Action: "delete"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
path := fmt.Sprintf("/%s/%s", parts[0], parts[1])
if _, err := ctx.CallAPI("DELETE", path, nil); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "deleted"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个仓库删除失败", summary.Failed, summary.Total)
}
return nil
}
// --- CSV / list helpers ---
func collectRepos(reposValue, csvPath string) ([]string, error) {
repos, err := parseRepoList(reposValue)
if err != nil {
return nil, err
}
if csvPath == "" {
return repos, nil
}
csvRepos, err := readReposFromCSV(csvPath)
if err != nil {
return nil, err
}
return mergeRepoStrings(repos, csvRepos), nil
}
func parseRepoList(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return normalizeRepoIDs(strings.Split(value, ","))
}
func readReposFromCSV(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
}
if len(records) == 0 {
return nil, nil
}
singleCol, ownerCol, repoCol := -1, -1, -1
startRow := 0
for i, cell := range records[0] {
switch strings.ToLower(strings.TrimSpace(cell)) {
case "owner/repo", "full_name":
singleCol = i
startRow = 1
case "owner":
ownerCol = i
startRow = 1
case "repo", "repository", "name":
if repoCol == -1 {
repoCol = i
}
startRow = 1
}
}
if ownerCol == -1 || repoCol == -1 {
// Not dual-column: use single-column mode
if singleCol == -1 {
singleCol = 0
}
ownerCol = -1
repoCol = -1
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
var repoID string
if singleCol >= 0 && singleCol < len(record) {
repoID = record[singleCol]
} else if ownerCol >= 0 && repoCol >= 0 && ownerCol < len(record) && repoCol < len(record) {
repoID = record[ownerCol] + "/" + record[repoCol]
} else {
continue
}
values = append(values, repoID)
}
return normalizeRepoIDs(values)
}
func normalizeRepoIDs(values []string) ([]string, error) {
repos := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
repoID := strings.TrimSpace(value)
if repoID == "" {
continue
}
if seen[repoID] {
continue
}
seen[repoID] = true
repos = append(repos, repoID)
}
return repos, nil
}
func mergeRepoStrings(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
for _, repos := range values {
for _, r := range repos {
if seen[r] {
continue
}
seen[r] = true
merged = append(merged, r)
}
}
return merged
}
func parseBool(value string) bool {
if strings.EqualFold(strings.TrimSpace(value), "true") {
return true
}
return false
}

View File

@ -3,12 +3,18 @@ package repo
import (
"fmt"
"net/url"
"os"
"os/exec"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCreateShortcut(),
newBatchForkShortcut(),
newBatchDeleteShortcut(),
{
Name: "list",
Description: "List repositories for a user or organization",
@ -33,21 +39,57 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return err
return fmt.Errorf("获取仓库列表失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "clone",
Description: "Clone a repository from GitLink",
Flags: []common.Flag{
{Name: "url", Short: "u", Usage: "Repository URL or owner/repo format", Required: true},
{Name: "dir", Short: "d", Usage: "Target directory (default: repo name)"},
},
Run: func(ctx *common.RuntimeContext) error {
rawURL, err := ctx.RequireArg("url")
if err != nil {
return err
}
targetDir := ctx.Arg("dir")
cloneURL, repoName, err := parseCloneTarget(rawURL, ctx.Client.BaseURL)
if err != nil {
return err
}
if targetDir == "" {
targetDir = repoName
}
fmt.Printf("正在克隆 %s 到 %s...\n", cloneURL, targetDir)
cmd := exec.Command("git", "clone", cloneURL, targetDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
return fmt.Errorf("克隆失败: %w", err)
}
return nil
},
},
{
Name: "info",
Description: "Show repository details",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return err
return fmt.Errorf("获取仓库详情失败: %w", err)
}
return ctx.Output(env)
},
@ -65,15 +107,14 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
// Get current user login for the create path
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return fmt.Errorf("failed to get current user: %w", err)
return fmt.Errorf("获取当前用户信息失败: %w", err)
}
userData, _ := userEnv.Data.(map[string]interface{})
login, _ := userData["login"].(string)
if login == "" {
return fmt.Errorf("cannot determine current user login")
return fmt.Errorf("无法获取当前用户名")
}
userID, _ := userData["user_id"].(float64)
body := map[string]interface{}{
@ -89,7 +130,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body)
if err != nil {
return err
return fmt.Errorf("创建仓库失败: %w", err)
}
return ctx.Output(env)
},
@ -99,11 +140,57 @@ func Shortcuts() []*common.Shortcut {
Description: "Fork a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil)
if err != nil {
return err
return fmt.Errorf("Fork 仓库失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "settings",
Description: "View or update repository settings",
Flags: []common.Flag{
{Name: "visibility", Usage: "Set visibility: public, private"},
{Name: "default-branch", Usage: "Set default branch"},
{Name: "description", Short: "d", Usage: "Update repository description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
visibility := ctx.Arg("visibility")
defaultBranch := ctx.Arg("default-branch")
description := ctx.Arg("description")
if visibility == "" && defaultBranch == "" && description == "" {
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return fmt.Errorf("获取仓库设置失败: %w", err)
}
return ctx.Output(env)
}
body := map[string]interface{}{}
if visibility != "" {
if visibility != "public" && visibility != "private" {
return fmt.Errorf("无效的 visibility 值 %q: 必须为 public 或 private", visibility)
}
body["visibility"] = visibility
}
if defaultBranch != "" {
body["default_branch"] = defaultBranch
}
if description != "" {
body["description"] = description
}
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
if err != nil {
return fmt.Errorf("更新仓库设置失败: %w", err)
}
return ctx.Output(env)
},
@ -113,14 +200,41 @@ func Shortcuts() []*common.Shortcut {
Description: "Delete a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
if err != nil {
return err
return fmt.Errorf("删除仓库失败: %w", err)
}
return ctx.Output(env)
},
},
}
}
// parseCloneTarget 把用户输入的 --url 解析成可克隆的 cloneURL 和默认目录名 repoName。
// 支持两种输入:
// - 完整 URLhttps://www.gitlink.org.cn/owner/repo[.git]
// - 简写格式owner/repo会基于 BaseURL 拼成完整 URL
//
// 抽成纯函数便于跨平台单元测试(不依赖 git / PATH
func parseCloneTarget(rawURL, baseURL string) (cloneURL, repoName string, err error) {
if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") {
cloneURL = rawURL
if !strings.HasSuffix(cloneURL, ".git") {
cloneURL += ".git"
}
parts := strings.Split(strings.TrimSuffix(rawURL, ".git"), "/")
repoName = parts[len(parts)-1]
return
}
parts := strings.SplitN(rawURL, "/", 2)
if len(parts) != 2 {
err = fmt.Errorf("无效的仓库格式 %q: 请使用 owner/repo 格式", rawURL)
return
}
webBase := strings.TrimSuffix(strings.TrimSuffix(baseURL, "/"), "/api")
cloneURL = fmt.Sprintf("%s/%s/%s.git", webBase, parts[0], parts[1])
repoName = parts[1]
return
}

397
shortcuts/repo/repo_test.go Executable file
View File

@ -0,0 +1,397 @@
package repo
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestRepoSettingsViewShowsCurrentSettings(t *testing.T) {
var capturedMethod string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedMethod = r.Method
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{
"name": "repo",
"visibility": "public",
"default_branch": "master",
"description": "A test repo",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{})
if err != nil {
t.Fatalf("settings shortcut failed: %v", err)
}
if capturedMethod != "GET" {
t.Fatalf("expected GET for viewing settings, got %s", capturedMethod)
}
}
func TestRepoSettingsUpdateVisibility(t *testing.T) {
var updateMethod string
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
updateMethod = r.Method
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"name": "repo",
"visibility": "private",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{
"visibility": "private",
})
if err != nil {
t.Fatalf("settings shortcut failed: %v", err)
}
if updateMethod != "PATCH" {
t.Fatalf("expected PATCH for updating settings, got %s", updateMethod)
}
assertEqual(t, updatePayload["visibility"], "private")
}
func TestRepoSettingsUpdateDescription(t *testing.T) {
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"description": "Updated description",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{
"description": "Updated description",
})
if err != nil {
t.Fatalf("settings shortcut failed: %v", err)
}
assertEqual(t, updatePayload["description"], "Updated description")
}
func TestRepoSettingsUpdateDefaultBranch(t *testing.T) {
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"default_branch": "main",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{
"default-branch": "main",
})
if err != nil {
t.Fatalf("settings shortcut failed: %v", err)
}
assertEqual(t, updatePayload["default_branch"], "main")
}
func TestRepoSettingsRejectsInvalidVisibility(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("should not make API call with invalid visibility")
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{
"visibility": "invalid",
})
if err == nil {
t.Fatal("expected error for invalid visibility, got nil")
}
}
func TestRepoSettingsUpdateMultipleFields(t *testing.T) {
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"visibility": "private",
"default_branch": "main",
"description": "new desc",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{
"visibility": "private",
"default-branch": "main",
"description": "new desc",
})
if err != nil {
t.Fatalf("settings shortcut failed: %v", err)
}
assertEqual(t, updatePayload["visibility"], "private")
assertEqual(t, updatePayload["default_branch"], "main")
assertEqual(t, updatePayload["description"], "new desc")
}
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)
}
}
// clone 测试直接验证 parseCloneTarget 的解析逻辑(纯函数,不依赖 git/PATH全平台可跑
const testCloneBaseURL = "https://www.gitlink.org.cn/api"
func TestRepoCloneParsesOwnerRepoFormat(t *testing.T) {
cloneURL, repoName, err := parseCloneTarget("myorg/myrepo", testCloneBaseURL)
if err != nil {
t.Fatalf("parseCloneTarget failed: %v", err)
}
if cloneURL != "https://www.gitlink.org.cn/myorg/myrepo.git" {
t.Errorf("cloneURL = %q, want .../myorg/myrepo.git", cloneURL)
}
if repoName != "myrepo" {
t.Errorf("repoName = %q, want myrepo", repoName)
}
}
func TestRepoCloneParsesFullURL(t *testing.T) {
cloneURL, repoName, err := parseCloneTarget("https://www.gitlink.org.cn/myorg/myrepo", testCloneBaseURL)
if err != nil {
t.Fatalf("parseCloneTarget failed: %v", err)
}
// 完整 URL 缺 .git 时应自动补上
if cloneURL != "https://www.gitlink.org.cn/myorg/myrepo.git" {
t.Errorf("cloneURL = %q, want .../myorg/myrepo.git", cloneURL)
}
if repoName != "myrepo" {
t.Errorf("repoName = %q, want myrepo", repoName)
}
}
func TestRepoCloneInvalidFormat(t *testing.T) {
_, _, err := parseCloneTarget("invalidformat", testCloneBaseURL)
if err == nil {
t.Fatal("expected error for invalid format, got nil")
}
if !strings.Contains(err.Error(), "owner/repo") {
t.Fatalf("expected error mentioning owner/repo, got: %v", err)
}
}
func TestRepoCloneURLWithGitSuffix(t *testing.T) {
cloneURL, repoName, err := parseCloneTarget("https://www.gitlink.org.cn/myorg/myrepo.git", testCloneBaseURL)
if err != nil {
t.Fatalf("parseCloneTarget failed: %v", err)
}
// 已带 .git 时不应重复追加
if cloneURL != "https://www.gitlink.org.cn/myorg/myrepo.git" {
t.Errorf("cloneURL = %q, want unchanged .../myorg/myrepo.git", cloneURL)
}
if repoName != "myrepo" {
t.Errorf("repoName = %q, want myrepo", repoName)
}
}
func TestRepoListWithoutUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/projects.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "name": "test-repo"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("repo list failed: %v", err)
}
}
func TestRepoListWithUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/users/testuser/projects.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "name": "test-repo"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "list", map[string]string{"user": "testuser"})
if err != nil {
t.Fatalf("repo list failed: %v", err)
}
}
func TestRepoInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/owner/repo.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "name": "test-repo", "visibility": "public",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "info", map[string]string{})
if err != nil {
t.Fatalf("repo info failed: %v", err)
}
}
func TestRepoCreate(t *testing.T) {
var createPath string
var createBody map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/users/me.json":
writeJSON(t, w, map[string]interface{}{
"login": "testuser",
"user_id": float64(42),
})
case r.Method == "POST" && r.URL.Path == "/testuser/myrepo.json":
createPath = r.URL.Path
createBody = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(99), "name": "myrepo",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "create", map[string]string{
"name": "myrepo",
"description": "test desc",
"private": "true",
})
if err != nil {
t.Fatalf("repo create failed: %v", err)
}
if createPath == "" {
t.Fatal("create endpoint was not called")
}
assertEqual(t, createBody["name"], "myrepo")
assertEqual(t, createBody["description"], "test desc")
assertEqual(t, createBody["private"], true)
}
func TestRepoFork(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/forks.json" {
writeJSON(t, w, map[string]interface{}{"message": "forked"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "fork", map[string]string{})
if err != nil {
t.Fatalf("repo fork failed: %v", err)
}
}
func TestRepoDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/owner/repo.json" {
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "delete", map[string]string{})
if err != nil {
t.Fatalf("repo delete failed: %v", err)
}
}

View File

@ -1,6 +1,7 @@
package search
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -17,14 +18,17 @@ func Shortcuts() []*common.Shortcut {
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
keyword, _ := ctx.RequireArg("keyword")
keyword, err := ctx.RequireArg("keyword")
if err != nil {
return err
}
q := url.Values{}
q.Set("search", keyword)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
if err != nil {
return err
return fmt.Errorf("搜索仓库失败: %w", err)
}
return ctx.Output(env)
},
@ -38,15 +42,50 @@ func Shortcuts() []*common.Shortcut {
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
keyword, _ := ctx.RequireArg("keyword")
keyword, err := ctx.RequireArg("keyword")
if err != nil {
return err
}
q := url.Values{}
q.Set("search", keyword)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
if err != nil {
return fmt.Errorf("搜索用户失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "issues",
Description: "Search issues across repositories",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "all"},
{Name: "owner", Short: "o", Usage: "Limit to owner's repositories"},
{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 {
keyword, err := ctx.RequireArg("keyword")
if err != nil {
return err
}
q := url.Values{}
q.Set("search", keyword)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("state"); s != "" && s != "all" {
q.Set("state", s)
}
if o := ctx.Arg("owner"); o != "" {
q.Set("owner", o)
}
env, err := ctx.CallAPIWithQuery("GET", "/issues", q)
if err != nil {
return fmt.Errorf("搜索 Issue 失败: %w", err)
}
return ctx.Output(env)
},
},

209
shortcuts/search/search_test.go Executable file
View File

@ -0,0 +1,209 @@
package search
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestSearchIssuesSendsKeywordAndState(t *testing.T) {
var capturedPath string
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
capturedQuery = r.URL.Query()
writeJSON(t, w, map[string]interface{}{
"data": []map[string]interface{}{
{"id": float64(1), "subject": "bug found"},
},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "issues", map[string]string{
"keyword": "bug",
"state": "open",
})
if err != nil {
t.Fatalf("issues shortcut failed: %v", err)
}
if capturedPath != "/issues.json" {
t.Fatalf("expected path /issues.json, got %s", capturedPath)
}
if capturedQuery.Get("search") != "bug" {
t.Fatalf("expected search=bug, got %s", capturedQuery.Get("search"))
}
if capturedQuery.Get("state") != "open" {
t.Fatalf("expected state=open, got %s", capturedQuery.Get("state"))
}
}
func TestSearchIssuesOmitsStateWhenAll(t *testing.T) {
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedQuery = r.URL.Query()
writeJSON(t, w, map[string]interface{}{
"data": []map[string]interface{}{},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "issues", map[string]string{
"keyword": "bug",
"state": "all",
})
if err != nil {
t.Fatalf("issues shortcut failed: %v", err)
}
if capturedQuery.Get("state") != "" {
t.Fatalf("expected no state filter, got %s", capturedQuery.Get("state"))
}
}
func TestSearchIssuesSendsOwnerWhenProvided(t *testing.T) {
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedQuery = r.URL.Query()
writeJSON(t, w, map[string]interface{}{
"data": []map[string]interface{}{},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "issues", map[string]string{
"keyword": "bug",
"owner": "myorg",
})
if err != nil {
t.Fatalf("issues shortcut failed: %v", err)
}
if capturedQuery.Get("owner") != "myorg" {
t.Fatalf("expected owner=myorg, got %s", capturedQuery.Get("owner"))
}
}
func TestSearchIssuesDefaultsPageAndLimit(t *testing.T) {
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedQuery = r.URL.Query()
writeJSON(t, w, map[string]interface{}{
"data": []map[string]interface{}{},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "issues", map[string]string{
"keyword": "bug",
"page": "1",
"limit": "20",
})
if err != nil {
t.Fatalf("issues shortcut failed: %v", err)
}
if capturedQuery.Get("page") != "1" {
t.Fatalf("expected page=1, got %s", capturedQuery.Get("page"))
}
if capturedQuery.Get("limit") != "20" {
t.Fatalf("expected limit=20, got %s", capturedQuery.Get("limit"))
}
}
func TestSearchRepos(t *testing.T) {
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedQuery = r.URL.Query()
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "name": "search-result"},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "repos", map[string]string{
"keyword": "machine-learning",
})
if err != nil {
t.Fatalf("search repos failed: %v", err)
}
if capturedQuery.Get("search") != "machine-learning" {
t.Fatalf("expected search=machine-learning, got %s", capturedQuery.Get("search"))
}
}
func TestSearchUsers(t *testing.T) {
var capturedPath string
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
capturedQuery = r.URL.Query()
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "login": "zhangsan"},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "users", map[string]string{
"keyword": "zhangsan",
})
if err != nil {
t.Fatalf("search users failed: %v", err)
}
if capturedPath != "/users/list.json" {
t.Fatalf("expected path /users/list.json, got %s", capturedPath)
}
if capturedQuery.Get("search") != "zhangsan" {
t.Fatalf("expected search=zhangsan, got %s", capturedQuery.Get("search"))
}
}
func runSearchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findSearchShortcut(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 findSearchShortcut(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)
}
}

85
shortcuts/star/star.go Normal file
View File

@ -0,0 +1,85 @@
package star
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "star",
Description: "Star (like) a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
env, err := ctx.CallAPI("POST",
fmt.Sprintf("/projects/%d/praise_tread/like", projectID), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "unstar",
Description: "Unstar (unlike) a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE",
fmt.Sprintf("/projects/%d/praise_tread/unlike", projectID), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "stars",
Description: "List stargazers of a repository",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Repository owner", Required: true},
{Name: "repo", Short: "r", Usage: "Repository name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
owner, _ := ctx.RequireArg("owner")
repo, _ := ctx.RequireArg("repo")
env, err := ctx.CallAPI("GET",
fmt.Sprintf("/%s/%s/stargazers", owner, repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func resolveProjectID(ctx *common.RuntimeContext) (int64, error) {
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return 0, fmt.Errorf("failed to get project info: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected project info response")
}
for _, key := range []string{"id", "project_id", "repo_id"} {
if id, ok := data[key].(float64); ok {
return int64(id), nil
}
}
return 0, fmt.Errorf("cannot find project id in response")
}

101
shortcuts/star/star_test.go Normal file
View File

@ -0,0 +1,101 @@
package star
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestStar(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
case r.Method == "POST" && r.URL.Path == "/projects/100/praise_tread/like.json":
writeJSON(t, w, map[string]interface{}{})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runStarShortcut(t, server, "star", map[string]string{}); err != nil {
t.Fatalf("star failed: %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 API calls, got %d", callCount)
}
}
func TestUnstar(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
case r.Method == "DELETE" && r.URL.Path == "/projects/100/praise_tread/unlike.json":
writeJSON(t, w, map[string]interface{}{})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runStarShortcut(t, server, "unstar", map[string]string{}); err != nil {
t.Fatalf("unstar failed: %v", err)
}
}
func TestStars(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/stargazers.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"count": 1,
"users": []map[string]interface{}{{"login": "alice"}},
})
}))
defer server.Close()
err := runStarShortcut(t, server, "stars", map[string]string{
"owner": "owner", "repo": "repo",
})
if err != nil {
t.Fatalf("stars failed: %v", err)
}
}
func runStarShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findStarShortcut(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 findStarShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}

View File

@ -14,7 +14,7 @@ func Shortcuts() []*common.Shortcut {
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return err
return fmt.Errorf("获取当前用户信息失败: %w", err)
}
return ctx.Output(env)
},
@ -32,7 +32,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
if err != nil {
return err
return fmt.Errorf("查看用户详情失败: %w", err)
}
return ctx.Output(env)
},

90
shortcuts/user/user_test.go Executable file
View File

@ -0,0 +1,90 @@
package user
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestUserMe(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/users/me.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"login": "testuser",
"name": "Test User",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runUserShortcut(t, server, "me", map[string]string{})
if err != nil {
t.Fatalf("user me failed: %v", err)
}
}
func TestUserInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/users/zhangsan.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(42),
"login": "zhangsan",
"name": "Zhang San",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runUserShortcut(t, server, "info", map[string]string{"login": "zhangsan"})
if err != nil {
t.Fatalf("user info failed: %v", err)
}
}
func runUserShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findUserShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findUserShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func 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)
}
}

91
shortcuts/watch/watch.go Normal file
View File

@ -0,0 +1,91 @@
package watch
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "watch",
Description: "Watch a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("target_type", "project")
q.Set("id", fmt.Sprintf("%d", projectID))
env, err := ctx.CallAPIWithQuery("POST", "/watchers/follow", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "unwatch",
Description: "Unwatch a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("target_type", "project")
q.Set("id", fmt.Sprintf("%d", projectID))
env, err := ctx.CallAPIWithQuery("DELETE", "/watchers/unfollow", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "watchers",
Description: "List watchers of a repository",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Repository owner", Required: true},
{Name: "repo", Short: "r", Usage: "Repository name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
owner, _ := ctx.RequireArg("owner")
repo, _ := ctx.RequireArg("repo")
ctx.Owner = owner
ctx.Repo = repo
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/watchers", owner, repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func resolveProjectID(ctx *common.RuntimeContext) (int64, error) {
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return 0, fmt.Errorf("failed to get project info: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected project info response")
}
for _, key := range []string{"id", "project_id", "repo_id"} {
if id, ok := data[key].(float64); ok {
return int64(id), nil
}
}
return 0, fmt.Errorf("cannot find project id in response")
}

View File

@ -0,0 +1,109 @@
package watch
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestWatch(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(100), "name": "repo"})
case r.Method == "POST" && r.URL.Path == "/watchers/follow.json":
if r.URL.Query().Get("target_type") != "project" {
t.Fatal("expected target_type=project")
}
if r.URL.Query().Get("id") != "100" {
t.Fatalf("expected id=100, got %s", r.URL.Query().Get("id"))
}
writeJSON(t, w, map[string]interface{}{"watched": true})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runWatchShortcut(t, server, "watch", map[string]string{}); err != nil {
t.Fatalf("watch failed: %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 calls (GET project + POST watch), got %d", callCount)
}
}
func TestUnwatch(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
case r.Method == "DELETE" && r.URL.Path == "/watchers/unfollow.json":
writeJSON(t, w, map[string]interface{}{"watched": false})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runWatchShortcut(t, server, "unwatch", map[string]string{}); err != nil {
t.Fatalf("unwatch failed: %v", err)
}
}
func TestWatchers(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/watchers.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"count": 1,
"users": []map[string]interface{}{{"login": "alice", "is_watch": true}},
})
}))
defer server.Close()
err := runWatchShortcut(t, server, "watchers", map[string]string{
"owner": "owner", "repo": "repo",
})
if err != nil {
t.Fatalf("watchers failed: %v", err)
}
}
// === helpers ===
func runWatchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findWatchShortcut(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 findWatchShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}

View File

@ -18,11 +18,11 @@ func Shortcuts() []*common.Shortcut {
Description: "List webhooks",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
env, err := ctx.CallAPI("GET", v1RepoPath(ctx)+"/webhooks", nil)
if err != nil {
return err
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
}
return ctx.Output(env)
},
@ -40,7 +40,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
webhookURL, err := ctx.RequireArg("url")
if err != nil {
@ -68,7 +68,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/webhooks", body)
if err != nil {
return err
return fmt.Errorf("创建 Webhook 失败: %w", err)
}
return ctx.Output(env)
},
@ -81,7 +81,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
@ -89,7 +89,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return err
return fmt.Errorf("查看 Webhook 详情失败: %w", err)
}
return ctx.Output(env)
},
@ -108,7 +108,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
@ -141,11 +141,11 @@ func Shortcuts() []*common.Shortcut {
body["active"] = active == "true"
}
if len(body) == 0 {
return fmt.Errorf("at least one update field is required")
return fmt.Errorf("至少需要指定一个要更新的字段")
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), body)
if err != nil {
return err
return fmt.Errorf("更新 Webhook 失败: %w", err)
}
return ctx.Output(env)
},
@ -158,7 +158,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
@ -166,7 +166,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return err
return fmt.Errorf("删除 Webhook 失败: %w", err)
}
return ctx.Output(env)
},
@ -179,7 +179,7 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
@ -187,7 +187,7 @@ func Shortcuts() []*common.Shortcut {
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", v1RepoPath(ctx), id), nil)
if err != nil {
return err
return fmt.Errorf("测试 Webhook 失败: %w", err)
}
return ctx.Output(env)
},

18
shortcuts/webhook/webhook_test.go Normal file → Executable file
View File

@ -113,6 +113,24 @@ func TestWebhookTest(t *testing.T) {
}
}
func TestWebhookView(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/1.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "url": "https://example.com/hook", "is_active": true,
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runWebhookShortcut(t, server, "view", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("webhook view failed: %v", err)
}
}
func TestWebhookUpdateRequiresAtLeastOneField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made when no fields are provided")

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

@ -0,0 +1,302 @@
package wiki
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const wikiBaseURL = "https://gateway.gitlink.org.cn/api"
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List wiki pages",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := fetchProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", fmt.Sprintf("%d", projectID))
return callWikiAPI(ctx, "GET", "/wiki/open/wikiPages", nil, q)
},
},
{
Name: "view",
Description: "View a wiki page",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Wiki page 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
}
projectID, err := fetchProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", fmt.Sprintf("%d", projectID))
q.Set("pageName", name)
return callWikiAPI(ctx, "GET", "/wiki/open/getWiki", nil, q)
},
},
{
Name: "create",
Description: "Create a wiki page",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
{Name: "content", Short: "c", Usage: "Page content (will be base64 encoded)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(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
}
projectID, err := fetchProjectID(ctx)
if err != nil {
return err
}
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": projectID,
"pageName": name,
"title": name,
"message": ctx.Arg("message"),
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
}
return callWikiAPI(ctx, "POST", "/wiki/open/createWiki", body, nil)
},
},
{
Name: "update",
Description: "Update a wiki page",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
{Name: "content", Short: "c", Usage: "New page content (will be base64 encoded)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(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
}
projectID, err := fetchProjectID(ctx)
if err != nil {
return err
}
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": projectID,
"pageName": name,
"title": name,
"message": ctx.Arg("message"),
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
}
return callWikiAPI(ctx, "PUT", "/wiki/open/updateWiki", body, nil)
},
},
{
Name: "delete",
Description: "Delete a wiki page and remove it from sidebar",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Wiki page 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
}
projectID, err := fetchProjectID(ctx)
if err != nil {
return err
}
// Step 1: Delete the wiki page
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": projectID,
"pageName": name,
}
if err := callWikiAPISilent(ctx, "DELETE", "/wiki/open/deleteWiki", body, nil); err != nil {
return err
}
// Step 2: Wait for GitLink async sidebar rebuild, then clean up
time.Sleep(2 * time.Second)
cleanSidebar(ctx, projectID, name)
fmt.Printf("Wiki page %q deleted successfully.\n", name)
return nil
},
},
}
}
// callWikiAPI sends a request to the wiki gateway.
// It switches the client BaseURL to the wiki gateway for the duration of the call,
// but skips the switch during tests (local httptest server).
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error {
origBase := ctx.Client.BaseURL
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
ctx.Client.BaseURL = wikiBaseURL
}
defer func() { ctx.Client.BaseURL = origBase }()
env, err := ctx.Client.DoRaw(method, path, body, query)
if err != nil {
return err
}
return ctx.Output(env)
}
// fetchProjectID resolves the numeric project ID from the repo info API.
func fetchProjectID(ctx *common.RuntimeContext) (int64, error) {
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return 0, fmt.Errorf("failed to get project info: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected project info response")
}
for _, key := range []string{"project_id", "repo_id", "id"} {
if id, ok := data[key].(float64); ok {
return int64(id), nil
}
}
return 0, fmt.Errorf("project id not found in response")
}
// callWikiAPISilent is like callWikiAPI but does not print output.
func callWikiAPISilent(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error {
origBase := ctx.Client.BaseURL
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
ctx.Client.BaseURL = wikiBaseURL
}
defer func() { ctx.Client.BaseURL = origBase }()
_, err := ctx.Client.DoRaw(method, path, body, query)
return err
}
const sidebarPageName = "_Sidebar" // GitLink uses capital S for the sidebar page
// cleanSidebar fetches the wiki sidebar, removes the deleted page link, and updates it.
func cleanSidebar(ctx *common.RuntimeContext, projectID int64, pageName string) {
// Fetch sidebar
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", fmt.Sprintf("%d", projectID))
q.Set("pageName", sidebarPageName)
origBase := ctx.Client.BaseURL
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
ctx.Client.BaseURL = wikiBaseURL
}
defer func() { ctx.Client.BaseURL = origBase }()
env, err := ctx.Client.DoRaw("GET", "/wiki/open/getWiki", nil, q)
if err != nil {
return // sidebar might not exist, silently skip
}
// Extract content_base64 from response.
// DoRaw auto-parses JSON, so env.Data is a map with "data" as either
// a nested dict (already parsed) or a JSON string (needs parsing).
outer, ok := env.Data.(map[string]interface{})
if !ok {
return
}
var inner map[string]interface{}
switch v := outer["data"].(type) {
case map[string]interface{}:
inner = v
case string:
if err := json.Unmarshal([]byte(v), &inner); err != nil {
return
}
default:
return
}
contentB64, ok := inner["content_base64"].(string)
if !ok {
return
}
contentBytes, err := base64.StdEncoding.DecodeString(contentB64)
if err != nil {
return
}
sidebar := string(contentBytes)
// Remove the line containing [[pageName]]
target := "[[" + pageName + "]]"
lines := strings.Split(sidebar, "\n")
var newLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != target {
newLines = append(newLines, line)
}
}
newSidebar := strings.Join(newLines, "\n")
// No change needed
if newSidebar == sidebar {
return
}
// Update sidebar
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": projectID,
"pageName": sidebarPageName,
"title": sidebarPageName,
"message": "Remove deleted page " + pageName + " from sidebar",
"content_base64": base64.StdEncoding.EncodeToString([]byte(newSidebar)),
}
ctx.Client.DoRaw("PUT", "/wiki/open/updateWiki", body, nil)
}

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

@ -0,0 +1,325 @@
package wiki
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// --- list ---
func TestWikiList(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages":
if r.URL.Query().Get("projectId") != "42" {
t.Fatalf("expected projectId=42, got %s", r.URL.Query().Get("projectId"))
}
writeJSON(t, w, map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"title": "Home", "sub_url": "Home"},
map[string]interface{}{"title": "Guide", "sub_url": "Guide"},
},
})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runWikiShortcut(t, server, "list", map[string]string{}); err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestWikiListWithProjectID(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"project_id": float64(99)})
case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages":
if r.URL.Query().Get("projectId") != "99" {
t.Fatalf("expected projectId=99, got %s", r.URL.Query().Get("projectId"))
}
writeJSON(t, w, map[string]interface{}{"data": []interface{}{}})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runWikiShortcut(t, server, "list", map[string]string{}); err != nil {
t.Fatalf("list with project_id failed: %v", err)
}
}
// --- view ---
func TestWikiView(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki":
if r.URL.Query().Get("pageName") != "Home" {
t.Fatalf("expected pageName=Home, got %s", r.URL.Query().Get("pageName"))
}
writeJSON(t, w, map[string]interface{}{
"data": map[string]interface{}{
"title": "Home",
"content_base64": base64.StdEncoding.EncodeToString([]byte("Welcome to wiki")),
},
})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runWikiShortcut(t, server, "view", map[string]string{"name": "Home"})
if err != nil {
t.Fatalf("view failed: %v", err)
}
}
func TestWikiViewRequiresName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --name")
}))
defer server.Close()
err := runWikiShortcut(t, server, "view", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --name")
}
}
// --- create ---
func TestWikiCreate(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
case r.Method == "POST" && r.URL.Path == "/wiki/open/createWiki":
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["pageName"] != "NewPage" {
t.Fatalf("expected pageName=NewPage, got %v", payload["pageName"])
}
if payload["title"] != "NewPage" {
t.Fatalf("expected title=NewPage, got %v", payload["title"])
}
if payload["owner"] != "owner" {
t.Fatalf("expected owner=owner, got %v", payload["owner"])
}
if payload["repo"] != "repo" {
t.Fatalf("expected repo=repo, got %v", payload["repo"])
}
if payload["projectId"].(float64) != 42 {
t.Fatalf("expected projectId=42, got %v", payload["projectId"])
}
expectedContent := base64.StdEncoding.EncodeToString([]byte("Hello Wiki!"))
if payload["content_base64"] != expectedContent {
t.Fatalf("content_base64 mismatch: got %v", payload["content_base64"])
}
writeJSON(t, w, map[string]interface{}{
"code": 201,
"data": map[string]interface{}{"title": "NewPage"},
})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runWikiShortcut(t, server, "create", map[string]string{
"name": "NewPage", "content": "Hello Wiki!", "message": "create page",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
}
func TestWikiCreateRequiresName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --name")
}))
defer server.Close()
err := runWikiShortcut(t, server, "create", map[string]string{"content": "test"})
if err == nil {
t.Fatal("expected error for missing --name")
}
}
func TestWikiCreateRequiresContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --content")
}))
defer server.Close()
err := runWikiShortcut(t, server, "create", map[string]string{"name": "Test"})
if err == nil {
t.Fatal("expected error for missing --content")
}
}
// --- update ---
func TestWikiUpdate(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki":
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["pageName"] != "Home" {
t.Fatalf("expected pageName=Home, got %v", payload["pageName"])
}
if payload["title"] != "Home" {
t.Fatalf("expected title=Home, got %v", payload["title"])
}
if payload["message"] != "update page" {
t.Fatalf("expected message=update page, got %v", payload["message"])
}
writeJSON(t, w, map[string]interface{}{"code": 200})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runWikiShortcut(t, server, "update", map[string]string{
"name": "Home", "content": "Updated content", "message": "update page",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
}
func TestWikiUpdateRequiresName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --name")
}))
defer server.Close()
err := runWikiShortcut(t, server, "update", map[string]string{"content": "test"})
if err == nil {
t.Fatal("expected error for missing --name")
}
}
// --- delete ---
func TestWikiDelete(t *testing.T) {
callCount := 0
sidebarContent := "[[Home]]\n[[OldPage]]\n[[Guide]]"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
case r.Method == "DELETE" && r.URL.Path == "/wiki/open/deleteWiki":
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["pageName"] != "OldPage" {
t.Fatalf("expected pageName=OldPage, got %v", payload["pageName"])
}
writeJSON(t, w, map[string]interface{}{"code": 200})
case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki":
if r.URL.Query().Get("pageName") != "_Sidebar" {
t.Fatalf("expected pageName=_Sidebar, got %s", r.URL.Query().Get("pageName"))
}
// Return sidebar with the page still in it
writeJSON(t, w, map[string]interface{}{
"code": 200,
"data": fmt.Sprintf(`{"content_base64":"%s"}`, base64.StdEncoding.EncodeToString([]byte(sidebarContent))),
})
case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki":
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["pageName"] != "_Sidebar" {
t.Fatalf("expected pageName=_Sidebar, got %v", payload["pageName"])
}
// Verify OldPage is removed from sidebar
updated, _ := base64.StdEncoding.DecodeString(payload["content_base64"].(string))
if strings.Contains(string(updated), "[[OldPage]]") {
t.Fatal("sidebar should not contain [[OldPage]] after delete")
}
if !strings.Contains(string(updated), "[[Home]]") {
t.Fatal("sidebar should still contain [[Home]]")
}
writeJSON(t, w, map[string]interface{}{"code": 200})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runWikiShortcut(t, server, "delete", map[string]string{"name": "OldPage"})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
}
func TestWikiDeleteRequiresName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --name")
}))
defer server.Close()
err := runWikiShortcut(t, server, "delete", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --name")
}
}
// --- helpers ---
func runWikiShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findWikiShortcut(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 findWikiShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}

View File

@ -0,0 +1,195 @@
package ai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/internal/config"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
const deepseekBaseURL = "https://api.deepseek.com/v1/chat/completions"
const defaultModel = "deepseek-chat"
// AIClient wraps the DeepSeek API for skill step execution.
type AIClient struct {
apiKey string
model string
http *http.Client
}
const jsonOutputInstruction = `
IMPORTANT: You MUST respond with a single JSON object in exactly this format:
{"analysis": "<your full markdown report as a single string>", "actions": [...list of actions...]}
The "analysis" field is a STRING containing your entire analysis report in markdown.
The "actions" field is an array of action objects: {"type":"cli", "module":"pr", "command":"+comment", "args":{"id":"4", "body":"..."}} or {"type":"api", "method":"POST", "path":"/v1/...", "body":{...}}.
Do NOT include any text outside the JSON object. Do NOT use markdown code fences around the JSON.`
// NewAIClient resolves the API key (env → config) and returns a client, or nil if unavailable.
func NewAIClient() *AIClient {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
cfg, err := config.Load()
if err == nil {
key = cfg.DeepSeekAPIKey
}
}
if key == "" {
return nil
}
return &AIClient{
apiKey: key,
model: defaultModel,
http: &http.Client{Timeout: 120 * time.Second},
}
}
// Analyze sends the skill prompt + upstream data to the DeepSeek API and parses the response.
func (c *AIClient) Analyze(req *wf.AIRequest) (*wf.AIResponse, error) {
if c == nil {
return nil, fmt.Errorf("AI client not configured: set DEEPSEEK_API_KEY or configure deepseek_api_key")
}
body := map[string]interface{}{
"model": c.model,
"max_tokens": 4096,
"temperature": 0.1,
"messages": []map[string]string{
{"role": "system", "content": req.SystemPrompt + jsonOutputInstruction},
{"role": "user", "content": req.UserData},
},
"response_format": map[string]string{"type": "json_object"},
}
payload, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequest("POST", deepseekBaseURL, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.http.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("API call: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("DeepSeek API returned %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if len(result.Choices) == 0 {
return nil, fmt.Errorf("empty response from DeepSeek API")
}
text := result.Choices[0].Message.Content
aiResp, err := parseAIResponse(text)
if err != nil {
if extracted := extractJSONFromMarkdown(text); extracted != "" {
aiResp2, err2 := parseAIResponse(extracted)
if err2 != nil {
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
}
aiResp = aiResp2
} else {
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
}
}
return aiResp, nil
}
// parseAIResponse unmarshals the AI's JSON output, coercing numeric arg values to strings.
func parseAIResponse(text string) (*wf.AIResponse, error) {
raw := struct {
Analysis json.RawMessage `json:"analysis"`
Actions []struct {
Type string `json:"type"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Body map[string]interface{} `json:"body,omitempty"`
Module string `json:"module,omitempty"`
Command string `json:"command,omitempty"`
Args map[string]interface{} `json:"args,omitempty"`
} `json:"actions"`
}{}
if err := json.Unmarshal([]byte(text), &raw); err != nil {
return nil, err
}
resp := &wf.AIResponse{}
if err := json.Unmarshal(raw.Analysis, &resp.Analysis); err != nil {
resp.Analysis = string(raw.Analysis)
}
for _, a := range raw.Actions {
args := make(map[string]string, len(a.Args))
for k, v := range a.Args {
args[k] = fmt.Sprint(v)
}
resp.Actions = append(resp.Actions, wf.AIAction{
Type: a.Type,
Method: a.Method,
Path: a.Path,
Body: a.Body,
Module: a.Module,
Command: a.Command,
Args: args,
})
}
return resp, nil
}
// HasKey reports whether the AI client is configured.
func (c *AIClient) HasKey() bool {
return c != nil && c.apiKey != ""
}
// extractJSONFromMarkdown tries to pull a JSON object out of a markdown code fence.
func extractJSONFromMarkdown(text string) string {
start := strings.Index(text, "```json")
if start == -1 {
start = strings.Index(text, "```")
}
if start == -1 {
return ""
}
nl := strings.Index(text[start:], "\n")
if nl == -1 {
return ""
}
content := text[start+nl+1:]
end := strings.Index(content, "```")
if end == -1 {
return ""
}
return strings.TrimSpace(content[:end])
}

View File

@ -0,0 +1,397 @@
package cli
import (
"fmt"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/daemon"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
)
// Shortcuts returns all workflow CLI commands.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List available workflows",
Flags: []common.Flag{
{Name: "category", Short: "c", Usage: "Filter by category", Default: ""},
},
Run: func(ctx *common.RuntimeContext) error {
cat := ctx.Arg("category")
workflows := wf.All()
filtered := make([]*wf.WorkflowDef, 0)
for _, w := range workflows {
if cat == "" || strings.EqualFold(w.Category, cat) {
filtered = append(filtered, w)
}
}
type listItem struct {
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
StepCount int `json:"step_count"`
}
items := make([]listItem, len(filtered))
for i, w := range filtered {
items[i] = listItem{
Name: w.Name,
Category: w.Category,
Description: w.Description,
StepCount: len(w.Steps),
}
}
return ctx.OutputData(items)
},
},
{
Name: "info",
Description: "Show workflow detail (steps and trigger)",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
return ctx.OutputData(w)
},
},
{
Name: "run",
Description: "Execute a workflow manually",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
{Name: "dry-run", Usage: "Preview mode (no AI calls)", Bool: true},
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
{Name: "desc", Short: "d", Usage: "Project description (for project-init workflow)", Default: ""},
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow, e.g. org/backend,org/frontend", Default: ""},
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
{Name: "wiki-repo", Usage: "Wiki target repo for publishing multi-repo report, e.g. org/dashboard", Default: ""},
{Name: "daemon-loop", Usage: "Internal: run in loop mode", Bool: true},
{Name: "interval", Usage: "Internal: loop interval", Default: "5m"},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
if desc := ctx.Arg("desc"); desc != "" {
ctx.Args["_desc"] = desc
}
if wr := ctx.Arg("wiki-repo"); wr != "" {
owner, repo := splitRepoRef(wr)
if owner != "" && repo != "" {
ctx.Args["_wiki_owner"] = owner
ctx.Args["_wiki_repo"] = repo
}
}
aiMode, err := resolveAIModeFromArgs(ctx)
if err != nil {
return err
}
if ctx.Arg("daemon-loop") == "true" {
intervalStr := ctx.Arg("interval")
interval, err := time.ParseDuration(intervalStr)
if err != nil {
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
}
return daemon.DaemonLoop(ctx, w, interval)
}
return runWorkflowCommand(ctx, w, ctx.Arg("dry-run") == "true", aiMode)
},
},
{
Name: "watch",
Description: "Poll for changes and trigger workflow on delta",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 30s, 5m, 1h)", Default: "5m"},
{Name: "step", Short: "s", Usage: "Step name to watch for changes (default: first step)", Default: ""},
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
if w.Name == "multi-repo" {
return fmt.Errorf("workflow +watch 不支持 multi-repo多仓库协同请求量较大请使用 workflow +run 手动检查,或 workflow +schedule --interval 6h/24h 做低频巡检")
}
intervalStr := ctx.Arg("interval")
interval, err := time.ParseDuration(intervalStr)
if err != nil {
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
}
aiMode, modeErr := resolveAIModeFromArgs(ctx)
if modeErr != nil {
return modeErr
}
ctx.AIMode = aiMode
return daemon.Watch(ctx, w, interval, ctx.Arg("step"))
},
},
{
Name: "schedule",
Description: "Run a workflow on a repeating schedule",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
{Name: "interval", Short: "i", Usage: "Run interval (e.g. 1h, 24h)", Default: "24h"},
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow", Default: ""},
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
{Name: "wiki-repo", Usage: "Wiki target repo for publishing multi-repo report, e.g. org/dashboard", Default: ""},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
intervalStr := ctx.Arg("interval")
interval, err := time.ParseDuration(intervalStr)
if err != nil {
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
}
if wr := ctx.Arg("wiki-repo"); wr != "" {
owner, repo := splitRepoRef(wr)
if owner != "" && repo != "" {
ctx.Args["_wiki_owner"] = owner
ctx.Args["_wiki_repo"] = repo
}
}
aiMode, modeErr := resolveAIModeFromArgs(ctx)
if modeErr != nil {
return modeErr
}
ctx.AIMode = aiMode
return daemon.Schedule(ctx, w, interval)
},
},
{
Name: "start",
Description: "Start workflow as background daemon",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 5m, 1h)", Default: "5m"},
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
{Name: "repos", Usage: "Comma-separated repositories for multi-repo workflow", Default: ""},
{Name: "from", Usage: "CSV file for multi-repo workflow with owner,repo columns", Default: ""},
{Name: "release", Usage: "Target release/tag for multi-repo release coordination", Default: ""},
{Name: "wiki-repo", Usage: "Wiki target repo for publishing multi-repo report, e.g. org/dashboard", Default: ""},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
intervalStr := ctx.Arg("interval")
interval, err := time.ParseDuration(intervalStr)
if err != nil {
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
}
if wr := ctx.Arg("wiki-repo"); wr != "" {
owner, repo := splitRepoRef(wr)
if owner != "" && repo != "" {
ctx.Args["_wiki_owner"] = owner
ctx.Args["_wiki_repo"] = repo
}
}
aiMode, modeErr := resolveAIModeFromArgs(ctx)
if modeErr != nil {
return modeErr
}
return daemon.StartDaemon(ctx, w, interval, aiMode)
},
},
{
Name: "stop",
Description: "Stop workflow daemon",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
return daemon.StopDaemon(name)
},
},
{
Name: "status",
Description: "Show daemon status",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
return daemon.StatusDaemon(name)
},
},
{
Name: "logs",
Description: "View daemon log output",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
{Name: "follow", Short: "f", Usage: "Follow log output (like tail -f)", Bool: true},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
return daemon.TailDaemonLog(name, ctx.Arg("follow") == "true")
},
},
{
Name: "install-systemd",
Description: "Generate systemd service unit for a workflow daemon",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
{Name: "interval", Short: "i", Usage: "Poll interval", Default: "5m"},
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
aiMode, modeErr := resolveAIModeFromArgs(ctx)
if modeErr != nil {
return modeErr
}
return daemon.InstallSystemdUnit(ctx, w, ctx.Arg("interval"), aiMode)
},
},
}
}
func resolveAIModeFromArgs(ctx *common.RuntimeContext) (string, error) {
ai := ctx.Arg("ai") == "true"
noAI := ctx.Arg("no-ai") == "true"
if ai && noAI {
return "", fmt.Errorf("--ai 和 --no-ai 互斥,只能指定其中一个")
}
if ai {
return "ai", nil
}
if noAI {
return "no-ai", nil
}
return "auto", nil
}
func runWorkflowCommand(ctx *common.RuntimeContext, w *wf.WorkflowDef, dryRun bool, aiMode string) error {
result, err := engine.RunWithMode(ctx, w, dryRun, aiMode)
if err != nil {
return err
}
needsAI := 0
ruleEngine := 0
for _, sr := range result.Steps {
if m, ok := sr.Data.(map[string]interface{}); ok {
if v, _ := m["_needs_ai"]; v == true {
needsAI++
}
if v, _ := m["_ai_used"]; v == true {
ruleEngine++
}
}
}
if needsAI > 0 {
fmt.Fprintf(os.Stderr, "\n⚠ %d 个 skill 步骤需要 AI 处理:\n", needsAI)
for _, sr := range result.Steps {
if m, ok := sr.Data.(map[string]interface{}); ok {
if v, _ := m["_needs_ai"]; v == true {
fmt.Fprintf(os.Stderr, " - %s (%s)\n", sr.Step, m["_skill"])
}
}
}
fmt.Fprintf(os.Stderr, "\n你可以:\n")
fmt.Fprintf(os.Stderr, " 1. 配置 API Key 启用全自动: gitlink-cli config set deepseek_api_key <key>\n")
fmt.Fprintf(os.Stderr, " 2. 将以上完整 JSON 输出交给 AI Agent 继续处理\n")
} else if ruleEngine > 0 {
fmt.Fprintf(os.Stderr, "🤖 AI 已处理 %d 个 skill 步骤\n", ruleEngine)
} else {
noAI := 0
for _, sr := range result.Steps {
if m, ok := sr.Data.(map[string]interface{}); ok {
if v, _ := m["_ai_used"]; v == false {
if _, hasSkill := m["_skill"]; hasSkill {
noAI++
}
}
}
}
if noAI > 0 {
fmt.Fprintf(os.Stderr, "⚙️ 规则引擎已处理 %d 个 skill 步骤 (未使用 AI)\n", noAI)
}
}
return ctx.Output(output.SuccessEnvelope(result, nil))
}
func splitRepoRef(raw string) (owner, repo string) {
parts := strings.SplitN(strings.TrimSpace(raw), "/", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
return "", ""
}

View File

@ -0,0 +1,26 @@
package cli
import "testing"
func TestShortcutsCount(t *testing.T) {
sc := Shortcuts()
if len(sc) != 10 {
t.Fatalf("expected 10 shortcuts (list, info, run, watch, schedule, start, stop, status, logs, install-systemd), got %d", len(sc))
}
names := map[string]bool{
"list": false, "info": false, "run": false, "watch": false,
"schedule": false, "start": false, "stop": false, "status": false,
"logs": false, "install-systemd": false,
}
for _, s := range sc {
if _, ok := names[s.Name]; !ok {
t.Fatalf("unexpected shortcut: %s", s.Name)
}
names[s.Name] = true
}
for n, found := range names {
if !found {
t.Fatalf("missing shortcut: %s", n)
}
}
}

View File

@ -0,0 +1,68 @@
package workflow
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// ParseCommandTarget splits a CLI command string into tokens, respecting quoted arguments.
func ParseCommandTarget(target string) []string {
var parts []string
var current strings.Builder
inQuote := false
quoteChar := byte(0)
for i := 0; i < len(target); i++ {
c := target[i]
switch {
case c == '"' || c == '\'':
if inQuote && c == quoteChar {
inQuote = false
quoteChar = 0
} else if !inQuote {
inQuote = true
quoteChar = c
} else {
current.WriteByte(c)
}
case c == ' ' && !inQuote:
if current.Len() > 0 {
parts = append(parts, current.String())
current.Reset()
}
default:
current.WriteByte(c)
}
}
if current.Len() > 0 {
parts = append(parts, current.String())
}
return parts
}
// ResolveCLIBinary finds the gitlink-cli binary for subprocess calls.
func ResolveCLIBinary() string {
if exe, err := os.Executable(); err == nil && exe != "" {
return exe
}
for _, p := range []string{"./gitlink-cli", "./gitlink-cli.exe", "../gitlink-cli", "../gitlink-cli.exe"} {
if _, err := os.Stat(p); err == nil {
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return p
}
}
return "gitlink-cli"
}
// ResolvePath replaces template placeholders in a path string.
func ResolvePath(template, owner, repo string) string {
base := fmt.Sprintf("/%s/%s", owner, repo)
v1 := fmt.Sprintf("/v1/%s/%s", owner, repo)
s := strings.Replace(template, "{v1}", v1, 1)
s = strings.Replace(s, "{base}", base, 1)
return s
}

View File

@ -0,0 +1,397 @@
package daemon
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
)
// StartDaemon launches a workflow as a background daemon process.
func StartDaemon(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, aiMode string) error {
bin, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot find executable: %w", err)
}
args := BuildDaemonArgs(ctx, wfDef, interval, aiMode)
cmd := exec.Command(bin, args...)
applyDaemonAttrs(cmd)
logPath := daemonLogPath(wfDef.Name)
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return fmt.Errorf("create log file: %w", err)
}
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.Stdin = nil
if err := cmd.Start(); err != nil {
logFile.Close()
return fmt.Errorf("start daemon: %w", err)
}
pid := cmd.Process.Pid
if err := savePID(wfDef.Name, pid); err != nil {
return fmt.Errorf("save pid: %w", err)
}
fmt.Printf("Daemon started for %q (PID: %d)\n", wfDef.Name, pid)
fmt.Printf("Log: %s\n", logPath)
return nil
}
// BuildDaemonArgs constructs the CLI arguments for the daemon subprocess.
func BuildDaemonArgs(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, aiMode string) []string {
args := []string{
"workflow", "+run", "--name", wfDef.Name,
"--format", "json", "--daemon-loop",
"--interval", interval.String(),
}
if !engine.IsExplicitMultiRepoRun(ctx, wfDef) {
args = append(args, "--owner", ctx.Owner, "--repo", ctx.Repo)
}
if aiMode == "ai" {
args = append(args, "--ai")
} else if aiMode == "no-ai" {
args = append(args, "--no-ai")
}
if repos := ctx.Arg("repos"); repos != "" {
args = append(args, "--repos", repos)
}
if from := ctx.Arg("from"); from != "" {
args = append(args, "--from", from)
}
if release := ctx.Arg("release"); release != "" {
args = append(args, "--release", release)
}
return args
}
// StopDaemon stops a running workflow daemon by name.
func StopDaemon(name string) error {
pid, err := readPID(name)
if err != nil {
return err
}
proc, err := os.FindProcess(pid)
if err != nil {
cleanPID(name)
return fmt.Errorf("daemon %q not running (PID %d not found)", name, pid)
}
if err := proc.Signal(os.Interrupt); err != nil {
cleanPID(name)
return fmt.Errorf("failed to stop daemon %q: %w", name, err)
}
cleanPID(name)
fmt.Printf("Daemon %q stopped (PID %d)\n", name, pid)
return nil
}
// StatusDaemon prints the current daemon status for a workflow.
func StatusDaemon(name string) error {
st, err := state.LoadState(name)
if err != nil {
return fmt.Errorf("load state: %w", err)
}
pid, pidErr := readPID(name)
running := pidErr == nil && processRunning(pid)
fmt.Printf("工作流: %s\n", name)
if running {
fmt.Printf("状态: 运行中 (PID: %d)\n", pid)
} else {
fmt.Println("状态: 已停止")
}
if st.LastRun != "" {
t, err := time.Parse(time.RFC3339, st.LastRun)
if err == nil {
fmt.Printf("上次运行: %s\n", t.Format("2006-01-02 15:04"))
} else {
fmt.Printf("上次运行: %s\n", st.LastRun)
}
}
fmt.Printf("累计运行: %d 次\n", st.TotalRuns)
fmt.Printf("快照步骤: %d 个\n", len(st.Snapshots))
fmt.Printf("日志文件: %s\n", daemonLogPath(name))
return nil
}
// DaemonLoop runs the workflow repeatedly in a loop.
func DaemonLoop(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration) error {
tick := time.NewTicker(interval)
defer tick.Stop()
doDaemonCycle(ctx, wfDef)
for {
select {
case <-tick.C:
doDaemonCycle(ctx, wfDef)
}
}
}
func doDaemonCycle(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) {
st, _ := state.LoadState(wfDef.Name)
dryResult, err := engine.Run(ctx, wfDef, true)
if err != nil {
fmt.Fprintf(os.Stderr, "[%s] error: %v\n", time.Now().Format(time.RFC3339), err)
return
}
changed := st.Diff(dryResult.Steps)
if len(changed) == 0 {
if st.TotalRuns > 0 {
fmt.Fprintf(os.Stderr, "[%s] 没有检测到变更\n", time.Now().Format(time.RFC3339))
st.TotalRuns++
st.Save()
return
}
} else {
fmt.Fprintf(os.Stderr, "[%s] 🔔 检测到变更: %v\n", time.Now().Format(time.RFC3339), changed)
}
fullResult, err := engine.Run(ctx, wfDef, false)
if err != nil {
fmt.Fprintf(os.Stderr, "[%s] run error: %v\n", time.Now().Format(time.RFC3339), err)
return
}
st, _ = state.LoadState(wfDef.Name)
st.TotalRuns++
st.Diff(fullResult.Steps)
st.UpdateSnapshots(fullResult.Steps)
st.Save()
ok, total := 0, len(fullResult.Steps)
for _, sr := range fullResult.Steps {
if sr.OK {
ok++
}
}
fmt.Fprintf(os.Stderr, "[%s] ✅ %d/%d steps OK\n", time.Now().Format(time.RFC3339), ok, total)
for _, sr := range fullResult.Steps {
if !sr.OK {
fmt.Fprintf(os.Stderr, "[%s] ❌ %s 失败: %s\n", time.Now().Format(time.RFC3339), sr.Step, sr.Error)
}
if sr.Type == wf.StepTypeSkill && sr.Data != nil {
logSkillFindings(sr)
}
}
}
func logSkillFindings(sr wf.StepResult) {
m, ok := sr.Data.(map[string]interface{})
if !ok {
return
}
skill, _ := m["_skill"].(string)
analysis := m["analysis"]
fmt.Fprintf(os.Stderr, "[%s] ── %s 分析结果 ──\n", time.Now().Format(time.RFC3339), skill)
if s, ok := analysis.(string); ok && s != "" {
for _, line := range strings.Split(s, "\n") {
fmt.Fprintf(os.Stderr, "[%s] %s\n", time.Now().Format(time.RFC3339), line)
}
return
}
am, _ := analysis.(map[string]interface{})
if am == nil {
return
}
if summary, ok := am["summary"].(string); ok && summary != "" {
fmt.Fprintf(os.Stderr, "[%s] 📋 %s\n", time.Now().Format(time.RFC3339), summary)
}
if findings, ok := am["findings"]; ok && findings != nil {
raw, _ := json.Marshal(findings)
var arr []interface{}
if json.Unmarshal(raw, &arr) == nil {
for _, f := range arr {
if fm, ok := f.(map[string]interface{}); ok {
sev := fm["severity"]
what := fm["what"]
fmt.Fprintf(os.Stderr, "[%s] [%v] %v\n", time.Now().Format(time.RFC3339), sev, what)
if prNum, ok := fm["pr_number"]; ok && prNum != nil {
fmt.Fprintf(os.Stderr, "[%s] PR: #%v\n", time.Now().Format(time.RFC3339), prNum)
}
}
}
}
}
if msg, ok := am["message"].(string); ok && msg != "" {
fmt.Fprintf(os.Stderr, "[%s] %s\n", time.Now().Format(time.RFC3339), msg)
}
if reviewData, ok := am["reviewed_prs"]; ok {
fmt.Fprintf(os.Stderr, "[%s] 已审查 PR 数: %v\n", time.Now().Format(time.RFC3339), reviewData)
}
if totalFindings, ok := am["total_findings"]; ok {
fmt.Fprintf(os.Stderr, "[%s] 发现问题数: %v\n", time.Now().Format(time.RFC3339), totalFindings)
}
}
// DaemonLogPath returns the log file path for a workflow daemon.
func DaemonLogPath(name string) string {
return daemonLogPath(name)
}
func daemonLogPath(name string) string {
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.log", name))
}
// TailDaemonLog reads and optionally follows a daemon log file.
func TailDaemonLog(name string, follow bool) error {
path := daemonLogPath(name)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("读取日志文件 %s: %w (daemon 可能尚未启动)", path, err)
}
fmt.Print(string(data))
if !follow {
return nil
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
offset := int64(len(data))
for {
select {
case <-sig:
return nil
case <-ticker.C:
fi, err := os.Stat(path)
if err != nil {
continue
}
if fi.Size() > offset {
f, err := os.Open(path)
if err != nil {
continue
}
f.Seek(offset, 0)
buf := make([]byte, fi.Size()-offset)
n, _ := f.Read(buf)
if n > 0 {
fmt.Print(string(buf[:n]))
}
offset = fi.Size()
f.Close()
}
}
}
}
// InstallSystemdUnit generates a systemd service unit file for a workflow daemon.
func InstallSystemdUnit(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval, aiMode string) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("resolve repo: %w", err)
}
bin, _ := os.Executable()
extraArgs := ""
if aiMode == "ai" {
extraArgs = " --ai"
} else if aiMode == "no-ai" {
extraArgs = " --no-ai"
}
unit := fmt.Sprintf(`[Unit]
Description=GitLink CLI Workflow: %s (%s/%s)
After=network.target
[Service]
Type=simple
ExecStart=%s workflow +run --name %s --owner %s --repo %s --format json --daemon-loop --interval %s%s
Restart=on-failure
RestartSec=30
StandardOutput=append:%s
StandardError=append:%s
[Install]
WantedBy=multi-user.target
`,
wfDef.Name, ctx.Owner, ctx.Repo,
bin, wfDef.Name, ctx.Owner, ctx.Repo, interval, extraArgs,
daemonLogPath(wfDef.Name), daemonLogPath(wfDef.Name),
)
unitPath := filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.service", wfDef.Name))
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
return fmt.Errorf("写入 unit 文件: %w", err)
}
fmt.Printf("Systemd unit 已写入: %s\n\n", unitPath)
fmt.Println("安装步骤:")
fmt.Printf(" sudo cp %s /etc/systemd/system/\n", unitPath)
fmt.Println(" sudo systemctl daemon-reload")
fmt.Printf(" sudo systemctl enable workflow-%s\n", wfDef.Name)
fmt.Printf(" sudo systemctl start workflow-%s\n", wfDef.Name)
fmt.Println()
fmt.Printf("查看日志: journalctl -u workflow-%s -f\n", wfDef.Name)
return nil
}
func savePID(name string, pid int) error {
dir := config.ConfigDir()
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
return os.WriteFile(pidPath(name), []byte(strconv.Itoa(pid)), 0600)
}
func readPID(name string) (int, error) {
data, err := os.ReadFile(pidPath(name))
if err != nil {
if os.IsNotExist(err) {
return 0, fmt.Errorf("daemon %q is not running (no PID file)", name)
}
return 0, err
}
return strconv.Atoi(string(data))
}
func cleanPID(name string) {
os.Remove(pidPath(name))
}
func processRunning(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
func pidPath(name string) string {
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.pid", name))
}

View File

@ -0,0 +1,43 @@
package daemon
import (
"testing"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestBuildDaemonArgsSkipsOwnerRepoForExplicitMultiRepo(t *testing.T) {
wfDef := &wf.WorkflowDef{
Name: "multi-repo",
}
ctx := &common.RuntimeContext{
Owner: "ignored",
Repo: "ignored",
Args: map[string]string{
"repos": "org/backend,org/frontend",
"release": "v1.4.0",
},
}
args := BuildDaemonArgs(ctx, wfDef, 24*time.Hour, "no-ai")
if stringSliceContains(args, "--owner") || stringSliceContains(args, "--repo") {
t.Fatalf("explicit multi-repo daemon args should not include owner/repo: %v", args)
}
if !stringSliceContains(args, "--repos") || !stringSliceContains(args, "org/backend,org/frontend") {
t.Fatalf("daemon args missing repos: %v", args)
}
if !stringSliceContains(args, "--release") || !stringSliceContains(args, "v1.4.0") {
t.Fatalf("daemon args missing release: %v", args)
}
}
func stringSliceContains(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}

View File

@ -0,0 +1,12 @@
//go:build !windows
package daemon
import (
"os/exec"
"syscall"
)
func applyDaemonAttrs(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}

View File

@ -0,0 +1,12 @@
//go:build windows
package daemon
import (
"os/exec"
"syscall"
)
func applyDaemonAttrs(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}

View File

@ -0,0 +1,68 @@
package daemon
import (
"fmt"
"os"
"os/signal"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
)
// Schedule runs the full workflow on a repeating interval. Blocks until Ctrl+C.
func Schedule(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration) error {
fmt.Printf("⏰ Scheduled %q every %v on %s/%s\n", wfDef.Name, interval, ctx.Owner, ctx.Repo)
fmt.Println(" Press Ctrl+C to stop")
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
tick := time.NewTicker(interval)
defer tick.Stop()
st, _ := state.LoadState(wfDef.Name)
dryResult, _ := engine.Run(ctx, wfDef, true)
st.UpdateSnapshots(dryResult.Steps)
st.TotalRuns++
st.Save()
for {
select {
case <-sig:
fmt.Println("\n👋 schedule stopped")
return nil
case t := <-tick.C:
dryResult, err := engine.Run(ctx, wfDef, true)
if err != nil {
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
continue
}
changed := st.Diff(dryResult.Steps)
st.UpdateSnapshots(dryResult.Steps)
st.TotalRuns++
st.Save()
if len(changed) == 0 {
fmt.Printf("[%s] ✓ no changes, skipped AI run\n", t.Format("15:04:05"))
continue
}
fmt.Printf("[%s] ⏳ changes detected, running %q with AI...\n", t.Format("15:04:05"), wfDef.Name)
result, err := engine.Run(ctx, wfDef, false)
if err != nil {
fmt.Printf("❌ error: %v\n", err)
continue
}
ok, total := 0, len(result.Steps)
for _, sr := range result.Steps {
if sr.OK {
ok++
}
}
fmt.Printf("✅ %d/%d steps OK\n", ok, total)
}
}
}

View File

@ -0,0 +1,72 @@
package daemon
import (
"fmt"
"os"
"os/signal"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/engine"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
)
// Watch polls the first step (or watchStep) every interval and triggers the
// full workflow (with AI) only when data changes. Blocks until Ctrl+C.
func Watch(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, interval time.Duration, watchStep string) error {
if watchStep == "" && len(wfDef.Steps) > 0 {
watchStep = wfDef.Steps[0].Name
}
fmt.Printf("👀 Watching %s/%s for %q changes every %v\n", ctx.Owner, ctx.Repo, watchStep, interval)
fmt.Printf(" Trigger: %s on %s\n", wfDef.Trigger.Type, wfDef.Trigger.On)
fmt.Println(" Press Ctrl+C to stop")
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
st, _ := state.LoadState(wfDef.Name)
tick := time.NewTicker(interval)
defer tick.Stop()
for {
select {
case <-sig:
fmt.Println("\n👋 watch stopped")
return nil
case t := <-tick.C:
dryResult, err := engine.Run(ctx, wfDef, true)
if err != nil {
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
continue
}
changed := st.Diff(dryResult.Steps)
if len(changed) == 0 && st.TotalRuns > 0 {
fmt.Printf("[%s] ✓ no changes\n", t.Format("15:04:05"))
continue
}
fmt.Printf("[%s] 🔔 change detected: %v\n", t.Format("15:04:05"), changed)
result, err := engine.Run(ctx, wfDef, false)
if err != nil {
fmt.Printf("[%s] ❌ AI run error: %v\n", t.Format("15:04:05"), err)
continue
}
st.UpdateSnapshots(result.Steps)
st.TotalRuns++
st.Save()
for _, sr := range result.Steps {
if sr.OK {
fmt.Printf(" ✓ %s\n", sr.Step)
} else {
fmt.Printf(" ✗ %s: %s\n", sr.Step, sr.Error)
}
}
}
}
}

View File

@ -0,0 +1,26 @@
package defs
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
// RegisterCodeQuality registers the code-quality workflow definition.
func RegisterCodeQuality() {
wf.Register(&wf.WorkflowDef{
Name: "code-quality",
Category: "质量",
Description: "代码质量看门人PR 提交 → Review → CI 检查 → 结果汇总",
Trigger: wf.TriggerDef{
Type: "poll",
On: "pr.opened",
Interval: "5m",
},
Steps: []wf.StepDef{
{Type: wf.StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
{Type: wf.StepTypeCommand, Name: "ci-builds", Purpose: "获取 CI 构建状态", Target: "ci +builds --limit 10"},
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "获取最近提交供 CI 关联分析", Target: "commit +list --limit 30"},
{Type: wf.StepTypeCommand, Name: "branches", Purpose: "获取分支列表检查保护状态", Target: "branch +list"},
{Type: wf.StepTypeSkill, Name: "review", Purpose: "AI 审查 PR 代码质量", Target: "gitlink-review", DependsOn: []string{"open-prs"}},
{Type: wf.StepTypeSkill, Name: "ci-diagnosis", Purpose: "AI 诊断 CI 构建失败并给出建议", Target: "gitlink-ci", DependsOn: []string{"ci-builds", "commits"}},
},
})
}

View File

@ -0,0 +1,29 @@
package defs
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
// RegisterCommunityOps registers the community-ops workflow definition.
func RegisterCommunityOps() {
wf.Register(&wf.WorkflowDef{
Name: "community-ops",
Category: "运营",
Description: "社区运营自动化Issue 智能分拣 → 生成周报 → 生成 Release Notes",
Trigger: wf.TriggerDef{
Type: "poll",
On: "issue.created",
Interval: "5m",
},
Steps: []wf.StepDef{
{Type: wf.StepTypeCommand, Name: "open-issues", Purpose: "获取所有开放 Issue 供 AI 分类", Target: "issue +list --state open --limit 50"},
{Type: wf.StepTypeCommand, Name: "labels", Purpose: "获取标签库供 AI 匹配", Target: "label +list"},
{Type: wf.StepTypeCommand, Name: "members", Purpose: "获取成员列表供 AI 分配责任人", Target: "member +list"},
{Type: wf.StepTypeSkill, Name: "triage", Purpose: "AI 分析前三步数据,输出分拣表格并执行打标签/分配", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}, RunWhen: wf.RunAlways},
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
{Type: wf.StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR 计算合并效率", Target: "pr +list --state merged --limit 50"},
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "获取提交历史分析活跃度", Target: "commit +list --limit 50"},
{Type: wf.StepTypeSkill, Name: "health-report", Purpose: "AI 根据指标生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}, RunWhen: wf.RunWeekly},
{Type: wf.StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
{Type: wf.StepTypeSkill, Name: "changelog", Purpose: "AI 分类 commit 生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}, RunWhen: wf.RunOnChange},
},
})
}

View File

@ -0,0 +1,26 @@
package defs
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
// RegisterContributorGrowth registers the contributor-growth workflow definition.
func RegisterContributorGrowth() {
wf.Register(&wf.WorkflowDef{
Name: "contributor-growth",
Category: "成长",
Description: "贡献者成长体系:追踪贡献者活动 → 生成排行 → 识别活跃与流失",
Trigger: wf.TriggerDef{
Type: "cron",
On: "0 9 * * 1",
Interval: "24h",
},
Steps: []wf.StepDef{
{Type: wf.StepTypeCommand, Name: "commits", Purpose: "提交历史统计代码贡献", Target: "commit +list --limit 100"},
{Type: wf.StepTypeCommand, Name: "open-issues", Purpose: "开放 Issue 统计 Issue 贡献", Target: "issue +list --state open --limit 50"},
{Type: wf.StepTypeCommand, Name: "closed-issues", Purpose: "已关闭 Issue 统计解决贡献", Target: "issue +list --state closed --limit 50"},
{Type: wf.StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR 统计代码贡献", Target: "pr +list --state merged --limit 50"},
{Type: wf.StepTypeCommand, Name: "members", Purpose: "项目成员列表统计参与度", Target: "member +list"},
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "项目基础数据Fork/Star/Watch", Target: "repo +info"},
{Type: wf.StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行并识别活跃与流失", Target: "gitlink-contributor-ranking", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
},
})
}

View File

@ -0,0 +1,9 @@
package defs
func init() {
RegisterCommunityOps()
RegisterCodeQuality()
RegisterProjectInit()
RegisterMultiRepo()
RegisterContributorGrowth()
}

View File

@ -0,0 +1,32 @@
package defs
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
// RegisterMultiRepo registers the multi-repo workflow definition.
func RegisterMultiRepo() {
wf.Register(&wf.WorkflowDef{
Name: "multi-repo",
Category: "协同",
Description: "多仓库协同:跨仓库 Issue/PR 状态看板、Release 协调发布",
Trigger: wf.TriggerDef{
Type: "cron",
On: "0 9 * * 1",
Interval: "24h",
},
Steps: []wf.StepDef{
{
Type: wf.StepTypeCommand,
Name: "multi-repo-snapshot",
Purpose: "采集多个仓库的 Issue/PR/Release/Milestone 状态",
Target: "workflow-internal:multi-repo-snapshot",
},
{
Type: wf.StepTypeSkill,
Name: "multi-repo-coordination",
Purpose: "生成统一 Issue 追踪、PR 看板、Release 协调报告",
Target: "gitlink-multi-repo",
DependsOn: []string{"multi-repo-snapshot"},
},
},
})
}

View File

@ -0,0 +1,27 @@
package defs
import wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
// RegisterProjectInit registers the project-init workflow definition.
func RegisterProjectInit() {
wf.Register(&wf.WorkflowDef{
Name: "project-init",
Category: "初始化",
Description: "项目一键初始化:创建仓库 → 脚手架文件 → 标签/里程碑/Issue → 许可证审计 → 健康报告",
Trigger: wf.TriggerDef{
Type: "manual",
On: "manual",
},
Steps: []wf.StepDef{
{Type: wf.StepTypeSkill, Name: "init-scaffold", Purpose: "根据描述创建仓库并初始化脚手架README/LICENSE/.gitignore/标签/里程碑/Issue", Target: "gitlink-init-scaffold"},
{Type: wf.StepTypeCommand, Name: "repo-info", Purpose: "确认仓库已创建并获取基础信息", Target: "repo +info"},
{Type: wf.StepTypeCommand, Name: "existing-files", Purpose: "验证 README/LICENSE 文件", Target: "file +list"},
{Type: wf.StepTypeCommand, Name: "labels", Purpose: "验证标签库", Target: "label +list"},
{Type: wf.StepTypeSkill, Name: "license-check", Purpose: "检查许可证合规并扫描敏感信息泄露", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
{Type: wf.StepTypeCommand, Name: "milestones", Purpose: "验证里程碑", Target: "milestone +list"},
{Type: wf.StepTypeCommand, Name: "existing-issues", Purpose: "验证初始 Issue", Target: "issue +list --state all --limit 10"},
{Type: wf.StepTypeCommand, Name: "branches", Purpose: "检查分支结构", Target: "branch +list"},
{Type: wf.StepTypeSkill, Name: "repo-audit", Purpose: "综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "existing-files", "labels", "milestones", "branches"}},
},
})
}

View File

@ -0,0 +1,129 @@
package engine
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func executeActions(ctx *common.RuntimeContext, actions []wf.AIAction) (int, []string) {
executed := 0
var errors []string
seen := make(map[string]bool, len(actions))
for _, action := range actions {
key := actionKey(action)
if seen[key] {
fmt.Fprintf(os.Stderr, "[workflow] skipped duplicate action: %s %s %s\n", action.Type, action.Module, action.Command)
continue
}
seen[key] = true
if !isActionAllowed(action) {
msg := fmt.Sprintf("blocked action: %s %s +%s", action.Type, action.Module, action.Command)
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
errors = append(errors, msg)
continue
}
if action.Type == "api" {
path := wf.ResolvePath(action.Path, ctx.Owner, ctx.Repo)
_, err := ctx.CallAPI(action.Method, path, action.Body)
if err != nil {
msg := fmt.Sprintf("api %s %s: %v", action.Method, path, err)
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
errors = append(errors, msg)
continue
}
executed++
} else if action.Type == "cli" {
args := []string{action.Module, action.Command}
for k, v := range action.Args {
args = append(args, "--"+k, v)
}
if ctx.Owner != "" {
args = append(args, "--owner", ctx.Owner)
}
if ctx.Repo != "" {
args = append(args, "--repo", ctx.Repo)
}
bin := wf.ResolveCLIBinary()
cmd := exec.Command(bin, args...)
var cliStderr bytes.Buffer
cmd.Stderr = &cliStderr
err := cmd.Run()
if err != nil {
stderrStr := strings.TrimSpace(cliStderr.String())
if action.Module == "repo" && action.Command == "+create" && strings.Contains(stderrStr, "已被使用") {
fmt.Fprintf(os.Stderr, "[workflow] repo %s already exists, reusing\n", action.Args["name"])
executed++
if name := action.Args["name"]; name != "" && ctx.Repo == "" {
ctx.Repo = name
}
continue
}
msg := fmt.Sprintf("cli %s: %v", strings.Join(args, " "), err)
if cliStderr.Len() > 0 {
msg += " — " + stderrStr
}
fmt.Fprintf(os.Stderr, "[workflow] %s\n", msg)
errors = append(errors, msg)
continue
}
executed++
if action.Module == "repo" && action.Command == "+create" {
if name := action.Args["name"]; name != "" && ctx.Repo == "" {
ctx.Repo = name
}
}
}
}
return executed, errors
}
func actionKey(action wf.AIAction) string {
raw, err := json.Marshal(action)
if err != nil {
return fmt.Sprintf("%s:%s:%s:%v:%s:%s", action.Type, action.Module, action.Command, action.Args, action.Method, action.Path)
}
sum := md5.Sum(raw)
return hex.EncodeToString(sum[:])
}
var allowedAPIMethods = map[string]bool{
"GET": true, "POST": true, "PATCH": true,
}
var allowedCLIModules = map[string]bool{
"issue": true, "pr": true, "release": true,
"wiki": true, "member": true, "label": true,
"milestone": true, "branch": true, "comment": true,
"repo": true, "file": true,
}
var blockedCLICommands = map[string]bool{
"+delete": true, "+remove": true, "+batch-delete": true,
"+fork": true, "+batch-fork": true,
}
func isActionAllowed(action wf.AIAction) bool {
if action.Type == "api" {
if !allowedAPIMethods[action.Method] {
return false
}
}
if action.Type == "cli" {
if !allowedCLIModules[action.Module] {
return false
}
if blockedCLICommands[action.Command] {
return false
}
}
return true
}

View File

@ -0,0 +1,31 @@
package engine
import (
"testing"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestActionAllowed(t *testing.T) {
cases := []struct {
name string
action wf.AIAction
allowed bool
}{
{"api GET", wf.AIAction{Type: "api", Method: "GET"}, true},
{"api POST", wf.AIAction{Type: "api", Method: "POST"}, true},
{"api PATCH", wf.AIAction{Type: "api", Method: "PATCH"}, true},
{"api DELETE blocked", wf.AIAction{Type: "api", Method: "DELETE"}, false},
{"cli issue comment", wf.AIAction{Type: "cli", Module: "issue", Command: "+comment"}, true},
{"cli delete blocked", wf.AIAction{Type: "cli", Module: "repo", Command: "+delete"}, false},
{"cli fork blocked", wf.AIAction{Type: "cli", Module: "repo", Command: "+fork"}, false},
{"cli repo module blocked", wf.AIAction{Type: "cli", Module: "org", Command: "+list"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isActionAllowed(tc.action); got != tc.allowed {
t.Errorf("isActionAllowed(%+v) = %v, want %v", tc.action, got, tc.allowed)
}
})
}
}

View File

@ -0,0 +1,254 @@
package engine
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestRunWithAPISteps(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
{"id": 1, "subject": "bug"},
{"id": 2, "subject": "feature"},
}, nil))
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/labels.json":
writeJSON(t, w, output.SuccessEnvelope([]map[string]interface{}{
{"id": 10, "name": "bug"},
{"id": 11, "name": "enhancement"},
}, nil))
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-api",
Steps: []wf.StepDef{
{Type: wf.StepTypeAPI, Name: "fetch-issues", Purpose: "get issues", Method: "GET", Target: "{v1}/issues"},
{Type: wf.StepTypeAPI, Name: "fetch-labels", Purpose: "get labels", Method: "GET", Target: "{v1}/labels"},
},
}
result, err := Run(ctx, wfDef, false)
if err != nil {
t.Fatalf("Run() failed: %v", err)
}
if result.Owner != "owner" || result.Repo != "repo" {
t.Fatalf("expected owner/repo = owner/repo, got %s/%s", result.Owner, result.Repo)
}
if len(result.Steps) != 2 {
t.Fatalf("expected 2 step results, got %d", len(result.Steps))
}
for _, sr := range result.Steps {
if !sr.OK {
t.Fatalf("step %q: expected ok=true, got error=%q", sr.Step, sr.Error)
}
}
}
func TestSkillStepReceivesUpstream(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/owner/repo/issues.json" {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
"issues": []map[string]interface{}{{"id": 1}},
}, nil))
} else if r.URL.Path == "/v1/owner/repo/labels.json" {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{
"labels": []map[string]interface{}{{"name": "bug"}},
}, nil))
} else {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-skill-upstream",
Steps: []wf.StepDef{
{Type: wf.StepTypeAPI, Name: "get-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
{Type: wf.StepTypeAPI, Name: "get-labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
{Type: wf.StepTypeSkill, Name: "ai-triage", Purpose: "triage", Target: "gitlink-triage"},
},
}
result, err := Run(ctx, wfDef, true) // dry-run to test upstream without AI
if err != nil {
t.Fatalf("Run() failed: %v", err)
}
skillData, ok := result.Steps[2].Data.(map[string]interface{})
if !ok {
t.Fatal("skill step data is not a map")
}
if v, _ := skillData["_dry_run"]; v != true {
t.Fatal("skill step should have _dry_run=true")
}
upstream, ok := skillData["_upstream"].(map[string]interface{})
if !ok {
t.Fatal("skill step missing _upstream map")
}
if _, hasIssues := upstream["get-issues"]; !hasIssues {
t.Fatal("_upstream missing get-issues key")
}
if _, hasLabels := upstream["get-labels"]; !hasLabels {
t.Fatal("_upstream missing get-labels key")
}
if skillData["_skill"] != "gitlink-triage" {
t.Fatalf("_skill = %q, want %q", skillData["_skill"], "gitlink-triage")
}
}
func TestSkillStepWithDependsOn(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-depends-on",
Steps: []wf.StepDef{
{Type: wf.StepTypeAPI, Name: "open-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
{Type: wf.StepTypeAPI, Name: "labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
{Type: wf.StepTypeAPI, Name: "members", Purpose: "members", Method: "GET", Target: "{v1}/members"},
{Type: wf.StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage",
DependsOn: []string{"open-issues", "labels"}},
},
}
result, err := Run(ctx, wfDef, true) // dry-run to test DependsOn filter without AI
if err != nil {
t.Fatalf("Run() failed: %v", err)
}
skillData, ok := result.Steps[3].Data.(map[string]interface{})
if !ok {
t.Fatal("skill step data is not a map")
}
upstream, ok := skillData["_upstream"].(map[string]interface{})
if !ok {
t.Fatal("skill step missing _upstream map")
}
if _, hasIssues := upstream["open-issues"]; !hasIssues {
t.Fatal("_upstream missing open-issues key")
}
if _, hasLabels := upstream["labels"]; !hasLabels {
t.Fatal("_upstream missing labels key")
}
if _, hasMembers := upstream["members"]; hasMembers {
t.Fatal("_upstream should NOT contain members (not in DependsOn)")
}
}
func TestRunStepFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
writeJSON(t, w, map[string]interface{}{
"ok": false, "error": "internal server error",
})
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-fail",
Steps: []wf.StepDef{
{Type: wf.StepTypeAPI, Name: "bad-step", Purpose: "will fail", Method: "GET", Target: "{v1}/bad"},
},
}
result, err := Run(ctx, wfDef, false)
if err != nil {
t.Fatalf("Run() returned error: %v (steps should fail gracefully)", err)
}
if result.Steps[0].OK {
t.Fatal("expected step to fail, but it passed")
}
}
func TestRunUnknownStepType(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("no request expected")
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-unknown",
Steps: []wf.StepDef{
{Type: wf.StepType("invalid"), Name: "bad", Purpose: "unknown", Target: "x"},
},
}
result, err := Run(ctx, wfDef, false)
if err != nil {
t.Fatalf("Run() returned error: %v", err)
}
if result.Steps[0].OK {
t.Fatal("unknown step type should fail")
}
}
func TestSkillStepDryRun(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, output.SuccessEnvelope(map[string]interface{}{"ok": true}, nil))
}))
defer server.Close()
ctx := newTestContext(t, server)
wfDef := &wf.WorkflowDef{
Name: "test-dry-run",
Steps: []wf.StepDef{
{Type: wf.StepTypeAPI, Name: "get-data", Purpose: "data", Method: "GET", Target: "{v1}/issues"},
{Type: wf.StepTypeSkill, Name: "ai-step", Purpose: "AI analysis", Target: "gitlink-triage",
DependsOn: []string{"get-data"}},
},
}
result, err := Run(ctx, wfDef, true)
if err != nil {
t.Fatalf("Run() dry-run failed: %v", err)
}
skillData, ok := result.Steps[1].Data.(map[string]interface{})
if !ok {
t.Fatal("skill step data is not a map")
}
if v, _ := skillData["_dry_run"]; v != true {
t.Fatal("dry-run skill step should have _dry_run=true")
}
}
func newTestContext(t *testing.T, server *httptest.Server) *common.RuntimeContext {
t.Helper()
return &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: map[string]string{},
}
}
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)
}
}

View File

@ -0,0 +1,175 @@
package engine
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
)
// Run executes every step in a workflow sequentially.
func Run(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool) (*wf.WorkflowResult, error) {
return RunWithMode(ctx, wfDef, dryRun, "")
}
// RunWithMode executes a workflow with explicit AI mode control.
func RunWithMode(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool, aiMode string) (*wf.WorkflowResult, error) {
if aiMode != "" {
ctx.AIMode = aiMode
}
return run(ctx, wfDef, dryRun)
}
func run(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef, dryRun bool) (*wf.WorkflowResult, error) {
if !shouldSkipOwnerRepoResolve(ctx, wfDef) {
if err := ctx.ResolveOwnerRepo(); err != nil {
return nil, fmt.Errorf("resolve repo: %w", err)
}
}
if ctx.Args == nil {
ctx.Args = make(map[string]string)
}
if _, ok := ctx.Args["dry_run"]; !ok && dryRun {
ctx.Args["dry_run"] = "true"
}
ctx.Args["__wf_name"] = wfDef.Name
if ctx.Owner != "" {
ctx.Args["_owner"] = ctx.Owner
}
if ctx.Repo != "" {
ctx.Args["_repo"] = ctx.Repo
}
var workflowState *state.WorkflowState
if !dryRun {
workflowState, _ = state.LoadState(wfDef.Name)
}
results := make([]wf.StepResult, 0, len(wfDef.Steps))
for _, step := range wfDef.Steps {
var upstreamHash string
if !dryRun && workflowState != nil {
upstream := collectUpstream(ctx, step)
upstreamHash = state.HashData(upstream)
}
isDaemon := ctx.Arg("daemon-loop") == "true"
if isDaemon && !dryRun && workflowState != nil && step.RunWhen != "" && step.RunWhen != wf.RunAlways {
shouldRun, skipReason := checkPhaseCondition(step, workflowState, upstreamHash)
if !shouldRun {
fmt.Fprintf(os.Stderr, "[%s] 跳过 %s: %s\n", wfDef.Name, step.Name, skipReason)
results = append(results, wf.StepResult{
Step: step.Name,
Purpose: step.Purpose,
Type: step.Type,
OK: true,
Skipped: true,
SkipReason: skipReason,
})
continue
}
}
sr := ExecuteStep(ctx, step, dryRun)
results = append(results, *sr)
if sr.OK && !sr.Skipped && step.Name == "init-scaffold" && ctx.Repo == "" {
if m, ok := sr.Data.(map[string]interface{}); ok {
if a, ok := m["analysis"].(map[string]interface{}); ok {
if r, ok := a["repo"].(string); ok && r != "" {
parts := strings.SplitN(r, "/", 2)
if len(parts) == 2 {
ctx.Repo = parts[1]
}
}
}
}
}
if !dryRun && workflowState != nil && sr.OK && !sr.Skipped {
if workflowState.PhaseLastRun == nil {
workflowState.PhaseLastRun = make(map[string]string)
}
workflowState.PhaseLastRun[step.Name] = time.Now().Format(time.RFC3339)
if workflowState.PhaseUpstream == nil {
workflowState.PhaseUpstream = make(map[string]string)
}
workflowState.PhaseUpstream[step.Name] = upstreamHash
}
if sr.Data != nil {
raw, err := json.Marshal(sr.Data)
if err == nil {
ctx.Args[step.Name] = string(raw)
} else {
ctx.Args[step.Name] = fmt.Sprint(sr.Data)
}
} else if !sr.OK && sr.Error != "" {
ctx.Args[step.Name] = fmt.Sprintf(`{"_error": true, "_message": %q}`, sr.Error)
}
}
if !dryRun && workflowState != nil {
workflowState.Save()
}
return &wf.WorkflowResult{
Workflow: wfDef.Name,
Owner: ctx.Owner,
Repo: ctx.Repo,
Steps: results,
}, nil
}
func shouldSkipOwnerRepoResolve(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
if wfDef != nil && wfDef.Name == "project-init" {
return true
}
return isExplicitMultiRepoRun(ctx, wfDef)
}
// IsExplicitMultiRepoRun reports whether this is a multi-repo run with explicit
// --repos or --from flags (as opposed to resolving from the current directory).
func IsExplicitMultiRepoRun(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
return isExplicitMultiRepoRun(ctx, wfDef)
}
func isExplicitMultiRepoRun(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) bool {
if wfDef == nil || wfDef.Name != "multi-repo" {
return false
}
return ctx.Arg("repos") != "" || ctx.Arg("from") != ""
}
func checkPhaseCondition(step wf.StepDef, workflowState *state.WorkflowState, upstreamHash string) (bool, string) {
switch step.RunWhen {
case wf.RunWeekly:
last := workflowState.PhaseLastRun[step.Name]
if last == "" {
return true, ""
}
t, err := time.Parse(time.RFC3339, last)
if err != nil {
return true, ""
}
if time.Since(t) >= 7*24*time.Hour {
return true, ""
}
next := t.Add(7 * 24 * time.Hour)
return false, fmt.Sprintf("下次运行: %s", next.Format("01-02 15:04"))
case wf.RunOnChange:
if prev, ok := workflowState.PhaseUpstream[step.Name]; ok && prev == upstreamHash {
return false, "数据无变化"
}
return true, ""
default:
return true, ""
}
}

View File

@ -0,0 +1,322 @@
package engine
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/ai"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/state"
)
func executeSkillStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult, dryRun bool) {
upstream := collectUpstream(ctx, step)
if step.Target == "gitlink-review" {
state.EnrichWithPRDiffs(upstream, ctx.Owner, ctx.Repo)
state.FilterReviewedPRs(upstream, ctx)
}
if dryRun {
sr.OK = true
sr.Data = map[string]interface{}{
"_skill": step.Target,
"_dry_run": true,
"_depends_on": step.DependsOn,
"_upstream": upstream,
"_hint": "预览模式:展示将要传给 AI/规则引擎 的上游数据,不实际执行。",
}
return
}
aiMode := resolveAIMode(ctx)
client := ai.NewAIClient()
var aiResp *wf.AIResponse
var usedAI bool
var aiAnalysis interface{}
switch aiMode {
case wf.AIModeNoAI:
resp, err := runRuleEngine(step, upstream)
if err != nil {
sr.OK = true
sr.Data = map[string]interface{}{
"_skill": step.Target,
"_needs_ai": true,
"_upstream": upstream,
"_error": fmt.Sprintf("rule engine failed: %v", err),
}
return
}
aiResp = resp
case wf.AIModeAI:
if !client.HasKey() {
sr.OK = false
sr.Error = "AI 模式需要配置 API Key设置 DEEPSEEK_API_KEY 环境变量或 config set deepseek_api_key"
return
}
resp, err := callAI(client, step, upstream)
if err != nil {
sr.OK = false
sr.Error = fmt.Sprintf("AI 调用失败: %v", err)
return
}
aiResp = resp
usedAI = true
default:
if client.HasKey() {
resp, err := callAI(client, step, upstream)
if err == nil {
aiResp = resp
usedAI = true
} else {
fmt.Fprintf(os.Stderr, "[workflow] AI 调用失败,降级到规则引擎: %v\n", err)
}
}
if aiResp == nil {
resp, err := runRuleEngine(step, upstream)
if err != nil {
sr.OK = true
sr.Data = map[string]interface{}{
"_skill": step.Target,
"_needs_ai": true,
"_upstream": upstream,
"_error": fmt.Sprintf("AI 和规则引擎均失败: %v", err),
}
return
}
aiResp = resp
}
}
if usedAI && step.Target == "gitlink-triage" {
if ruleResp, err := runRuleEngine(step, upstream); err == nil {
aiAnalysis = aiResp.Analysis
aiResp.Actions = ruleResp.Actions
aiResp.Analysis = ruleResp.Analysis
} else {
fmt.Fprintf(os.Stderr, "[workflow] triage rule action fallback failed: %v\n", err)
}
}
if usedAI && step.Target == "gitlink-init-scaffold" {
if suggested := extractRepoNameFromAIAnalysis(aiResp.Analysis); suggested != "" {
upstream["_repo"] = suggested
}
if ruleResp, err := runRuleEngine(step, upstream); err == nil {
aiAnalysis = aiResp.Analysis
aiResp.Actions = ruleResp.Actions
} else {
fmt.Fprintf(os.Stderr, "[workflow] init-scaffold rule fallback failed: %v\n", err)
}
}
if usedAI && step.Name == "health-report" {
if ruleResp, err := runRuleEngine(step, upstream); err == nil && len(ruleResp.Actions) > 0 {
content := ""
if s, ok := aiResp.Analysis.(string); ok && s != "" {
content = s
}
if content == "" {
if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
content = "# 项目健康度报告\n\n" + string(b) + "\n"
}
}
for _, a := range ruleResp.Actions {
if a.Module == "wiki" && a.Command == "+create" {
if content != "" {
a.Args["content"] = content
}
aiResp.Actions = append(aiResp.Actions, a)
}
}
}
}
if usedAI && step.Name == "contributor-ranking" {
content := ""
if s, ok := aiResp.Analysis.(string); ok {
content = s
} else if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
content = "# 贡献者排行榜\n\n```json\n" + string(b) + "\n```\n"
}
if content != "" {
pageName := "贡献者排行榜 " + time.Now().Format("2006-01-02")
aiResp.Actions = append(aiResp.Actions,
wf.AIAction{
Type: "cli", Module: "wiki", Command: "+create",
Args: map[string]string{
"name": pageName,
"content": content,
"message": "自动生成贡献者排行榜",
},
},
wf.AIAction{
Type: "cli", Module: "wiki", Command: "+update",
Args: map[string]string{
"name": pageName,
"content": content,
"message": "自动更新贡献者排行榜",
},
},
)
}
}
if usedAI && step.Name == "multi-repo-coordination" {
if ruleResp, err := runRuleEngine(step, upstream); err == nil && len(ruleResp.Actions) > 0 {
content := ""
if s, ok := aiResp.Analysis.(string); ok && s != "" {
content = s
}
if content == "" {
if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil {
content = "# 多仓库协同报告\n\n```json\n" + string(b) + "\n```\n"
}
}
for _, a := range ruleResp.Actions {
if a.Module == "wiki" && a.Command == "+create" {
if content != "" {
a.Args["content"] = content
}
aiResp.Actions = append(aiResp.Actions, a)
}
}
}
}
executed, actionErrors := executeActions(ctx, aiResp.Actions)
if step.Target == "gitlink-review" && !dryRun {
state.SaveReviewedPRFingerprints(upstream, ctx)
}
sr.OK = executed > 0 || len(aiResp.Actions) == 0
data := map[string]interface{}{
"ok": sr.OK,
"analysis": aiResp.Analysis,
"executed": executed,
"_ai_used": usedAI,
"_skill": step.Target,
}
if len(actionErrors) > 0 {
data["errors"] = actionErrors
}
if aiAnalysis != nil {
data["ai_analysis"] = aiAnalysis
}
sr.Data = data
}
func resolveAIMode(ctx *common.RuntimeContext) wf.AIMode {
switch ctx.AIMode {
case "ai":
return wf.AIModeAI
case "no-ai":
return wf.AIModeNoAI
default:
return wf.AIModeAuto
}
}
func callAI(client *ai.AIClient, step wf.StepDef, upstream map[string]interface{}) (*wf.AIResponse, error) {
skillMD := readSkillDoc(step.Target)
upstreamJSON, _ := json.MarshalIndent(upstream, "", " ")
return client.Analyze(&wf.AIRequest{
SystemPrompt: skillMD,
UserData: string(upstreamJSON),
})
}
func extractRepoNameFromAIAnalysis(analysis interface{}) string {
m, ok := analysis.(map[string]interface{})
if !ok {
return ""
}
for _, key := range []string{"repo_name", "repo", "_repo", "name", "suggested_name"} {
if v, ok := m[key].(string); ok && v != "" {
v = strings.ToLower(strings.TrimSpace(v))
v = regexp.MustCompile(`[^a-z0-9-]+`).ReplaceAllString(v, "-")
v = regexp.MustCompile(`-+`).ReplaceAllString(v, "-")
v = strings.Trim(v, "-")
if len(v) >= 2 {
return v
}
}
}
return ""
}
func runRuleEngine(step wf.StepDef, upstream map[string]interface{}) (*wf.AIResponse, error) {
engine, ok := wf.RuleEngines[step.Target]
if !ok {
return nil, wf.ErrNoRuleEngine(step.Target)
}
return engine(upstream, step.Name)
}
func collectUpstream(ctx *common.RuntimeContext, step wf.StepDef) map[string]interface{} {
upstream := make(map[string]interface{})
for k, v := range ctx.Args {
if strings.HasPrefix(k, "_") {
upstream[k] = v
}
}
for _, dep := range step.DependsOn {
if v, ok := ctx.Args[dep]; ok {
var parsed interface{}
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
upstream[dep] = parsed
} else {
upstream[dep] = v
}
}
}
if len(step.DependsOn) == 0 {
for k, v := range ctx.Args {
var parsed interface{}
if err := json.Unmarshal([]byte(v), &parsed); err == nil {
upstream[k] = parsed
} else {
upstream[k] = v
}
}
}
return upstream
}
func readSkillDoc(target string) string {
paths := []string{}
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
paths = append(paths, filepath.Join(exeDir, "skills", target, "SKILL.md"))
paths = append(paths, filepath.Join(exeDir, "shortcuts", "workflow", "skills", target, "SKILL.md"))
}
paths = append(paths,
filepath.Join("skills", target, "SKILL.md"),
filepath.Join("shortcuts", "workflow", "skills", target, "SKILL.md"),
filepath.Join("/etc/gitlink-cli/skills", target, "SKILL.md"),
)
home, err := os.UserHomeDir()
if err == nil {
paths = append(paths, filepath.Join(home, ".config", "gitlink-cli", "skills", target, "SKILL.md"))
}
for _, p := range paths {
data, err := os.ReadFile(p)
if err == nil {
return string(data)
}
}
return fmt.Sprintf("# %s\n\nSkill documentation not found.", target)
}

View File

@ -0,0 +1,106 @@
package engine
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow/snapshot"
)
// ExecuteStep dispatches a step to the right executor based on its Type.
func ExecuteStep(ctx *common.RuntimeContext, step wf.StepDef, dryRun bool) *wf.StepResult {
sr := &wf.StepResult{
Step: step.Name,
Purpose: step.Purpose,
Type: step.Type,
}
switch step.Type {
case wf.StepTypeAPI:
executeAPIStep(ctx, step, sr)
case wf.StepTypeCommand:
executeCommandStep(ctx, step, sr)
case wf.StepTypeSkill:
executeSkillStep(ctx, step, sr, dryRun)
default:
sr.OK = false
sr.Error = fmt.Sprintf("unknown step type: %q", step.Type)
}
return sr
}
func executeAPIStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult) {
path := wf.ResolvePath(step.Target, ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery(step.Method, path, step.Query)
if err != nil {
sr.OK = false
sr.Error = err.Error()
} else {
sr.OK = env.OK
sr.Data = env.Data
}
}
func executeCommandStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult) {
if strings.HasPrefix(step.Target, "workflow-internal:") {
executeInternalCommandStep(ctx, step, sr)
return
}
parts := wf.ParseCommandTarget(step.Target)
if len(parts) == 0 {
sr.OK = false
sr.Error = fmt.Sprintf("empty command target: %q", step.Target)
return
}
bin := wf.ResolveCLIBinary()
args := append(parts, "--format", "json")
if ctx.Owner != "" {
args = append(args, "--owner", ctx.Owner)
}
if ctx.Repo != "" {
args = append(args, "--repo", ctx.Repo)
}
cmd := exec.Command(bin, args...)
cmd.Stderr = nil
out, err := cmd.Output()
if err != nil {
sr.OK = false
sr.Error = fmt.Sprintf("command failed: %v", err)
return
}
var data interface{}
if err := json.Unmarshal(out, &data); err != nil {
sr.OK = true
sr.Data = strings.TrimSpace(string(out))
} else {
sr.OK = true
sr.Data = data
}
}
func executeInternalCommandStep(ctx *common.RuntimeContext, step wf.StepDef, sr *wf.StepResult) {
switch strings.TrimPrefix(step.Target, "workflow-internal:") {
case "multi-repo-snapshot":
snap, err := snapshot.BuildMultiRepoSnapshot(ctx)
if err != nil {
sr.OK = false
sr.Error = err.Error()
return
}
sr.OK = true
sr.Data = snap
default:
sr.OK = false
sr.Error = fmt.Sprintf("unknown internal workflow command: %q", step.Target)
}
}

View File

@ -0,0 +1,29 @@
package workflow
import "sort"
var registry = map[string]*WorkflowDef{}
// Register adds a workflow definition to the global registry.
func Register(wf *WorkflowDef) {
registry[wf.Name] = wf
}
// All returns all registered workflows sorted by name.
func All() []*WorkflowDef {
names := make([]string, 0, len(registry))
for n := range registry {
names = append(names, n)
}
sort.Strings(names)
result := make([]*WorkflowDef, len(names))
for i, n := range names {
result[i] = registry[n]
}
return result
}
// Get returns a workflow by name, or nil.
func Get(name string) *WorkflowDef {
return registry[name]
}

View File

@ -0,0 +1,149 @@
package rules
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// AutoMergeRule merges PRs when code quality criteria are met.
// Criteria: no high-severity security findings + CI is healthy.
func AutoMergeRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
prs := extractPRs(upstream, "open-prs")
if len(prs) == 0 {
return &workflow.AIResponse{
Analysis: map[string]interface{}{
"merged": 0,
"reason": "没有开放的 PR",
},
}, nil
}
// Check CI health from ci-diagnosis step result.
ciHealthy := true
if ciData, ok := upstream["ci-diagnosis"].(map[string]interface{}); ok {
if ciAnalysis, ok := ciData["analysis"].(map[string]interface{}); ok {
ciHealthy = IsCIHealthy(ciAnalysis)
}
}
// Check for high-severity security findings from review step.
hasHighSeverity := false
if reviewData, ok := upstream["review"].(map[string]interface{}); ok {
if reviewAnalysis, ok := reviewData["analysis"].(map[string]interface{}); ok {
hasHighSeverity = hasHighSeverityFindings(reviewAnalysis)
}
}
if hasHighSeverity {
return &workflow.AIResponse{
Analysis: map[string]interface{}{
"merged": 0,
"reason": "Review 发现高危安全问题,阻止自动合并",
},
}, nil
}
if !ciHealthy {
return &workflow.AIResponse{
Analysis: map[string]interface{}{
"merged": 0,
"reason": "CI 构建未通过,阻止自动合并",
},
}, nil
}
// All checks passed — merge each open PR.
var actions []workflow.AIAction
openCount := 0
for _, pr := range prs {
// Only merge open PRs.
status := str(pr, "pull_request_status", "pull_request_staus", "status", "state")
if status != "" && status != "open" {
continue
}
openCount++
prNum := interfaceToString(pr["pull_request_number"])
if prNum == "" {
prNum = interfaceToString(pr["id"])
}
if prNum == "" {
prNum = interfaceToString(pr["number"])
}
if prNum == "" {
prNum = interfaceToString(pr["pull_request_id"])
}
if prNum == "" {
continue
}
actions = append(actions, workflow.AIAction{
Type: "cli",
Module: "pr",
Command: "+merge",
Args: map[string]string{
"id": prNum,
"method": "squash",
},
})
}
if len(actions) == 0 {
reason := "无法解析 PR 编号"
if openCount == 0 {
reason = "没有开放的 PR"
}
return &workflow.AIResponse{
Analysis: map[string]interface{}{
"merged": 0,
"reason": reason,
},
}, nil
}
return &workflow.AIResponse{
Analysis: map[string]interface{}{
"merged": len(actions),
"reason": fmt.Sprintf("质量达标,已合并 %d 个 PR", len(actions)),
},
Actions: actions,
}, nil
}
// hasHighSeverityFindings checks if the review analysis contains any
// high-severity security findings that should block auto-merge.
func hasHighSeverityFindings(analysis map[string]interface{}) bool {
findings, ok := analysis["findings"]
if !ok || findings == nil {
return false
}
// Handle []finding (direct from rule engine).
if fList, ok := findings.([]finding); ok {
for _, f := range fList {
if f.Severity == "high" && f.Lens == "security" {
return true
}
}
return false
}
// Handle []interface{} (after JSON round-trip).
if fList, ok := findings.([]interface{}); ok {
for _, item := range fList {
if fm, ok := item.(map[string]interface{}); ok {
sev := ""
lens := ""
if s, ok := fm["severity"].(string); ok {
sev = s
}
if l, ok := fm["lens"].(string); ok {
lens = l
}
if sev == "high" && lens == "security" {
return true
}
}
}
}
return false
}

View File

@ -0,0 +1,269 @@
package rules
import (
"fmt"
"regexp"
"sort"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// commitGroup holds category → commits mapping for changelog generation.
type commitGroup struct {
Category string
Emoji string
Commits []map[string]interface{}
}
var changelogRules = []struct {
emoji string
name string
patterns []*regexp.Regexp
}{
{"✨", "新功能", []*regexp.Regexp{
regexp.MustCompile(`^(feat|feature|add|新增|支持)(\(.+\))?!?[:]`),
}},
{"🐛", "Bug 修复", []*regexp.Regexp{
regexp.MustCompile(`^(fix|bugfix|hotfix|修复|解决)(\(.+\))?!?[:]`),
}},
{"🔧", "改进优化", []*regexp.Regexp{
regexp.MustCompile(`^(refactor|perf|improve|enhance|style|fmt|optimize|优化|增强|完善|调整|格式化)(\(.+\))?!?[:]`),
}},
{"📚", "文档", []*regexp.Regexp{
regexp.MustCompile(`^(docs|doc|文档|README|注释)(\(.+\))?!?[:]`),
}},
{"🧪", "测试", []*regexp.Regexp{
regexp.MustCompile(`^(test|tests|测试)(\(.+\))?!?[:]`),
}},
{"🏗️", "构建/CI", []*regexp.Regexp{
regexp.MustCompile(`^(build|ci|chore|构建|部署|Docker)(\(.+\))?!?[:]`),
}},
}
var breakingRe = regexp.MustCompile(`!:`)
var breakingBodyRe = regexp.MustCompile(`BREAKING[ -]CHANGE`)
// ChangelogRule classifies commits and generates release notes.
func ChangelogRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
commits := extractCommits(upstream)
releases := extractList(upstream, "releases")
mergedPRs := extractPRs(upstream, "merged-prs")
groups := []commitGroup{}
breaking := []map[string]interface{}{}
uncategorized := []map[string]interface{}{}
for _, c := range commits {
msg := str(c, "title", "message", "commit", "subject")
body := str(c, "body", "description")
if msg == "" {
continue
}
// Check breaking change.
if breakingRe.MatchString(msg) || breakingBodyRe.MatchString(body) {
breaking = append(breaking, c)
continue
}
categorized := false
for i, rule := range changelogRules {
for _, re := range rule.patterns {
if re.MatchString(msg) {
// Extend existing group or create new.
found := false
for j, g := range groups {
if g.Category == rule.name {
groups[j].Commits = append(groups[j].Commits, c)
found = true
break
}
}
if !found {
groups = append(groups, commitGroup{
Category: rule.name,
Emoji: rule.emoji,
Commits: []map[string]interface{}{c},
})
}
_ = i // suppress unused
categorized = true
break
}
}
if categorized {
break
}
}
if !categorized {
uncategorized = append(uncategorized, c)
}
}
// Sort groups: features first, then bug fixes, then rest.
sort.SliceStable(groups, func(i, j int) bool {
return orderOf(groups[i].Category) < orderOf(groups[j].Category)
})
// Add breaking changes group at top if present.
if len(breaking) > 0 {
groups = append([]commitGroup{{
Category: "破坏性变更",
Emoji: "⚠️",
Commits: breaking,
}}, groups...)
}
// Add uncategorized at end.
if len(uncategorized) > 0 {
groups = append(groups, commitGroup{
Category: "其他",
Emoji: "🔀",
Commits: uncategorized,
})
}
// Build analysis.
sections := []map[string]interface{}{}
for _, g := range groups {
items := []string{}
for _, c := range g.Commits {
msg := str(c, "title", "message", "commit", "subject")
sha := str(c, "sha", "id", "commit_id")
if sha != "" && len(sha) > 7 {
sha = sha[:7]
}
items = append(items, fmt.Sprintf("%s %s", sha, msg))
}
sections = append(sections, map[string]interface{}{
"category": g.Emoji + " " + g.Category,
"count": len(g.Commits),
"items": items,
})
}
analysis := map[string]interface{}{
"total_commits": len(commits),
"sections": sections,
}
// Build release creation action if there are categorized commits.
var actions []workflow.AIAction
if len(commits) > 0 {
// Determine next version tag.
latestTag := "v0.0.0"
for _, rel := range releases {
if t := str(rel, "tag_name", "tag", "name"); t != "" {
if compareTags(t, latestTag) > 0 {
latestTag = t
}
}
}
// Also check merged PRs for version hints.
for _, pr := range mergedPRs {
labels := str(pr, "labels")
if strings.Contains(labels, "release") || strings.Contains(labels, "version") {
// PR merged with release label — bump version.
}
}
nextTag := bumpTag(latestTag)
if len(breaking) > 0 {
nextTag = bumpMajor(latestTag)
}
body := buildChangelogBody(sections, nextTag)
if nextTag != latestTag {
actions = append(actions, workflow.AIAction{
Type: "cli",
Module: "release",
Command: "+create",
Args: map[string]string{
"tag": nextTag,
"name": nextTag,
"body": body,
},
})
}
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
func orderOf(cat string) int {
order := map[string]int{
"破坏性变更": 0,
"新功能": 1,
"Bug 修复": 2,
"改进优化": 3,
"文档": 4,
"测试": 5,
"构建/CI": 6,
"其他": 7,
}
if o, ok := order[cat]; ok {
return o
}
return 99
}
func compareTags(a, b string) int {
an := normalizeTag(a)
bn := normalizeTag(b)
if an > bn {
return 1
} else if an < bn {
return -1
}
return 0
}
func normalizeTag(t string) string {
t = strings.TrimPrefix(t, "v")
parts := strings.Split(t, ".")
for len(parts) < 3 {
parts = append(parts, "0")
}
return strings.Join(parts, ".")
}
func bumpTag(tag string) string {
parts := strings.Split(normalizeTag(tag), ".")
if len(parts) < 3 {
return "v0.1.0"
}
minor := atoi(parts[1])
return fmt.Sprintf("v%s.%d.0", parts[0], minor+1)
}
func bumpMajor(tag string) string {
parts := strings.Split(normalizeTag(tag), ".")
if len(parts) < 1 {
return "v1.0.0"
}
major := atoi(parts[0])
return fmt.Sprintf("v%d.0.0", major+1)
}
func atoi(s string) int {
var n int
fmt.Sscanf(s, "%d", &n)
return n
}
func buildChangelogBody(sections []map[string]interface{}, tag string) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", tag)
for _, sec := range sections {
fmt.Fprintf(&b, "## %s (%d)\n\n", sec["category"], sec["count"])
if items, ok := sec["items"].([]string); ok {
for _, item := range items {
fmt.Fprintf(&b, "- %s\n", item)
}
}
b.WriteString("\n")
}
return b.String()
}

View File

@ -0,0 +1,105 @@
package rules
import (
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestChangelogConventionalCommits(t *testing.T) {
upstream := map[string]interface{}{
"commits": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"sha": "abc12345", "title": "feat: add user login"},
map[string]interface{}{"sha": "def12345", "title": "fix: resolve null pointer"},
map[string]interface{}{"sha": "ghi12345", "title": "docs: update README"},
},
},
"releases": map[string]interface{}{"data": []interface{}{}},
"merged-prs": map[string]interface{}{"data": []interface{}{}},
}
resp, err := ChangelogRule(upstream, "changelog")
if err != nil {
t.Fatalf("ChangelogRule failed: %v", err)
}
if resp.Analysis == nil {
t.Fatal("expected non-nil Analysis")
}
analysis := resp.Analysis.(map[string]interface{})
sections := analysis["sections"].([]map[string]interface{})
if len(sections) < 3 {
t.Fatalf("expected at least 3 sections (features, bugs, docs), got %d", len(sections))
}
}
func TestChangelogBreakingChange(t *testing.T) {
upstream := map[string]interface{}{
"commits": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"sha": "abc12345", "title": "feat!: drop support for v1"},
},
},
"releases": map[string]interface{}{"data": []interface{}{}},
"merged-prs": map[string]interface{}{"data": []interface{}{}},
}
resp, err := ChangelogRule(upstream, "changelog")
if err != nil {
t.Fatalf("ChangelogRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
sections := analysis["sections"].([]map[string]interface{})
if len(sections) == 0 {
t.Fatal("expected breaking changes section")
}
first := sections[0]
if cat := first["category"]; cat == nil {
t.Fatal("first section missing category")
}
}
func TestChangelogChineseKeywords(t *testing.T) {
upstream := map[string]interface{}{
"commits": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"sha": "aaa11111", "title": "新增:用户管理模块"},
map[string]interface{}{"sha": "bbb11111", "title": "修复:登录页面报错"},
},
},
"releases": map[string]interface{}{"data": []interface{}{}},
"merged-prs": map[string]interface{}{"data": []interface{}{}},
}
resp, err := ChangelogRule(upstream, "changelog")
if err != nil {
t.Fatalf("ChangelogRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
// Should have at least 2 sections.
sections := analysis["sections"].([]map[string]interface{})
if len(sections) < 2 {
t.Fatalf("expected at least 2 sections, got %d", len(sections))
}
}
func TestChangelogNoCommits(t *testing.T) {
resp, err := ChangelogRule(map[string]interface{}{}, "changelog")
if err != nil {
t.Fatalf("ChangelogRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
if v := analysis["total_commits"]; v.(int) != 0 {
t.Fatalf("expected 0 total_commits, got %v", v)
}
}
func TestChangelogOutputFormat(t *testing.T) {
resp, err := ChangelogRule(map[string]interface{}{}, "changelog")
if err != nil {
t.Fatalf("ChangelogRule failed: %v", err)
}
var _ *workflow.AIResponse = resp
}

View File

@ -0,0 +1,191 @@
package rules
import (
"fmt"
"regexp"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// CIDiagnosisRule matches CI build error logs against known patterns.
func CIDiagnosisRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
builds := extractList(upstream, "ci-builds")
commits := extractCommits(upstream)
type diagnosis struct {
BuildNumber interface{} `json:"build_number"`
Status string `json:"status"`
Pattern string `json:"matched_pattern"`
Diagnosis string `json:"diagnosis"`
Suggestion string `json:"suggestion"`
RelatedSHA string `json:"related_commit"`
}
var diagnoses []diagnosis
var actions []workflow.AIAction
for _, build := range builds {
status := str(build, "status", "state", "result")
if status != "failed" && status != "failure" && status != "error" && status != "3" {
// Check nested: some APIs use "build" wrapper.
if inner, ok := build["build"].(map[string]interface{}); ok {
build = inner
status = str(build, "status", "state", "result")
if status != "failed" && status != "failure" && status != "error" && status != "3" {
continue
}
} else {
continue
}
}
log := str(build, "log", "logs", "output", "build_log")
if log == "" {
continue
}
d := diagnoseLog(log)
buildNum := build["build_number"]
if buildNum == nil {
buildNum = build["id"]
}
// Find related commit.
relatedSHA := ""
for _, c := range commits {
cSha := str(c, "sha", "id", "commit_id")
if cSha != "" && containsAny(str(c, "title", "message", "commit"), d.Pattern) {
relatedSHA = cSha
break
}
}
diagnoses = append(diagnoses, diagnosis{
BuildNumber: buildNum,
Status: status,
Pattern: d.Pattern,
Diagnosis: d.Diagnosis,
Suggestion: d.Suggestion,
RelatedSHA: relatedSHA,
})
// Auto-retry for transient failures.
if d.Transient {
actions = append(actions, workflow.AIAction{
Type: "api",
Method: "POST",
Path: fmt.Sprintf("{v1}/builds/%v/retry", buildNum),
Body: map[string]interface{}{},
})
}
}
if len(diagnoses) == 0 {
return &workflow.AIResponse{
Analysis: map[string]interface{}{"diagnoses": nil, "message": "no failed builds found"},
Actions: nil,
}, nil
}
analysis := map[string]interface{}{
"diagnoses": diagnoses,
"total_failures": len(diagnoses),
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
type logPattern struct {
Re *regexp.Regexp
Pattern string
Diagnosis string
Suggestion string
Transient bool
}
var ciPatterns = []logPattern{
{regexp.MustCompile(`cannot find package|package .* is not in`), "cannot find package",
"依赖缺失",
"检查 go.mod/package.json 确认依赖已声明",
false},
{regexp.MustCompile(`syntax error|unexpected token|unexpected EOF`), "syntax error",
"语法错误",
"检查最近提交中的语法问题",
false},
{regexp.MustCompile(`permission denied|access denied|forbidden|401|403`), "permission denied",
"权限不足",
"检查密钥配置和访问权限",
false},
{regexp.MustCompile(`connection refused|connection reset|no route to host|dial tcp`), "connection refused",
"服务不可达",
"检查外部服务状态和网络连接",
true},
{regexp.MustCompile(`out of memory|OOM|killed|signal: killed`), "out of memory",
"资源不足(内存溢出)",
"优化内存使用或增加构建资源",
false},
{regexp.MustCompile(`No such file|file not found|not found`), "No such file",
"文件缺失",
"检查 .devops/ 路径和依赖文件配置",
false},
{regexp.MustCompile(`docker:.*not found|docker.*command not found`), "docker not found",
"Docker 环境缺失",
"构建环境未配置 Docker检查 CI 配置",
false},
{regexp.MustCompile(`FAIL|exit status [1-9]|Test.*failed`), "exit status 1",
"测试失败",
"查看测试输出定位失败用例",
false},
{regexp.MustCompile(`timeout|timed out|deadline exceeded`), "timeout",
"构建超时",
"优化构建脚本或增加超时时间",
true},
{regexp.MustCompile(`undefined:|undefined symbol|cannot use|type mismatch`), "undefined:",
"编译错误(未定义符号)",
"检查导入和类型定义",
false},
}
func diagnoseLog(log string) logPattern {
for _, p := range ciPatterns {
if p.Re.MatchString(log) {
return p
}
}
return logPattern{
Pattern: "unknown",
Diagnosis: "未知错误",
Suggestion: "请人工查看 CI 日志进行诊断",
Transient: false,
}
}
func containsAny(s string, patterns ...string) bool {
for _, p := range patterns {
if p != "" && len(s) > 0 && len(p) > 0 {
// Simple substring check.
if len(s) >= len(p) {
for i := 0; i <= len(s)-len(p); i++ {
if s[i:i+len(p)] == p {
return true
}
}
}
}
}
return false
}
// IsCIHealthy checks whether CI diagnosis results indicate no failures.
func IsCIHealthy(analysis map[string]interface{}) bool {
if msg, ok := analysis["message"].(string); ok && msg == "no failed builds found" {
return true
}
if tf, ok := analysis["total_failures"]; ok {
switch v := tf.(type) {
case float64:
return v == 0
case int:
return v == 0
}
}
return true
}

View File

@ -0,0 +1,74 @@
package rules
import (
"testing"
)
func TestCIDiagnosisPatterns(t *testing.T) {
tests := []struct {
log string
pattern string
transient bool
}{
{"cannot find package github.com/foo/bar", "cannot find package", false},
{"syntax error: unexpected token at line 42", "syntax error", false},
{"permission denied: unable to access /tmp/build", "permission denied", false},
{"connection refused: dial tcp 10.0.0.1:8080", "connection refused", true},
{"out of memory: process killed", "out of memory", false},
{"No such file or directory: .devops/build.yml", "No such file", false},
{"FAIL: TestLogin (0.23s)", "exit status 1", false},
{"timeout: deadline exceeded after 300s", "timeout", true},
{"undefined: UserService in main.go:15", "undefined:", false},
}
for _, tc := range tests {
d := diagnoseLog(tc.log)
if d.Pattern != tc.pattern {
t.Errorf("log=%q: expected pattern %q, got %q", tc.log, tc.pattern, d.Pattern)
}
if d.Transient != tc.transient {
t.Errorf("log=%q: expected transient=%v, got %v", tc.log, tc.transient, d.Transient)
}
}
}
func TestCIDiagnosisNoFailures(t *testing.T) {
upstream := map[string]interface{}{
"ci-builds": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": "1", "status": "success", "log": "build passed"},
},
},
}
resp, err := CIDiagnosisRule(upstream, "ci-diagnosis")
if err != nil {
t.Fatalf("CIDiagnosisRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
if msg := analysis["message"]; msg != "no failed builds found" {
t.Fatalf("expected 'no failed builds found', got %v", msg)
}
}
func TestCIDiagnosisWithFailures(t *testing.T) {
upstream := map[string]interface{}{
"ci-builds": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": "1", "status": "failed", "log": "connection refused"},
},
},
"commits": map[string]interface{}{
"data": []interface{}{},
},
}
resp, err := CIDiagnosisRule(upstream, "ci-diagnosis")
if err != nil {
t.Fatalf("CIDiagnosisRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
if v := analysis["total_failures"]; v.(int) != 1 {
t.Fatalf("expected 1 failure, got %v", v)
}
}

View File

@ -0,0 +1,410 @@
package rules
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// contributorEntry holds per-contributor aggregate data.
type contributorEntry struct {
Login string `json:"login"`
Name string `json:"name"`
Commits int `json:"commits"`
Issues int `json:"issues"`
PRs int `json:"prs"`
Total int `json:"total"`
Trend float64 `json:"trend"`
LastActivity string `json:"last_activity"`
Tags []string `json:"tags"`
}
// ContributorRankingRule produces a contributor ranking report.
func ContributorRankingRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
commits := extractCommits(upstream)
issues := append(extractIssues(upstream, "open-issues"), extractIssues(upstream, "closed-issues")...)
prs := extractPRs(upstream, "merged-prs")
members := extractMembers(upstream)
// Aggregate per login.
stats := map[string]*contributorEntry{}
for _, c := range commits {
login := authorLogin(c)
if login == "" {
continue
}
e := ensureEntry(stats, login, members)
e.Commits++
e.Total++
if ts := commitTimestamp(c); ts != "" && ts > e.LastActivity {
e.LastActivity = ts
}
}
for _, i := range issues {
login := authorLogin(i)
if login == "" {
continue
}
e := ensureEntry(stats, login, members)
e.Issues++
e.Total++
if ts := issueTimestamp(i); ts != "" && ts > e.LastActivity {
e.LastActivity = ts
}
}
for _, p := range prs {
login := authorLogin(p)
if login == "" {
continue
}
e := ensureEntry(stats, login, members)
e.PRs++
e.Total++
if ts := prTimestamp(p); ts != "" && ts > e.LastActivity {
e.LastActivity = ts
}
}
// Calculate trends using 30-day windows.
now := time.Now()
cutoff30 := now.Add(-30 * 24 * time.Hour)
cutoff60 := now.Add(-60 * 24 * time.Hour)
recent := countInWindow(commits, cutoff30, now)
prev := countInWindow(commits, cutoff60, cutoff30)
for login := range stats {
rc := recent[login]
pc := prev[login]
if pc > 0 {
stats[login].Trend = float64(rc-pc) / float64(pc) * 100
} else if rc > 0 {
stats[login].Trend = 100
}
// Tagging.
if stats[login].Trend > 50 {
stats[login].Tags = append(stats[login].Tags, "new-star")
}
if stats[login].LastActivity != "" {
t, err := time.Parse(time.RFC3339, stats[login].LastActivity)
if err == nil && now.Sub(t) > 30*24*time.Hour {
stats[login].Tags = append(stats[login].Tags, "churn-risk")
}
}
}
// Sort by total desc.
entries := make([]contributorEntry, 0, len(stats))
for _, e := range stats {
entries = append(entries, *e)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Total > entries[j].Total })
// Build analysis.
rankings := make([]map[string]interface{}, len(entries))
for i, e := range entries {
rankings[i] = map[string]interface{}{
"rank": i + 1,
"login": e.Login,
"name": e.Name,
"commits": e.Commits,
"issues": e.Issues,
"prs": e.PRs,
"total": e.Total,
"trend": e.Trend,
"last_activity": e.LastActivity,
"tags": e.Tags,
}
}
analysis := map[string]interface{}{
"title": "贡献者排行榜",
"rankings": rankings,
"churn_risk": filterByTag(rankings, "churn-risk"),
"new_stars": filterByTag(rankings, "new-star"),
}
// Build wiki page content.
wikiContent := buildContributorWiki(analysis, rankings)
pageName := "贡献者排行榜 " + time.Now().Format("2006-01-02")
actions := []workflow.AIAction{
{
Type: "cli", Module: "wiki", Command: "+create",
Args: map[string]string{
"name": pageName,
"content": wikiContent,
"message": "自动生成贡献者排行榜",
},
},
{
Type: "cli", Module: "wiki", Command: "+update",
Args: map[string]string{
"name": pageName,
"content": wikiContent,
"message": "自动更新贡献者排行榜",
},
},
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
func ensureEntry(stats map[string]*contributorEntry, login string, members map[string]string) *contributorEntry {
if e, ok := stats[login]; ok {
return e
}
e := &contributorEntry{Login: login, Name: members[login]}
stats[login] = e
return e
}
func filterByTag(rankings []map[string]interface{}, tag string) []map[string]interface{} {
var out []map[string]interface{}
for _, r := range rankings {
if tags, ok := r["tags"].([]string); ok {
for _, t := range tags {
if t == tag {
out = append(out, r)
break
}
}
}
}
return out
}
func countInWindow(commits []map[string]interface{}, start, end time.Time) map[string]int {
m := map[string]int{}
for _, c := range commits {
ts := commitTimestamp(c)
if ts == "" {
continue
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
continue
}
if t.After(start) && t.Before(end) {
m[authorLogin(c)]++
}
}
return m
}
// --- helpers ---
func extractCommits(upstream map[string]interface{}) []map[string]interface{} {
return extractList(upstream, "commits")
}
func extractIssues(upstream map[string]interface{}, key string) []map[string]interface{} {
return extractList(upstream, key)
}
func extractPRs(upstream map[string]interface{}, key string) []map[string]interface{} {
return extractList(upstream, key)
}
func extractMembers(upstream map[string]interface{}) map[string]string {
raw, ok := upstream["members"]
if !ok {
return nil
}
// Unwrap envelope: {"ok": true, "data": {"collaborators": [...]}}
if m, ok := raw.(map[string]interface{}); ok {
if data, ok := m["data"]; ok {
raw = data
}
}
// Unwrap inner key: {"members": [...]} or {"collaborators": [...]}
if m, ok := raw.(map[string]interface{}); ok {
for _, key := range []string{"members", "collaborators"} {
if list, ok := m[key]; ok {
raw = list
break
}
}
}
members := map[string]string{}
list, ok := raw.([]interface{})
if !ok {
return members
}
for _, item := range list {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
login := str(m, "login", "username", "name")
id := fmt.Sprint(m["id"])
if login != "" && id != "" && id != "0" && id != "<nil>" {
members[login] = id
}
}
return members
}
func extractList(upstream map[string]interface{}, key string) []map[string]interface{} {
raw, ok := upstream[key]
if !ok {
return nil
}
// upstream values may be stored as an envelope: {"ok": true, "data": [...]}
if m, ok := raw.(map[string]interface{}); ok {
if data, ok := m["data"]; ok {
raw = data
}
}
// Some CLI commands return data as a JSON-encoded string; try to decode it.
if s, ok := raw.(string); ok {
var parsed interface{}
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
raw = parsed
}
}
// GitLink API wraps lists inside a map: {"issues": [...], "milestones": [...], ...}
if m, ok := raw.(map[string]interface{}); ok {
for _, listKey := range []string{"commits", "issues", "pull_requests", "issue_tags", "tags", "releases", "members", "items", "milestones", "branches"} {
if v, ok := m[listKey]; ok {
if arr, ok := v.([]interface{}); ok {
raw = arr
break
}
}
}
}
list, _ := raw.([]interface{})
var out []map[string]interface{}
for _, item := range list {
if m, ok := item.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
func authorLogin(m map[string]interface{}) string {
// Try top-level keys first.
if s := str(m, "login", "username"); s != "" {
return s
}
// Try nested author/committer/user.
for _, key := range []string{"author", "committer", "user"} {
if a, ok := m[key].(map[string]interface{}); ok {
if s := str(a, "login", "username", "name"); s != "" {
return s
}
} else if s, ok := m[key].(string); ok && s != "" {
return s
}
}
return ""
}
func commitTimestamp(m map[string]interface{}) string {
// Try Unix timestamp (commit_time).
for _, key := range []string{"commit_time", "committed_date", "authored_date"} {
switch v := m[key].(type) {
case float64:
if v > 0 {
return time.Unix(int64(v), 0).UTC().Format(time.RFC3339)
}
case string:
if v != "" {
return v
}
}
}
// Try string timestamps.
for _, key := range []string{"created_at", "updated_at"} {
if s := str(m, key); s != "" {
return s
}
}
// Try nested author.
if a, ok := m["author"].(map[string]interface{}); ok {
return str(a, "date", "created_at")
}
if a, ok := m["committer"].(map[string]interface{}); ok {
return str(a, "date", "created_at")
}
return ""
}
func issueTimestamp(m map[string]interface{}) string {
return str(m, "created_at", "updated_at", "closed_at")
}
func prTimestamp(m map[string]interface{}) string {
return str(m, "created_at", "merged_at", "updated_at")
}
// str returns the first non-empty string value for the given keys.
func str(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
v, _ := m[k].(string)
if v != "" {
return v
}
}
return ""
}
// buildContributorWiki generates a markdown wiki page from ranking data.
func buildContributorWiki(analysis map[string]interface{}, rankings []map[string]interface{}) string {
var sb strings.Builder
sb.WriteString("# 贡献者排行榜\n\n")
sb.WriteString(fmt.Sprintf("> 自动生成于 %s\n\n", time.Now().Format("2006-01-02 15:04")))
sb.WriteString("## 总览\n\n")
sb.WriteString("| 排名 | 贡献者 | 提交 | Issue | PR | 总计 | 趋势 | 标签 |\n")
sb.WriteString("|------|--------|------|-------|-----|------|------|------|\n")
for _, r := range rankings {
name := fmt.Sprint(r["name"])
if name == "" || name == "<nil>" {
name = fmt.Sprint(r["login"])
}
tags := ""
if t, ok := r["tags"].([]string); ok && len(t) > 0 {
tags = strings.Join(t, ", ")
}
sb.WriteString(fmt.Sprintf("| %v | %s | %v | %v | %v | %v | %.0f%% | %s |\n",
r["rank"], name, r["commits"], r["issues"], r["prs"], r["total"], r["trend"], tags))
}
// 新星
if newStars, ok := analysis["new_stars"].([]map[string]interface{}); ok && len(newStars) > 0 {
sb.WriteString("\n## 新星\n\n")
for _, s := range newStars {
name := fmt.Sprint(s["name"])
if name == "" || name == "<nil>" {
name = fmt.Sprint(s["login"])
}
sb.WriteString(fmt.Sprintf("- **%s** — 趋势 +%.0f%%\n", name, s["trend"]))
}
}
// 流失风险
if churn, ok := analysis["churn_risk"].([]map[string]interface{}); ok && len(churn) > 0 {
sb.WriteString("\n## 流失风险\n\n")
for _, c := range churn {
name := fmt.Sprint(c["name"])
if name == "" || name == "<nil>" {
name = fmt.Sprint(c["login"])
}
last := fmt.Sprint(c["last_activity"])
if t, err := time.Parse(time.RFC3339, last); err == nil {
last = t.Format("2006-01-02")
}
sb.WriteString(fmt.Sprintf("- **%s** — 最后活动 %s\n", name, last))
}
}
sb.WriteString("\n> 由 contributor-growth 工作流自动生成\n")
return sb.String()
}

View File

@ -0,0 +1,148 @@
package rules
import (
"testing"
"time"
)
func TestContributorRanking(t *testing.T) {
upstream := map[string]interface{}{
"commits": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"created_at": "2025-06-01T00:00:00Z", "author": "dev1"},
map[string]interface{}{"created_at": "2025-06-05T00:00:00Z", "author": "dev1"},
map[string]interface{}{"created_at": "2025-06-10T00:00:00Z", "author": "dev2"},
},
},
"open-issues": map[string]interface{}{"data": []interface{}{}},
"closed-issues": map[string]interface{}{"data": []interface{}{}},
"merged-prs": map[string]interface{}{"data": []interface{}{}},
"members": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"login": "dev1", "name": "Dev One"},
map[string]interface{}{"login": "dev2", "name": "Dev Two"},
},
},
}
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
if err != nil {
t.Fatalf("ContributorRankingRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
rankings := analysis["rankings"].([]map[string]interface{})
if len(rankings) != 2 {
t.Fatalf("expected 2 rankings, got %d", len(rankings))
}
// dev1 should be ranked #1 (2 commits vs 1).
first := rankings[0]
if first["login"] != "dev1" {
t.Errorf("expected dev1 as #1, got %v", first["login"])
}
if first["rank"] != 1 {
t.Errorf("expected rank 1, got %v", first["rank"])
}
if first["commits"] != 2 {
t.Errorf("expected 2 commits, got %v", first["commits"])
}
}
func TestChurnRiskDetection(t *testing.T) {
// 35 days ago — should trigger churn risk.
oldDate := "2025-01-01T00:00:00Z"
upstream := map[string]interface{}{
"commits": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"created_at": oldDate, "author": "dev1"},
},
},
"open-issues": map[string]interface{}{"data": []interface{}{}},
"closed-issues": map[string]interface{}{"data": []interface{}{}},
"merged-prs": map[string]interface{}{"data": []interface{}{}},
"members": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"login": "dev1", "name": "Dev One"},
},
},
}
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
if err != nil {
t.Fatalf("ContributorRankingRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
rankings := analysis["rankings"].([]map[string]interface{})
if len(rankings) > 0 {
tags := rankings[0]["tags"].([]string)
for _, tag := range tags {
if tag == "churn-risk" {
return // success
}
}
t.Errorf("expected churn-risk tag for old activity, got tags: %v", tags)
}
}
func TestContributorOutputFormat(t *testing.T) {
resp, err := ContributorRankingRule(map[string]interface{}{}, "contributor-ranking")
if err != nil {
t.Fatalf("ContributorRankingRule failed: %v", err)
}
if resp.Analysis == nil {
t.Fatal("expected non-nil Analysis")
}
if len(resp.Actions) < 1 {
t.Fatal("expected at least 1 wiki action")
}
}
func TestNewStarDetection(t *testing.T) {
// Use very recent dates so the commits appear in the last 30 days.
now := time.Now()
d1 := now.Add(-2 * 24 * time.Hour).Format(time.RFC3339)
d2 := now.Add(-3 * 24 * time.Hour).Format(time.RFC3339)
d3 := now.Add(-4 * 24 * time.Hour).Format(time.RFC3339)
upstream := map[string]interface{}{
"commits": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"created_at": d1, "author": "dev1"},
map[string]interface{}{"created_at": d2, "author": "dev1"},
map[string]interface{}{"created_at": d3, "author": "dev1"},
},
},
"open-issues": map[string]interface{}{"data": []interface{}{}},
"closed-issues": map[string]interface{}{"data": []interface{}{}},
"merged-prs": map[string]interface{}{"data": []interface{}{}},
"members": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"login": "dev1", "name": "Dev One"},
},
},
}
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
if err != nil {
t.Fatalf("ContributorRankingRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
// Should have new-star or at least ranking.
rankings := analysis["rankings"].([]map[string]interface{})
if len(rankings) == 0 {
t.Fatal("expected at least 1 ranking entry")
}
tags, _ := rankings[0]["tags"].([]string)
t.Logf("tags for dev1: %v", tags)
// With only recent commits (no previous period), trend should be 100%, triggering new-star.
found := false
for _, tag := range tags {
if tag == "new-star" {
found = true
}
}
if !found {
t.Errorf("expected new-star tag, got: %v", tags)
}
}

View File

@ -0,0 +1,31 @@
package rules
import "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
// HealthDispatchRule routes "gitlink-health" skill calls to the correct engine
// based on step name and upstream data shape.
func HealthDispatchRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
// contributor-ranking: from contributor_growth workflow.
if stepName == "contributor-ranking" {
return ContributorRankingRule(upstream, stepName)
}
// repo-health: from multi_repo workflow.
if stepName == "repo-health" {
return HealthReportRule(upstream, stepName)
}
// health-report: from community_ops workflow.
if stepName == "health-report" {
return HealthReportRule(upstream, stepName)
}
// Default: inspect upstream shape to decide.
// If upstream has "members" and "merged-prs" but no "repo-info", it's contributor ranking.
_, hasRepoInfo := upstream["repo-info"]
_, hasMembers := upstream["members"]
if hasMembers && !hasRepoInfo {
return ContributorRankingRule(upstream, stepName)
}
return HealthReportRule(upstream, stepName)
}

View File

@ -0,0 +1,413 @@
package rules
import (
"fmt"
"math"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// HealthReportRule computes a 4-dimension weighted health score.
func HealthReportRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
repoInfo := extractFirst(upstream, "repo-info")
mergedPRs := extractPRs(upstream, "merged-prs")
commits := extractCommits(upstream)
openIssues := extractIssues(upstream, "open-issues")
// Compute raw metrics.
totalIssues := len(openIssues) // approximation
totalPRs := len(mergedPRs)
now := time.Now()
recentCommits := countRecent(commits, now, 30)
releaseCount := 0
if repoInfo != nil {
if v, ok := repoInfo["release_count"].(float64); ok {
releaseCount = int(v)
}
}
// Dimension 1: Issue Health (30%)
issueScore := scoreIssueHealth(totalIssues, openIssues, now)
// Dimension 2: PR Health (30%)
prScore := scorePRHealth(totalPRs, mergedPRs, now)
// Dimension 3: Contributor Health (20%)
contributorScore := scoreContributorHealth(commits, now)
// Dimension 4: Activity (20%)
activityScore := scoreActivity(recentCommits, releaseCount, repoInfo)
// Composite score.
composite := issueScore*0.30 + prScore*0.30 + contributorScore*0.20 + activityScore*0.20
analysis := map[string]interface{}{
"title": "项目健康度报告",
"composite": math.Round(composite*10) / 10,
"grade": grade(composite),
"dimensions": map[string]interface{}{
"issue_health": map[string]interface{}{
"score": math.Round(issueScore*10) / 10,
"weight": 0.30,
"grade": grade(issueScore),
},
"pr_health": map[string]interface{}{
"score": math.Round(prScore*10) / 10,
"weight": 0.30,
"grade": grade(prScore),
},
"contributor_health": map[string]interface{}{
"score": math.Round(contributorScore*10) / 10,
"weight": 0.20,
"grade": grade(contributorScore),
},
"activity": map[string]interface{}{
"score": math.Round(activityScore*10) / 10,
"weight": 0.20,
"grade": grade(activityScore),
"recent_commits": recentCommits,
"releases": releaseCount,
},
},
}
// Build wiki page action to publish the report.
body := buildHealthReportMarkdown(analysis, upstream)
pageName := fmt.Sprintf("健康度报告-%s", now.Format("2006-01-02"))
actions := []workflow.AIAction{
{
Type: "cli",
Module: "wiki",
Command: "+create",
Args: map[string]string{
"name": pageName,
"content": body,
"message": "自动生成项目健康度报告",
},
},
{
Type: "cli",
Module: "wiki",
Command: "+update",
Args: map[string]string{
"name": pageName,
"content": body,
"message": "自动更新项目健康度报告",
},
},
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
// buildHealthReportMarkdown renders the analysis and upstream data as a markdown report.
func buildHealthReportMarkdown(analysis map[string]interface{}, upstream map[string]interface{}) string {
var b strings.Builder
now := time.Now()
b.WriteString("# 项目健康度报告\n\n")
fmt.Fprintf(&b, "> 报告生成时间:%s\n\n", now.Format("2006-01-02 15:04"))
composite, _ := analysis["composite"].(float64)
grade, _ := analysis["grade"].(string)
fmt.Fprintf(&b, "## 总体评分:%.1f 分(%s\n\n", composite, grade)
dims, _ := analysis["dimensions"].(map[string]interface{})
b.WriteString("| 维度 | 得分 | 权重 | 等级 |\n")
b.WriteString("|------|------|------|------|\n")
dimDefs := []struct{ key, label string }{
{"issue_health", "Issue 健康度"},
{"pr_health", "PR 健康度"},
{"contributor_health", "贡献者活跃度"},
{"activity", "项目活跃度"},
}
for _, d := range dimDefs {
if dim, ok := dims[d.key].(map[string]interface{}); ok {
score, _ := dim["score"].(float64)
weight, _ := dim["weight"].(float64)
g, _ := dim["grade"].(string)
fmt.Fprintf(&b, "| %s | %.1f | %.0f%% | %s |\n", d.label, score, weight*100, g)
}
}
fmt.Fprintf(&b, "| **综合评分** | **%.1f** | — | **%s** |\n\n", composite, grade)
b.WriteString("等级标准:优秀(≥80) | 良好(60-79) | 需改进(<60)\n\n")
// Detail sections
b.WriteString("## 各项指标详情\n\n")
// Issue health
b.WriteString("### Issue 健康度\n\n")
openIssues := extractIssues(upstream, "open-issues")
fmt.Fprintf(&b, "- 开放 Issue 数:%d\n", len(openIssues))
staleCount := 0
for _, iss := range openIssues {
ts := issueTimestamp(iss)
if ts == "" {
continue
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
continue
}
if now.Sub(t) > 30*24*time.Hour {
staleCount++
}
}
fmt.Fprintf(&b, "- 超 30 天未关闭 Issue 数:%d\n\n", staleCount)
// PR health
b.WriteString("### PR 健康度\n\n")
mergedPRs := extractPRs(upstream, "merged-prs")
fmt.Fprintf(&b, "- 已合并 PR 数:%d\n", len(mergedPRs))
var totalHours float64
prCount := 0
for _, pr := range mergedPRs {
created := prTimestamp(pr)
merged := str(pr, "merged_at")
if created == "" || merged == "" {
continue
}
ct, err1 := time.Parse(time.RFC3339, created)
mt, err2 := time.Parse(time.RFC3339, merged)
if err1 != nil || err2 != nil {
continue
}
totalHours += mt.Sub(ct).Hours()
prCount++
}
if prCount > 0 {
fmt.Fprintf(&b, "- 平均合并耗时:%.1f 天\n\n", totalHours/float64(prCount)/24)
} else {
b.WriteString("- 平均合并耗时N/A\n\n")
}
// Contributor health
b.WriteString("### 贡献者活跃度\n\n")
commits := extractCommits(upstream)
authors := map[string]bool{}
for _, c := range commits {
ts := commitTimestamp(c)
if ts == "" {
continue
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
continue
}
if now.Sub(t) <= 30*24*time.Hour {
authors[authorLogin(c)] = true
}
}
fmt.Fprintf(&b, "- 近 30 天活跃贡献者:%d 人\n\n", len(authors))
// Activity
b.WriteString("### 项目活跃度\n\n")
recentCommits := countRecent(commits, now, 30)
releaseCount := 0
if repoInfo := extractFirst(upstream, "repo-info"); repoInfo != nil {
if v, ok := repoInfo["release_count"].(float64); ok {
releaseCount = int(v)
}
}
fmt.Fprintf(&b, "- 近 30 天提交数:%d\n", recentCommits)
fmt.Fprintf(&b, "- 发行版本数:%d\n\n", releaseCount)
// Improvement suggestions
b.WriteString("## 改进建议\n\n")
suggestions := []string{}
if composite < 60 {
suggestions = append(suggestions, "- 项目整体健康度较低,建议重点关注以下改进方向")
}
if dim, ok := dims["issue_health"].(map[string]interface{}); ok {
if score, _ := dim["score"].(float64); score < 60 {
suggestions = append(suggestions, "- **Issue 管理**:及时关闭已解决的 Issue减少超 30 天未响应的 Issue 堆积")
}
}
if dim, ok := dims["pr_health"].(map[string]interface{}); ok {
if score, _ := dim["score"].(float64); score < 60 {
suggestions = append(suggestions, "- **PR 审查**:加快 PR Review 速度,目标将平均合并时间控制在 3 天以内")
}
}
if dim, ok := dims["contributor_health"].(map[string]interface{}); ok {
if score, _ := dim["score"].(float64); score < 60 {
suggestions = append(suggestions, "- **社区建设**:吸引更多贡献者参与项目,可以标记 good-first-issue 降低新贡献者参与门槛")
}
}
if dim, ok := dims["activity"].(map[string]interface{}); ok {
if score, _ := dim["score"].(float64); score < 60 {
suggestions = append(suggestions, "- **项目活跃度**:保持定期提交和版本发布节奏,增加项目可见度")
}
}
if len(suggestions) == 0 {
suggestions = append(suggestions, "- 项目整体健康度良好,继续保持当前节奏")
}
for _, s := range suggestions {
b.WriteString(s)
b.WriteString("\n")
}
b.WriteString("\n> 由 community-ops 工作流自动生成\n")
return b.String()
}
func scoreIssueHealth(total int, openIssues []map[string]interface{}, now time.Time) float64 {
if total == 0 {
return 80 // neutral
}
// Stale issues: open for >30 days.
stale := 0
for _, iss := range openIssues {
ts := issueTimestamp(iss)
if ts == "" {
continue
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
continue
}
if now.Sub(t) > 30*24*time.Hour {
stale++
}
}
ratio := float64(stale) / float64(max(total, 1))
score := (1 - ratio) * 100
return clamp(score)
}
func scorePRHealth(total int, mergedPRs []map[string]interface{}, now time.Time) float64 {
if total == 0 {
return 80 // neutral
}
// Avg merge time from creation.
var totalHours float64
count := 0
for _, pr := range mergedPRs {
created := prTimestamp(pr)
merged := str(pr, "merged_at")
if created == "" || merged == "" {
continue
}
ct, err1 := time.Parse(time.RFC3339, created)
mt, err2 := time.Parse(time.RFC3339, merged)
if err1 != nil || err2 != nil {
continue
}
totalHours += mt.Sub(ct).Hours()
count++
}
if count == 0 {
return 80
}
avgDays := totalHours / float64(count) / 24
// <3 days = excellent (100), 3-7 = good (80), >7 = needs improvement (50).
if avgDays < 3 {
return 100
} else if avgDays < 7 {
return 80
}
return 50
}
func scoreContributorHealth(commits []map[string]interface{}, now time.Time) float64 {
// Unique authors in last 30 days.
authors := map[string]bool{}
for _, c := range commits {
ts := commitTimestamp(c)
if ts == "" {
continue
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
continue
}
if now.Sub(t) <= 30*24*time.Hour {
authors[authorLogin(c)] = true
}
}
n := len(authors)
// >3 active = 100, 1-3 = 60, 0 = 30.
if n > 3 {
return 100
} else if n >= 1 {
return 60
}
return 30
}
func scoreActivity(recentCommits int, releaseCount int, repoInfo map[string]interface{}) float64 {
score := 0.0
if recentCommits >= 10 {
score += 50
} else if recentCommits > 0 {
score += float64(recentCommits) / 10 * 50
}
if releaseCount >= 3 {
score += 50
} else if releaseCount > 0 {
score += float64(releaseCount) / 3 * 50
}
if score == 0 {
score = 30 // bare minimum if repo exists
}
return score
}
func countRecent(commits []map[string]interface{}, now time.Time, days int) int {
n := 0
for _, c := range commits {
ts := commitTimestamp(c)
if ts == "" {
continue
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
continue
}
if now.Sub(t) <= time.Duration(days)*24*time.Hour {
n++
}
}
return n
}
// extractFirst returns the first map from upstream by key (some upstream data is wrapped).
func extractFirst(upstream map[string]interface{}, key string) map[string]interface{} {
list := extractList(upstream, key)
if len(list) > 0 {
return list[0]
}
// Try direct map — key may contain the envelope {ok, data, ...}.
if m, ok := upstream[key].(map[string]interface{}); ok {
// Unwrap envelope if present.
if data, ok := m["data"]; ok {
if inner, ok := data.(map[string]interface{}); ok {
return inner
}
}
return m
}
return nil
}
func grade(score float64) string {
if score >= 80 {
return "优秀"
} else if score >= 60 {
return "良好"
}
return "需改进"
}
func clamp(v float64) float64 {
if v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}

View File

@ -0,0 +1,94 @@
package rules
import (
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestHealthReportScoring(t *testing.T) {
upstream := map[string]interface{}{
"repo-info": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"description": "test repo", "open_issues_count": 5, "release_count": 2},
},
},
"merged-prs": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"created_at": "2025-01-01T00:00:00Z",
"merged_at": "2025-01-03T00:00:00Z",
"author": "dev1",
},
},
},
"commits": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"created_at": "2025-06-01T00:00:00Z", "author": "dev1"},
map[string]interface{}{"created_at": "2025-06-05T00:00:00Z", "author": "dev1"},
map[string]interface{}{"created_at": "2025-06-10T00:00:00Z", "author": "dev2"},
map[string]interface{}{"created_at": "2025-06-15T00:00:00Z", "author": "dev3"},
},
},
"open-issues": map[string]interface{}{
"data": []interface{}{},
},
}
resp, err := HealthReportRule(upstream, "health-report")
if err != nil {
t.Fatalf("HealthReportRule failed: %v", err)
}
if resp.Analysis == nil {
t.Fatal("expected non-nil Analysis")
}
analysis := resp.Analysis.(map[string]interface{})
if _, ok := analysis["composite"]; !ok {
t.Fatal("missing composite score")
}
if _, ok := analysis["grade"]; !ok {
t.Fatal("missing grade")
}
if dims, ok := analysis["dimensions"].(map[string]interface{}); !ok {
t.Fatal("missing dimensions")
} else {
for _, dim := range []string{"issue_health", "pr_health", "contributor_health", "activity"} {
if _, ok := dims[dim]; !ok {
t.Errorf("missing dimension: %s", dim)
}
}
}
if len(resp.Actions) != 2 {
t.Fatalf("expected 2 wiki actions (create + update), got %d", len(resp.Actions))
}
action := resp.Actions[0]
if action.Type != "cli" || action.Module != "wiki" || action.Command != "+create" {
t.Fatalf("expected wiki +create action, got %s %s %s", action.Type, action.Module, action.Command)
}
if action.Args["name"] == "" {
t.Fatal("expected non-empty wiki page name")
}
if action.Args["content"] == "" {
t.Fatal("expected non-empty wiki page content")
}
}
func TestHealthReportNoData(t *testing.T) {
resp, err := HealthReportRule(map[string]interface{}{}, "health-report")
if err != nil {
t.Fatalf("HealthReportRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
// Should still produce a score (neutral defaults).
if v := analysis["composite"]; v == nil {
t.Fatal("expected composite score even with no data")
}
}
func TestHealthReportOutputFormat(t *testing.T) {
resp, err := HealthReportRule(map[string]interface{}{}, "health-report")
if err != nil {
t.Fatalf("HealthReportRule failed: %v", err)
}
var _ *workflow.AIResponse = resp
}

View File

@ -0,0 +1,319 @@
package rules
import (
"crypto/md5"
"encoding/base64"
"fmt"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
const mitLicense = `MIT License
Copyright (c) %d
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
`
const gitignoreGo = `# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
bin/
dist/
# Test binary
*.test
# Output of go coverage
*.out
# Go workspace
go.work
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.local
`
type labelDef struct{ Name, Color string }
var defaultLabels = []labelDef{
{"bug", "#d73a4a"},
{"enhancement", "#a2eeef"},
{"documentation", "#0075ca"},
{"good first issue", "#7057ff"},
{"question", "#d876e3"},
{"duplicate", "#cfd3d7"},
{"wontfix", "#ffffff"},
}
var defaultIssueTemplates = []struct {
Title string
Body string
}{
{
"项目初始化",
"# 项目初始化\n\n完成仓库基本配置和代码框架搭建。\n\n- [ ] README 文档\n- [ ] LICENSE 文件\n- [ ] .gitignore 配置\n- [ ] CI/CD 流水线",
},
{
"代码框架搭建",
"# 代码框架搭建\n\n搭建项目基本目录结构和核心代码框架。\n\n- [ ] 项目目录结构\n- [ ] 入口文件\n- [ ] 核心模块骨架",
},
{
"首个版本发布 v0.1.0",
"# v0.1.0 发布准备\n\n完成首个可用版本的开发和测试。\n\n- [ ] 核心功能开发\n- [ ] 单元测试\n- [ ] 发布说明",
},
}
// InitScaffoldRule creates a new repository and initializes it with standard
// project scaffolding based on a user-supplied description.
//
// Upstream keys used:
//
// _desc — project description (generates repo name + README)
// _repo — explicit repo name (overrides auto-generation)
// _owner — repository owner
func InitScaffoldRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
desc := str(upstream, "_desc")
owner := str(upstream, "_owner")
repo := str(upstream, "_repo")
if owner == "" {
return nil, fmt.Errorf("missing _owner in upstream")
}
// Generate repo name from description if not explicitly provided.
if repo == "" && desc != "" {
repo = deriveRepoName(desc)
}
if repo == "" {
repo = "new-project"
}
// Generate README from description.
readme := fmt.Sprintf("# %s\n\n%s\n", repo, desc)
if desc == "" {
readme = fmt.Sprintf("# %s\n\nProject description.\n", repo)
}
projectDesc := desc
if projectDesc == "" {
projectDesc = repo
}
var actions []workflow.AIAction
// 1. Create the repository via CLI (handles user_id resolution internally).
actions = append(actions, workflow.AIAction{
Type: "cli", Module: "repo", Command: "+create",
Args: map[string]string{
"name": repo,
"description": projectDesc,
},
})
// 2. README.md
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{base}/create_file",
Body: map[string]interface{}{
"filepath": "README.md",
"content": base64.StdEncoding.EncodeToString([]byte(readme)),
"message": "docs: add README.md",
"branch": "master",
},
})
// 3. LICENSE (MIT)
license := fmt.Sprintf(mitLicense, time.Now().Year())
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{base}/create_file",
Body: map[string]interface{}{
"filepath": "LICENSE",
"content": base64.StdEncoding.EncodeToString([]byte(license)),
"message": "docs: add MIT LICENSE",
"branch": "master",
},
})
// 4. .gitignore
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{base}/create_file",
Body: map[string]interface{}{
"filepath": ".gitignore",
"content": base64.StdEncoding.EncodeToString([]byte(gitignoreGo)),
"message": "chore: add .gitignore",
"branch": "master",
},
})
// 5. Default labels.
for _, l := range defaultLabels {
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{v1}/issue_tags",
Body: map[string]interface{}{
"name": l.Name,
"color": l.Color,
},
})
}
// 6. Initial milestone: v0.1.0, due 3 months from now.
due := time.Now().AddDate(0, 3, 0).Format("2006-01-02")
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{v1}/milestones",
Body: map[string]interface{}{
"name": "v0.1.0",
"description": "首个版本发布",
"effective_date": due,
},
})
// 7. Initial issues.
for _, tpl := range defaultIssueTemplates {
actions = append(actions, workflow.AIAction{
Type: "api", Method: "POST",
Path: "{v1}/issues",
Body: map[string]interface{}{
"subject": tpl.Title,
"description": tpl.Body,
"status_id": 1, // open
"priority_id": 2, // normal
"done_ratio": 0,
},
})
}
analysis := map[string]interface{}{
"repo": fmt.Sprintf("%s/%s", owner, repo),
"description": projectDesc,
"files_created": 3,
"labels_created": len(defaultLabels),
"milestones_created": 1,
"issues_created": len(defaultIssueTemplates),
"summary": fmt.Sprintf(
"仓库 %s/%s 创建完成:%d 个文件,%d 个标签,%d 个里程碑,%d 个 Issue",
owner, repo, 3, len(defaultLabels), 1, len(defaultIssueTemplates),
),
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
// deriveRepoName generates a short ASCII repo name from a description.
// GitLink only allows ASCII letters, digits, underscores, hyphens, and dots
// in repo identifiers — Chinese characters are rejected by the API.
func deriveRepoName(desc string) string {
// 1. Extract English words first (handles mixed Chinese-English descriptions).
engWords := extractEnglishWords(desc)
if len(engWords) >= 2 {
return strings.ToLower(strings.Join(engWords[:min(3, len(engWords))], "-"))
}
if len(engWords) == 1 {
return strings.ToLower(engWords[0])
}
// 2. Strip non-ASCII characters, then sanitize what remains.
ascii := strings.Map(func(r rune) rune {
if r < 128 {
return r
}
return -1
}, desc)
ascii = strings.TrimSpace(ascii)
ascii = strings.ToLower(ascii)
ascii = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(ascii, "-")
ascii = strings.Trim(ascii, "-")
if len(ascii) >= 2 {
if len(ascii) > 30 {
ascii = ascii[:30]
}
return ascii
}
// 3. No usable ASCII content — use a stable hash-based name.
h := md5.Sum([]byte(desc))
return fmt.Sprintf("project-%x", h[:4])
}
func extractEnglishWords(s string) []string {
re := regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9]*`)
words := re.FindAllString(s, -1)
// Filter out common stop words.
stop := map[string]bool{
"a": true, "an": true, "the": true, "is": true, "are": true,
"for": true, "of": true, "to": true, "in": true, "and": true,
"or": true, "it": true, "on": true, "at": true, "by": true,
}
var result []string
for _, w := range words {
if len(w) >= 2 && !stop[strings.ToLower(w)] {
result = append(result, w)
}
}
return result
}
func extractChinese(s string) string {
var result []rune
for _, r := range s {
if unicode.Is(unicode.Han, r) {
result = append(result, r)
}
}
if len(result) == 0 {
return ""
}
// Return at most 10 Chinese characters.
if len(result) > 10 {
result = result[:10]
}
return string(result)
}
func min(a, b int) int {
_ = utf8.RuneLen('a') // ensure unicode/utf8 import is used
if a < b {
return a
}
return b
}

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