Compare commits

..

50 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
195 changed files with 25387 additions and 2316 deletions

View File

@ -21,7 +21,7 @@ workflow:
ssh_port: '"22"'
ssh_user: '"root"'
ssh_cmd: >-
"cd /opt/gitlink-cli && git fetch origin && git reset --hard origin/master && 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"
"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

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

View File

@ -1,279 +0,0 @@
# GitLink-CLI 项目状态总结
> 最后更新2026-06-03
> 仓库:`D:\自用\self\word\大三下\软件演化\gitlink-cli`
> 远程:`https://gitlink.org.cn/chroe/gitlink-cli.git`
---
## 一、课程任务背景
**课程**:《软件演化与运维》课程实践
**进阶任务**GitLink 智能化能力提升项目
**当前阶段**:子任务一 — 增加和完善 GitLink-CLI 能力50%,截止 6月4日
### 子任务一交付要求
- 向 gitlink-cli 主仓库提交 PR可多个
- 每个 PR 包含:功能代码 + 单元测试 + 命令帮助文档更新
- 提供变更说明文档
- 撰写《软件分析及建模报告》、《新需求构思报告》、《变更影响分析及测试报告》
---
## 二、技术栈与架构
- **语言**Go 1.26.1
- **CLI 框架**spf13/cobra
- **密钥存储**zalando/go-keyringOS keychain + 文件 fallback
- **配置**gopkg.in/yaml.v3存放于 `~/.config/gitlink-cli/config.yaml`
- **三层命令架构**
1. **基础命令**cmd/`auth`, `api`, `config`, `version`
2. **Shortcut 命令**shortcuts/`repo +list`, `issue +create` 等 94 个命令
3. **Raw API**`api GET /path`
### Shortcut 模块开发模式
每个模块位于 `shortcuts/<name>/<name>.go`,结构固定:
```go
package <name>
import "github.com/gitlink-org/gitlink-cli/shortcuts/common"
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "Description here",
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { return err }
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/something", nil)
if err != nil { return err }
return ctx.Output(env)
},
},
}
}
```
注册到 `shortcuts/register.go`
```go
import "github.com/gitlink-org/gitlink-cli/shortcuts/<name>"
// 在 RegisterAll() 中添加:
// "<name>": <name>.Shortcuts(),
// descriptions["<name>"] = "Description",
```
### 单元测试模式httptest mock
```go
func TestXxx(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 校验 method/path/query返回 mock JSON
}))
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner", Repo: "repo", Format: "json", Args: args,
}
err := findShortcut(t, "list").Run(ctx)
// 断言
}
```
---
## 三、已实现的 18 个 Shortcut 模块94 个命令)
| 模块 | 命令数 | 文件 | 测试 |
|------|--------|------|------|
| repo | 9含 batch-delete/fork | shortcuts/repo/ | ✅ |
| issue | 12含 batch-close/assign/label/milestone | shortcuts/issue/ | ✅ |
| pr | 9 | shortcuts/pr/ | ✅ |
| release | 5 | shortcuts/release/ | ✅ |
| branch | 5 | shortcuts/branch/ | ✅ |
| ci | 4 | shortcuts/ci/ | ✅ |
| commit | 4 | shortcuts/commit/ | ✅ |
| file | 5 | shortcuts/file/ | ✅ |
| member | 4 | shortcuts/member/ | ✅ |
| star | 3 | shortcuts/star/ | ✅ |
| watch | 3 | shortcuts/watch/ | ✅ |
| label | 4 | shortcuts/label/ | ✅ |
| milestone | 6 | shortcuts/milestone/ | ✅ |
| webhook | 6 | shortcuts/webhook/ | ✅ |
| org | 5含 batch-invite | shortcuts/org/ | ✅ |
| search | 3 | shortcuts/search/ | ✅ |
| user | 2 | shortcuts/user/ | ✅ |
| wiki 🆕 | 5 | shortcuts/wiki/ | ✅ 12个测试 |
### 批量操作汇总7个
| 批量命令 | 所属模块 | 输入方式 | 预览模式 |
|----------|----------|----------|----------|
| batch-close | issue | --numbers 逗号 / --from CSV | --dry-run |
| batch-assign | issue | 同上 | --dry-run |
| batch-label | issue | 同上 | --dry-run |
| batch-milestone | issue | 同上 | --dry-run |
| batch-delete | repo | --repos owner/repo 列表 / --from CSV | --dry-run |
| batch-fork | repo | 同上 | --dry-run |
| batch-invite | org | --users 用户名列表 / --from CSV | --dry-run |
---
## 四、Wiki 模块开发记录
### Wiki API 关键发现
| 发现 | 说明 |
|------|------|
| API 域名 | `gateway.gitlink.org.cn` 而非 `www.gitlink.org.cn` |
| API 路径 | `/wiki/open/...`(有 `/open/` 中间段) |
| 无 `.json` 后缀 | Gateway 不接受 `.json`,需要 `client.DoRaw()` |
| Sidebar 名称 | `_Sidebar`(大写 S非小写 `_sidebar` |
| projectId 字段 | 仓库返回 `project_id` 而非 `id` |
| create/update 需要 `title` | API 必填字段 |
| delete 异步重建 sidebar | 需延迟 2 秒后再清理 sidebar |
| `data.data` 格式 | `DoRaw` 返回 dict 而非 string解析需兼容 |
### delete 命令的 sidebar 自动清理流程
1. 删除 wiki 页面DELETE
2. 等待 2 秒(让 GitLink 完成异步 sidebar 重建)
3. 获取 `_Sidebar` 内容
4. 移除已删页面的 `[[PageName]]` 链接
5. 更新 `_Sidebar`
---
## 五、开发踩坑记录
### 修改 CLI 代码后必须同时重建 showcase
showcase 通过 `findCLIBinary()` 在运行时查找 `gitlink-cli.exe`。每次修改 CLI 代码后需要:
```bash
cd gitlink-cli
go build -o gitlink-cli.exe . # 重建 CLI
cd showcase && go build -o showcase.exe . # 重建 showcase更新嵌入的 HTML
# 然后重启 showcase.exe
```
如果只重建 showcase 不重建 CLIshowcase 用的还是旧版二进制。
### Showcase 参数传递坑
showcase 后端曾用 `strings.Split(args, " ")` 按空格切割参数,导致 `"Hello Wiki!"` 被截断为 `"Hello"`
修复:前端给含空格的值加引号,后端用支持引号解析的 `parseShellArgs()` 替代简单 Split。
### Showcase 布尔参数坑(已修复)
Cobra 的 BoolP 标志:`--dry-run false`(空格分隔)会被解析为 `--dry-run=true` + 多余参数 "false"。
所以批量操作 `--dry-run false` 实际上是在预览模式下运行,不会真正执行。
修复:布尔参数改用 `<select>` 下拉框JS 只在选"是"时才传 `--flag`,选"否"时不传。
### Showcase 重复参数坑(已修复)
服务器后端自动添加 `--owner chroe --repo gitlink-cli`。如果前端卡片也传 `--owner`/`--repo`
会导致参数重复。修复watch +watchers 和 star +stars 移除了多余的 owner/repo 输入框。
### Showcase null 显示(已修复)
CLI 命令返回空数据时,前端显示 "null"。修复JS 层对 null/空对象/空字符串统一显示"操作完成,无返回数据"。
---
## 六、CI/CD 流水线
### 部署架构
```
代码 push → GitLink 触发流水线 → SSH 到服务器 → git fetch + reset → docker build → 重启容器
```
### 流水线文件
- `.devops/构建部署Showcase.yml` — GitLink DevOps 流水线push 自动触发)
### 流水线关键配置
- 使用 `git fetch + git reset --hard origin/master` 避免 git pull 的本地修改冲突
- 使用 `docker build --no-cache` 确保每次用最新代码构建
- 容器启动时注入 `-e GITLINK_TOKEN=cookie:autologin_trustie=...` 解决认证问题
- GitLink 密钥管理:`deploy_server.server_password` = 服务器密码
### 服务器信息
- **IP**: 118.31.4.168
- **用户**: root
- **部署路径**: /opt/gitlink-cli
- **容器名**: gitlink-cli-showcase
- **端口**: 9090
- **访问地址**: http://118.31.4.168:9090
- **Showcase 展示模块数**: 12 个模块卡片milestone/webhook/label/commit/wiki/file/member/watch/star/issue批量/repo批量/org批量
### Docker 构建注意
Dockerfile 已配置 `ENV GOPROXY=https://goproxy.cn,direct`,解决国内网络 Go 模块下载问题。
服务器 Docker daemon 已配置国内镜像加速器(`/etc/docker/daemon.json`)。
---
## 七、已完成工作
### ✅ 子任务一完成项
- [x] 基础命令框架auth/api/config/version
- [x] 18 个 Shortcut 模块94 个命令),其中 wiki 为新增
- [x] 7 个批量操作命令batch-close/assign/label/milestone/delete/fork/invite
- [x] 12 个 AI Agent Skills
- [x] Showcase Dashboard 在线展示12 个模块卡片,支持真实运行)
- [x] GitHub CI/CDrelease + npm publish + FreeBSD 支持)
- [x] GitLink DevOps 流水线push 自动部署,已配置密钥,正常运行)
- [x] 单元测试wiki 12个 + 其他模块 47+
- [x] client.DoRaw 方法(不带 .json 后缀的原始 API 调用)
- [x] Showcase 布尔参数/重复参数/null显示 修复
### 待写文档 📄
- [ ] 《软件分析及建模报告》
- [ ] 《新需求构思报告》
- [ ] 《变更影响分析及测试报告》
---
## 八、小组分工
| 角色 | 负责人 | 负责内容 |
|------|--------|----------|
| 组长 A | — | commit(4命令)、milestone(6)、webhook(6)、label(4)、Showcase |
| 同学 B | — | file(5)、member(4)、watch(3)、star(3) |
| 同学 C | — | 批量操作增强(batch-assign/label/milestone/delete/fork/invite)、table 输出优化、测试覆盖、FreeBSD构建 |
| 全员 | — | wiki(5命令)、流水线部署 |
---
## 九、快速开始
```bash
# 构建
cd gitlink-cli && make build
# 运行测试
go test ./shortcuts/... -v
# 登录
./gitlink-cli auth login
# 使用示例
./gitlink-cli repo +list --owner gitlink
./gitlink-cli issue +list --owner gitlink --repo gitlink-cli
./gitlink-cli wiki +list --owner chroe --repo gitlink_help_center
```
---
## 十、当前进度 & 下一步
### 已完成 ✅
1. 全部 18 个 Shortcut 模块 + 94 个命令
2. 7 个批量操作命令
3. Showcase 展示页12 个模块,可在线运行)
4. GitLink DevOps 流水线push 自动部署)
5. 单元测试 47+ 个
6. Showcase 各种 bug 修复布尔参数、null 显示、重复参数、member ID 提示)
### 下一步 📋
1. 向 gitlink-cli 主仓库提交 PR
2. 撰写《软件分析及建模报告》
3. 撰写《新需求构思报告》
4. 撰写《变更影响分析及测试报告》
5. 变更说明文档

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

@ -1,335 +0,0 @@
# gitlink-cli 新增功能使用指南
> 任务B — file / member / watch / star 四大模块15 个新命令
---
## 一、file — 仓库文件操作
### file +list — 列出仓库文件
```bash
gitlink-cli file +list --owner <owner> --repo <repo> [--ref <分支>] [--search <关键词>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--owner` | 否* | 仓库拥有者 |
| `--repo` | 否* | 仓库名 |
| `--ref`, `-r` | 否 | 分支/标签/commit SHA |
| `--search`, `-s` | 否 | 搜索关键词 |
\* 在 git 仓库目录下运行时自动解析,无需手动指定
**示例:**
```bash
# 列出根目录文件
gitlink-cli file +list --owner chroe --repo gitlink-cli
# 列出 dev 分支的文件
gitlink-cli file +list --owner chroe --repo gitlink-cli --ref dev
# 搜索包含 "test" 的文件
gitlink-cli file +list --owner chroe --repo gitlink-cli --search test
```
### file +tree — 查看文件树
```bash
gitlink-cli file +tree --owner <owner> --repo <repo> [--sha <分支>] [--recursive] [--page <n>] [--limit <n>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--sha`, `-s` | 否 | 分支/标签/commit默认 `master` |
| `--recursive` | 否 | 递归展开所有子目录(`true`/`false` |
| `--page`, `-p` | 否 | 页码,默认 `1` |
| `--limit`, `-l` | 否 | 每页条数,默认 `20` |
**示例:**
```bash
# 默认 tree 输出table 格式)
gitlink-cli file +tree --owner chroe --repo gitlink-cli --sha master
# 递归列出所有文件JSON 格式)
gitlink-cli file +tree --owner chroe --repo gitlink-cli --sha master --recursive true --format json
```
### file +get — 查看文件/目录内容
```bash
gitlink-cli file +get --owner <owner> --repo <repo> --path <路径> [--ref <分支>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--path`, `-p` | **是** | 文件或目录路径 |
| `--ref`, `-r` | 否 | 分支/标签/commit默认 `master` |
**示例:**
```bash
# 查看 README.md 内容
gitlink-cli file +get --owner chroe --repo gitlink-cli --path README.md
# 查看 src 目录的子条目
gitlink-cli file +get --owner chroe --repo gitlink-cli --path src
# 查看 dev 分支上的文件
gitlink-cli file +get --owner chroe --repo gitlink-cli --path main.go --ref dev
```
### file +create — 新建文件
```bash
gitlink-cli file +create --owner <owner> --repo <repo> --path <路径> --content <内容> --message <提交信息> [--branch <分支>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--path`, `-p` | **是** | 文件路径(含文件名) |
| `--content`, `-c` | **是** | 文件内容(明文,自动 Base64 编码) |
| `--message`, `-m` | **是** | Git 提交信息 |
| `--branch`, `-b` | 否 | 目标分支,默认 `master` |
**示例:**
```bash
gitlink-cli file +create \
--owner caoweiqiong --repo Aether \
--path docs/readme.txt \
--content "Hello GitLink!" \
--message "添加文档" \
--branch master
```
### file +delete — 删除文件
```bash
gitlink-cli file +delete --owner <owner> --repo <repo> --path <路径> --sha <BlobSHA> --message <提交信息> [--branch <分支>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--path`, `-p` | **是** | 要删除的文件路径 |
| `--sha`, `-s` | **是** | 文件的 blob SHA`file +list` 获取) |
| `--message`, `-m` | **是** | Git 提交信息 |
| `--branch`, `-b` | 否 | 目标分支,默认 `master` |
**示例:**
```bash
# 先列出文件获取 SHA
gitlink-cli file +list --owner caoweiqiong --repo Aether
# 然后删除指定文件
gitlink-cli file +delete \
--owner caoweiqiong --repo Aether \
--path docs/old.txt \
--sha abc123def456 \
--message "删除过期文档"
```
---
## 二、member — 项目成员管理
### member +list — 列出成员
```bash
gitlink-cli member +list --owner <owner> --repo <repo> [--keyword <搜索>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--keyword`, `-k` | 否 | 按用户名搜索成员 |
**示例:**
```bash
gitlink-cli member +list --owner chroe --repo gitlink-cli --format table
# 搜索特定成员
gitlink-cli member +list --owner chroe --repo gitlink-cli --keyword caoweiqiong
```
输出示例:
```
id login role_name
-- ----- ---------
149027 chroe Manager
141645 caoweiqiong Developer
148915 yetja Developer
```
### member +add — 添加成员
```bash
gitlink-cli member +add --owner <owner> --repo <repo> --user-id <数字ID>
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--user-id`, `-u` | **是** | 用户的数字 ID`user +info` 获取) |
**示例:**
```bash
# 先查到目标用户的 user_id
gitlink-cli user +info --login zhangsan
# 然后添加
gitlink-cli member +add --owner caoweiqiong --repo Aether --user-id 123456
```
### member +remove — 移除成员
```bash
gitlink-cli member +remove --owner <owner> --repo <repo> --user-id <数字ID>
```
**示例:**
```bash
gitlink-cli member +remove --owner caoweiqiong --repo Aether --user-id 123456
```
### member +update — 修改成员角色
```bash
gitlink-cli member +update --owner <owner> --repo <repo> --user-id <数字ID> --role <角色>
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--user-id`, `-u` | **是** | 用户数字 ID |
| `--role`, `-r` | **是** | 三种角色之一:`Manager` / `Developer` / `Reporter` |
**示例:**
```bash
gitlink-cli member +update --owner caoweiqiong --repo Aether --user-id 123456 --role Developer
```
---
## 三、watch — 关注仓库
### watch +watch — 关注仓库
```bash
gitlink-cli watch +watch --owner <owner> --repo <repo>
```
无需手动传 project-id命令内部自动解析。
**示例:**
```bash
gitlink-cli watch +watch --owner chroe --repo gitlink-cli
# → 返回 {"watched": true}
```
### watch +unwatch — 取消关注
```bash
gitlink-cli watch +unwatch --owner <owner> --repo <repo>
```
**示例:**
```bash
gitlink-cli watch +unwatch --owner chroe --repo gitlink-cli
# → 返回 {"watched": false}
```
### watch +watchers — 查看关注者列表
```bash
gitlink-cli watch +watchers --owner <owner> --repo <repo>
```
**示例:**
```bash
gitlink-cli watch +watchers --owner chroe --repo gitlink-cli --format table
```
---
## 四、star — 点赞仓库
### star +star — 点赞仓库
```bash
gitlink-cli star +star --owner <owner> --repo <repo>
```
无需手动传 project-id命令内部自动解析。
**示例:**
```bash
gitlink-cli star +star --owner chroe --repo gitlink-cli
```
### star +unstar — 取消点赞
```bash
gitlink-cli star +unstar --owner <owner> --repo <repo>
```
**示例:**
```bash
gitlink-cli star +unstar --owner chroe --repo gitlink-cli
```
### star +stars — 查看点赞者列表
```bash
gitlink-cli star +stars --owner <owner> --repo <repo>
```
**示例:**
```bash
gitlink-cli star +stars --owner chroe --repo gitlink-cli --format table
```
---
## 全局参数
所有命令都支持以下全局参数:
| 参数 | 说明 | 示例 |
|------|------|------|
| `--owner` | 仓库拥有者git 目录下自动解析) | `--owner chroe` |
| `--repo` | 仓库名git 目录下自动解析) | `--repo gitlink-cli` |
| `--format` | 输出格式:`json` / `table` / `yaml` | `--format table` |
| `--debug` | 开启调试输出 | `--debug` |
## 使用技巧
1. **利用自动解析**:在 git 克隆的目录下直接运行,无需写 `--owner``--repo`
```bash
cd my-project
gitlink-cli file +list # 自动识别当前仓库
gitlink-cli member +list # 同上
```
2. **JSON 输出用于脚本**
```bash
gitlink-cli file +tree --owner chroe --repo gitlink-cli --sha master --format json | jq '.data.entries[] | .name'
```
3. **file 创建自动 Base64**:传 `--content` 时直接写明文,命令会自动转为 Base64不用自己编码。
4. **watch/star 透明化**`watch` 和 `star` 命令会自动查 project-id你只需关心 owner/repo与其他命令体验一致。

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

@ -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

@ -14,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 {
@ -91,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
}
@ -110,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

@ -66,7 +66,19 @@ func printTable(w io.Writer, envelope *Envelope) error {
return nil
}
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{}:
@ -82,11 +94,23 @@ func printTable(w io.Writer, envelope *Envelope) error {
}
}
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",
"releases", "entries", "tags", "watchers", "results",
} {
if v, ok := m[key]; ok {
if slice, ok := v.([]interface{}); ok && len(slice) > 0 {

View File

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

View File

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

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

@ -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.

View File

@ -280,9 +280,9 @@ func assignIssue(ctx *common.RuntimeContext, number, user string) error {
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"assigned_to_id": userID,
"subject": current.Subject,
"description": current.Description,
"assigner_ids": []int64{int64(userID)},
}
if current.StatusID != nil {
body["status_id"] = current.StatusID
@ -474,6 +474,89 @@ func setMilestone(ctx *common.RuntimeContext, number, milestoneID string) error
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

View File

@ -28,6 +28,7 @@ func Shortcuts() []*common.Shortcut {
newBatchAssignShortcut(),
newBatchLabelShortcut(),
newBatchMilestoneShortcut(),
newBatchCommentShortcut(),
{
Name: "list",
Description: "List issues",
@ -257,9 +258,9 @@ func Shortcuts() []*common.Shortcut {
return fmt.Errorf("user 参数必须是数字 ID而不是用户名")
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"assigned_to_id": userID,
"subject": current.Subject,
"description": current.Description,
"assigner_ids": []int64{int64(userID)},
}
if current.StatusID != nil {
body["status_id"] = current.StatusID

View File

@ -281,16 +281,17 @@ func TestIssueComment(t *testing.T) {
}
func TestIssueAssignSendsCorrectUser(t *testing.T) {
var assignPath string
var assignPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/issues/42/assignees.json":
assignPath = r.URL.Path
assignPayload = decodeJSON(t, r)
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"message": "指派成功",
"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)
}
@ -299,16 +300,18 @@ func TestIssueAssignSendsCorrectUser(t *testing.T) {
err := runIssueShortcut(t, server, "assign", map[string]string{
"number": "42",
"user": "zhangsan",
"user": "42",
})
if err != nil {
t.Fatalf("assign shortcut failed: %v", err)
}
if assignPath == "" {
t.Fatal("assign endpoint was not called")
// 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, assignPayload["assigned_to_id"], "zhangsan")
assertEqual(t, got[0], float64(42))
}
func TestIssueAssignRequiresNumberAndUser(t *testing.T) {
@ -386,15 +389,15 @@ func TestIssueLabelRemoveSendsDeleteRequests(t *testing.T) {
}
func TestBatchAssign(t *testing.T) {
var assignPath string
var assignPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/issues/1/assignees.json":
assignPath = r.URL.Path
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 == "PUT" && r.URL.Path == "/v1/owner/repo/issues/2/assignees.json":
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)
@ -404,17 +407,18 @@ func TestBatchAssign(t *testing.T) {
err := runIssueShortcut(t, server, "batch-assign", map[string]string{
"numbers": "1,2",
"user": "zhangsan",
"user": "42",
"dry-run": "false",
})
if err != nil {
t.Fatalf("batch-assign failed: %v", err)
}
if assignPath == "" {
t.Fatal("assign endpoint was not called")
got, ok := assignPayload["assigner_ids"].([]interface{})
if !ok || len(got) != 1 {
t.Fatalf("expected assigner_ids [42], got %#v", assignPayload["assigner_ids"])
}
assertEqual(t, assignPayload["assigned_to_id"], "zhangsan")
assertEqual(t, got[0], float64(42))
}
func TestBatchAssignDryRun(t *testing.T) {

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
}

View File

@ -10,6 +10,7 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
batchAddShortcut(),
{
Name: "list",
Description: "List project members",

View File

@ -22,6 +22,9 @@ import (
"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.
@ -45,6 +48,7 @@ func RegisterAll(root *cobra.Command) {
"watch": watch.Shortcuts(),
"star": star.Shortcuts(),
"wiki": wiki.Shortcuts(),
"workflow": cli.Shortcuts(),
}
descriptions := map[string]string{
@ -66,6 +70,7 @@ func RegisterAll(root *cobra.Command) {
"watch": "Watch repository operations",
"star": "Star repository operations",
"wiki": "Wiki page operations",
"workflow": "Automated workflow operations",
}
for name, shortcuts := range groups {

View File

@ -25,20 +25,22 @@ type batchRepoSummary struct {
Results []batchRepoResult `json:"results" yaml:"results"`
}
func newBatchDeleteShortcut() *common.Shortcut {
func newBatchCreateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-delete",
Description: "批量删除仓库,支持逗号分隔列表或 CSV 文件",
Name: "batch-create",
Description: "批量创建仓库,支持逗号分隔列表或 CSV 文件",
Flags: []common.Flag{
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo例如: alice/proj1,bob/proj2"},
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo、owner,repo 双列或 owner/repo 单列格式"},
{Name: "dry-run", Usage: "仅预览将要删除的仓库,不实际执行", Bool: true, Default: "false"},
{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: runBatchDelete,
Run: runBatchCreate,
}
}
func runBatchDelete(ctx *common.RuntimeContext) error {
func runBatchCreate(ctx *common.RuntimeContext) error {
start := time.Now()
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
@ -46,24 +48,44 @@ func runBatchDelete(ctx *common.RuntimeContext) error {
return err
}
if len(repos) == 0 {
return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv")
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"))
dryRunVal := false
if ctx.Arg("dry-run") != "" {
dryRunVal = dryRun
}
_ = dryRunVal
summary := batchRepoSummary{
Total: len(repos),
Results: make([]batchRepoResult, 0, len(repos)),
Total: len(names),
Results: make([]batchRepoResult, 0, len(names)),
}
for _, repoID := range repos {
parts := strings.SplitN(repoID, "/", 2)
result := batchRepoResult{Repo: repoID, Action: "delete"}
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++
@ -71,13 +93,25 @@ func runBatchDelete(ctx *common.RuntimeContext) error {
continue
}
path := fmt.Sprintf("/%s/%s", parts[0], parts[1])
if _, err := ctx.CallAPI("DELETE", path, nil); err != nil {
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 = "deleted"
result.Status = "created"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
@ -89,7 +123,7 @@ func runBatchDelete(ctx *common.RuntimeContext) error {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个仓库删除失败", summary.Failed, summary.Total)
return fmt.Errorf("%d / %d 个仓库创建失败", summary.Failed, summary.Total)
}
return nil
}
@ -100,7 +134,7 @@ func newBatchForkShortcut() *common.Shortcut {
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 双列或 owner/repo 单列格式"},
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo 单列或 owner、repo 双列格式"},
{Name: "dry-run", Usage: "仅预览将要 Fork 的仓库,不实际执行", Bool: true, Default: "false"},
},
Run: runBatchFork,
@ -119,7 +153,6 @@ func runBatchFork(ctx *common.RuntimeContext) error {
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchRepoSummary{
Total: len(repos),
Results: make([]batchRepoResult, 0, len(repos)),
@ -158,6 +191,71 @@ func runBatchFork(ctx *common.RuntimeContext) error {
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 {
@ -171,7 +269,7 @@ func collectRepos(reposValue, csvPath string) ([]string, error) {
if err != nil {
return nil, err
}
return mergeRepoLists(repos, csvRepos), nil
return mergeRepoStrings(repos, csvRepos), nil
}
func parseRepoList(value string) ([]string, error) {
@ -198,7 +296,6 @@ func readReposFromCSV(path string) ([]string, error) {
return nil, nil
}
// Detect column layout: single owner/repo column, or owner+repo dual columns
singleCol, ownerCol, repoCol := -1, -1, -1
startRow := 0
for i, cell := range records[0] {
@ -210,14 +307,20 @@ func readReposFromCSV(path string) ([]string, error) {
ownerCol = i
startRow = 1
case "repo", "repository", "name":
repoCol = i
if repoCol == -1 {
repoCol = i
}
startRow = 1
}
}
// Fallback: no header — first column is owner/repo
if singleCol == -1 && ownerCol == -1 && repoCol == -1 {
singleCol = 0
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)
@ -243,10 +346,6 @@ func normalizeRepoIDs(values []string) ([]string, error) {
if repoID == "" {
continue
}
parts := strings.SplitN(repoID, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, fmt.Errorf("无效的仓库标识 %q: 请使用 owner/repo 格式", repoID)
}
if seen[repoID] {
continue
}
@ -256,7 +355,7 @@ func normalizeRepoIDs(values []string) ([]string, error) {
return repos, nil
}
func mergeRepoLists(values ...[]string) []string {
func mergeRepoStrings(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
for _, repos := range values {

View File

@ -12,8 +12,9 @@ import (
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchDeleteShortcut(),
newBatchCreateShortcut(),
newBatchForkShortcut(),
newBatchDeleteShortcut(),
{
Name: "list",
Description: "List repositories for a user or organization",
@ -57,24 +58,9 @@ func Shortcuts() []*common.Shortcut {
}
targetDir := ctx.Arg("dir")
var cloneURL string
var repoName string
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]
} else {
parts := strings.SplitN(rawURL, "/", 2)
if len(parts) != 2 {
return fmt.Errorf("无效的仓库格式 %q: 请使用 owner/repo 格式", rawURL)
}
webBase := strings.TrimSuffix(strings.TrimSuffix(ctx.Client.BaseURL, "/"), "/api")
cloneURL = fmt.Sprintf("%s/%s/%s.git", webBase, parts[0], parts[1])
repoName = parts[1]
cloneURL, repoName, err := parseCloneTarget(rawURL, ctx.Client.BaseURL)
if err != nil {
return err
}
if targetDir == "" {
@ -225,3 +211,30 @@ func Shortcuts() []*common.Shortcut {
},
}
}
// 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
}

View File

@ -5,8 +5,6 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@ -219,50 +217,38 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
}
}
func TestRepoCloneParsesOwnerRepoFormat(t *testing.T) {
tmpDir := t.TempDir()
fakeGit := createFakeGit(t, tmpDir)
// clone 测试直接验证 parseCloneTarget 的解析逻辑(纯函数,不依赖 git/PATH全平台可跑
const testCloneBaseURL = "https://www.gitlink.org.cn/api"
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
"url": "myorg/myrepo",
})
func TestRepoCloneParsesOwnerRepoFormat(t *testing.T) {
cloneURL, repoName, err := parseCloneTarget("myorg/myrepo", testCloneBaseURL)
if err != nil {
t.Fatalf("clone shortcut failed: %v", err)
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) {
tmpDir := t.TempDir()
fakeGit := createFakeGit(t, tmpDir)
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
"url": "https://www.gitlink.org.cn/myorg/myrepo",
})
cloneURL, repoName, err := parseCloneTarget("https://www.gitlink.org.cn/myorg/myrepo", testCloneBaseURL)
if err != nil {
t.Fatalf("clone shortcut failed: %v", err)
t.Fatalf("parseCloneTarget failed: %v", err)
}
}
func TestRepoCloneCustomTargetDir(t *testing.T) {
tmpDir := t.TempDir()
fakeGit := createFakeGit(t, tmpDir)
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
"url": "myorg/myrepo",
"dir": "/custom/dir",
})
if err != nil {
t.Fatalf("clone shortcut 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) {
tmpDir := t.TempDir()
fakeGit := createFakeGit(t, tmpDir)
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
"url": "invalidformat",
})
_, _, err := parseCloneTarget("invalidformat", testCloneBaseURL)
if err == nil {
t.Fatal("expected error for invalid format, got nil")
}
@ -272,52 +258,17 @@ func TestRepoCloneInvalidFormat(t *testing.T) {
}
func TestRepoCloneURLWithGitSuffix(t *testing.T) {
tmpDir := t.TempDir()
fakeGit := createFakeGit(t, tmpDir)
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
"url": "https://www.gitlink.org.cn/myorg/myrepo.git",
})
cloneURL, repoName, err := parseCloneTarget("https://www.gitlink.org.cn/myorg/myrepo.git", testCloneBaseURL)
if err != nil {
t.Fatalf("clone shortcut failed: %v", err)
t.Fatalf("parseCloneTarget failed: %v", err)
}
}
func createFakeGit(t *testing.T, dir string) string {
t.Helper()
fakeGitPath := filepath.Join(dir, "git")
script := `#!/bin/bash
# Record arguments for test verification
echo "git $@" > ` + filepath.Join(dir, "git_args.txt") + `
exit 0
`
if err := os.WriteFile(fakeGitPath, []byte(script), 0755); err != nil {
t.Fatalf("failed to create fake git: %v", err)
// 已带 .git 时不应重复追加
if cloneURL != "https://www.gitlink.org.cn/myorg/myrepo.git" {
t.Errorf("cloneURL = %q, want unchanged .../myorg/myrepo.git", cloneURL)
}
return fakeGitPath
}
func runRepoCloneShortcut(t *testing.T, fakeGitPath string, args map[string]string) error {
t.Helper()
shortcut := findRepoShortcut(t, "clone")
// Set up PATH to include fake git
origPath := os.Getenv("PATH")
gitDir := filepath.Dir(fakeGitPath)
os.Setenv("PATH", gitDir+":"+origPath)
defer os.Setenv("PATH", origPath)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: nil,
BaseURL: "https://www.gitlink.org.cn/api",
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
if repoName != "myrepo" {
t.Errorf("repoName = %q, want myrepo", repoName)
}
return shortcut.Run(ctx)
}
func TestRepoListWithoutUser(t *testing.T) {

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
}

View File

@ -0,0 +1,76 @@
package rules
import (
"testing"
)
func TestInitScaffoldRuleActions(t *testing.T) {
upstream := map[string]interface{}{
"_owner": "testuser",
"_repo": "test-project",
"_desc": "A test project for CI/CD",
}
resp, err := InitScaffoldRule(upstream, "init-scaffold")
if err != nil {
t.Fatalf("InitScaffoldRule failed: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
if resp.Analysis == nil {
t.Fatal("expected analysis in response")
}
// Actions: 1 repo + 3 files + 7 labels + 1 milestone + 3 issues = 15
if len(resp.Actions) != 15 {
t.Errorf("expected 15 actions, got %d", len(resp.Actions))
}
// First action should be repo creation (CLI action).
if resp.Actions[0].Type != "cli" || resp.Actions[0].Command != "+create" {
t.Errorf("first action should be cli +create for repo creation, got type=%s command=%s",
resp.Actions[0].Type, resp.Actions[0].Command)
}
analysis := resp.Analysis.(map[string]interface{})
if files := analysis["files_created"].(int); files != 3 {
t.Errorf("files_created = %d, want 3", files)
}
if labels := analysis["labels_created"].(int); labels != 7 {
t.Errorf("labels_created = %d, want 7", labels)
}
if milestones := analysis["milestones_created"].(int); milestones != 1 {
t.Errorf("milestones_created = %d, want 1", milestones)
}
if issues := analysis["issues_created"].(int); issues != 3 {
t.Errorf("issues_created = %d, want 3", issues)
}
}
func TestInitScaffoldRuleWithDescription(t *testing.T) {
upstream := map[string]interface{}{
"_owner": "testuser",
"_desc": "Docker 容器管理平台",
}
resp, err := InitScaffoldRule(upstream, "init-scaffold")
if err != nil {
t.Fatalf("InitScaffoldRule failed: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
// Repo name should be auto-generated from description.
analysis := resp.Analysis.(map[string]interface{})
repo := analysis["repo"].(string)
if repo == "" || repo == "testuser/new-project" {
t.Errorf("expected auto-generated repo name, got %q", repo)
}
}
func TestInitScaffoldRuleMissingOwner(t *testing.T) {
_, err := InitScaffoldRule(map[string]interface{}{}, "init-scaffold")
if err == nil {
t.Fatal("expected error when _owner is missing")
}
}

View File

@ -0,0 +1,217 @@
package rules
import (
"regexp"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// riskEntry describes a single license/security risk finding.
type riskEntry struct {
File string `json:"file"`
Risk string `json:"risk"`
Message string `json:"message"`
}
// LicenseCheckRule scans file lists and content for license compliance and sensitive data.
func LicenseCheckRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
files := extractList(upstream, "existing-files")
if len(files) == 0 {
files = extractList(upstream, "files")
}
var findings []riskEntry
hasLicense := false
licenseType := ""
for _, f := range files {
name := str(f, "name", "filename", "path", "file_name")
if name == "" {
continue
}
// License file detection.
if isLicenseFile(name) {
hasLicense = true
content := str(f, "content", "body", "text")
if content != "" {
licenseType = detectLicenseType(content)
}
}
// Sensitive file name detection.
for _, fp := range filePatterns {
if fp.re.MatchString(strings.ToLower(name)) {
findings = append(findings, riskEntry{
File: name,
Risk: fp.risk,
Message: fp.message,
})
}
}
// Sensitive content detection.
content := str(f, "content", "body", "text")
if content != "" {
for _, cp := range contentPatterns {
if cp.re.MatchString(content) {
// Apply exclusion rules.
matches := cp.re.FindAllString(content, -1)
for _, match := range matches {
if isPlaceholder(match) {
continue
}
findings = append(findings, riskEntry{
File: name,
Risk: "high",
Message: cp.message + " → `" + truncate(match, 40) + "`",
})
}
}
}
}
}
// Compute scores.
licenseScore := 0.0
if hasLicense {
licenseScore = 100
if licenseType != "" {
licenseScore = 100
} else {
licenseScore = 70
}
}
sensitiveScore := 100.0
highCount := 0
for _, f := range findings {
if f.Risk == "high" {
highCount++
}
}
if highCount > 0 {
sensitiveScore = max(0, 100-float64(highCount)*20)
}
composite := licenseScore*0.35 + sensitiveScore*0.40 + 50*0.15 + 50*0.10
analysis := map[string]interface{}{
"has_license": hasLicense,
"license_type": licenseType,
"license_score": licenseScore,
"sensitive_score": sensitiveScore,
"composite_score": composite,
"grade": grade(composite),
"findings": findings,
"total_findings": len(findings),
}
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
}
// --- license file detection ---
func isLicenseFile(name string) bool {
lower := strings.ToLower(name)
for _, pattern := range []string{"license", "copying", "notice", "licence"} {
if strings.Contains(lower, pattern) {
return true
}
}
return false
}
func detectLicenseType(content string) string {
for _, lp := range licensePatterns {
if lp.re.MatchString(content) {
return lp.name
}
}
return "Unknown"
}
var licensePatterns = []struct {
re *regexp.Regexp
name string
}{
{regexp.MustCompile(`(?i)MIT\s+License|Permission is hereby granted`), "MIT"},
{regexp.MustCompile(`(?i)Apache\s+License.*Version\s+2\.0|http://www\.apache\.org/licenses`), "Apache 2.0"},
{regexp.MustCompile(`(?i)GNU GENERAL PUBLIC LICENSE.*Version 3|GPL\s*v3`), "GPL v3"},
{regexp.MustCompile(`(?i)GNU GENERAL PUBLIC LICENSE.*Version 2|GPL\s*v2`), "GPL v2"},
{regexp.MustCompile(`(?i)BSD\s+(3-Clause|2-Clause|License)`), "BSD"},
{regexp.MustCompile(`(?i)Mulan\s+Permissive|木兰宽松许可证`), "Mulan PSL v2"},
{regexp.MustCompile(`(?i)Mozilla Public License|MPL`), "MPL"},
{regexp.MustCompile(`(?i)ISC\s+License`), "ISC"},
{regexp.MustCompile(`(?i)Creative Commons|CC-BY`), "Creative Commons"},
{regexp.MustCompile(`(?i)Unlicense|public\s+domain`), "Unlicense"},
}
// --- file pattern scanning ---
type fileRiskPattern struct {
re *regexp.Regexp
risk string
message string
}
var filePatterns = []fileRiskPattern{
{regexp.MustCompile(`\.pem$|\.key$|\.p12$|\.pfx$`), "high", "私钥/证书文件,确认是否应纳入版本控制"},
{regexp.MustCompile(`id_rsa|id_dsa|id_ecdsa|id_ed25519`), "high", "SSH 私钥文件,不应提交到仓库"},
{regexp.MustCompile(`^\.env$|\.env\.`), "high", "环境变量文件,可能包含敏感凭据"},
{regexp.MustCompile(`credentials\.|\.secret$|secret\.yml`), "high", "凭据文件,可能包含敏感信息"},
{regexp.MustCompile(`serviceAccount\.json|\.service-account\.json`), "high", "服务账号密钥文件"},
{regexp.MustCompile(`.*token.*|.*secret.*`), "medium", "文件名包含 token/secret检查内容"},
{regexp.MustCompile(`coverage\.out$`), "low", "覆盖率输出文件,建议添加到 .gitignore"},
{regexp.MustCompile(`\.exe$|\.bin$|\.dll$|\.so$`), "low", "二进制文件,检查是否应纳入版本控制"},
{regexp.MustCompile(`\.log$|\.tmp$`), "low", "日志/临时文件,建议添加到 .gitignore"},
}
// --- content pattern scanning ---
type contentRiskPattern struct {
re *regexp.Regexp
message string
}
var contentPatterns = []contentRiskPattern{
{regexp.MustCompile(`(?i)(token|api[_-]?key|apikey|secret|password|passwd|authorization)\s*[:=]\s*['"][^\s'"]{8,}['"]`),
"检测到硬编码凭据赋值"},
{regexp.MustCompile(`-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----`),
"检测到私钥头部"},
{regexp.MustCompile(`(?i)GITLINK_TOKEN\s*[:=]\s*['"][^\s'"]+['"]`),
"检测到 GitLink Token"},
{regexp.MustCompile(`(?i)(mongodb|mysql|postgres|redis|jdbc)://[^\s'"]+@`),
"检测到数据库连接字符串"},
{regexp.MustCompile(`AKIA[0-9A-Z]{16}`),
"检测到 AWS Access Key"},
{regexp.MustCompile(`ghp_[a-zA-Z0-9]{36}`),
"检测到 GitHub 个人访问令牌"},
{regexp.MustCompile(`(?i)eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}`),
"检测到 JWT 令牌格式"},
{regexp.MustCompile(`(?i)(\d{1,3}\.){3}\d{1,3}`),
"检测到硬编码 IP 地址"},
{regexp.MustCompile(`(?i)password\s*[:=]\s*['"]['"]`),
"检测到空密码"},
}
func isPlaceholder(s string) bool {
lower := strings.ToLower(s)
for _, p := range []string{"((variable))", "<token>", "your_token_here", "xxx", "replace_me", "<your", "placeholder"} {
if strings.Contains(lower, p) {
return true
}
}
// Skip if it looks like a format variable: {{.Var}} or ${VAR}.
if matched, _ := regexp.MatchString(`\{\{\.?\w+\}\}|\$\{\w+\}`, s); matched {
return true
}
return false
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View File

@ -0,0 +1,101 @@
package rules
import (
"testing"
)
func TestLicenseFileDetection(t *testing.T) {
upstream := map[string]interface{}{
"existing-files": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"name": "src/main.go", "content": "package main"},
map[string]interface{}{"name": "LICENSE", "content": "MIT License\n\nPermission is hereby granted..."},
map[string]interface{}{"name": "README.md", "content": "# Project"},
},
},
}
resp, err := LicenseCheckRule(upstream, "license-check")
if err != nil {
t.Fatalf("LicenseCheckRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
if v := analysis["has_license"]; v != true {
t.Fatal("expected has_license=true")
}
if v := analysis["license_type"]; v != "MIT" {
t.Fatalf("expected license_type=MIT, got %v", v)
}
}
func TestSensitiveContentDetection(t *testing.T) {
upstream := map[string]interface{}{
"existing-files": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"name": "config.go", "content": `api_key = "sk-1234567890abcdef"`},
},
},
}
resp, err := LicenseCheckRule(upstream, "license-check")
if err != nil {
t.Fatalf("LicenseCheckRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
findings := analysis["findings"].([]riskEntry)
if len(findings) == 0 {
t.Fatal("expected findings for hardcoded key")
}
}
func TestPlaceholderExclusion(t *testing.T) {
upstream := map[string]interface{}{
"existing-files": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"name": "config.go", "content": `api_key = "your_token_here"`},
},
},
}
resp, err := LicenseCheckRule(upstream, "license-check")
if err != nil {
t.Fatalf("LicenseCheckRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
findings := analysis["findings"].([]riskEntry)
// Placeholder should not generate riskEntry findings.
// But the api_key pattern may still match — check that it's excluded.
for _, f := range findings {
if f.File == "config.go" {
t.Logf("finding: %+v", f)
// Should NOT be a high risk for the api_key pattern.
}
}
// The placeholder exclusion should filter out the match from content patterns.
// But filename-based detections may still fire. Let's just check there are no
// high risk findings for the placeholder content.
for _, f := range findings {
if f.Risk == "high" && f.File == "config.go" {
t.Errorf("placeholder should be excluded, but got high risk finding: %s", f.Message)
}
}
}
func TestLicenseFileMissing(t *testing.T) {
upstream := map[string]interface{}{
"existing-files": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"name": "README.md"},
},
},
}
resp, err := LicenseCheckRule(upstream, "license-check")
if err != nil {
t.Fatalf("LicenseCheckRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
if v := analysis["has_license"]; v != false {
t.Fatal("expected has_license=false")
}
}

View File

@ -0,0 +1,633 @@
package rules
import (
"fmt"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func MultiRepoCoordinationRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
snapshot, err := extractMultiRepoSnapshot(upstream)
if err != nil {
return nil, err
}
now := time.Now()
release := mrString(snapshot["release"])
repos := extractSnapshotRepos(snapshot)
issueRows := make([]map[string]interface{}, 0, len(repos))
prRows := make([]map[string]interface{}, 0, len(repos))
releaseRows := make([]map[string]interface{}, 0, len(repos))
blockers := []map[string]interface{}{}
recommendations := []string{}
detailIssues := []map[string]interface{}{}
detailPRs := []map[string]interface{}{}
totalOpenIssues := 0
totalBlockerIssues := 0
totalStaleIssues := 0
totalHighPriorityIssues := 0
totalOpenPRs := 0
totalStalePRs := 0
totalConflictPRs := 0
alreadyReleasedRepos := 0
for _, repo := range repos {
owner := mrString(repo["owner"])
name := mrString(repo["repo"])
repoName := owner + "/" + name
if owner == "" {
repoName = name
}
issues := extractAnyList(repo["open_issues"])
prs := extractAnyList(repo["open_prs"])
releases := extractAnyList(repo["releases"])
blockerIssues := filterIssues(issues, isBlockerIssue)
highPriorityIssues := filterIssues(issues, isHighPriorityIssue)
staleIssues := filterStale(issues, now, 7)
stalePRs := filterStale(prs, now, 3)
conflictPRs := filterPRs(prs, isConflictPR)
totalOpenIssues += len(issues)
totalBlockerIssues += len(blockerIssues)
totalStaleIssues += len(staleIssues)
totalHighPriorityIssues += len(highPriorityIssues)
totalOpenPRs += len(prs)
totalStalePRs += len(stalePRs)
totalConflictPRs += len(conflictPRs)
issueRows = append(issueRows, map[string]interface{}{
"repo": repoName,
"open": len(issues),
"blockers": len(blockerIssues),
"stale_7d": len(staleIssues),
"high_priority": len(highPriorityIssues),
})
prRows = append(prRows, map[string]interface{}{
"repo": repoName,
"open": len(prs),
"stale_3d": len(stalePRs),
"conflicts": len(conflictPRs),
"needs_review": countNeedsReviewPRs(prs),
})
detailIssues = appendUniqueDetails(detailIssues, repoName, append(blockerIssues, highPriorityIssues...), issueDetail, now)
detailPRs = appendUniqueDetails(detailPRs, repoName, append(conflictPRs, stalePRs...), prDetail, now)
releaseExists := release != "" && hasTargetRelease(releases, release)
readyToRelease := release != "" && !releaseExists && len(blockerIssues) == 0 && len(conflictPRs) == 0 && len(stalePRs) == 0
releaseRows = append(releaseRows, map[string]interface{}{
"repo": repoName,
"target": release,
"release_exists": releaseExists,
"already_released": releaseExists,
"ready_to_release": readyToRelease,
"blocker_issues": len(blockerIssues),
"open_prs": len(prs),
"stale_or_conflict_pr": len(stalePRs) + len(conflictPRs),
})
if len(blockerIssues) > 0 {
blockers = append(blockers, map[string]interface{}{
"repo": repoName,
"type": "blocker_issues",
"count": len(blockerIssues),
"reason": fmt.Sprintf("%s 还有 %d 个阻塞 Issue", repoName, len(blockerIssues)),
})
recommendations = append(recommendations, fmt.Sprintf("优先处理 %s 的阻塞 Issue", repoName))
}
if len(conflictPRs) > 0 {
blockers = append(blockers, map[string]interface{}{
"repo": repoName,
"type": "conflict_prs",
"count": len(conflictPRs),
"reason": fmt.Sprintf("%s 还有 %d 个疑似冲突 PR", repoName, len(conflictPRs)),
})
recommendations = append(recommendations, fmt.Sprintf("先解决 %s 的冲突 PR", repoName))
}
if len(stalePRs) > 0 {
blockers = append(blockers, map[string]interface{}{
"repo": repoName,
"type": "stale_prs",
"count": len(stalePRs),
"reason": fmt.Sprintf("%s 还有 %d 个超过 3 天未合并 PR", repoName, len(stalePRs)),
})
}
if releaseExists {
alreadyReleasedRepos++
recommendations = append(recommendations, fmt.Sprintf("%s 已存在 %s Release确认是否属于重复发布检查", repoName, release))
}
}
errors := extractAnyList(snapshot["errors"])
readyToRelease := release != "" && len(blockers) == 0 && len(errors) == 0 && alreadyReleasedRepos == 0
if release == "" {
recommendations = append(recommendations, "指定 --release 可启用跨仓库发布协调检查")
}
if len(errors) > 0 {
blockers = append(blockers, map[string]interface{}{
"type": "collection_errors",
"count": len(errors),
"reason": fmt.Sprintf("采集过程中有 %d 个错误,需要先确认数据完整性", len(errors)),
})
recommendations = append(recommendations, "先处理采集失败的仓库或接口权限问题,再判断发布状态")
}
analysis := map[string]interface{}{
"title": "多仓库协同报告",
"summary": map[string]interface{}{
"repos": len(repos),
"target_release": release,
"open_issues": totalOpenIssues,
"blocker_issues": totalBlockerIssues,
"stale_issues_7d": totalStaleIssues,
"high_priority": totalHighPriorityIssues,
"open_prs": totalOpenPRs,
"stale_prs_3d": totalStalePRs,
"conflict_prs": totalConflictPRs,
"collection_errors": len(errors),
},
"issue_tracking": map[string]interface{}{
"total_open": totalOpenIssues,
"total_blockers": totalBlockerIssues,
"total_stale_7d": totalStaleIssues,
"total_high_priority": totalHighPriorityIssues,
"by_repo": issueRows,
"details": detailIssues,
},
"pr_board": map[string]interface{}{
"total_open": totalOpenPRs,
"total_stale_3d": totalStalePRs,
"total_conflicts": totalConflictPRs,
"by_repo": prRows,
"details": detailPRs,
},
"release_coordination": map[string]interface{}{
"target": release,
"ready": readyToRelease,
"ready_to_release": readyToRelease,
"by_repo": releaseRows,
"blockers": blockers,
},
"recommendations": uniqueStrings(recommendations),
}
var actions []workflow.AIAction
wikiOwner := mrString(upstream["_wiki_owner"])
wikiRepo := mrString(upstream["_wiki_repo"])
if wikiOwner != "" && wikiRepo != "" {
body := buildMultiRepoMarkdown(analysis)
pageName := fmt.Sprintf("多仓库协同报告-%s", now.Format("2006-01-02"))
actions = []workflow.AIAction{
{
Type: "cli", Module: "wiki", Command: "+create",
Args: map[string]string{
"owner": wikiOwner,
"repo": wikiRepo,
"name": pageName,
"content": body,
"message": "自动生成多仓库协同报告",
},
},
{
Type: "cli", Module: "wiki", Command: "+update",
Args: map[string]string{
"owner": wikiOwner,
"repo": wikiRepo,
"name": pageName,
"content": body,
"message": "自动更新多仓库协同报告",
},
},
}
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
func extractMultiRepoSnapshot(upstream map[string]interface{}) (map[string]interface{}, error) {
raw, ok := upstream["multi-repo-snapshot"]
if !ok {
return nil, fmt.Errorf("missing multi-repo-snapshot upstream data")
}
if m, ok := raw.(map[string]interface{}); ok {
if data, ok := m["data"].(map[string]interface{}); ok {
return data, nil
}
return m, nil
}
return nil, fmt.Errorf("invalid multi-repo-snapshot upstream data")
}
func extractSnapshotRepos(snapshot map[string]interface{}) []map[string]interface{} {
return extractAnyList(snapshot["repos"])
}
func extractAnyList(raw interface{}) []map[string]interface{} {
for i := 0; i < 3; i++ {
m, ok := raw.(map[string]interface{})
if !ok {
break
}
unwrapped := false
if data, ok := m["data"]; ok {
raw = data
unwrapped = true
} else {
for _, key := range []string{"repos", "issues", "pull_requests", "releases", "milestones", "items", "errors"} {
if v, ok := m[key]; ok {
raw = v
unwrapped = true
break
}
}
}
if !unwrapped {
break
}
}
list, _ := raw.([]interface{})
out := make([]map[string]interface{}, 0, len(list))
for _, item := range list {
if m, ok := item.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
func filterIssues(items []map[string]interface{}, pred func(map[string]interface{}) bool) []map[string]interface{} {
return filterMaps(items, pred)
}
func filterPRs(items []map[string]interface{}, pred func(map[string]interface{}) bool) []map[string]interface{} {
return filterMaps(items, pred)
}
func filterMaps(items []map[string]interface{}, pred func(map[string]interface{}) bool) []map[string]interface{} {
out := []map[string]interface{}{}
for _, item := range items {
if pred(item) {
out = append(out, item)
}
}
return out
}
func filterStale(items []map[string]interface{}, now time.Time, days int) []map[string]interface{} {
out := []map[string]interface{}{}
for _, item := range items {
t, ok := itemUpdatedAt(item)
if ok && now.Sub(t) >= time.Duration(days)*24*time.Hour {
out = append(out, item)
}
}
return out
}
func isBlockerIssue(issue map[string]interface{}) bool {
text := strings.ToLower(mrString(issue["subject"]) + " " + mrString(issue["title"]) + " " + mrString(issue["description"]) + " " + labelsText(issue))
return strings.Contains(text, "blocker") || strings.Contains(text, "阻塞") || strings.Contains(text, "critical") || strings.Contains(text, "严重")
}
func isHighPriorityIssue(issue map[string]interface{}) bool {
text := strings.ToLower(mrString(issue["subject"]) + " " + mrString(issue["title"]) + " " + mrString(issue["priority"]) + " " + labelsText(issue))
if strings.Contains(text, "high") || strings.Contains(text, "urgent") || strings.Contains(text, "高优先级") || strings.Contains(text, "紧急") {
return true
}
if id, ok := numberValue(issue["priority_id"]); ok && id >= 4 {
return true
}
return false
}
func isConflictPR(pr map[string]interface{}) bool {
text := strings.ToLower(mrString(pr["title"]) + " " + mrString(pr["body"]) + " " + mrString(pr["status"]) + " " + mrString(pr["merge_status"]))
return strings.Contains(text, "conflict") || strings.Contains(text, "冲突") || strings.Contains(text, "cannot merge")
}
func countNeedsReviewPRs(prs []map[string]interface{}) int {
count := 0
for _, pr := range prs {
text := strings.ToLower(mrString(pr["status"]) + " " + mrString(pr["review_status"]) + " " + labelsText(pr))
if text == "" || strings.Contains(text, "review") || strings.Contains(text, "待审") {
count++
}
}
return count
}
func hasTargetRelease(releases []map[string]interface{}, target string) bool {
for _, rel := range releases {
for _, key := range []string{"tag_name", "tag", "name", "title", "version"} {
if mrString(rel[key]) == target {
return true
}
}
}
return false
}
func issueDetail(repo string, issue map[string]interface{}, now time.Time) map[string]interface{} {
days := daysSince(issue, now)
return map[string]interface{}{
"repo": repo,
"number": firstNonEmpty(issue, "number", "id", "issue_id"),
"title": firstNonEmpty(issue, "subject", "title"),
"assignee": assigneeName(issue),
"stale_days": days,
"blocker": isBlockerIssue(issue),
"high": isHighPriorityIssue(issue),
}
}
func prDetail(repo string, pr map[string]interface{}, now time.Time) map[string]interface{} {
return map[string]interface{}{
"repo": repo,
"number": firstNonEmpty(pr, "pull_request_number", "number", "id", "pull_request_id"),
"title": firstNonEmpty(pr, "title", "subject"),
"assignee": assigneeName(pr),
"stale_days": daysSince(pr, now),
"conflict": isConflictPR(pr),
}
}
func appendUniqueDetails(
dst []map[string]interface{},
repo string,
items []map[string]interface{},
detailFn func(string, map[string]interface{}, time.Time) map[string]interface{},
now time.Time,
) []map[string]interface{} {
seen := make(map[string]bool, len(dst)+len(items))
for _, item := range dst {
seen[detailKey(item)] = true
}
for _, item := range items {
detail := detailFn(repo, item, now)
key := detailKey(detail)
if seen[key] {
continue
}
seen[key] = true
dst = append(dst, detail)
}
return dst
}
func detailKey(item map[string]interface{}) string {
repo := mrString(item["repo"])
number := mrString(item["number"])
if number == "" {
number = mrString(item["title"])
}
return repo + "#" + number
}
func itemUpdatedAt(item map[string]interface{}) (time.Time, bool) {
for _, key := range []string{"updated_at", "updated_on", "created_at", "created_on"} {
switch v := item[key].(type) {
case string:
if t, ok := parseTime(v); ok {
return t, true
}
case float64:
if v > 0 {
return time.Unix(int64(v), 0), true
}
}
}
return time.Time{}, false
}
func parseTime(raw string) (time.Time, bool) {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z07:00", "2006-01-02 15:04:05", "2006-01-02"} {
if t, err := time.Parse(layout, raw); err == nil {
return t, true
}
}
return time.Time{}, false
}
func daysSince(item map[string]interface{}, now time.Time) int {
t, ok := itemUpdatedAt(item)
if !ok {
return 0
}
return int(now.Sub(t).Hours() / 24)
}
func firstNonEmpty(m map[string]interface{}, keys ...string) string {
for _, key := range keys {
if s := mrString(m[key]); s != "" {
return s
}
}
return ""
}
func labelsText(m map[string]interface{}) string {
var parts []string
for _, key := range []string{"labels", "tags", "issue_tags"} {
for _, label := range extractAnyList(m[key]) {
parts = append(parts, firstNonEmpty(label, "name", "title"))
}
}
return strings.Join(parts, " ")
}
func assigneeName(m map[string]interface{}) string {
for _, key := range []string{"assignee", "assigned_to", "user"} {
if nested, ok := m[key].(map[string]interface{}); ok {
if name := firstNonEmpty(nested, "name", "login", "username"); name != "" {
return name
}
}
}
return firstNonEmpty(m, "assignee", "assigned_to", "author_name")
}
func mrString(v interface{}) string {
if v == nil {
return ""
}
s := strings.TrimSpace(fmt.Sprint(v))
if s == "<nil>" {
return ""
}
return s
}
func numberValue(v interface{}) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case int:
return float64(n), true
default:
return 0, false
}
}
func uniqueStrings(items []string) []string {
seen := map[string]bool{}
out := []string{}
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return out
}
func buildMultiRepoMarkdown(analysis map[string]interface{}) string {
var b strings.Builder
title := "多仓库协同报告"
if t, ok := analysis["title"].(string); ok && t != "" {
title = t
}
b.WriteString("# ")
b.WriteString(title)
b.WriteString("\n\n")
// Summary
if summary, ok := analysis["summary"].(map[string]interface{}); ok {
target := mrString(summary["target_release"])
b.WriteString("## 总览\n\n")
b.WriteString("| 指标 | 数值 |\n")
b.WriteString("|------|------|\n")
fmt.Fprintf(&b, "| 仓库数 | %v |\n", summary["repos"])
if target != "" {
fmt.Fprintf(&b, "| 目标版本 | %s |\n", target)
}
fmt.Fprintf(&b, "| 开放 Issue | %v |\n", summary["open_issues"])
fmt.Fprintf(&b, "| 阻塞 Issue | %v |\n", summary["blocker_issues"])
fmt.Fprintf(&b, "| 超 7 天未更新 Issue | %v |\n", summary["stale_issues_7d"])
fmt.Fprintf(&b, "| 高优先级 Issue | %v |\n", summary["high_priority"])
fmt.Fprintf(&b, "| 开放 PR | %v |\n", summary["open_prs"])
fmt.Fprintf(&b, "| 超 3 天未合并 PR | %v |\n", summary["stale_prs_3d"])
fmt.Fprintf(&b, "| 冲突 PR | %v |\n", summary["conflict_prs"])
fmt.Fprintf(&b, "| 采集错误 | %v |\n\n", summary["collection_errors"])
}
// Issue tracking by repo
if it, ok := analysis["issue_tracking"].(map[string]interface{}); ok {
b.WriteString("## Issue 追踪\n\n")
if rows, ok := it["by_repo"].([]map[string]interface{}); ok && len(rows) > 0 {
b.WriteString("| 仓库 | 开放 | 阻塞 | 超7天 | 高优先级 |\n")
b.WriteString("|------|------|------|------|----------|\n")
for _, row := range rows {
fmt.Fprintf(&b, "| %s | %v | %v | %v | %v |\n",
mrString(row["repo"]), row["open"], row["blockers"], row["stale_7d"], row["high_priority"])
}
b.WriteString("\n")
}
if details, ok := it["details"].([]map[string]interface{}); ok && len(details) > 0 {
b.WriteString("### 需关注的 Issue\n\n")
b.WriteString("| 仓库 | 编号 | 标题 | 负责人 | 陈旧(天) | 阻塞 | 高优先级 |\n")
b.WriteString("|------|------|------|--------|-----------|------|----------|\n")
for _, d := range details {
blocker := "否"
if v, _ := d["blocker"].(bool); v {
blocker = "是"
}
high := "否"
if v, _ := d["high"].(bool); v {
high = "是"
}
fmt.Fprintf(&b, "| %s | %v | %s | %s | %v | %s | %s |\n",
mrString(d["repo"]), d["number"], mrString(d["title"]),
mrString(d["assignee"]), d["stale_days"], blocker, high)
}
b.WriteString("\n")
}
}
// PR board by repo
if pr, ok := analysis["pr_board"].(map[string]interface{}); ok {
b.WriteString("## PR 看板\n\n")
if rows, ok := pr["by_repo"].([]map[string]interface{}); ok && len(rows) > 0 {
b.WriteString("| 仓库 | 开放 | 超3天 | 冲突 | 待审查 |\n")
b.WriteString("|------|------|------|------|--------|\n")
for _, row := range rows {
fmt.Fprintf(&b, "| %s | %v | %v | %v | %v |\n",
mrString(row["repo"]), row["open"], row["stale_3d"], row["conflicts"], row["needs_review"])
}
b.WriteString("\n")
}
if details, ok := pr["details"].([]map[string]interface{}); ok && len(details) > 0 {
b.WriteString("### 需关注的 PR\n\n")
b.WriteString("| 仓库 | 编号 | 标题 | 负责人 | 陈旧(天) | 冲突 |\n")
b.WriteString("|------|------|------|--------|-----------|------|\n")
for _, d := range details {
conflict := "否"
if v, _ := d["conflict"].(bool); v {
conflict = "是"
}
fmt.Fprintf(&b, "| %s | %v | %s | %s | %v | %s |\n",
mrString(d["repo"]), d["number"], mrString(d["title"]),
mrString(d["assignee"]), d["stale_days"], conflict)
}
b.WriteString("\n")
}
}
// Release coordination
if rc, ok := analysis["release_coordination"].(map[string]interface{}); ok {
target := mrString(rc["target"])
if target != "" {
b.WriteString("## Release 协调\n\n")
ready := false
if v, ok := rc["ready_to_release"].(bool); ok {
ready = v
}
if ready {
b.WriteString("**状态:可以发布**\n\n")
} else {
b.WriteString("**状态:存在阻塞项**\n\n")
}
if rows, ok := rc["by_repo"].([]map[string]interface{}); ok && len(rows) > 0 {
b.WriteString("| 仓库 | 目标版本 | 已发布 | 阻塞Issue | 冲突/陈旧PR |\n")
b.WriteString("|------|----------|--------|-----------|-------------|\n")
for _, row := range rows {
released := "否"
if v, _ := row["already_released"].(bool); v {
released = "是"
}
fmt.Fprintf(&b, "| %s | %s | %s | %v | %v |\n",
mrString(row["repo"]), mrString(row["target"]), released,
row["blocker_issues"], row["stale_or_conflict_pr"])
}
b.WriteString("\n")
}
if blockers, ok := rc["blockers"].([]map[string]interface{}); ok && len(blockers) > 0 {
b.WriteString("### 阻塞项\n\n")
for _, blk := range blockers {
fmt.Fprintf(&b, "- %s\n", mrString(blk["reason"]))
}
b.WriteString("\n")
}
}
}
// Recommendations
if recs, ok := analysis["recommendations"].([]string); ok && len(recs) > 0 {
b.WriteString("## 行动建议\n\n")
for _, r := range recs {
fmt.Fprintf(&b, "- %s\n", r)
}
b.WriteString("\n")
}
b.WriteString("> 由 multi-repo 工作流自动生成\n")
return b.String()
}

View File

@ -0,0 +1,123 @@
package rules
import "testing"
func TestMultiRepoCoordinationRuleBlocksRelease(t *testing.T) {
upstream := map[string]interface{}{
"multi-repo-snapshot": map[string]interface{}{
"release": "v1.4.0",
"repos": []interface{}{
map[string]interface{}{
"owner": "org",
"repo": "backend",
"open_issues": map[string]interface{}{
"issues": []interface{}{
map[string]interface{}{
"id": float64(23),
"subject": "登录接口超时 blocker",
"priority_id": float64(4),
"updated_at": "2026-06-20T00:00:00Z",
},
},
},
"open_prs": map[string]interface{}{
"pull_requests": []interface{}{},
},
"releases": []interface{}{
map[string]interface{}{"tag_name": "v1.4.0"},
},
},
map[string]interface{}{
"owner": "org",
"repo": "frontend",
"open_issues": map[string]interface{}{
"issues": []interface{}{},
},
"open_prs": map[string]interface{}{
"pull_requests": []interface{}{
map[string]interface{}{
"id": float64(39),
"title": "fix: 修复登录样式 conflict",
"updated_at": "2026-06-25T00:00:00Z",
"merge_status": "conflict",
},
},
},
"releases": []interface{}{},
},
},
},
}
resp, err := MultiRepoCoordinationRule(upstream, "multi-repo-coordination")
if err != nil {
t.Fatalf("MultiRepoCoordinationRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
release := analysis["release_coordination"].(map[string]interface{})
if release["ready_to_release"].(bool) {
t.Fatal("expected release ready_to_release=false")
}
blockers := release["blockers"].([]map[string]interface{})
if len(blockers) != 3 {
t.Fatalf("expected blocker issue plus conflict and stale PR blockers, got %v", blockers)
}
rows := release["by_repo"].([]map[string]interface{})
if !rows[0]["already_released"].(bool) {
t.Fatalf("expected backend to be marked already_released")
}
if rows[1]["release_exists"].(bool) {
t.Fatalf("expected frontend release_exists=false without treating it as blocker")
}
summary := analysis["summary"].(map[string]interface{})
if summary["blocker_issues"].(int) != 1 {
t.Fatalf("blocker_issues = %v, want 1", summary["blocker_issues"])
}
if summary["conflict_prs"].(int) != 1 {
t.Fatalf("conflict_prs = %v, want 1", summary["conflict_prs"])
}
issues := analysis["issue_tracking"].(map[string]interface{})["details"].([]map[string]interface{})
if len(issues) != 1 {
t.Fatalf("expected duplicate issue details to be deduped, got %v", issues)
}
}
func TestMultiRepoCoordinationRuleAlreadyReleasedIsNotReadyToRelease(t *testing.T) {
upstream := map[string]interface{}{
"multi-repo-snapshot": map[string]interface{}{
"release": "v1.4.0",
"repos": []interface{}{
map[string]interface{}{
"owner": "org",
"repo": "backend",
"open_issues": map[string]interface{}{
"issues": []interface{}{},
},
"open_prs": map[string]interface{}{
"pull_requests": []interface{}{},
},
"releases": []interface{}{
map[string]interface{}{"tag_name": "v1.4.0"},
},
},
},
},
}
resp, err := MultiRepoCoordinationRule(upstream, "multi-repo-coordination")
if err != nil {
t.Fatalf("MultiRepoCoordinationRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
release := analysis["release_coordination"].(map[string]interface{})
if release["ready_to_release"].(bool) {
t.Fatal("already released repo should not be marked ready_to_release")
}
rows := release["by_repo"].([]map[string]interface{})
if !rows[0]["release_exists"].(bool) || !rows[0]["already_released"].(bool) {
t.Fatalf("expected release existence flags, got %+v", rows[0])
}
if rows[0]["ready_to_release"].(bool) {
t.Fatalf("already released repo row should not be ready_to_release: %+v", rows[0])
}
}

View File

@ -0,0 +1,17 @@
package rules
import "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
func init() {
workflow.RegisterRuleEngine("gitlink-triage", TriageRule)
workflow.RegisterRuleEngine("gitlink-health", HealthDispatchRule)
workflow.RegisterRuleEngine("gitlink-contributor-ranking", ContributorRankingRule)
workflow.RegisterRuleEngine("gitlink-changelog", ChangelogRule)
workflow.RegisterRuleEngine("gitlink-review", CodeReviewRule)
workflow.RegisterRuleEngine("gitlink-ci", CIDiagnosisRule)
workflow.RegisterRuleEngine("gitlink-license", LicenseCheckRule)
workflow.RegisterRuleEngine("gitlink-repo", RepoAuditRule)
workflow.RegisterRuleEngine("gitlink-init-scaffold", InitScaffoldRule)
workflow.RegisterRuleEngine("gitlink-auto-merge", AutoMergeRule)
workflow.RegisterRuleEngine("gitlink-multi-repo", MultiRepoCoordinationRule)
}

View File

@ -0,0 +1,60 @@
package rules
import (
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestRegistryAllRegistered(t *testing.T) {
expected := []string{
"gitlink-triage",
"gitlink-health",
"gitlink-changelog",
"gitlink-review",
"gitlink-ci",
"gitlink-license",
"gitlink-repo",
"gitlink-contributor-ranking",
"gitlink-init-scaffold",
"gitlink-auto-merge",
"gitlink-multi-repo",
}
for _, target := range expected {
if _, ok := workflow.RuleEngines[target]; !ok {
t.Errorf("rule engine not registered for target %q", target)
}
}
if len(workflow.RuleEngines) != len(expected) {
t.Fatalf("expected %d rule engines, got %d", len(expected), len(workflow.RuleEngines))
}
}
func TestHealthDispatchRule(t *testing.T) {
// contributor-ranking step should delegate to ContributorRankingRule.
resp, err := HealthDispatchRule(map[string]interface{}{}, "contributor-ranking")
if err != nil {
t.Fatalf("HealthDispatchRule failed: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
// health-report step should delegate to HealthReportRule.
resp, err = HealthDispatchRule(map[string]interface{}{}, "health-report")
if err != nil {
t.Fatalf("HealthDispatchRule for health-report failed: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response for health-report")
}
// repo-health step should delegate to HealthReportRule too.
resp, err = HealthDispatchRule(map[string]interface{}{}, "repo-health")
if err != nil {
t.Fatalf("HealthDispatchRule for repo-health failed: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response for repo-health")
}
}

View File

@ -0,0 +1,125 @@
package rules
import (
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// RepoAuditRule evaluates a repository against a 6-dimension checklist.
func RepoAuditRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
repoInfo := extractFirst(upstream, "repo-info")
labels := extractLabels(upstream)
milestones := extractList(upstream, "milestones")
branches := extractList(upstream, "branches")
type dimScore struct {
Name string `json:"name"`
Score float64 `json:"score"`
Weight float64 `json:"weight"`
Status string `json:"status"`
Detail string `json:"detail"`
}
var dims []dimScore
missing := []string{}
// Readme check — use file list since repo +info API doesn't return has_readme.
if detectFileInList(upstream, "README.md", "readme.md", "README", "readme") {
dims = append(dims, dimScore{Name: "README", Score: 100, Weight: 0.25, Status: "ok", Detail: "README 已存在"})
} else {
dims = append(dims, dimScore{Name: "README", Score: 0, Weight: 0.25, Status: "missing", Detail: "缺少 README 文件"})
missing = append(missing, "README")
}
// License check.
if detectLicenseInFiles(upstream) {
dims = append(dims, dimScore{Name: "LICENSE", Score: 100, Weight: 0.20, Status: "ok", Detail: "LICENSE 文件已存在"})
} else {
dims = append(dims, dimScore{Name: "LICENSE", Score: 0, Weight: 0.20, Status: "missing", Detail: "缺少 LICENSE 文件"})
missing = append(missing, "LICENSE")
}
// Labels check.
if len(labels) > 5 {
dims = append(dims, dimScore{Name: "标签库", Score: 100, Weight: 0.15, Status: "ok", Detail: "标签配置完善"})
} else if len(labels) > 0 {
dims = append(dims, dimScore{Name: "标签库", Score: 50, Weight: 0.15, Status: "partial", Detail: "标签较少,建议补充"})
} else {
dims = append(dims, dimScore{Name: "标签库", Score: 0, Weight: 0.15, Status: "missing", Detail: "未配置标签"})
missing = append(missing, "labels")
}
// Milestones check.
if len(milestones) > 0 {
dims = append(dims, dimScore{Name: "里程碑", Score: 100, Weight: 0.15, Status: "ok", Detail: "已配置里程碑"})
} else {
dims = append(dims, dimScore{Name: "里程碑", Score: 0, Weight: 0.15, Status: "missing", Detail: "未配置里程碑"})
missing = append(missing, "milestones")
}
// Branches check.
if len(branches) > 2 {
dims = append(dims, dimScore{Name: "分支结构", Score: 100, Weight: 0.10, Status: "ok", Detail: "分支结构完善"})
} else if len(branches) > 1 {
dims = append(dims, dimScore{Name: "分支结构", Score: 70, Weight: 0.10, Status: "ok", Detail: "至少有一个开发分支"})
} else {
dims = append(dims, dimScore{Name: "分支结构", Score: 40, Weight: 0.10, Status: "partial", Detail: "仅主分支,建议创建 develop 分支"})
}
// DevOps check.
devops := false
if repoInfo != nil {
if v, ok := repoInfo["open_devops"].(bool); ok {
devops = v
}
if v, ok := repoInfo["devops_enabled"].(bool); ok {
devops = v
}
}
if devops {
dims = append(dims, dimScore{Name: "DevOps", Score: 100, Weight: 0.15, Status: "ok", Detail: "DevOps 已开启"})
} else {
dims = append(dims, dimScore{Name: "DevOps", Score: 0, Weight: 0.15, Status: "missing", Detail: "DevOps 未开启"})
missing = append(missing, "DevOps")
}
// Composite.
var composite float64
for _, d := range dims {
composite += d.Score * d.Weight
}
analysis := map[string]interface{}{
"composite_score": composite,
"grade": grade(composite),
"dimensions": dims,
"missing_items": missing,
"recommendation": strings.Join(missing, "、") + " 需要补充",
}
if len(missing) == 0 {
analysis["recommendation"] = "项目初始化完善,所有检查项均已通过"
}
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
}
func detectLicenseInFiles(upstream map[string]interface{}) bool {
return detectFileInList(upstream, "LICENSE", "license", "LICENSE.txt", "LICENSE.md", "COPYING")
}
func detectFileInList(upstream map[string]interface{}, names ...string) bool {
files := extractList(upstream, "existing-files")
if len(files) == 0 {
files = extractList(upstream, "files")
}
for _, f := range files {
name := str(f, "name", "filename", "path", "file_name")
for _, n := range names {
if strings.EqualFold(name, n) {
return true
}
}
}
return false
}

View File

@ -0,0 +1,93 @@
package rules
import (
"testing"
)
func TestRepoAuditComplete(t *testing.T) {
upstream := map[string]interface{}{
"repo-info": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"description": "A well-maintained project",
"has_readme": true,
"open_devops": true,
},
},
},
"existing-files": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"name": "README.md", "content": "# Project"},
map[string]interface{}{"name": "LICENSE", "content": "MIT"},
},
},
"labels": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"name": "bug"},
map[string]interface{}{"name": "enhancement"},
map[string]interface{}{"name": "question"},
map[string]interface{}{"name": "docs"},
map[string]interface{}{"name": "security"},
map[string]interface{}{"name": "refactor"},
},
},
"milestones": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"title": "v1.0"},
},
},
"branches": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"name": "master"},
map[string]interface{}{"name": "develop"},
map[string]interface{}{"name": "feature/x"},
},
},
}
resp, err := RepoAuditRule(upstream, "repo-audit")
if err != nil {
t.Fatalf("RepoAuditRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
// Fully complete repo should have a high score.
composite := analysis["composite_score"].(float64)
if composite < 80 {
t.Errorf("expected composite >= 80 for complete repo, got %.1f", composite)
}
// Should have no missing items.
missing := analysis["missing_items"].([]string)
if len(missing) != 0 {
t.Errorf("expected 0 missing items, got %v", missing)
}
}
func TestRepoAuditEmpty(t *testing.T) {
upstream := map[string]interface{}{
"repo-info": map[string]interface{}{"data": []interface{}{}},
"labels": map[string]interface{}{"data": []interface{}{}},
"milestones": map[string]interface{}{"data": []interface{}{}},
"branches": map[string]interface{}{"data": []interface{}{}},
}
resp, err := RepoAuditRule(upstream, "repo-audit")
if err != nil {
t.Fatalf("RepoAuditRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
// Empty repo should have a low score.
composite := analysis["composite_score"].(float64)
if composite >= 50 {
t.Errorf("expected composite < 50 for empty repo, got %.1f", composite)
}
// Should have missing items.
missing := analysis["missing_items"].([]string)
if len(missing) == 0 {
t.Fatal("expected missing items for empty repo")
}
t.Logf("missing items: %v", missing)
}

View File

@ -0,0 +1,267 @@
package rules
import (
"fmt"
"regexp"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// finding holds a single code review finding.
type finding struct {
PRNumber interface{} `json:"pr_number"`
PRTitle string `json:"pr_title"`
Lens string `json:"lens"`
Severity string `json:"severity"`
What string `json:"what"`
Why string `json:"why"`
Fix string `json:"fix"`
}
// CodeReviewRule performs static analysis on PR metadata.
func CodeReviewRule(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{}{"findings": nil, "message": "no open PRs to review"},
Actions: nil,
}, nil
}
var findings []finding
var actions []workflow.AIAction
// Diffs may have been pre-fetched by the workflow engine.
prDiffsMap := extractDiffsFromUpstream(upstream)
for _, pr := range prs {
title := str(pr, "title", "name")
body := str(pr, "body", "description")
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
}
diffText := prDiffsMap[prNum]
text := title + " " + body + " " + diffText
var prFindings []finding
// Security scan.
for _, p := range reviewSecurityPatterns {
if p.re.MatchString(text) {
f := finding{
PRNumber: prNum,
PRTitle: title,
Lens: "security",
Severity: p.severity,
What: p.what,
Why: "PR 标题/描述中包含可能存在安全风险的代码模式",
Fix: p.fix,
}
prFindings = append(prFindings, f)
findings = append(findings, f)
}
}
// Maintainability scan.
filesCount := 0
if v, ok := pr["files_count"].(float64); ok {
filesCount = int(v)
}
if filesCount > 50 {
f := finding{
PRNumber: prNum,
PRTitle: title,
Lens: "maintainability",
Severity: "medium",
What: fmt.Sprintf("PR 包含 %d 个文件,建议拆分为更小的 PR", filesCount),
Why: "大 PR 难以审查,增加合并风险和回滚难度",
Fix: "将改动按功能模块拆分为多个小 PR",
}
prFindings = append(prFindings, f)
findings = append(findings, f)
}
if body == "" && len(title) < 10 {
f := finding{
PRNumber: prNum,
PRTitle: title,
Lens: "maintainability",
Severity: "low",
What: "PR 缺少描述信息",
Why: "不清晰的 PR 描述增加审查时间,降低代码质量",
Fix: "添加 PR 描述,说明改动原因、影响范围和测试方式",
}
prFindings = append(prFindings, f)
findings = append(findings, f)
}
// Always post a review comment for every PR.
commentBody := buildReviewComment(title, prFindings)
actions = append(actions, workflow.AIAction{
Type: "cli",
Module: "pr",
Command: "+comment",
Args: map[string]string{
"id": prNum,
"body": commentBody,
},
})
// Auto-merge if no issues found.
if len(prFindings) == 0 {
actions = append(actions, workflow.AIAction{
Type: "cli",
Module: "pr",
Command: "+merge",
Args: map[string]string{
"id": prNum,
"method": "squash",
},
})
}
}
analysis := map[string]interface{}{
"reviewed_prs": len(prs),
"total_findings": len(findings),
"findings": findings,
"summary": fmt.Sprintf("审查了 %d 个 PR发现 %d 个问题", len(prs), len(findings)),
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
type reviewPattern struct {
re *regexp.Regexp
severity string
what string
fix string
}
var reviewSecurityPatterns = []reviewPattern{
{
regexp.MustCompile(`(?i)(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*['"][^\s'"]{8,}['"]`),
"high",
"检测到硬编码凭据(密码/Token/密钥)",
"将凭据移至环境变量或密钥管理服务,使用占位符替换",
},
{
regexp.MustCompile(`(?i)SELECT\s.*\sFROM\s.*WHERE\s.*\+`),
"high",
"检测到潜在 SQL 注入模式(字符串拼接构建 SQL",
"使用参数化查询或 ORM 框架",
},
{
regexp.MustCompile(`(?i)innerHTML\s*=|document\.write\(|eval\(`),
"medium",
"检测到潜在 XSS 风险innerHTML / eval 使用)",
"使用 textContent 替代 innerHTML避免使用 eval",
},
{
regexp.MustCompile(`(?i)-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----`),
"high",
"检测到私钥明文",
"立即删除私钥,使用密钥管理服务",
},
{
regexp.MustCompile(`(?i)ghp_[a-zA-Z0-9]{36}`),
"high",
"检测到 GitHub 个人访问令牌",
"撤销此令牌,使用环境变量存储新令牌",
},
{
regexp.MustCompile(`(?i)os\.system\(|exec\(|subprocess\.call\(`),
"medium",
"检测到潜在命令注入风险",
"避免将用户输入直接拼接到系统命令中,使用参数列表形式",
},
}
// buildReviewComment generates a review comment for a PR.
func buildReviewComment(prTitle string, prFindings []finding) string {
var b strings.Builder
b.WriteString("🤖 **代码质量审查报告**\n\n")
if len(prFindings) == 0 {
b.WriteString("✅ **审查通过**:未发现安全风险或代码质量问题,正在自动合并。\n")
return b.String()
}
fmt.Fprintf(&b, "审查发现 **%d** 个问题:\n\n", len(prFindings))
for _, f := range prFindings {
icon := "🔴"
switch f.Severity {
case "medium":
icon = "🟡"
case "low":
icon = "🟢"
}
fmt.Fprintf(&b, "- %s **[%s] %s**%s\n", icon, f.Severity, f.What, f.Why)
if f.Fix != "" {
fmt.Fprintf(&b, " - 建议:%s\n", f.Fix)
}
}
hasHigh := false
for _, f := range prFindings {
if f.Severity == "high" {
hasHigh = true
break
}
}
if hasHigh {
b.WriteString("\n⚠ 存在高危问题,请修复后重新提交审查。\n")
} else {
b.WriteString("\n请评估以上问题是否需要修复。\n")
}
return b.String()
}
func interfaceToString(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case float64:
return fmt.Sprintf("%.0f", val)
case int:
return fmt.Sprintf("%d", val)
default:
return fmt.Sprint(v)
}
}
// extractDiffsFromUpstream retrieves pre-fetched PR diffs from the upstream data.
func extractDiffsFromUpstream(upstream map[string]interface{}) map[string]string {
raw, ok := upstream["_pr_diffs"]
if !ok {
return nil
}
if m, ok := raw.(map[string]string); ok {
return m
}
if m, ok := raw.(map[string]interface{}); ok {
diffs := make(map[string]string)
for k, v := range m {
if s, ok := v.(string); ok {
diffs[k] = s
}
}
return diffs
}
return nil
}

View File

@ -0,0 +1,92 @@
package rules
import (
"testing"
)
func TestCodeReviewStaticAnalysis(t *testing.T) {
upstream := map[string]interface{}{
"open-prs": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"id": "1",
"title": "add feature",
"body": "password = 'hardcoded12345678'",
},
map[string]interface{}{
"id": "2",
"title": "wip",
"body": "",
"files_count": 60.0,
},
map[string]interface{}{
"id": "3",
"title": "clean refactor with proper description",
"body": "refactoring the auth module to use new token service",
},
},
},
}
resp, err := CodeReviewRule(upstream, "review")
if err != nil {
t.Fatalf("CodeReviewRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
total := analysis["total_findings"].(int)
if total == 0 {
t.Fatal("expected findings for hardcoded password and large PR")
}
findings := analysis["findings"].([]finding)
hasSecurity := false
hasMaint := false
for _, f := range findings {
if f.Lens == "security" {
hasSecurity = true
}
if f.Lens == "maintainability" {
hasMaint = true
}
}
if !hasSecurity {
t.Error("expected security finding for hardcoded password")
}
if !hasMaint {
t.Error("expected maintainability finding for large PR or missing body")
}
// Every PR gets a comment.
commentCount := 0
mergeCount := 0
for _, a := range resp.Actions {
if a.Command == "+comment" {
commentCount++
}
if a.Command == "+merge" {
mergeCount++
}
}
if commentCount != 3 {
t.Fatalf("expected 3 comment actions (one per PR), got %d", commentCount)
}
// PR #3 has no findings, should be auto-merged.
if mergeCount != 1 {
t.Fatalf("expected 1 merge action (clean PR), got %d", mergeCount)
}
}
func TestCodeReviewNoPRs(t *testing.T) {
upstream := map[string]interface{}{
"open-prs": map[string]interface{}{"data": []interface{}{}},
}
resp, err := CodeReviewRule(upstream, "review")
if err != nil {
t.Fatalf("CodeReviewRule failed: %v", err)
}
analysis := resp.Analysis.(map[string]interface{})
if msg := analysis["message"]; msg != "no open PRs to review" {
t.Fatalf("expected 'no open PRs to review', got %v", msg)
}
}

View File

@ -0,0 +1,355 @@
package rules
import (
"fmt"
"regexp"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// TriageRule classifies issues, assigns priorities, matches labels, and distributes work.
func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
issues := extractIssues(upstream, "open-issues")
labels := extractLabels(upstream)
members := extractMembers(upstream)
if len(issues) == 0 {
return &workflow.AIResponse{
Analysis: map[string]interface{}{"classified": 0, "message": "no open issues to triage"},
Actions: nil,
}, nil
}
var actions []workflow.AIAction
memberLoad := map[string]int{}
classified := []map[string]interface{}{}
gfi := []map[string]interface{}{}
for _, issue := range issues {
num := issueNumber(issue)
title := str(issue, "title")
body := str(issue, "body", "description")
text := title + " " + body
open := isIssueOpen(issue)
processed := isIssueProcessed(issue)
cat := classifyIssue(text)
pri := assignPriority(text)
labelIDs := matchLabels(cat, labels)
assignee := leastLoaded(memberLoad, members)
if assignee != "" {
memberLoad[assignee]++
}
result := map[string]interface{}{
"number": num,
"title": title,
"category": cat,
"priority": pri,
"assignee": assignee,
"processed": processed,
}
classified = append(classified, result)
if processed {
continue
}
// Build PATCH action for fields supported by issue update.
body2 := map[string]interface{}{}
body2["subject"] = title
body2["description"] = body
if statusID := issueStatusID(issue); statusID != nil {
body2["status_id"] = statusID
}
if len(labelIDs) > 0 {
body2["issue_tag_ids"] = labelIDs
}
if assignee != "" {
body2["assigner_ids"] = []string{assignee}
}
if pri > 0 {
body2["priority_id"] = pri
}
if open && len(body2) > 0 && num != "" {
actions = append(actions, workflow.AIAction{
Type: "api", Method: "PATCH",
Path: fmt.Sprintf("{v1}/issues/%s", num),
Body: body2,
})
}
if open && isGoodFirstIssue(text) {
gfi = append(gfi, result)
actions = append(actions, workflow.AIAction{
Type: "cli", Module: "issue", Command: "+comment",
Args: map[string]string{
"number": num,
"body": "👋 感谢提交 Issue这个 Issue 已被标记为 **Good First Issue**,适合新贡献者参与。欢迎提交 PR\n\n---\n*此评论由社区运营工作流自动生成*",
},
})
}
}
analysis := map[string]interface{}{
"classified": len(classified),
"results": classified,
"good_first_issues": gfi,
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
}
// --- classification ---
var catPatterns = []struct {
re *regexp.Regexp
category string
}{
{regexp.MustCompile(`(?i)错误|失败|异常|崩溃|crash|error|bug|broken|404|500`), "bug"},
{regexp.MustCompile(`(?i)安全|漏洞|泄露|vulnerability|CVE|敏感`), "security"},
{regexp.MustCompile(`(?i)性能|慢|卡顿|优化|performance|speed`), "performance"},
{regexp.MustCompile(`(?i)建议|希望|新增|支持|feature|enhancement|add|improve`), "enhancement"},
{regexp.MustCompile(`(?i)重构|代码质量|refactor|clean\s*up|tech\s*debt`), "refactor"},
{regexp.MustCompile(`(?i)文档|README|帮助|doc|documentation|typo`), "docs"},
{regexp.MustCompile(`(?i)如何|怎么|请问|how\s*to|question|help|求助`), "question"},
}
var priorityPatterns = []struct {
re *regexp.Regexp
pri int
}{
{regexp.MustCompile(`(?i)紧急|urgent|critical|崩溃|crash|严重|安全|漏洞|CVE|P0`), 4},
{regexp.MustCompile(`(?i)重要|high|important|P1|阻断`), 3},
{regexp.MustCompile(`(?i)低|low|trivial|minor|P3`), 1},
}
func classifyIssue(text string) string {
for _, p := range catPatterns {
if p.re.MatchString(text) {
return p.category
}
}
return "enhancement" // default
}
func assignPriority(text string) int {
for _, p := range priorityPatterns {
if p.re.MatchString(text) {
return p.pri
}
}
return 2 // default: medium
}
func isGoodFirstIssue(text string) bool {
gfiRe := regexp.MustCompile(`(?i)good\s*first\s*issue|beginner|easy|简单|新手|入门`)
if gfiRe.MatchString(text) {
return true
}
// Also mark simple enhancements/docs as GFI.
cat := classifyIssue(text)
pri := assignPriority(text)
return (cat == "docs" || cat == "enhancement") && pri <= 2 &&
len(strings.Fields(text)) < 200
}
func isIssueOpen(issue map[string]interface{}) bool {
for _, k := range []string{"state", "status", "issue_status", "status_name"} {
s := strings.ToLower(strings.TrimSpace(str(issue, k)))
switch s {
case "closed", "close", "resolved", "done", "已关闭", "关闭", "已解决":
return false
case "open", "opened", "active", "new", "新增", "开启", "打开":
return true
}
}
for _, k := range []string{"status_id", "state_id"} {
switch fmt.Sprint(issue[k]) {
case "3", "5":
return false
case "1", "2":
return true
}
}
return true
}
func issueStatusID(issue map[string]interface{}) interface{} {
for _, k := range []string{"status_id", "state_id"} {
if v := issue[k]; v != nil {
return v
}
}
return nil
}
func isIssueProcessed(issue map[string]interface{}) bool {
return commentCount(issue) > 0 || len(extractIssueTags(issue)) > 0 || hasAssignee(issue)
}
func hasAssignee(issue map[string]interface{}) bool {
for _, k := range []string{"assigners", "assignees", "assigned_to", "assignee", "assigned_to_id", "assigner_ids"} {
v, ok := issue[k]
if !ok || v == nil {
continue
}
switch x := v.(type) {
case []interface{}:
if len(x) > 0 {
return true
}
case []string:
if len(x) > 0 {
return true
}
case string:
if strings.TrimSpace(x) != "" {
return true
}
default:
if fmt.Sprint(x) != "" && fmt.Sprint(x) != "0" && fmt.Sprint(x) != "<nil>" {
return true
}
}
}
return false
}
// --- label matching ---
func extractLabels(upstream map[string]interface{}) []map[string]interface{} {
return extractList(upstream, "labels")
}
func matchLabels(category string, labels []map[string]interface{}) []interface{} {
catLower := strings.ToLower(category)
aliases := labelAliases(catLower)
var ids []interface{}
for _, l := range labels {
name := strings.ToLower(str(l, "name", "title", "label"))
if name == "" {
continue
}
// Direct match or contains.
if matchesLabelName(catLower, aliases, name) {
if id := labelID(l); id != nil {
ids = append(ids, id)
}
}
}
// Also match sub-categories for bug.
if catLower == "bug" {
for _, l := range labels {
name := strings.ToLower(str(l, "name", "title", "label"))
if strings.Contains(name, "bug") || strings.Contains(name, "fix") {
if id := labelID(l); id != nil {
ids = append(ids, id)
}
}
}
}
return ids
}
func labelAliases(category string) []string {
switch category {
case "bug":
return []string{"bug", "bugs", "fix", "修复", "疑修", "缺陷", "错误", "故障", "问题"}
case "security":
return []string{"security", "安全", "漏洞", "cve"}
case "performance":
return []string{"performance", "perf", "性能", "优化"}
case "refactor":
return []string{"refactor", "重构", "代码质量", "技术债"}
case "enhancement":
return []string{"enhancement", "feature", "功能", "需求", "新增", "改进"}
case "docs":
return []string{"docs", "documentation", "文档", "readme", "帮助"}
case "question":
return []string{"question", "help", "疑问", "问题", "求助"}
default:
return []string{category}
}
}
func matchesLabelName(category string, aliases []string, name string) bool {
if name == category || strings.Contains(name, category) || strings.Contains(category, name) {
return true
}
for _, alias := range aliases {
alias = strings.ToLower(alias)
if name == alias || strings.Contains(name, alias) || strings.Contains(alias, name) {
return true
}
}
return false
}
func labelID(l map[string]interface{}) interface{} {
for _, k := range []string{"id", "tag_id", "label_id"} {
if v := l[k]; v != nil {
return v
}
}
return nil
}
// --- assignment ---
func leastLoaded(load map[string]int, members map[string]string) string {
if len(members) == 0 {
return ""
}
best := ""
bestN := -1
for login, id := range members {
n := load[login]
if bestN < 0 || n < bestN {
bestN = n
best = id
}
}
return best
}
// --- helpers ---
func issueNumber(issue map[string]interface{}) string {
for _, k := range []string{"project_issues_index", "number", "iid", "id"} {
s := fmt.Sprint(issue[k])
if s != "" && s != "0" && s != "<nil>" {
return s
}
}
return ""
}
// extractIssueTags returns the existing tags on an issue.
func extractIssueTags(issue map[string]interface{}) []interface{} {
for _, k := range []string{"issue_tags", "tags", "labels"} {
if v, ok := issue[k]; ok {
if arr, ok := v.([]interface{}); ok && len(arr) > 0 {
return arr
}
}
}
return nil
}
// commentCount returns the number of comments on an issue.
func commentCount(issue map[string]interface{}) int {
for _, k := range []string{"comment_journals_count", "comments_count", "comment_count"} {
if v, ok := issue[k]; ok {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
}
}
}
return 0
}

View File

@ -0,0 +1,253 @@
package rules
import (
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestTriageRuleClassifiesByKeyword(t *testing.T) {
upstream := map[string]interface{}{
"open-issues": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"project_issues_index": "1", "title": "fix crash on startup", "body": "应用启动时崩溃"},
map[string]interface{}{"project_issues_index": "2", "title": "新增导出功能", "body": ""},
map[string]interface{}{"project_issues_index": "3", "title": "如何配置SSO", "body": "请问怎么配"},
},
},
"labels": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": 1, "name": "bug"},
map[string]interface{}{"id": 2, "name": "enhancement"},
map[string]interface{}{"id": 3, "name": "question"},
},
},
"members": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"login": "dev1", "name": "Dev One"},
},
},
}
resp, err := TriageRule(upstream, "triage")
if err != nil {
t.Fatalf("TriageRule failed: %v", err)
}
if resp.Analysis == nil {
t.Fatal("expected non-nil Analysis")
}
analysis, ok := resp.Analysis.(map[string]interface{})
if !ok {
t.Fatal("Analysis is not a map")
}
if v := analysis["classified"]; v.(int) != 3 {
t.Fatalf("expected 3 classified, got %v", v)
}
if len(resp.Actions) == 0 {
t.Fatal("expected actions for issue triage")
}
// Verify each action type.
for _, a := range resp.Actions {
if a.Type == "api" && a.Method != "PATCH" {
t.Errorf("unexpected API method: %s", a.Method)
}
}
}
func TestTriageRuleEmptyIssues(t *testing.T) {
upstream := map[string]interface{}{
"open-issues": map[string]interface{}{"data": []interface{}{}},
"labels": map[string]interface{}{"data": []interface{}{}},
"members": map[string]interface{}{"data": []interface{}{}},
}
resp, err := TriageRule(upstream, "triage")
if err != nil {
t.Fatalf("TriageRule failed: %v", err)
}
if len(resp.Actions) != 0 {
t.Fatalf("expected 0 actions for empty issues, got %d", len(resp.Actions))
}
analysis := resp.Analysis.(map[string]interface{})
if v := analysis["classified"]; v.(int) != 0 {
t.Fatalf("expected 0 classified, got %v", v)
}
}
func TestTriageRuleGoodFirstIssue(t *testing.T) {
upstream := map[string]interface{}{
"open-issues": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"project_issues_index": "10", "title": "good first issue: add docs", "body": "easy task for beginners"},
},
},
"labels": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": 1, "name": "docs"},
},
},
"members": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"login": "dev1", "name": "Dev"},
},
},
}
resp, err := TriageRule(upstream, "triage")
if err != nil {
t.Fatalf("TriageRule failed: %v", err)
}
hasComment := false
for _, a := range resp.Actions {
if a.Type == "cli" && a.Module == "issue" && a.Command == "+comment" {
hasComment = true
break
}
}
if !hasComment {
t.Fatal("expected a cli comment action for good first issue")
}
}
func TestTriageRuleLabelsWithChineseAlias(t *testing.T) {
upstream := map[string]interface{}{
"open-issues": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"project_issues_index": "21", "title": "新增代码质量看门人工作流", "body": ""},
},
},
"labels": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": 323830, "name": "功能"},
},
},
"members": map[string]interface{}{"data": []interface{}{}},
}
resp, err := TriageRule(upstream, "triage")
if err != nil {
t.Fatalf("TriageRule failed: %v", err)
}
for _, a := range resp.Actions {
if a.Type == "api" {
ids, ok := a.Body["issue_tag_ids"].([]interface{})
if !ok || len(ids) != 1 || ids[0] != 323830 {
t.Fatalf("unexpected issue_tag_ids: %+v", a.Body["issue_tag_ids"])
}
return
}
}
t.Fatal("expected PATCH action with Chinese 功能 label")
}
func TestTriageRuleDoesNotCommentClosedIssue(t *testing.T) {
upstream := map[string]interface{}{
"open-issues": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"project_issues_index": "10", "title": "good first issue: add docs", "body": "easy task for beginners", "status": "closed"},
},
},
"labels": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": 1, "name": "docs"},
},
},
"members": map[string]interface{}{"data": []interface{}{}},
}
resp, err := TriageRule(upstream, "triage")
if err != nil {
t.Fatalf("TriageRule failed: %v", err)
}
for _, a := range resp.Actions {
if a.Type == "cli" && a.Module == "issue" && a.Command == "+comment" {
t.Fatal("did not expect a cli comment action for closed issue")
}
if a.Type == "api" && a.Method == "PATCH" {
t.Fatal("did not expect a patch action for closed issue")
}
}
}
func TestTriageRuleSkipsProcessedIssue(t *testing.T) {
upstream := map[string]interface{}{
"open-issues": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"project_issues_index": "11",
"title": "新增导出功能",
"body": "简单功能",
"comment_journals_count": 1,
"tags": []interface{}{map[string]interface{}{"id": 323830, "name": "功能"}},
"assigners": []interface{}{map[string]interface{}{"id": 148915, "login": "yetja"}},
},
},
},
"labels": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": 323830, "name": "功能"},
},
},
"members": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"id": 148915, "login": "yetja"},
},
},
}
resp, err := TriageRule(upstream, "triage")
if err != nil {
t.Fatalf("TriageRule failed: %v", err)
}
if len(resp.Actions) != 0 {
t.Fatalf("expected processed issue to be ignored, got actions: %+v", resp.Actions)
}
}
func TestTriageRulePriority(t *testing.T) {
cases := []struct {
title string
expected int
}{
{"紧急: 安全漏洞", 4},
{"重要功能", 3},
{"普通建议", 2},
{"低优先级改进", 1},
}
for _, tc := range cases {
upstream := map[string]interface{}{
"open-issues": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"project_issues_index": "1", "title": tc.title, "body": ""},
},
},
"labels": map[string]interface{}{"data": []interface{}{}},
"members": map[string]interface{}{"data": []interface{}{}},
}
resp, err := TriageRule(upstream, "triage")
if err != nil {
t.Fatalf("TriageRule failed for %q: %v", tc.title, err)
}
if len(resp.Actions) > 0 {
body := resp.Actions[0].Body
if v, ok := body["priority_id"]; ok {
if v.(int) != tc.expected {
t.Errorf("title=%q: priority_id=%v, want %d", tc.title, v, tc.expected)
}
}
}
}
}
func TestTriageRuleOutputFormat(t *testing.T) {
resp, err := TriageRule(map[string]interface{}{}, "triage")
if err != nil {
t.Fatalf("TriageRule failed: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
// Verify it's a valid workflow.AIResponse.
var _ *workflow.AIResponse = resp
}

View File

@ -0,0 +1,36 @@
# gitlink-auto-merge质量自动合并
基于 Review 和 CI 诊断结果,当代码质量达标时自动合并 PR。
## 合并条件
1. Review 中不存在 high-severity 的 security 类型发现问题
2. CI 构建健康(无失败构建,或仓库未配置 CI
## 操作
当条件满足时,为每个符合条件的 PR 执行:
```
pr +merge --id <number> --method squash
```
## 条件不满足时
不执行合并,返回原因:
- "Review 发现高危安全问题,阻止自动合并"
- "CI 构建未通过,阻止自动合并"
## 输出格式
```json
{
"analysis": {
"merged": 1,
"reason": "质量达标,已合并 1 个 PR"
},
"actions": [
{"type": "cli", "module": "pr", "command": "+merge", "args": {"id": "6", "method": "squash"}}
]
}
```

View File

@ -0,0 +1,85 @@
---
name: gitlink-contributor-ranking
version: 1.0.0
description: "贡献者排行与成长体系:统计贡献者活跃度、生成排行榜、识别新星与流失风险,并自动颁发成就徽章。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli --help"
---
# gitlink-contributor-ranking贡献者成长体系
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。**
## 概述
本 Skill 用于分析项目贡献者数据,生成贡献者排行榜,识别社区新星和流失风险,并为符合条件的贡献者颁发成就徽章。
## 输入数据
上游已采集以下数据(通过 JSON 传入):
- `commits` — 最近 50-100 条提交记录
- `open-issues` — 开放中的 Issue 列表
- `closed-issues` — 已关闭的 Issue 列表
- `merged-prs` — 已合并的 PR 列表
- `members` — 项目成员/协作者列表
每条记录包含作者信息、时间戳等元数据。
## 输出格式
**CRITICAL — 你只需要输出贡献者排行榜和徽章建议,不需要生成项目健康度报告(那是社区运营工作流的事)。**
```markdown
# 贡献者排行榜
> 统计周期:最近 30 天 | 生成时间YYYY-MM-DD HH:mm
## 总排行
| 排名 | 贡献者 | 提交 | Issue | PR | 总计 | 趋势 | 勋章 |
|------|--------|------|-------|-----|------|------|------|
| 1 | ... | N | N | N | N | ↑/↓/→ | 🥇 |
| 2 | ... | N | N | N | N | ↑/↓/→ | 🥈 |
## 新星 🌟
- **贡献者名** — 近期活跃度显著提升(趋势 > 50%),建议颁发「新星」徽章
## 流失风险 ⚠️
- **贡献者名** — 超过 30 天无活动记录,建议社区管理员关注
## 徽章颁发建议
根据分析结果,建议为以下贡献者颁发徽章:
| 贡献者 | 建议徽章 | 原因 |
|--------|----------|------|
| xxx | 代码贡献者 | 近 30 天提交 N 次 |
| yyy | Issue 猎手 | 关闭 N 个 Issue |
| zzz | 新星 | 活跃度上升 N% |
> 由 contributor-growth 工作流自动生成
```
## 徽章规则
根据数据自动判定:
| 条件 | 徽章 |
|------|------|
| 近 30 天提交 ≥ 10 次 | 代码贡献者 |
| 近 30 天关闭 Issue ≥ 5 个 | Issue 猎手 |
| 近 30 天合并 PR ≥ 3 个 | PR 达人 |
| 趋势上升 > 50% | 新星 |
## 注意事项
- 输出纯 Markdown不要包含 JSON 包装
- 如果某项数据为空(如无 PR跳过对应徽章
- 排行至少展示 Top 10如果总数不足则全部展示
- 趋势:近 30 天 vs 前 30 天对比,超过 30 天无活动标注「流失风险」

View File

@ -0,0 +1,36 @@
# gitlink-init-scaffold
根据项目描述一键创建仓库并初始化脚手架。
## 输入
上游数据(`_desc`)包含项目的自然语言描述。
## 输出
```json
{
"analysis": {
"repo": "owner/repo-name",
"description": "项目描述",
"created": true
},
"actions": [
{"type": "cli", "module": "repo", "command": "+create", "args": {"name": "repo-name", "description": "..."}},
{"type": "cli", "module": "file", "command": "+create", "args": {"path": "README.md", "content": "..."}},
{"type": "cli", "module": "file", "command": "+create", "args": {"path": "LICENSE", "content": "MIT"}},
{"type": "cli", "module": "file", "command": "+create", "args": {"path": ".gitignore", "content": "..."}},
{"type": "cli", "module": "label", "command": "+create", "args": {"name": "bug", "color": "#d73a4a"}},
{"type": "cli", "module": "milestone", "command": "+create", "args": {"title": "v0.1.0"}},
{"type": "cli", "module": "issue", "command": "+create", "args": {"title": "项目初始化", "body": "..."}}
]
}
```
## 规则
1. 从 `_desc` 提取英文关键词生成仓库名;若无英文词则用 `_repo` 字段
2. 创建 README项目名 + 描述 + 快速开始、MIT LICENSE、Go .gitignore
3. 创建 7 个默认标签bug/security/performance/enhancement/refactor/docs/question
4. 创建 v0.1.0 里程碑
5. 创建 3 个初始 Issue项目初始化、CI/CD 配置、文档完善

View File

@ -0,0 +1,26 @@
# gitlink-multi-repo
多仓库协同工作流:基于多个仓库的 Issue、PR、Release 和 Milestone 快照,生成统一 Issue 追踪、PR 状态看板和 Release 协调发布报告。
## 原则
- 核心统计和发布阻塞判断以规则引擎结果为准。
- AI 只做语义增强:总结风险、解释阻塞原因、生成行动清单和 Markdown 报告。
- 不执行写操作,不自动创建 Release不批量评论 Issue/PR。
## 输入
上游数据来自 `multi-repo-snapshot`,结构包含:
- `release`: 目标版本,例如 `v1.4.0`
- `repos`: 每个仓库的 `info`、`open_issues`、`open_prs`、`releases`、`milestones`
- `errors`: 采集失败项
## 输出建议
输出应包含:
- 统一 Issue 追踪:按仓库统计 open、blocker、超期、高优先级 Issue。
- PR 状态看板:按仓库统计 open、待 review、冲突、超期 PR。
- Release 协调:判断目标版本是否建议发布,列出阻塞原因。
- 行动建议:按优先级列出需要处理的仓库和事项。

View File

@ -0,0 +1,221 @@
package snapshot
import (
"encoding/csv"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// MultiRepoSnapshot is a cross-repository state snapshot.
type MultiRepoSnapshot struct {
Release string `json:"release,omitempty"`
Repos []RepoSnapshot `json:"repos"`
Errors []RepoError `json:"errors,omitempty"`
Generated string `json:"generated_at"`
}
// RepoSnapshot holds collected data for a single repository.
type RepoSnapshot struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Info interface{} `json:"info,omitempty"`
OpenIssues interface{} `json:"open_issues,omitempty"`
OpenPRs interface{} `json:"open_prs,omitempty"`
Releases interface{} `json:"releases,omitempty"`
Milestones interface{} `json:"milestones,omitempty"`
}
// RepoError records an error encountered while collecting repo data.
type RepoError struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Step string `json:"step"`
Error string `json:"error"`
}
type repoRef = RepoRef
// BuildMultiRepoSnapshot collects Issue/PR/Release/Milestone data for multiple repos.
func BuildMultiRepoSnapshot(ctx *common.RuntimeContext) (*MultiRepoSnapshot, error) {
repos, err := parseMultiRepoRefs(ctx.Arg("repos"), ctx.Arg("from"))
if err != nil {
return nil, err
}
if len(repos) == 0 {
return nil, fmt.Errorf("multi-repo workflow requires --repos owner/repo[,owner/repo...] or --from repos.csv")
}
snapshot := &MultiRepoSnapshot{
Release: ctx.Arg("release"),
Repos: make([]RepoSnapshot, 0, len(repos)),
Generated: time.Now().Format(time.RFC3339),
}
for _, ref := range repos {
repoSnap := RepoSnapshot{Owner: ref.Owner, Repo: ref.Repo}
collectRepoSnapshot(ctx, ref, &repoSnap, &snapshot.Errors)
snapshot.Repos = append(snapshot.Repos, repoSnap)
}
return snapshot, nil
}
func collectRepoSnapshot(ctx *common.RuntimeContext, ref repoRef, snap *RepoSnapshot, errs *[]RepoError) {
call := func(step, method, path string, q url.Values) interface{} {
env, err := ctx.CallAPIWithQuery(method, path, q)
if err != nil {
*errs = append(*errs, RepoError{Owner: ref.Owner, Repo: ref.Repo, Step: step, Error: err.Error()})
return nil
}
if env == nil || !env.OK {
msg := "request failed"
if env != nil && env.Error != nil {
msg = env.Error.Message
}
*errs = append(*errs, RepoError{Owner: ref.Owner, Repo: ref.Repo, Step: step, Error: msg})
return nil
}
return env.Data
}
base := fmt.Sprintf("/%s/%s", ref.Owner, ref.Repo)
v1 := fmt.Sprintf("/v1/%s/%s", ref.Owner, ref.Repo)
snap.Info = call("repo-info", "GET", base, nil)
issueQ := url.Values{}
issueQ.Set("page", "1")
issueQ.Set("limit", "100")
issueQ.Set("state", "open")
snap.OpenIssues = call("open-issues", "GET", v1+"/issues", issueQ)
prQ := url.Values{}
prQ.Set("page", "1")
prQ.Set("limit", "100")
prQ.Set("state", "open")
snap.OpenPRs = call("open-prs", "GET", base+"/pulls", prQ)
releaseQ := url.Values{}
releaseQ.Set("page", "1")
releaseQ.Set("limit", "100")
snap.Releases = call("releases", "GET", base+"/releases", releaseQ)
milestoneQ := url.Values{}
milestoneQ.Set("page", "1")
milestoneQ.Set("limit", "100")
milestoneQ.Set("category", "opening")
milestoneQ.Set("sort_by", "created_on")
milestoneQ.Set("sort_direction", "desc")
snap.Milestones = call("milestones", "GET", v1+"/milestones", milestoneQ)
}
// ParseRepoCSV reads repo references from a CSV file.
func ParseRepoCSV(path string) ([]RepoRef, error) {
refs, err := parseRepoCSV(path)
if err != nil {
return nil, err
}
out := make([]RepoRef, len(refs))
for i, r := range refs {
out[i] = RepoRef(r)
}
return out, nil
}
// ParseMultiRepoRefs parses repo references from a comma-separated string and/or CSV file.
func ParseMultiRepoRefs(reposArg, fromPath string) ([]RepoRef, error) {
return parseMultiRepoRefs(reposArg, fromPath)
}
// RepoRef is an owner/repo pair.
type RepoRef struct {
Owner string
Repo string
}
func parseMultiRepoRefs(reposArg, fromPath string) ([]repoRef, error) {
var refs []repoRef
if reposArg != "" {
for _, raw := range strings.Split(reposArg, ",") {
ref, err := parseRepoRef(raw)
if err != nil {
return nil, err
}
refs = append(refs, ref)
}
}
if fromPath != "" {
fileRefs, err := parseRepoCSV(fromPath)
if err != nil {
return nil, err
}
refs = append(refs, fileRefs...)
}
return dedupeRepoRefs(refs), nil
}
func parseRepoRef(raw string) (repoRef, error) {
raw = strings.TrimSpace(raw)
parts := strings.Split(raw, "/")
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return repoRef{}, fmt.Errorf("invalid repo %q: expected owner/repo", raw)
}
return repoRef{Owner: strings.TrimSpace(parts[0]), Repo: strings.TrimSpace(parts[1])}, nil
}
func parseRepoCSV(path string) ([]repoRef, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read repo file %s: %w", path, err)
}
defer f.Close()
reader := csv.NewReader(f)
reader.FieldsPerRecord = -1
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse repo file %s: %w", path, err)
}
var refs []repoRef
for i, rec := range records {
if len(rec) == 0 {
continue
}
if i == 0 && len(rec) >= 2 && strings.EqualFold(strings.TrimSpace(rec[0]), "owner") && strings.EqualFold(strings.TrimSpace(rec[1]), "repo") {
continue
}
if len(rec) == 1 {
ref, err := parseRepoRef(rec[0])
if err != nil {
return nil, fmt.Errorf("line %d: %w", i+1, err)
}
refs = append(refs, ref)
continue
}
if strings.TrimSpace(rec[0]) == "" || strings.TrimSpace(rec[1]) == "" {
return nil, fmt.Errorf("line %d: owner and repo must not be empty", i+1)
}
refs = append(refs, repoRef{Owner: strings.TrimSpace(rec[0]), Repo: strings.TrimSpace(rec[1])})
}
return refs, nil
}
func dedupeRepoRefs(refs []repoRef) []repoRef {
seen := make(map[string]bool, len(refs))
out := make([]repoRef, 0, len(refs))
for _, ref := range refs {
key := ref.Owner + "/" + ref.Repo
if seen[key] {
continue
}
seen[key] = true
out = append(out, ref)
}
return out
}

View File

@ -0,0 +1,38 @@
package snapshot
import (
"os"
"path/filepath"
"testing"
)
func TestParseMultiRepoRefs(t *testing.T) {
refs, err := ParseMultiRepoRefs("org/backend, org/frontend,org/backend", "")
if err != nil {
t.Fatalf("ParseMultiRepoRefs failed: %v", err)
}
if len(refs) != 2 {
t.Fatalf("expected 2 deduped refs, got %d: %+v", len(refs), refs)
}
if refs[0].Owner != "org" || refs[0].Repo != "backend" {
t.Fatalf("unexpected first ref: %+v", refs[0])
}
}
func TestParseRepoCSV(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "repos.csv")
if err := os.WriteFile(path, []byte("owner,repo\norg,backend\norg/frontend\n"), 0600); err != nil {
t.Fatal(err)
}
refs, err := ParseRepoCSV(path)
if err != nil {
t.Fatalf("ParseRepoCSV failed: %v", err)
}
if len(refs) != 2 {
t.Fatalf("expected 2 refs, got %d", len(refs))
}
if refs[1].Owner != "org" || refs[1].Repo != "frontend" {
t.Fatalf("unexpected second ref: %+v", refs[1])
}
}

View File

@ -0,0 +1,231 @@
package state
import (
"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"
)
// FilterReviewedPRs removes PRs from upstream that were already reviewed
// with the same fingerprint, preventing re-review on every poll cycle.
func FilterReviewedPRs(upstream map[string]interface{}, ctx *common.RuntimeContext) {
wfName := ctx.Arg("__wf_name")
if wfName == "" {
return
}
state, err := LoadState(wfName)
if err != nil || state == nil {
return
}
if state.ReviewedPRs == nil {
state.ReviewedPRs = make(map[string]string)
}
prs := extractPRListFromUpstream(upstream, "open-prs")
if len(prs) == 0 {
return
}
filtered := make([]interface{}, 0, len(prs))
skipped := 0
for _, pr := range prs {
prNum := prNumberFromMap(pr)
if prNum == "" {
filtered = append(filtered, pr)
continue
}
fp := prFingerprint(pr)
if stored, ok := state.ReviewedPRs[prNum]; ok && stored == fp {
skipped++
continue
}
filtered = append(filtered, pr)
}
if skipped > 0 {
fmt.Fprintf(os.Stderr, "[workflow] 跳过 %d 个已审查的 PR无变化\n", skipped)
}
upstream["open-prs"] = map[string]interface{}{"data": filtered}
}
// SaveReviewedPRFingerprints stores PR fingerprints after a successful review.
func SaveReviewedPRFingerprints(upstream map[string]interface{}, ctx *common.RuntimeContext) {
wfName := ctx.Arg("__wf_name")
if wfName == "" {
return
}
state, err := LoadState(wfName)
if err != nil || state == nil {
return
}
if state.ReviewedPRs == nil {
state.ReviewedPRs = make(map[string]string)
}
prs := extractPRListFromUpstream(upstream, "open-prs")
for _, pr := range prs {
prNum := prNumberFromMap(pr)
if prNum == "" {
continue
}
state.ReviewedPRs[prNum] = prFingerprint(pr)
}
state.Save()
}
// EnrichWithPRDiffs fetches diffs for open PRs and stores them in upstream.
func EnrichWithPRDiffs(upstream map[string]interface{}, owner, repo string) {
prs := extractPRListFromUpstream(upstream, "open-prs")
if len(prs) == 0 {
return
}
diffs := make(map[string]string)
for _, pr := range prs {
status := ""
switch v := pr["pull_request_status"].(type) {
case string:
status = v
case float64:
if v == 0 {
status = "open"
}
}
if status == "" {
if s, ok := pr["pull_request_staus"].(string); ok {
status = s
}
}
if status != "" && status != "open" {
continue
}
prNum := ""
if n, ok := pr["pull_request_number"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["id"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["number"]; ok {
prNum = fmt.Sprintf("%v", n)
} else if n, ok := pr["pull_request_id"]; ok {
prNum = fmt.Sprintf("%v", n)
}
if prNum == "" {
continue
}
bin := wf.ResolveCLIBinary()
cmd := exec.Command(bin, "pr", "+diff", "--id", prNum, "--owner", owner, "--repo", repo, "--format", "json")
out, err := cmd.Output()
if err != nil {
continue
}
var resp struct {
OK bool `json:"ok"`
Data struct {
Files []struct {
Sections []struct {
Lines []struct {
Content string `json:"content"`
} `json:"lines"`
} `json:"sections"`
} `json:"files"`
} `json:"data"`
}
if err := json.Unmarshal(out, &resp); err != nil || !resp.OK || len(resp.Data.Files) == 0 {
continue
}
var sb strings.Builder
for _, f := range resp.Data.Files {
for _, sec := range f.Sections {
for _, line := range sec.Lines {
sb.WriteString(line.Content)
sb.WriteString("\n")
}
}
}
diffText := sb.String()
if diffText == "" {
continue
}
diffs[prNum] = diffText
}
if len(diffs) > 0 {
upstream["_pr_diffs"] = diffs
}
}
// prFingerprint returns an MD5 hash of key PR fields for change detection.
func prFingerprint(pr map[string]interface{}) string {
var parts []string
if t := strFromMap(pr, "title", "name"); t != "" {
parts = append(parts, "title:"+t)
}
if b := strFromMap(pr, "body", "description"); b != "" {
parts = append(parts, "body:"+b)
}
if s := strFromMap(pr, "pull_request_status", "pull_request_staus", "status", "state"); s != "" {
parts = append(parts, "status:"+s)
}
h := md5.Sum([]byte(strings.Join(parts, "|")))
return hex.EncodeToString(h[:])
}
// prNumberFromMap extracts a PR number string from a PR data map.
func prNumberFromMap(pr map[string]interface{}) string {
for _, k := range []string{"pull_request_number", "id", "number", "pull_request_id"} {
if v := pr[k]; v != nil {
s := fmt.Sprintf("%v", v)
if s != "" && s != "0" && s != "<nil>" {
return s
}
}
}
return ""
}
// strFromMap returns the first non-empty string value from the given keys.
func strFromMap(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && v != "" {
return v
}
}
return ""
}
// extractPRListFromUpstream extracts PR list from upstream data.
func extractPRListFromUpstream(upstream map[string]interface{}, key string) []map[string]interface{} {
raw, ok := upstream[key]
if !ok {
return nil
}
if m, ok := raw.(map[string]interface{}); ok {
if data, ok := m["data"]; ok {
raw = data
}
}
if m, ok := raw.(map[string]interface{}); ok {
for _, listKey := range []string{"issues", "pull_requests"} {
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
}

View File

@ -0,0 +1,109 @@
package state
import (
"crypto/md5"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/gitlink-org/gitlink-cli/internal/config"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// WorkflowState tracks persistent run state and change detection snapshots.
type WorkflowState struct {
Workflow string `json:"workflow"`
LastRun string `json:"last_run"`
TotalRuns int `json:"total_runs"`
Snapshots map[string]string `json:"snapshots"`
PhaseLastRun map[string]string `json:"phase_last_run,omitempty"`
PhaseUpstream map[string]string `json:"phase_upstream,omitempty"`
ReviewedPRs map[string]string `json:"reviewed_prs,omitempty"`
}
// LoadState reads the persisted workflow state from disk.
func LoadState(name string) (*WorkflowState, error) {
path := statePath(name)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &WorkflowState{
Workflow: name,
Snapshots: make(map[string]string),
PhaseLastRun: make(map[string]string),
PhaseUpstream: make(map[string]string),
}, nil
}
return nil, err
}
var s WorkflowState
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parse state file %s: %w", path, err)
}
if s.Snapshots == nil {
s.Snapshots = make(map[string]string)
}
if s.PhaseLastRun == nil {
s.PhaseLastRun = make(map[string]string)
}
if s.PhaseUpstream == nil {
s.PhaseUpstream = make(map[string]string)
}
return &s, nil
}
// Save persists the workflow state to disk.
func (s *WorkflowState) Save() error {
s.LastRun = time.Now().Format(time.RFC3339)
path := statePath(s.Workflow)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// Diff compares current step results against stored snapshots.
func (s *WorkflowState) Diff(results []wf.StepResult) []string {
changed := []string{}
for _, sr := range results {
if !sr.OK || sr.Data == nil {
continue
}
hash := HashData(sr.Data)
if prev, ok := s.Snapshots[sr.Step]; ok && prev != hash {
changed = append(changed, sr.Step)
}
}
return changed
}
// UpdateSnapshots stores hashes of current step results for future diff.
func (s *WorkflowState) UpdateSnapshots(results []wf.StepResult) {
for _, sr := range results {
if !sr.OK || sr.Data == nil {
continue
}
s.Snapshots[sr.Step] = HashData(sr.Data)
}
}
// HashData computes an MD5 hash of the JSON-encoded data.
func HashData(data interface{}) string {
b, err := json.Marshal(data)
if err != nil {
return ""
}
return fmt.Sprintf("%x", md5.Sum(b))
}
// statePath returns the file path for a workflow's state file.
func statePath(name string) string {
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s-state.json", name))
}

View File

@ -0,0 +1,80 @@
package state
import (
"os"
"path/filepath"
"testing"
wf "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
func TestStateSaveLoad(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
s := &WorkflowState{
Workflow: "test-wf",
TotalRuns: 5,
Snapshots: map[string]string{"step1": "abc123"},
}
if err := s.Save(); err != nil {
t.Fatalf("Save failed: %v", err)
}
loaded, err := LoadState("test-wf")
if err != nil {
t.Fatalf("LoadState failed: %v", err)
}
if loaded.TotalRuns != 5 {
t.Fatalf("TotalRuns = %d, want 5", loaded.TotalRuns)
}
if loaded.Snapshots["step1"] != "abc123" {
t.Fatalf("Snapshots[step1] = %q, want abc123", loaded.Snapshots["step1"])
}
os.Remove(filepath.Join(dir, "workflow-test-wf-state.json"))
}
func TestStateDiff(t *testing.T) {
s := &WorkflowState{
Workflow: "test-diff",
Snapshots: map[string]string{"step1": "oldhash"},
}
results := []wf.StepResult{
{Step: "step1", OK: true, Data: "changed data"},
{Step: "step2", OK: true, Data: "new step"},
{Step: "step3", OK: false, Data: "ignored"},
}
changed := s.Diff(results)
if len(changed) != 1 {
t.Fatalf("Diff: expected 1 changed step, got %d", len(changed))
}
if changed[0] != "step1" {
t.Fatalf("Diff: expected 'step1' to change, got %q", changed[0])
}
s.UpdateSnapshots(results)
if _, ok := s.Snapshots["step2"]; !ok {
t.Fatal("step2 should be added to snapshots after UpdateSnapshots")
}
if _, ok := s.Snapshots["step3"]; ok {
t.Fatal("step3 (failed) should NOT be added to snapshots")
}
}
func TestLoadStateNotExist(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", dir)
s, err := LoadState("nonexistent")
if err != nil {
t.Fatalf("LoadState should not error for missing file: %v", err)
}
if s.Workflow != "nonexistent" {
t.Fatalf("Workflow = %q, want nonexistent", s.Workflow)
}
if s.Snapshots == nil {
t.Fatal("Snapshots should be initialized as empty map")
}
}

120
shortcuts/workflow/types.go Normal file
View File

@ -0,0 +1,120 @@
package workflow
import (
"fmt"
"net/url"
)
// AIMode controls whether AI is used for skill steps.
type AIMode string
const (
AIModeAuto AIMode = "auto"
AIModeAI AIMode = "ai"
AIModeNoAI AIMode = "no-ai"
)
// RuleEngineFunc is the signature for a deterministic rule engine.
type RuleEngineFunc func(upstream map[string]interface{}, stepName string) (*AIResponse, error)
// RuleEngines is a registry of skill-target → rule-engine mappings.
var RuleEngines = map[string]RuleEngineFunc{}
// RegisterRuleEngine registers a rule engine function for a given skill target.
func RegisterRuleEngine(target string, fn RuleEngineFunc) {
RuleEngines[target] = fn
}
// ErrNoRuleEngine is returned when no rule engine is registered for a target.
func ErrNoRuleEngine(target string) error {
return fmt.Errorf("no rule engine registered for skill target %q", target)
}
// StepType classifies what mechanism executes a step.
type StepType string
const (
StepTypeSkill StepType = "skill"
StepTypeCommand StepType = "command"
StepTypeAPI StepType = "api"
)
// RunWhen controls how often a step executes.
type RunWhen string
const (
RunAlways RunWhen = "always"
RunWeekly RunWhen = "weekly"
RunOnChange RunWhen = "on_change"
)
// StepDef defines a single step in a workflow.
type StepDef struct {
Type StepType `json:"type"`
Name string `json:"name"`
Purpose string `json:"purpose"`
Target string `json:"target"`
DependsOn []string `json:"depends_on,omitempty"`
RunWhen RunWhen `json:"run_when,omitempty"`
Method string `json:"method,omitempty"`
Query url.Values `json:"-"`
}
// TriggerDef configures when a workflow runs.
type TriggerDef struct {
Type string `json:"type"`
On string `json:"on"`
Interval string `json:"interval,omitempty"`
}
// WorkflowDef is a named, ordered sequence of steps with a trigger.
type WorkflowDef struct {
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
Trigger TriggerDef `json:"trigger"`
Steps []StepDef `json:"steps"`
}
// AIAction is a write instruction returned by an AI skill step.
type AIAction 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]string `json:"args,omitempty"`
}
// AIRequest bundles the data needed for an AI skill step call.
type AIRequest struct {
SystemPrompt string
UserData string
}
// AIResponse is the parsed structured output from an AI skill step.
type AIResponse struct {
Analysis interface{} `json:"analysis"`
Actions []AIAction `json:"actions"`
}
// WorkflowResult holds the outcome of a full workflow run.
type WorkflowResult struct {
Workflow string `json:"workflow"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Steps []StepResult `json:"steps"`
}
// StepResult holds the outcome of executing one step.
type StepResult struct {
Step string `json:"step"`
Purpose string `json:"purpose"`
Type StepType `json:"type"`
OK bool `json:"ok"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
SkipReason string `json:"skip_reason,omitempty"`
}

View File

@ -0,0 +1,259 @@
package workflow
import (
"testing"
)
func init() {
// Register all workflows for tests (defs init doesn't run in test context
// because importing defs would create an import cycle).
Register(&WorkflowDef{
Name: "community-ops",
Category: "运营",
Description: "社区运营自动化",
Trigger: TriggerDef{Type: "poll", On: "issue.created", Interval: "5m"},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "open-issues", Purpose: "获取开放 Issue", Target: "issue +list --state open --limit 50"},
{Type: StepTypeCommand, Name: "labels", Purpose: "获取标签库", Target: "label +list"},
{Type: StepTypeCommand, Name: "members", Purpose: "获取成员列表", Target: "member +list"},
{Type: StepTypeSkill, Name: "triage", Purpose: "AI 分析并执行分拣", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}, RunWhen: RunAlways},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取项目基础信息", Target: "repo +info"},
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "获取已合并 PR", Target: "pr +list --state merged --limit 50"},
{Type: StepTypeCommand, Name: "commits", Purpose: "获取提交历史", Target: "commit +list --limit 50"},
{Type: StepTypeSkill, Name: "health-report", Purpose: "AI 生成周报", Target: "gitlink-health", DependsOn: []string{"repo-info", "merged-prs", "commits", "open-issues"}, RunWhen: RunWeekly},
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
{Type: StepTypeSkill, Name: "changelog", Purpose: "生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}, RunWhen: RunOnChange},
},
})
Register(&WorkflowDef{
Name: "code-quality",
Category: "质量",
Description: "代码质量看门人",
Trigger: TriggerDef{Type: "poll", On: "pr.opened", Interval: "5m"},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
{Type: StepTypeCommand, Name: "ci-builds", Purpose: "获取 CI 构建状态", Target: "ci +builds --limit 10"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
{Type: StepTypeCommand, Name: "commits", Purpose: "获取最近提交", Target: "commit +list --limit 30"},
{Type: StepTypeCommand, Name: "branches", Purpose: "获取分支列表", Target: "branch +list"},
{Type: StepTypeSkill, Name: "review", Purpose: "AI 审查 PR 代码质量", Target: "gitlink-review", DependsOn: []string{"open-prs"}},
{Type: StepTypeSkill, Name: "ci-diagnosis", Purpose: "AI 诊断 CI 构建失败", Target: "gitlink-ci", DependsOn: []string{"ci-builds", "commits"}},
},
})
Register(&WorkflowDef{
Name: "project-init",
Category: "初始化",
Description: "项目一键初始化",
Trigger: TriggerDef{Type: "manual", On: "manual"},
Steps: []StepDef{
{Type: StepTypeSkill, Name: "init-scaffold", Purpose: "创建仓库并初始化脚手架", Target: "gitlink-init-scaffold"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "确认仓库已创建", Target: "repo +info"},
{Type: StepTypeCommand, Name: "existing-files", Purpose: "验证文件", Target: "file +list"},
{Type: StepTypeCommand, Name: "labels", Purpose: "验证标签库", Target: "label +list"},
{Type: StepTypeSkill, Name: "license-check", Purpose: "检查许可证合规", Target: "gitlink-license", DependsOn: []string{"existing-files"}},
{Type: StepTypeCommand, Name: "milestones", Purpose: "验证里程碑", Target: "milestone +list"},
{Type: StepTypeCommand, Name: "existing-issues", Purpose: "验证初始 Issue", Target: "issue +list --state all --limit 10"},
{Type: StepTypeCommand, Name: "branches", Purpose: "检查分支结构", Target: "branch +list"},
{Type: StepTypeSkill, Name: "repo-audit", Purpose: "综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "existing-files", "labels", "milestones", "branches"}},
},
})
Register(&WorkflowDef{
Name: "multi-repo",
Category: "协同",
Description: "多仓库协同",
Trigger: TriggerDef{Type: "cron", On: "0 9 * * 1", Interval: "24h"},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "multi-repo-snapshot", Purpose: "采集多个仓库状态", Target: "workflow-internal:multi-repo-snapshot"},
{Type: StepTypeSkill, Name: "multi-repo-coordination", Purpose: "生成统一报告", Target: "gitlink-multi-repo", DependsOn: []string{"multi-repo-snapshot"}},
},
})
Register(&WorkflowDef{
Name: "contributor-growth",
Category: "成长",
Description: "贡献者成长体系",
Trigger: TriggerDef{Type: "cron", On: "0 9 * * 1", Interval: "24h"},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "commits", Purpose: "提交历史", Target: "commit +list --limit 100"},
{Type: StepTypeCommand, Name: "open-issues", Purpose: "开放 Issue", Target: "issue +list --state open --limit 50"},
{Type: StepTypeCommand, Name: "closed-issues", Purpose: "已关闭 Issue", Target: "issue +list --state closed --limit 50"},
{Type: StepTypeCommand, Name: "merged-prs", Purpose: "已合并 PR", Target: "pr +list --state merged --limit 50"},
{Type: StepTypeCommand, Name: "members", Purpose: "项目成员列表", Target: "member +list"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "项目基础数据", Target: "repo +info"},
{Type: StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行", Target: "gitlink-contributor-ranking", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
},
})
}
func TestRegistry(t *testing.T) {
if len(registry) != 5 {
t.Fatalf("expected 5 workflows, got %d", len(registry))
}
for _, name := range []string{"community-ops", "code-quality", "project-init", "multi-repo", "contributor-growth"} {
wf := Get(name)
if wf == nil {
t.Fatalf("workflow %q not found", name)
}
if len(wf.Steps) == 0 {
t.Fatalf("workflow %q has no steps", name)
}
if wf.Trigger.Type == "" {
t.Fatalf("workflow %q has no trigger.type", name)
}
}
all := All()
if len(all) != 5 {
t.Fatalf("All() returned %d workflows, expected 5", len(all))
}
}
func TestGetNonexistent(t *testing.T) {
if Get("nonexistent") != nil {
t.Fatal("expected nil for nonexistent workflow")
}
}
func TestResolvePath(t *testing.T) {
cases := []struct {
template, owner, repo, expected string
}{
{"{v1}/issues", "chroe", "gitlink-cli", "/v1/chroe/gitlink-cli/issues"},
{"{base}/pulls", "chroe", "gitlink-cli", "/chroe/gitlink-cli/pulls"},
{"{base}", "org", "proj", "/org/proj"},
{"{v1}/issues?state=open", "x", "y", "/v1/x/y/issues?state=open"},
}
for _, tc := range cases {
got := ResolvePath(tc.template, tc.owner, tc.repo)
if got != tc.expected {
t.Fatalf("ResolvePath(%q, %s, %s) = %q, want %q", tc.template, tc.owner, tc.repo, got, tc.expected)
}
}
}
func TestTriggers(t *testing.T) {
expected := map[string]struct {
on string
typ string
}{
"community-ops": {"issue.created", "poll"},
"code-quality": {"pr.opened", "poll"},
"project-init": {"manual", "manual"},
"multi-repo": {"0 9 * * 1", "cron"},
"contributor-growth": {"0 9 * * 1", "cron"},
}
for name, want := range expected {
wf := Get(name)
if wf.Trigger.On != want.on {
t.Fatalf("%s: trigger.on = %q, want %q", name, wf.Trigger.On, want.on)
}
if wf.Trigger.Type != want.typ {
t.Fatalf("%s: trigger.type = %q, want %q", name, wf.Trigger.Type, want.typ)
}
}
}
func TestStepTypes(t *testing.T) {
wf := Get("community-ops")
if wf == nil {
t.Fatal("community-ops not found")
}
typeCounts := map[StepType]int{}
for _, s := range wf.Steps {
typeCounts[s.Type]++
}
if typeCounts[StepTypeCommand] < 1 {
t.Fatal("community-ops should have at least one command step")
}
if typeCounts[StepTypeSkill] < 1 {
t.Fatal("community-ops should have at least one skill step")
}
}
func TestSkillStepDependsOn(t *testing.T) {
wf := Get("community-ops")
if wf == nil {
t.Fatal("community-ops not found")
}
var triage *StepDef
for i := range wf.Steps {
if wf.Steps[i].Name == "triage" {
triage = &wf.Steps[i]
break
}
}
if triage == nil {
t.Fatal("triage step not found")
}
if len(triage.DependsOn) != 3 {
t.Fatalf("triage step should have 3 dependencies, got %d: %v", len(triage.DependsOn), triage.DependsOn)
}
expectedDeps := map[string]bool{"open-issues": false, "labels": false, "members": false}
for _, dep := range triage.DependsOn {
if _, ok := expectedDeps[dep]; !ok {
t.Fatalf("unexpected dependency: %s", dep)
}
expectedDeps[dep] = true
}
}
func TestMultiRepoWorkflowShape(t *testing.T) {
wf := Get("multi-repo")
if wf == nil {
t.Fatal("multi-repo not found")
}
if len(wf.Steps) != 2 {
t.Fatalf("multi-repo should have 2 steps, got %d", len(wf.Steps))
}
if wf.Steps[0].Name != "multi-repo-snapshot" || wf.Steps[0].Target != "workflow-internal:multi-repo-snapshot" {
t.Fatalf("unexpected snapshot step: %+v", wf.Steps[0])
}
if wf.Steps[1].Target != "gitlink-multi-repo" {
t.Fatalf("multi-repo skill target = %q, want gitlink-multi-repo", wf.Steps[1].Target)
}
}
func TestParseCommandTarget(t *testing.T) {
cases := []struct {
input string
expected []string
}{
{"issue +list --state open", []string{"issue", "+list", "--state", "open"}},
{"repo +info", []string{"repo", "+info"}},
{"pr +list --state merged --limit 50", []string{"pr", "+list", "--state", "merged", "--limit", "50"}},
{"issue +list --state open --limit 50", []string{"issue", "+list", "--state", "open", "--limit", "50"}},
}
for _, tc := range cases {
got := ParseCommandTarget(tc.input)
if len(got) != len(tc.expected) {
t.Fatalf("ParseCommandTarget(%q): len=%d, want len=%d (got=%v)", tc.input, len(got), len(tc.expected), got)
}
for i := range got {
if got[i] != tc.expected[i] {
t.Fatalf("ParseCommandTarget(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.expected[i])
}
}
}
}
func TestCodeQualityHasReviewStep(t *testing.T) {
wf := Get("code-quality")
if wf == nil {
t.Fatal("code-quality not found")
}
if len(wf.Steps) < 7 {
t.Fatalf("code-quality should have at least 7 steps (including review), got %d", len(wf.Steps))
}
found := false
for _, s := range wf.Steps {
if s.Target == "gitlink-review" {
found = true
break
}
}
if !found {
t.Fatal("code-quality missing gitlink-review skill step")
}
}

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM golang:1.26-alpine AS builder
@ -9,17 +10,20 @@ RUN go mod download
COPY . .
# Build gitlink-cli binary
RUN CGO_ENABLED=0 GOOS=linux go build -o gitlink-cli .
# Build showcase server binary
RUN CGO_ENABLED=0 GOOS=linux go build -o showcase-server ./showcase/
# 合并为一个 RUNshowcase-server 复用 gitlink-cli 已编译的依赖(同一 RUN 层内 Go 编译缓存共享)。
# --mount=type=cacheBuildKit 跨构建保留 Go 编译缓存(/root/.cache/go-build
# 之后只重编改动的包,不再全量重编(需 docker build 时 DOCKER_BUILDKIT=1
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o gitlink-cli . && \
CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o showcase-server ./showcase/
# Stage 2: Runtime
FROM alpine:latest
RUN apk add --no-cache ca-certificates git
RUN mkdir -p /root/.config/gitlink-cli
WORKDIR /app
COPY --from=builder /build/gitlink-cli .

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
package main
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
@ -31,6 +32,7 @@ func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Write(indexHTML)
})
@ -41,7 +43,9 @@ func main() {
command := r.URL.Query().Get("command")
owner := r.URL.Query().Get("owner")
repo := r.URL.Query().Get("repo")
format := r.URL.Query().Get("format")
extraArgs := r.URL.Query().Get("args")
descParam := r.URL.Query().Get("desc")
if module == "" || command == "" {
json.NewEncoder(w).Encode(RunResult{Error: "missing module or command"})
@ -58,31 +62,67 @@ func main() {
}
}
args := []string{module, "+" + command, "--owner", owner, "--repo", repo, "--format", "json"}
args := []string{module, "+" + command, "--owner", owner, "--format", format}
if !strings.Contains(extraArgs, "project-init") && !strings.Contains(extraArgs, "multi-repo") {
args = append(args, "--repo", repo)
}
if extraArgs != "" {
args = append(args, parseShellArgs(extraArgs)...)
}
// If desc is passed as a dedicated query parameter, ensure it is
// passed intact (bypasses the space-splitting in parseShellArgs).
if descParam != "" {
// Remove any --desc from parseShellArgs output (old JS path).
filtered := make([]string, 0, len(args))
skipNext := false
for i, a := range args {
if skipNext {
skipNext = false
continue
}
if a == "--desc" || a == "--description" {
skipNext = true
continue
}
if strings.HasPrefix(a, "--desc=") || strings.HasPrefix(a, "--description=") {
continue
}
_ = i
filtered = append(filtered, a)
}
args = filtered
args = append(args, "--desc", descParam)
}
cmdStr := "gitlink-cli " + strings.Join(args, " ")
log.Printf("Running: %s", cmdStr)
cmd := exec.Command(cliBin, args...)
output, err := cmd.CombinedOutput()
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
result := RunResult{
Command: cmdStr,
}
if err != nil {
result.Error = strings.TrimSpace(string(output))
if runErr != nil {
result.Error = strings.TrimSpace(stderr.String())
if result.Error == "" {
result.Error = strings.TrimSpace(stdout.String())
}
if result.Error == "" {
result.Error = runErr.Error()
}
result.Output = nil
} else {
result.OK = true
var parsed interface{}
if json.Unmarshal(output, &parsed) == nil {
if json.Unmarshal(stdout.Bytes(), &parsed) == nil {
result.Output = parsed
} else {
result.Output = strings.TrimSpace(string(output))
result.Output = strings.TrimSpace(stdout.String())
}
}
@ -93,8 +133,6 @@ func main() {
log.Fatal(http.ListenAndServe(":"+port, nil))
}
// parseShellArgs splits a shell-style argument string, respecting quoted values.
// e.g. `--content "Hello Wiki!" --message "create page"` -> ["--content", "Hello Wiki!", "--message", "create page"]
func parseShellArgs(s string) []string {
var args []string
var current strings.Builder

View File

@ -108,8 +108,24 @@ skills/
│ └── ci-workflow.md # CI 工作流
├── gitlink-pm/ # 项目管理
│ └── SKILL.md # PM 操作指南
└── gitlink-workflow/ # AI 自动化工作流
└── SKILL.md # 工作流模板Issue 分类、PR Review、Release Notes
├── gitlink-workflow/ # AI 自动化工作流
│ └── SKILL.md # 工作流模板Issue 分类、PR Review、Release Notes
├── gitlink-health/ # 项目健康度报告
│ ├── SKILL.md # 健康度报告操作指南
│ ├── REFERENCE.md # 指标体系与评分算法
│ └── examples/
│ ├── health-report.md # 完整健康度报告示例
│ └── weekly-report.md # 周报模式示例
├── gitlink-changelog/ # Release Notes 自动生成
│ ├── SKILL.md # Changelog 操作指南
│ ├── REFERENCE.md # Commit 分类规则与字段映射
│ └── examples/
│ └── generate-release-notes.md # Release Notes 生成示例
└── gitlink-triage/ # Issue 智能分拣与新人引导
├── SKILL.md # 分拣操作指南
├── REFERENCE.md # 标签体系与分配算法
└── examples/
└── batch-triage.md # 批量分拣示例
```
---
@ -138,6 +154,17 @@ skills/
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
### 🆕 智能 Skillsv1.2 新增)
| Skill | 说明 | 场景 |
|-------|------|------|
| **gitlink-health** | 项目健康度报告 | Issue 响应时间、PR 合并效率、贡献者活跃度统计 |
| **gitlink-changelog** | Release Notes 自动生成 | 从 commit/PR/Issue 历史自动生成版本说明 |
| **gitlink-triage** | Issue 智能分拣 + 新人引导 | 自动分类、打标签、分配责任人、good-first-issue 引导 |
| **gitlink-review** | 智能代码审查 | 分析 PR diff多视角评审 + 对抗式自检,结构化 Review 意见自动评论 |
| **gitlink-research-fair** | 科研软件 X 光 | fair.py 抽论文/数据/复现/引用画像,四维裁决 + 真科研图谱 + 特有关键发现报告,可选处方 PR |
| **gitlink-spark** | 文献-代码语义缺口挖掘机 | arXiv×GitLink×GitHub 三源挖"理论无实现/需求无解答"缺口,出机会报告,一键 fork+issue 起跑 |
---
## 🎯 使用场景
@ -304,6 +331,23 @@ AI 代理可以:
---
## 🤖 在 Claude Code 中通过 Skill 工具调用
默认情况下,这些 Skill 以**文档驱动**方式使用AI 代理读取 `SKILL.md` 后执行 `gitlink-cli` 命令(见上文流程图)。
若希望像标准 Skill 一样,由 Claude Code 的 **Skill 工具**直接加载并调用(例如 `Skill gitlink-issue`),需要把 `skills/gitlink-*` 链接到 Claude Code 扫描的 `~/.claude/skills/` 目录。
仓库提供一键脚本,自动检测平台并创建链接(**Windows 用目录联接 junction无需管理员权限macOS/Linux 用 symlink**),均指向仓库内的 `skills/`,更新仓库后内容自动同步:
```bash
# 在仓库根目录执行Windows 需 Git Bash 或 WSL
bash scripts/setup-skills.sh
```
执行后即可在 Claude Code 中通过 Skill 工具调用任意 `gitlink-*` Skill。脚本可重复执行会安全更新已有链接——移除时只删 junction/symlink 本身,**不会影响 `skills/` 下的源文件**。
---
## 📊 测试状态
**生产就绪** (8.5/10)

View File

@ -0,0 +1,109 @@
# gitlink-branch 参考手册
> 本文档定义分支管理命令的参数说明、API 字段映射和注意事项。
---
## 一、命令参考
### branch +list
列出仓库所有分支。
```bash
gitlink-cli branch +list --owner <owner> --repo <repo> --format json
```
返回字段:
| 字段 | 说明 |
|------|------|
| `name` | 分支名称 |
| `protected` | 是否受保护true/false |
实测返回chroe/gitlink-cli
```
master, test/pr-workflow-demo, pr/showcase-dashboard, pr/shortcuts-modules,
fix/showcase-member-v2, fix/showcase-member-org, fix/batch-invite-userid,
feat/task-a-shortcuts, fix/windows-npm-install
共 9 个分支
```
### branch +create
从指定分支或 commit 创建新分支。
```bash
gitlink-cli branch +create --owner <owner> --repo <repo> \
--name <新分支名> --from <源分支> --format json
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--name, -n` | 是 | 新分支名称 |
| `--from, -f` | 否 | 源分支或 commit默认 master |
API
```
POST /v1/{owner}/{repo}/branches.json
Body: { "branch_name": name, "ref": from }
```
### branch +delete
删除指定分支。
```bash
gitlink-cli branch +delete --owner <owner> --repo <repo> \
--name <分支名> --format json
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--name, -n` | 是 | 要删除的分支名称 |
> ⚠️ **写入操作** — 删除分支不可恢复,执行前确认用户意图。不能删除默认分支。
### branch +protect
设置分支保护。
```bash
gitlink-cli branch +protect --owner <owner> --repo <repo> \
--name <分支名> --format json
```
| 参数 | 必填 | 说明 |
|------|------|------|
| `--name, -n` | 是 | 要保护的分支名称 |
> ⚠️ 受保护的分支不允许直接 push 和强制推送。
---
## 二、分支命名约定
| 前缀 | 用途 | 示例 |
|------|------|------|
| `feat/` | 新功能 | `feat/task-a-shortcuts` |
| `fix/` | Bug 修复 | `fix/showcase-member-v2` |
| `pr/` | PR 相关 | `pr/showcase-dashboard` |
| `test/` | 测试 | `test/pr-workflow-demo` |
---
## 三、常见问题
### Q: branch +list 返回的分支数量很多怎么办?
A: 正常现象,开发过程中会积累功能分支。定期清理已合并的分支。
### Q: 能删除 master 分支吗?
A: 不能。GitLink 不允许删除默认分支。
### Q: branch +protect 和仓库设置里的保护有什么区别?
A: 效果相同CLI 命令是仓库设置的快捷方式。

View File

@ -1,70 +1,44 @@
# 分支管理完整工作流
# 示例:分支管理(真实数据)
本示例演示一个完整的分支管理流程:查看分支 → 创建分支 → 开发推送 → 保护分支 → 清理旧分支
> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-09 在 Claude Code 中实际执行
## 场景描述
---
你正在维护一个 GitLink 仓库,需要开发一个新功能。开发完成后需要保护主分支,并清理已完成的功能分支。
## 步骤
### 1. 查看当前所有分支
## 执行命令序列
```bash
gitlink-cli branch +list
# Step 1: 查看所有分支
gitlink-cli branch +list --owner chroe --repo gitlink-cli --format json
# 结果: 9 个分支
# master protected=False
# test/pr-workflow-demo protected=False
# pr/showcase-dashboard protected=False
# pr/shortcuts-modules protected=False
# fix/showcase-member-v2 protected=False
# fix/showcase-member-org protected=False
# fix/batch-invite-userid protected=False
# feat/task-a-shortcuts protected=False
# fix/windows-npm-install protected=False
# Step 2: 保护 master 分支
gitlink-cli branch +protect --owner chroe --repo gitlink-cli --name master --format json
# 结果: ok=True
# branch_name=master, created_at=2026-06-13 10:47
```
输出示例:
```
Branch Protected
master Yes
develop No
feature/old-login No
## 完整工作流
```
用户:"帮我看看仓库有哪些分支,把 master 保护一下"
### 2. 创建新功能分支
```bash
gitlink-cli branch +create --name feature/new-auth --from develop
```
输出确认分支创建成功。
### 3. 本地开发并推送
```bash
git checkout -b feature/new-auth
# ... 编写代码 ...
git add .
git commit -m "feat: add new auth module"
git push gitlink feature/new-auth
```
### 4. 创建 PR 并合并
通过 `gitlink-cli pr +create` 创建 PR审查后合并。合并后功能分支 `feature/new-auth` 将自动删除(取决于仓库设置)。
### 5. 保护主分支
确保 `master` 分支有保护规则,防止误删或直接推送:
```bash
gitlink-cli branch +protect --name master
```
### 6. 清理旧分支(可选)
开发完成后,删除不再需要的旧分支:
```bash
# 确认旧分支已合并或无保留价值后
gitlink-cli branch +delete --name feature/old-login
AI 执行流程:
1. branch +list → 列出所有分支,发现 9 个分支master 未保护
2. branch +protect --name master → 保护 master 分支
3. 报告结果master 已保护,其他 8 个功能分支可以按需清理
```
## 注意事项
- `branch +delete`**Destructive Operation**,操作前请确认分支内容已合并或无保留价值
- `branch +protect` 会对分支施加保护规则,影响后续的推送和合并流程
- `branch +unprotect` 仅支持简单分支名(如 `main`),含 `/` 的路径需通过 Web 页面操作
- 建议使用 `branch +list` 先查看分支状态,再执行写入/删除操作
- 建议使用 `branch +list` 先查看分支状态,再执行写入/删除操作
- `branch +delete` 是写入操作,执行前确认用户意图
- 不能删除默认分支master
- 建议先 `branch +list` 查看状态再执行写入操作

View File

@ -0,0 +1,197 @@
# gitlink-changelog 参考手册
> 本文档定义 Release Notes 自动生成的分类规则、数据字段和 API 注意事项。
---
## 一、Commit 分类规则(详细版)
### 主分类
| 分类 | 图标 | 前缀关键词 | 优先级 |
|------|------|-----------|--------|
| 新功能 | ✨ | `feat`, `feature`, `add`, `新增`, `支持` | 高 |
| Bug 修复 | 🐛 | `fix`, `bugfix`, `hotfix`, `修复`, `解决` | 高 |
| 破坏性变更 | ⚠️ | 标题含 `!`(如 `feat!:`)或 body 含 `BREAKING CHANGE` | 最高 |
| 改进优化 | 🔧 | `refactor`, `perf`, `improve`, `优化`, `增强`, `完善`, `enhance` | 中 |
| 文档更新 | 📚 | `docs`, `doc`, `文档`, `README`, `注释` | 低 |
| 测试 | 🧪 | `test`, `tests`, `测试` | 低 |
| 构建/CI | 🏗️ | `build`, `ci`, `chore`, `构建`, `部署`, `Docker` | 低 |
| 样式 | 💄 | `style`, `fmt`, `格式化` | 最低 |
### 分类逻辑
```
1. 优先检测破坏性变更(!后缀 或 BREAKING CHANGE body
2. 检查前缀是否匹配已知分类
3. 无前缀时AI 根据 commit message 语义判断:
- 包含"新增"、"添加" → 新功能
- 包含"修复"、"解决"、"fix" → Bug 修复
- 包含"优化"、"改进"、"重构" → 改进优化
- 其他 → 归入"其他变更"分类
```
### 模块识别
从 commit message 或 PR 标题中提取涉及的模块:
| 模式 | 示例 | 提取模块 |
|------|------|---------|
| `feat(<module>):` | `feat(wiki): add create` | wiki |
| `fix <module>:` | `fix batch operations:` | batch |
| 中文括号 | `新增 wiki 模块` | wiki |
| 文件路径 | `shortcuts/wiki/wiki.go` | wiki |
---
## 二、数据字段映射
### commit +list 关键字段
```json
{
"sha": "abc123def456",
"message": "feat(wiki): add create and update commands",
"author": {
"login": "username",
"name": "用户名",
"image_url": "..."
},
"timestamp": "2026-06-01T10:00:00+08:00"
}
```
| 字段 | 用途 |
|------|------|
| `message` | 分类依据,提取功能描述 |
| `author.login` | 贡献者归属 |
| `timestamp` | 判断是否属于当前版本区间 |
| `sha` | 可作为变更追溯引用 |
### pr +list / pr +view 关键字段
```json
{
"id": 42,
"title": "feat(wiki): add wiki management commands",
"body": "## 变更说明\n...",
"pull_request_status": 1,
"created_at": "2026-06-01T10:00:00+08:00",
"updated_at": "2026-06-02T15:00:00+08:00",
"merged_at": "2026-06-02T15:00:00+08:00",
"user": { "login": "username", "name": "用户名" },
"head": "feature/wiki",
"base": "master"
}
```
| 字段 | 用途 |
|------|------|
| `title` | 分类依据 |
| `body` | 提取详细变更说明 |
| `merged_at` | 判断是否属于当前版本区间 |
| `user.login` | 贡献者归属 |
| `id` | PR 编号引用 |
### issue +list 关键字段
```json
{
"id": 1,
"project_issues_index": 15,
"subject": "Wiki 中文路径乱码",
"description": "...",
"created_on": "2026-05-28T10:00:00+08:00",
"updated_on": "2026-06-01T15:00:00+08:00",
"status": { "id": 5, "name": "关闭" },
"author": { "login": "username" },
"tags": [{ "id": 1, "name": "bug" }]
}
```
| 字段 | 用途 |
|------|------|
| `subject` | Issue 标题引用 |
| `status.id === 5` | 已关闭 |
| `updated_on` | 判断关闭时间是否在版本区间内 |
| `tags` | 辅助分类bug 标签 → Bug 修复) |
| `project_issues_index` | Issue 编号引用(#15 |
### release +list 关键字段
```json
{
"id": 3,
"tag_name": "v1.1.0",
"name": "v1.1.0",
"body": "## ✨ 新功能\n...",
"created_at": "2026-05-15T10:00:00+08:00"
}
```
| 字段 | 用途 |
|------|------|
| `tag_name` | 版本号,确定版本基线 |
| `created_at` | 版本发布时间,作为时间基线 |
---
## 三、版本区间计算
### 方法 1基于上一个 Release 的时间戳
```
版本区间开始 = release +list 中最新一条的 created_at
版本区间结束 = 当前时间
```
### 方法 2基于 Git Tag 对比(已废弃)
> ⚠️ **此方法不可用。** compare API 实测返回 HTML 而非 JSON无法使用。请改用方法 1 或方法 3。
### 方法 3无历史版本时
```
版本区间开始 = repo +info 的 created_on项目创建时间
版本区间结束 = 当前时间
```
---
## 四、Release Notes 发布注意事项
1. **`release +create` 需要 `--tag` 参数**:即 Git tag 名称(如 `v1.2.0`
2. **`--body` 中的换行**:使用 `\n` 转义,或由 CLI 内部处理多行文本
3. **`release +view` 使用 `version_id` 而非 tag_name**:创建后从 `release +list` 获取 `version_id`
4. **发布前确认**:写入操作前必须确认用户意图
5. **Tag 必须存在**:如果 tag 不存在于仓库,需要先通过 git 创建 tag 并推送
---
## 五、常见问题
### Q: Commit message 不规范怎么办?
> ⚠️ **注意**compare API (`api GET /:owner/:repo/compare/...`) 实测返回 HTML 而非 JSON不可使用。请改用 `commit +list` 按时间戳筛选版本区间内的提交。
A: AI 根据 message 内容语义自动判断分类,无需严格遵循 Conventional Commits。但规范的 message 能显著提高分类准确度。
### Q: 如何处理合并提交?
A: 合并提交merge commit的 message 通常为 `Merge pull request #xx`AI 应跳过这些提交,改为从对应的 PR 记录获取变更信息。
### Q: 大量提交时如何处理?
A: 使用分页参数逐步采集AI 可先统计总量再决定是否需要完整采集。对于超过 100 条提交的版本,建议按模块汇总而非逐条列出。
### Q: 如何生成英文 Release Notes
A: 在提示 AI 时指定"Generate Release Notes in English"。Skill 支持中英文输出,由 AI 根据用户语言偏好决定。
### Q: PR `--state merged` 返回的数据准确吗?
A: 注意 GitLink 的 `--state` 参数仅影响统计计数,返回列表可能包含所有状态的 PR。需要通过 `pull_request_status` 字段客户端过滤:`pull_request_status === 1` 才是真正已合并的 PR。
### Q: 项目无历史 Release 时怎么确定版本区间?
A: 使用 `repo +info` 获取项目创建时间(`created_on`),作为版本区间的起始时间。无需指定 base 版本。

View File

@ -0,0 +1,209 @@
---
name: gitlink-changelog
version: 1.0.0
description: "Release Notes 自动生成:从 commit、PR、Issue 历史自动分析并生成结构化版本说明。当用户需要生成 Changelog、Release Notes、版本发布说明时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli --help"
---
# gitlink-changelogRelease Notes 自动生成)
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 分类 commit 和生成 Release Notes 前,必须先阅读 [`REFERENCE.md`](REFERENCE.md),其中包含完整的分类规则、优先级逻辑和 API 字段映射。**
**CRITICAL — 本 Skill 的数据采集阶段仅读取数据;最终发布 Release 时为写入操作,发布前务必确认用户意图。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## 概述
本 Skill 从 GitLink 项目的提交历史、PR 记录和 Issue 数据中自动提取版本变更信息,由 AI 分析分类后生成结构化的 Release Notes / Changelog。支持一键发布到 GitLink Release。
## 数据采集命令
### 第一步:获取版本基线
```bash
# 获取上一个 Release 信息(确定版本基线)
gitlink-cli release +list --owner <owner> --repo <repo> --format json
```
### 第二步:获取提交历史
```bash
# 获取提交列表AI 根据上一个 Release 的时间筛选新增提交)
gitlink-cli commit +list --owner <owner> --repo <repo> --format json
# 注意:提交可能需要翻页,用 --page 参数获取全部
gitlink-cli commit +list --owner <owner> --repo <repo> --page 2 --format json
```
> ⚠️ **不要使用 compare API**。`api GET /:owner/:repo/compare/...` 实测会返回 HTML 而非 JSON无法用于变更对比。请改用 `commit +list` 按时间戳筛选。
### 第三步:获取 PR 记录
```bash
# 获取已合并的 PR 列表
gitlink-cli pr +list --owner <owner> --repo <repo> --state merged --format json
# 查看具体 PR 详情(获取关联 Issue 和变更内容)
gitlink-cli pr +view --owner <owner> --repo <repo> --id <pr_id> --format json
# 查看 PR 变更文件
gitlink-cli pr +files --owner <owner> --repo <repo> --id <pr_id> --format json
```
### 第四步:获取已关闭 Issue
```bash
# 获取已关闭的 Issue
gitlink-cli issue +list --owner <owner> --repo <repo> --state closed --format json
```
## Release Notes 输出格式
AI 根据采集到的数据,按以下结构生成 Release Notes
```markdown
# 🚀 <版本号> (<发布日期>)
## 📝 版本摘要
<一段话总结本次版本的核心变更>
## ✨ 新功能 (Features)
- **<模块>**: <功能描述> (#<PR号>) by @<作者>
- ...
## 🐛 问题修复 (Bug Fixes)
- **<模块>**: <修复描述> (#<Issue号>) by @<修复者>
- ...
## 🔧 改进优化 (Improvements)
- **<模块>**: <改进描述> (#<PR号>)
- ...
## 📚 文档更新 (Documentation)
- <文档变更描述>
- ...
## 🧪 测试 (Tests)
- <新增/改进的测试描述>
- ...
## 🏗️ 构建/CI (Build & CI)
- <构建或 CI 变更描述>
- ...
## ⚠️ 破坏性变更 (Breaking Changes)
- <破坏性变更描述及迁移指南>
> 如果没有破坏性变更,此节不显示
## 🙏 贡献者
感谢以下贡献者参与本版本开发:
@contributor-a, @contributor-b, @contributor-c
## 📊 变更统计
| 类型 | 数量 |
|------|------|
| 新功能 | N |
| Bug 修复 | N |
| 改进优化 | N |
| 文档更新 | N |
| 测试 | N |
| 合计 | N |
```
## Commit 分类规则
AI 根据 commit message 的关键词自动分类:
| 分类 | 关键词前缀 | 示例 |
|------|-----------|------|
| ✨ 新功能 | `feat`, `feature`, `add`, `新增`, `支持` | `feat: add wiki module` |
| 🐛 Bug 修复 | `fix`, `bugfix`, `hotfix`, `修复`, `解决` | `fix: resolve wiki encoding issue` |
| 🔧 改进优化 | `refactor`, `perf`, `improve`, `优化`, `增强`, `完善`, `enhance` | `refactor: simplify batch logic` |
| 📚 文档更新 | `docs`, `doc`, `文档`, `README`, `注释` | `docs: update API reference` |
| 🧪 测试 | `test`, `tests`, `测试` | `test: add wiki module tests` |
| 🏗️ 构建/CI | `build`, `ci`, `chore`, `构建`, `部署`, `Docker` | `ci: add FreeBSD build target` |
| 💄 样式 | `style`, `fmt`, `格式化` | `style: fix indentation`(归入改进优化) |
| ⚠️ 破坏性变更 | commit body 含 `BREAKING CHANGE` 或标题含 `!` | `feat!: change API response format` |
> 完整分类规则和优先级逻辑见 [`REFERENCE.md`](REFERENCE.md)。
## 使用场景
### 场景 1为指定版本生成 Release Notes
```
用户:"为 v1.2.0 生成 Release Notes"
AI 执行流程:
1. release +list → 找到上一个版本 v1.1.0 的发布时间
2. commit +list → 筛选 v1.1.0 之后的所有提交
3. pr +list --state merged → 筛选同一时间段的已合并 PR
4. issue +list --state closed → 筛选已关闭的 Issue
5. 按分类规则整理 → 生成结构化 Release Notes
```
### 场景 2生成完整 Changelog所有版本
```
用户:"生成项目完整 Changelog"
AI 执行流程:
1. release +list → 获取所有版本
2. 对每个版本区间分别采集 commit/PR/Issue
3. 按版本倒序排列,生成完整 Changelog
```
### 场景 3生成并一键发布
```
用户:"生成 v1.2.0 Release Notes 并发布"
AI 执行流程:
1-5. 同场景 1生成 Release Notes
6. 确认用户意图:"即将发布 v1.2.0 Release内容如上确认发布"
7. 用户确认后执行:
gitlink-cli release +create --owner <owner> --repo <repo> \
--tag v1.2.0 --name "v1.2.0" --body "<Release Notes 内容>"
```
## 发布命令
```bash
# 创建 Release需要认证
gitlink-cli release +create --owner <owner> --repo <repo> \
--tag v1.2.0 \
--name "v1.2.0" \
--body "## ✨ 新功能\n- feat: xxx\n\n## 🐛 Bug 修复\n- fix: yyy"
# 查看 Release 是否创建成功
gitlink-cli release +view --owner <owner> --repo <repo> --id <version_id>
```
## 最佳实践
- 所有数据采集命令使用 `--format json`
- commit message 建议遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范,便于自动分类
- 发布前让用户预览 Release Notes 并确认
- 保留上一个版本的时间戳作为基线,避免遗漏或重复
- 如果项目未发布过 Release则从项目创建时间开始采集所有数据
- 关联 PR 和 Issue 编号,增强 Release Notes 的可追溯性
## 详细参考
详见 [`REFERENCE.md`](REFERENCE.md) 了解完整的 commit 分类规则、数据字段映射和 API 注意事项。

View File

@ -0,0 +1,112 @@
# 示例Release Notes 生成(真实数据)
> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-09 在 Claude Code 中实际执行。
> 展示从 v0.1.13-freebsd上一个 Release2026-06-03 23:51到当前时间段的变更。
---
## 执行命令序列
```bash
# Step 1: 获取上一个版本(确定时间基线)
gitlink-cli release +list --owner chroe --repo gitlink-cli --format json
# 结果: 1 个版本, tag=v0.1.13-freebsd, created=2026-06-03 23:51
# Step 2: 获取提交历史AI 按时间戳筛选基线之后的提交)
gitlink-cli commit +list --owner chroe --repo gitlink-cli --format json
# 结果: 本页 20 个提交,基线之后 20 个(含 2 个 merge commit 需跳过)
# 如需更多: gitlink-cli commit +list --owner chroe --repo gitlink-cli --page 2 --format json
# Step 3: 获取 PR本项目为 Fork无 PR 记录)
gitlink-cli pr +list --owner chroe --repo gitlink-cli --state merged --format json
# 结果: merged_issues_size=0
# Step 4: 获取已关闭 Issue
gitlink-cli issue +list --owner chroe --repo gitlink-cli --state closed --format json
# 结果: 6 个已关闭 Issue#1~#6
```
## Commit 分类过程20 条提交)
| # | commit message首行 | 作者 | 分类结果 |
|---|----------------------|------|---------|
| 1 | `fix(skills): gitlink-health 评分算法修复...` | chroe | 🐛 Bug 修复 |
| 2 | `feat(skills): 新增 3 个 AI Agent Skill...` | chroe | ✨ 新功能 |
| 3 | `fix(showcase): 移除 rebase 残留...` | chroe | 🐛 Bug 修复 |
| 4 | `refactor(showcase): 成员管理卡片优化` | chroe | 🔧 改进优化 |
| 5 | `删除不可用的批量操作...新增批量评论...` | yetja | 🔧 改进优化 |
| 6 | `refactor(showcase): 批量组织邀请合并...` | chroe | 🔧 改进优化 |
| 7 | `fix(org): batch-invite 用户名自动解析...` | chroe | 🐛 Bug 修复 |
| 8 | `chore: batch-delete 默认改用 fork 后的路径` | chroe | 🏗️ 构建/CI |
| 9 | `chore: 更新仓库管理默认示例为真实仓库` | chroe | 🏗️ 构建/CI |
| 10 | `chore: 恢复 repo 批量管理卡片` | chroe | 🏗️ 构建/CI |
| 11 | `fix: 移除 batch-milestone (v1 PATCH不生效)` | chroe | 🐛 Bug 修复 |
| 12 | `fix: 移除 batch-label (API不支持标签关联)` | chroe | 🐛 Bug 修复 |
| 13 | `fix: 移除 assign/batch-assign (API不支持)` | chroe | 🐛 Bug 修复 |
| 14 | `fix: assign 传递数字 ID + 保留 status_id` | chroe | 🐛 Bug 修复 |
| 15 | `Merge branch 'master' of...` | chroe | ⏭️ 跳过 |
| 16 | `feat: 添加 reopen/batch-reopen 命令` | chroe | ✨ 新功能 |
| 17 | `delete test file` | chroe | ⏭️ 跳过 |
| 18 | `add test file` | chroe | ⏭️ 跳过 |
| 19 | `fix: watch/star 模块 resolveProjectID...` | chroe | 🐛 Bug 修复 |
| 20 | `Merge branch 'master' of...` | chroe | ⏭️ 跳过 |
## 生成的 Release Notes
```markdown
# 🚀 v0.2.0 (2026-06-09)
> 基于 v0.1.13-freebsd (2026-06-03 23:51) 之后的变更生成
## 📝 版本摘要
本版本为 Skills 生态扩展版本,新增 3 个 AI Agent Skill项目健康度报告、Release Notes 自动生成、Issue 智能分拣),
新增 reopen/batch-reopen 命令,清理了多个 GitLink API 不支持的批量操作命令,并修复了 Showcase 前端和模块兼容性问题。
## ✨ 新功能 (Features)
- **skills**: 新增 `gitlink-health` Skill — 项目健康度报告Issue/PR/贡献者/活跃度四维评分)(by @chroe)
- **skills**: 新增 `gitlink-changelog` Skill — 从 commit/PR/Issue 历史自动生成 Release Notes (by @chroe)
- **skills**: 新增 `gitlink-triage` Skill — Issue 智能分拣 + 新人引导 (by @chroe)
- **issue**: 新增 reopen/batch-reopen 命令 (by @chroe)
## 🐛 问题修复 (Bug Fixes)
- **skills**: 修复 gitlink-health 评分算法 — fallback 策略和贡献集中度计算 (by @chroe)
- **showcase**: 修复 rebase 残留 commit message 导致 JS 语法错误白屏 (by @chroe)
- **org**: batch-invite 用户名自动解析为数字 ID修正 Showcase 默认值 (by @chroe)
- **watch/star**: 修复 resolveProjectID 兼容 project_id 字段 (by @chroe)
- **batch**: 移除 batch-milestoneGitLink v1 PATCH 实际不生效)(by @chroe)
- **batch**: 移除 batch-labelGitLink API 不支持 Issue 标签关联)(by @chroe)
- **batch**: 移除 assign/batch-assignGitLink API 不支持)(by @chroe)
- **assign**: 传递数字 ID + 保留 status_id 防止状态被重置 (by @chroe)
## 🔧 改进优化 (Improvements)
- **showcase**: 成员管理卡片优化 — 组织 ID 查找改为 `org +info`,新增仓库选择器 (by @chroe)
- **showcase**: 批量组织邀请合并到成员管理卡片 (by @chroe)
- **batch**: 移除不可用的批量操作,新增批量评论、批量仓库管理 (by @yetja)
## 🏗️ 构建/CI (Build & CI)
- batch-delete 默认改用 fork 后的仓库路径 (by @chroe)
- 更新仓库管理默认示例为真实仓库 (by @chroe)
- 恢复 repo 批量管理卡片 (by @chroe)
## 🙏 贡献者
感谢以下贡献者参与本版本开发:
@chroe, @yetja, @caoweiqiong (CWQ)
## 📊 变更统计
| 类型 | 数量 |
|------|------|
| 新功能 | 4 |
| Bug 修复 | 8 |
| 改进优化 | 3 |
| 构建/CI | 3 |
| 跳过merge/test | 4 |
| 合计 | 18有效 16|
```

View File

@ -0,0 +1,198 @@
# gitlink-ci 参考手册
> 本文档定义 CI/CD 的 API 字段映射、错误诊断模式、流水线配置参考和已知问题。
---
## 一、前置条件DevOps 状态
### 检查方法
```bash
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
# 关注字段: "open_devops": true/false
```
| open_devops | CI 命令可用性 |
|-------------|-------------|
| `true` | `ci +builds/logs/restart/stop` 全部可用 |
| `false` | 所有 CI 命令返回 `[-1] 接口数据异常` |
### 激活 DevOps
```bash
# 通过 Raw API 激活
gitlink-cli api POST /<owner>/<repo>/activate
# 或通过 Web 页面:
# 仓库 → 设置 → DevOps → 开启
```
### 实测数据
`chroe/gitlink-cli` 上(`open_devops: false`
```bash
gitlink-cli ci +builds --owner chroe --repo gitlink-cli
# 输出: 错误: 获取构建列表失败: [-1] 接口数据异常
```
---
## 二、CI API 字段映射
### ci +builds 预期响应结构
```json
{
"ok": true,
"data": {
"builds": [
{
"id": 42,
"number": 42,
"status": "failed",
"branch": "feature/new-auth",
"commit": "abc1234def",
"commit_message": "feat: add new auth",
"created_at": "2026-06-12T10:00:00+08:00",
"duration": 120,
"stages": [
{ "number": 1, "name": "build", "status": "success" },
{ "number": 2, "name": "test", "status": "failed" }
]
}
],
"total_count": 50
}
}
```
| 字段 | 用途 |
|------|------|
| `number` | 构建编号,用于 `ci +logs` / `ci +restart` / `ci +stop` |
| `status` | `success` / `failed` / `running` / `stopped` |
| `branch` | 触发构建的分支 |
| `commit` | 触发构建的 commit SHA |
| `stages` | 流水线阶段列表,每个 stage 有独立状态 |
| `duration` | 构建耗时(秒) |
### ci +logs API
```
GET /<owner>/<repo>/builds/<build-number>/logs/<stage>/<step>
```
参数说明:
- `build-number`:从 `ci +builds` 获取
- `stage`:阶段编号,默认 1编译阶段
- `step`:步骤编号,默认 1
---
## 三、构建错误诊断模式库
### 编译错误
| 模式 | 正则 | 常见原因 |
|------|------|---------|
| 依赖缺失 | `cannot find package` | `go.mod``package.json` 不完整 |
| 语法错误 | `syntax error` | 代码语法问题 |
| 类型错误 | `cannot use .* as type` | 类型不匹配 |
| 未定义引用 | `undefined: ` | 缺少 import 或拼写错误 |
| 导入路径错误 | `no required module provides package` | Go module 路径变更 |
### 测试失败
| 模式 | 常见原因 |
|------|---------|
| `FAIL: TestXxx` | 测试断言失败 |
| `panic: runtime error` | 测试中空指针或越界 |
| `--- FAIL: TestXxx (0.00s)` | 测试立即失败setup 错误) |
| `too many arguments` | 测试函数签名不匹配 |
### 环境/基础设施
| 模式 | 常见原因 |
|------|---------|
| `connection refused` | 数据库/外部服务不可用 |
| `out of memory` | 构建内存不足 |
| `permission denied` | 密钥或文件权限问题 |
| `docker: not found` | 构建环境缺少 Docker |
| `No space left on device` | 磁盘空间不足 |
### GitLink 特定错误
| 模式 | 说明 |
|------|------|
| `[-1] 接口数据异常` | 仓库未开启 DevOps |
| 返回 HTML 而非 JSON | API 路径错误(如缺少 `/v1/` 前缀) |
---
## 四、流水线配置文件
### 文件位置
```
仓库根目录/
└── .devops/
└── <流水线名称>.yml
```
### 示例配置Go 项目)
```yaml
name: 构建部署
on:
push:
branches: [master]
jobs:
build:
runs-on: docker
steps:
- name: 构建
run: go build -o app .
- name: 测试
run: go test ./...
- name: 部署
run: |
ssh root@server "cd /opt/app && git pull && docker build -t app . && docker-compose up -d"
```
### 关键注意事项
- Docker 构建需配置 `GOPROXY=https://goproxy.cn,direct`(国内网络)
- 服务器 Docker daemon 需配置国内镜像加速器(`/etc/docker/daemon.json`
- GitLink 密钥管理:敏感信息通过 `deploy_server.server_password` 注入
- 使用 `git fetch + git reset --hard` 替代 `git pull` 避免本地修改冲突
---
## 五、已知限制
| 限制 | 说明 |
|------|------|
| DevOps 默认关闭 | 大部分仓库的 `open_devops``false`,需手动开启 |
| 接口数据异常 | 通用错误码 `-1`,无结构化错误信息 |
| 无构建触发 API | 无法通过 CLI 触发新构建,只能通过 git push 触发 |
| 日志可能截断 | 长日志可能被分页或截断 |
---
## 六、常见问题
### Q: 所有 CI 命令都返回"接口数据异常"
A: 99% 的情况是因为仓库未开启 DevOps。检查 `repo +info` 中的 `open_devops` 字段。
### Q: 如何触发一次新构建?
A: GitLink 没有"手动触发构建"的 API。只能通过 `git push` 到触发分支(如 master来启动构建。
### Q: ci +logs 的 stage/step 是什么意思?
A: 每个流水线有多个 stage阶段每个 stage 有多个 step步骤。默认 stage=1, step=1 通常是第一个编译步骤。
### Q: CI 构建没有日志输出?
A: 尝试不同的 stage/step 组合。如果 stage=1,step=1 无输出,试试 stage=2,step=1。

View File

@ -1,55 +1,232 @@
---
name: gitlink-ci
version: 1.0.0
description: "CI/CD 操作:查看构建列表、构建日志、重启/停止构建。当用户需要操作 GitLink CI 时触发。"
version: 2.0.0
description: "CI/CD 构建诊断与监控:检查 DevOps 状态、诊断构建失败、分析 CI 日志、管理构建生命周期。当用户需要排查 CI 失败、监控构建状态、配置流水线时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli ci --help"
---
# gitlink-ciCI/CD 操作
# gitlink-ciCI/CD 构建诊断与监控
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
**CRITICAL — CI 功能依赖仓库开启 DevOps`open_devops: true`)。未开启的仓库所有 CI 命令会返回"接口数据异常"。使用前务必检查 DevOps 状态。**
**CRITICAL — `ci +restart``ci +stop` 是写入操作,执行前需确认用户意图。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## Shortcuts
## 概述
| Shortcut | 说明 | 需要认证 |
|----------|------|----------|
| `ci +builds` | 构建列表 | 是 |
| `ci +logs` | 构建日志 | 是 |
| `ci +restart` | 重启构建 | 是 |
| `ci +stop` | 停止构建 | 是 |
本 Skill 通过 gitlink-cli 的 CI 命令和 Raw API由 AI 分析后完成:
1. **DevOps 状态检查**:扫描仓库是否开启 CI未开启的引导激活
2. **构建失败诊断**:获取失败构建的日志 → AI 分析错误 → 定位根因 → 建议修复
3. **CI 健康监控**:查看近期构建成功率、平均构建时间
4. **流水线配置审计**:检查 `.devops/` 目录下的流水线文件
## 使用示例
## 前置检查命令
### 必须:确认 DevOps 状态
```bash
# 查看构建列表
gitlink-cli ci +builds --owner myuser --repo myrepo
# 查看构建日志
gitlink-cli ci +logs --build 42 --stage 1 --step 1
# 重启构建
gitlink-cli ci +restart --build 42
# 停止构建
gitlink-cli ci +stop --build 42
# 检查仓库是否开启 DevOps
gitlink-cli repo +info --owner <owner> --repo <repo> --format json | grep open_devops
```
## Raw API 补充
如果返回 `"open_devops": false`,后续 CI 命令将不可用。需要先激活:
```bash
# 激活 CI
gitlink-cli api POST /:owner/:repo/activate
# 激活 DevOps
gitlink-cli api POST /<owner>/<repo>/activate
```
> ⚠️ **实测**`chroe/gitlink-cli` 等仓库的 `open_devops``false`,调用 `ci +builds` 会返回"接口数据异常"。这不是 CLI 的 bug而是 GitLink 平台的前置要求。
## 数据采集命令
### 第一步:获取构建列表
```bash
# 查看构建历史
gitlink-cli ci +builds --owner <owner> --repo <repo> --format json
# 分页
gitlink-cli ci +builds --owner <owner> --repo <repo> --page 2 --format json
```
### 第二步:查看失败构建日志
```bash
# 查看默认 stage=1, step=1 的日志
gitlink-cli ci +logs --owner <owner> --repo <repo> --build <build-number>
# 查看特定 stage/step如 stage 2 = 测试阶段)
gitlink-cli ci +logs --owner <owner> --repo <repo> --build <build-number> --stage 2 --step 1
```
### 第三步:查看 CI 配置
```bash
# 检查 CI 授权状态
gitlink-cli api GET /<owner>/<repo>/ci_authorize
# 查看构建详情
gitlink-cli api GET /<owner>/<repo>/builds/<build_id>
```
## AI 分析规则
### 构建失败诊断流程
```
1. ci +builds → 筛选 status=failed 的构建
2. ci +logs --build <n> → 获取日志
3. AI 分析日志中的错误模式
4. 匹配已知错误类型 → 给出修复建议
5. 如有多个 stage逐 stage 排查
```
### 常见错误模式识别
| 日志关键词 | 诊断 | 建议修复 |
|-----------|------|---------|
| `cannot find package` | 依赖缺失 | 检查 `go.mod``package.json` |
| `syntax error` / `unexpected token` | 语法错误 | 检查最近提交的代码 |
| `permission denied` | 权限不足 | 检查密钥配置、文件权限 |
| `connection refused` | 服务不可达 | 检查外部服务/数据库连接 |
| `out of memory` / `killed` | 资源不足 | 优化内存使用或增加构建资源 |
| `No such file` | 文件缺失 | 检查 `.devops/` 配置文件路径 |
| `docker: command not found` | 环境缺失 | 确认构建环境有 Docker |
| `exit status 1` / `FAIL` | 测试失败 | 查看具体测试输出 |
| `timeout` | 构建超时 | 优化构建脚本或增加超时时间 |
### 构建健康评分
| 指标 | 计算方式 |
|------|---------|
| 成功率 | 成功构建 / 总构建 × 100% |
| 平均耗时 | 所有构建的 `duration` 平均值 |
| 失败趋势 | 最近 10 次构建中失败次数的变化方向 |
## 执行命令
### 重启失败的构建
```bash
gitlink-cli ci +restart --owner <owner> --repo <repo> --build <build-number>
```
### 停止异常构建
```bash
gitlink-cli ci +stop --owner <owner> --repo <repo> --build <build-number>
```
### 管理 CI 状态
```bash
# 停用 CI
gitlink-cli api DELETE /:owner/:repo/deactivate
gitlink-cli api DELETE /<owner>/<repo>/deactivate
# CI 授权状态
gitlink-cli api GET /:owner/:repo/ci_authorize
# 激活 CI
gitlink-cli api POST /<owner>/<repo>/activate
```
## 输出格式
### 构建失败诊断报告
```markdown
# 🔧 构建失败诊断报告
> 仓库:<owner/repo>
> 构建编号:#<n>
> 诊断时间:<timestamp>
## 失败概况
| 项目 | 详情 |
|------|------|
| 构建编号 | #42 |
| 触发分支 | feature/new-auth |
| 失败 Stage | stage 2 (测试) |
| 失败时间 | 2026-06-12 10:30 |
## 错误日志(关键部分)
```
[ERROR] cannot find package "github.com/example/lib"
at main.go:5
```
## AI 诊断
| 错误类型 | 依赖缺失 |
|---------|---------|
| **根因** | `go.mod` 中缺少 `github.com/example/lib` 依赖 |
| **影响范围** | 所有 import 该包的 Go 文件 |
| **修复建议** | 运行 `go get github.com/example/lib && go mod tidy` 后提交 |
## 修复步骤
1. 本地运行 `go get github.com/example/lib`
2. 运行 `go mod tidy` 更新依赖
3. 提交修改后的 `go.mod``go.sum`
4. 运行 `gitlink-cli ci +restart --build 42` 重启构建
```
## 使用场景
### 场景 1构建失败排查
```
用户:"帮我看看 repo 的构建为什么失败了"
AI 执行流程:
1. repo +info → 检查 open_devops
2. ci +builds → 获取构建列表,找到失败构建
3. ci +logs --build <n> → 获取失败日志
4. AI 分析日志中的错误模式
5. 匹配已知错误 → 生成诊断报告和修复建议
6. 询问用户是否要重启构建
```
### 场景 2CI 配置审计
```
用户:"帮我检查项目 CI 配置是否正常"
AI 执行流程:
1. repo +info → 检查 open_devops
2. ci +builds → 查看最近的构建记录
3. raw api GET /<owner>/<repo>/ci_authorize → 检查授权状态
4. 计算近期构建成功率
5. 检查 .devops/ 目录下的流水线配置(如有本地代码)
6. 生成 CI 健康度报告
```
### 场景 3批量 DevOps 检查
```
用户:"检查我所有仓库的 DevOps 开启情况"
AI 执行流程:
1. repo +list --category all → 获取所有仓库
2. 筛选 open_devops = true 的仓库
3. 对开启了 DevOps 的仓库,获取最近构建状态
4. 对未开启的仓库,列出可激活的选项
5. 生成 DevOps 覆盖率报告
```
## 最佳实践
- **先查 DevOps 状态**:每次 CI 操作前检查 `open_devops`,避免"接口数据异常"错误
- **逐 stage 排查**:多 stage 流水线中,从第一个失败的 stage 开始分析
- **关联 commit**:失败构建通常与最近的代码变更相关,关联 `commit +list` 查看
- **重启前确认**:确认已修复根因后再重启,避免反复失败
- **日志截断**CI 日志可能很长AI 应提取错误关键词而非全文搬运
## 详细参考
详见 [`REFERENCE.md`](REFERENCE.md) 了解 CI API 字段映射、错误诊断模式和流水线配置。

View File

@ -0,0 +1,144 @@
# 示例CI DevOps 状态检查与构建诊断(真实数据)
> 本示例基于真实仓库于 2026-06-12 在 Claude Code 中实际执行。
> 展示 DevOps 状态检查 → 发现未开启 → 给出激活方案的完整流程。
---
## 场景:发现 CI 不可用
用户想查看 `chroe/gitlink-cli` 的构建状态。
### Step 1: 检查 DevOps 状态
```bash
gitlink-cli repo +info --owner chroe --repo gitlink-cli --format json | python3 -c "
import sys,json
d = json.load(sys.stdin)['data']
print(f'DevOps: {d[\"open_devops\"]}')
print(f'项目ID: {d[\"project_id\"]}')
"
```
真实输出:
```
DevOps: False
项目ID: 1547045
```
### Step 2: 尝试 CI 命令(预期失败)
```bash
gitlink-cli ci +builds --owner chroe --repo gitlink-cli
```
真实输出:
```
错误: 获取构建列表失败: [-1] 接口数据异常
```
### Step 3: 检查是否有其他仓库开启了 DevOps
```bash
gitlink-cli repo +list --category all --format json | python3 -c "
import sys,json
for p in json.load(sys.stdin)['data']['projects']:
if p.get('open_devops'):
print(f\" ✅ {p['identifier']} (devops enabled)\")
"
```
真实输出(在 yetja 用户下):
```
(无输出 — 没有任何仓库开启 DevOps
```
甚至 Gitlink 组织的热门仓库也是关闭的:
```
Gitlink/build → devops: True ← 仅有此仓库开启
Gitlink/gitlink-cli → devops: False
Gitlink/forgeplus → devops: False
```
## AI 分析
```markdown
# 🔧 CI DevOps 状态检查报告
> 检查时间2026-06-12
> 检查范围yetja 用户下的仓库 + Gitlink 组织热门仓库
## 总览
| 指标 | 数值 |
|------|------|
| 检查仓库数 | 20 |
| DevOps 已开启 | 1 (5%) |
| DevOps 未开启 | 19 (95%) |
## DevOps 已开启的仓库
| 仓库 | 说明 |
|------|------|
| Gitlink/build | GitLink 官方 CI/CD 项目 |
## 关于 chroe/gitlink-cli
- **DevOps 状态**:未开启(`open_devops: false`
- **CI 命令**:全部不可用,返回 `[-1] 接口数据异常`
- **项目类型**Fork 自 `Gitlink/gitlink-cli`
- **项目 ID**1547045
## 如何开启 DevOps
### 方法 1Web 页面(推荐)
1. 打开 https://gitlink.org.cn/chroe/gitlink-cli
2. 进入 设置 → DevOps
3. 点击"开启 DevOps"
### 方法 2CLI Raw API
```bash
gitlink-cli api POST /chroe/gitlink-cli/activate
```
> ⚠️ `api POST` 存在已知 URL 拼接 bug可能返回 404。建议使用 Web 页面。
## 开启后的流水线配置
本项目已有 `.devops/构建部署Showcase.yml` 配置文件:
- **触发条件**push 到 master 分支
- **构建环境**Docker 容器
- **部署目标**118.31.4.168:9090
- **容器名**gitlink-cli-showcase
开启 DevOps 后,每次 push 将自动触发构建和部署。
```
---
## 补充示例:开启 DevOps 后的工作流(预期)
如果 DevOps 已开启,典型的构建诊断流程如下:
```bash
# 1. 获取近期构建
gitlink-cli ci +builds --owner chroe --repo gitlink-cli --format json
# 2. 找到失败构建,查看日志
gitlink-cli ci +logs --build 42 --stage 1 --step 1
# 3. AI 分析日志中的错误
# (日志内容示例)
# [ERROR] cannot find package "github.com/example/lib"
# 4. AI 诊断:依赖缺失 → 建议 go get + go mod tidy
# 5. 修复后重启构建
gitlink-cli ci +restart --build 42
# 6. 验证修复
gitlink-cli ci +logs --build 42 --stage 1 --step 1
```

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