Compare commits

...

19 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
139 changed files with 11775 additions and 6104 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,153 +0,0 @@
# 实验踩坑记录
> 课程:《软件演化与运维》进阶任务 — 子任务二:编写和丰富 GitLink Skills
> 记录时间2026-06-09 ~
> 截止日期2026-06-18
---
## 一、开发环境信息
| 项目 | 信息 |
|------|------|
| OS | Windows 11 Home China 10.0.26200 |
| Go | 1.26.1 |
| gitlink-cli | dev 版(本地构建) |
| Git 安装路径 | `D:/自用/奇奇怪怪的软件/Git/` |
| 认证方式 | cookie (auth login) |
| 测试项目 | chroe/gitlink-cli |
---
## 二、踩坑记录
### ✅ 坑 1`api` 子命令 URL 污染(已修复)
**发现时间**2026-06-09**修复时间**2026-06-13
**影响范围**:所有使用 `gitlink-cli api GET/POST` 的 Skill
**现象**
```bash
gitlink-cli --debug api GET /v1/chroe/gitlink-cli/issues/1 --format json
# 实际请求 URL
# https://www.gitlink.org.cn/api/D:/自用/奇奇怪怪的软件/Git/v1/chroe/gitlink-cli/issues/1.json
# ^^^^^^^^^^^^^^^^^^^^^^^^
# git exec-path 被注入!
```
**根因**CLI 的 `api` 子命令在构建 URL 时,错误地把 `git --exec-path` 的输出拼入了 API 路径。Shortcut 命令(如 `issue +list`)内部自行构建路径所以不受影响。
**根因**Windows Git Bash (MSYS2) 的路径自动转换。用户输入 `/v1/owner/repo`MSYS2 把以 `/` 开头的参数当成 Unix 绝对路径,自动转换成 git 安装目录 `D:/自用/奇奇怪怪的软件/Git/v1/owner/repo`
**修复**:在 `cmd/api/api.go``runAPI` 中加 MSYS2 路径检测——如果 path 以 Windows 盘符开头(如 `D:/`),则查找 `/v1/`、`/api/` 等常见 API 前缀并截断还原。已修复,`api` 命令现在正常工作。
**额外发现**Issue 更新接口要用 **PATCH** 方法POST 返回 404。
---
### ✅ 坑 2`issue +update` 不支持标签/责任人/优先级(已有替代方案)
**发现时间**2026-06-09**修复时间**2026-06-13
**发现时间**2026-06-09
**现象**`issue +update` 只有 `--title`、`--body`、`--state` 三个参数,不支持 `--tags`、`--assignee`、`--priority`
**影响**triage Skill 的核心写入操作(打标签、分配责任人)无法通过 shortcut 执行
**替代方案**(已验证可用):用 `api PATCH /v1/<owner>/<repo>/issues/<num>` 配合 `--body` 传递 JSON可同时打标签/分配责任人/设置优先级。坑 1 修复后此方案完全可用。
**实测**:对 chroe/gitlink-cli Issue #7 执行 `api PATCH` 成功打标签「缺陷」+ 分配 chroe + 优先级「高」。
**永久修复(可选)**:扩展 `issue +update` 命令添加 `--tags`、`--assignee`、`--priority-id` 参数更易用。
---
### ✅ 坑 2.5:责任人字段名是 `assigner_ids` 不是 `assigned_to_id`(关键发现)
**发现时间**2026-06-14
**现象**:项目记录里写"GitLink API 不支持 assign",实测发现是**字段名错了**
- ❌ `assigned_to_id`(单个数字)→ PATCH 返回 ok=true 但 assigners 仍为空
- ✅ `assigner_ids`(数组)→ PATCH 后 assigners 立即生效
**教训**:之前因为旧记录说"不支持"就放弃了没深入测试。GitLink 其实完全支持分配责任人,只是字段名跟常见 REST API 不同。
**修复**triage Skill 中所有 `assigned_to_id` 改为 `assigner_ids`
---
### 🟡 坑 3`issue +list --state open` 过滤不生效
**发现时间**2026-06-09
**现象**`--state open` 返回的 `issues` 列表包含已关闭的 Issuestatus_id=5`opened_count` 字段是准确的
**影响**AI 执行 triage 时可能处理已关闭的 Issue
**解决方案**:客户端过滤 `status.id !== 5`status_id=5 是关闭状态)
---
### 🟡 坑 4compare API 全面返回 HTML
**发现时间**2026-06-09
**现象**:不仅是部分项目,所有项目的 compare API 都返回 HTML 而非 JSON
**影响**changelog Skill 无法使用 compare API 对比版本差异
**解决方案**:改用 `commit +list` 按时间戳筛选版本区间内的提交
---
### 🟡 坑 5journals API 全面返回 HTML
**发现时间**2026-06-09之前以为是"部分项目"
**现象**:所有项目的 journals API`/v1/:owner/:repo/issues/:number/journals`)都返回 HTML
**影响**:无法获取 Issue 评论的详细内容和时间
**解决方案**:统一使用 `comment_journals_count` 字段判断是否有响应(>0 表示有人响应)
---
### 🟢 坑 6changelog 样式分类与输出格式不一致
**发现时间**2026-06-09
**现象**SKILL.md 分类规则表有"💄 样式"类,但 Release Notes 输出模板没有样式节
**解决方案**:标注样式类 commit 归入"改进优化"分类
---
## 三、验证记录
### gitlink-changelog 验证2026-06-09 二次复测)
| # | 命令 | 结果 | 备注 |
|---|------|------|------|
| 1 | `release +list --format json` | ✅ | 1 个版本 v0.1.13-freebsd |
| 2 | `commit +list --format json` | ✅ | 20 条,基线后 20 条 |
| 3 | `pr +list --state merged` | ✅ | 0 个Fork 仓库) |
| 4 | `issue +list --state closed` | ✅ | 6 个已关闭 |
| 5 | `release +view --id 1986` | ✅ | 正常返回 |
| 6 | `release +create --help` | ✅ | 参数完整可用 |
| 7 | ~~`api GET /compare/...`~~ | ❌ 返回 HTML | 已在 SKILL.md 标注不可用 |
| 8 | Commit 分类 20 条 | ✅ 20/20 正确 | 含 4 条正确跳过 |
| 9 | Release Notes 生成 | ✅ | 格式完整,含真实数据 |
### gitlink-triage 验证2026-06-09 二次复测,上游仓库)
| # | 命令 | 结果 | 备注 |
|---|------|------|------|
| 1 | `issue +list --state open`(上游 gitlink/gitlink-cli | ⚠️ | 列表 18 条含已关闭,需客户端过滤 |
| 2 | `issue +view --number N`6 个 Issue | ✅ | 全部返回详情 |
| 3 | `label +list`(上游) | ✅ | 10 个中文标签 |
| 4 | `member +list`(上游) | ✅ | 46 个成员 |
| 5 | `label +create --help` | ✅ | 可用 |
| 6 | `label +update --help` | ✅ | 可用 |
| 7 | `issue +comment --help` | ✅ | 可用 |
| 8 | `issue +update --state` | ✅ | 仅 title/body/state |
| 9 | ~~`api GET /journals`~~ | ❌ 返回 HTML | 已在 SKILL.md 标注 |
| 10 | ~~`api POST /issues/N`~~ | ❌ 404 | URL 污染 bug已标注 |
| 11 | Issue 分类 6 条 | ✅ 6/6 正确 | 上游真实 Issue |
| 12 | Good First Issue 评估 | ✅ | 识别出 2 个 GFI#14, #18 |
---
## 四、开发 Skill 的正确流程(血的教训)
```
1. 确定 Skill 场景
2. 写 SKILL.md
3. ⚠️ 在终端中逐条执行 SKILL.md 中的每一条命令
- 确认返回 JSON 而非 HTML
- 确认写入操作 API 路径正确
- 确认 Raw API URL 不被 git 路径污染
4. 修复发现的问题
5. 写 REFERENCE.md
6. 写 examples用真实数据
7. 在 Claude Code 中运行验证
8. 写 VERIFICATION.md
```
> **核心原则**「AI 看着合理」≠「实际能跑」。必须真机验证。

View File

@ -1,404 +0,0 @@
# GitLink-CLI 项目状态总结
> 最后更新2026-06-08
> 仓库:`D:\自用\self\word\大三下\软件演化\gitlink-cli`
> 远程:`https://gitlink.org.cn/chroe/gitlink-cli.git`
---
## 一、课程任务背景
**课程**:《软件演化与运维》课程实践
**进阶任务**GitLink 智能化能力提升项目
**当前阶段**:子任务二 — 编写和丰富 GitLink Skills20%,截止 6月18日
### 子任务一交付要求(已完成)
- 向 gitlink-cli 主仓库提交 PR可多个
- 每个 PR 包含:功能代码 + 单元测试 + 命令帮助文档更新
- 提供变更说明文档
- 撰写《软件分析及建模报告》、《新需求构思报告》、《变更影响分析及测试报告》
### 子任务二交付要求(进行中)
- 遵循 gitlink-cli Skills 规范(参考 `skills/README.md`
- 每个 Skill 包含SKILL.md + 使用示例 + 至少在一个 Agent 平台上验证通过
- 兼容至少一个主流 AI Agent如 Claude Code、OpenClaw、Cursor 等)
- 撰写《新需求构思报告》和《变更影响分析及测试报告》
---
## 二、技术栈与架构
- **语言**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、子任务二编写和丰富 GitLink Skills
### 📋 总体策略
1. **先做新 Skill**:开发课程建议的新场景 Skill
2. **后完善老 Skill**:丰富已有的 12 个 Skill补充 REFERENCE.md、examples、验证记录
### 新 Skill 开发进度
| 优先级 | Skill 名称 | 场景 | 状态 | 说明 |
|--------|-----------|------|------|------|
| 🥇 | `gitlink-health` | 项目健康度报告 | ✅ 已完成 | 评分算法验证通过,含 fallback 策略 |
| 🥈 | `gitlink-changelog` | Release Notes 自动生成 | ✅ 已完成 | commit 分类规则完整,含 PR 过滤注意事项 |
| 🥉 | `gitlink-triage` | Issue 智能分拣 + 新人引导 | ✅ 已完成 | 含 subject/description 保留警告、标签映射说明 |
### Skill 开发指南(给队友看的)
> 完整踩坑记录见 `EXPERIMENT_LOG.md`,这里只写怎么动手。
#### 每个 Skill 的标准交付物3 个文件)
```
skills/gitlink-<name>/
├── SKILL.md # 主文件:触发条件、数据采集命令、输出格式、使用场景
├── REFERENCE.md # 技术参考字段映射、API 注意事项、Q&A
└── examples/
└── <scenario>.md # 使用示例(= Agent 验证证明)
```
**不需要** VERIFICATION.md — example 里放真实命令输出,本身就是验证记录。
#### 开发步骤(按顺序做)
```
第一步:确定场景
- 这个 Skill 解决什么问题?什么时候触发?
第二步:写 SKILL.md
- 照着已有的 Skill 抄格式(推荐抄 gitlink-issue/SKILL.md
- 必须有的内容:
· frontmattername/version/description/metadata
· CRITICAL 块引用 gitlink-shared/SKILL.md
· 数据采集命令(分步骤,每步一个代码块)
· 输出格式模板
· 2-3 个使用场景
· 最佳实践
第三步:⚠️ 在终端里逐条跑 SKILL.md 中的命令(最重要!)
- 每条命令都要跑,看返回的是 JSON 还是 HTML
- 写入命令确认参数完整、能执行
- 记录每条命令的真实输出(后面写 example 要用)
第四步:写 REFERENCE.md
- API 字段映射(命令返回的 JSON 长什么样)
- 注意事项和限制
- 常见问题 Q&A
第五步:写 examples/
- 用第三步记录的真实输出
- 格式:命令序列 + 真实返回数据 + AI 生成的结果
- 不准编造数据!
```
#### ⚠️ 绝对不能踩的坑
| 坑 | 说明 | 怎么避 |
|----|------|--------|
| `api` 子命令不可用 | `gitlink-cli api GET/POST` 存在 URL 污染 bug会把 git 安装路径拼进 URL | **不要用** `api` 子命令,只用 shortcut 命令(如 `issue +list`、`repo +info` |
| `--state open` 过滤不准 | `issue +list --state open` 可能返回已关闭的 Issue | 客户端检查 `status_id``5` = 关闭 |
| journals API 返回 HTML | `/v1/:owner/:repo/issues/:number/journals` 返回网页 | 用 `issue +view``comment_journals_count` 字段代替 |
| compare API 返回 HTML | `/owner/:repo/compare/...` 返回网页 | 用 `commit +list` 按时间筛选代替 |
| Issue 更新会清空字段 | 更新时不带 subject/description 会导致标题和描述被清空 | 先 `issue +view` 获取当前内容,更新时一并提交 |
| 标签名是中文 | 项目已有标签是"缺陷/功能/疑问",不是英文 | 用 `label +list` 获取已有标签,优先匹配 |
| `issue +update` 不支持打标签/分配 | 只有 title/body/state 三个参数 | 写入 CRITICAL 警告,引导用户网页操作 |
#### 已有 Skill 完善任务分配
| Skill | 当前状态 | 要做的事 | 分配给 |
|-------|---------|---------|--------|
| **gitlink-issue** | 有 SKILL.md + references/(7个文件) | 补 examples/(用 chroe/gitlink-cli 真实数据) | — |
| **gitlink-pr** | 有 SKILL.md + references/(6个文件) | 补 examples/ | — |
| **gitlink-release** | 有 SKILL.md + references/(4个文件) | 补 examples/ | — |
| **gitlink-workflow** | 仅有空壳 SKILL.md | 重写 SKILL.md + 补 REFERENCE.md + examples | — |
| **gitlink-ci** | 仅有 SKILL.md | 补 REFERENCE.md + examples | — |
| **gitlink-pm** | 仅有 SKILL.md | 补 REFERENCE.md + examples | — |
| **gitlink-repo** | 有 SKILL.md + references/(9个文件) | 补 examples/ | — |
| **gitlink-search** | 有 SKILL.md + references/(2个文件) | 补 examples/ | — |
| **gitlink-user** | 有 SKILL.md + references/(2个文件) | 补 examples/ | — |
| **gitlink-org** | 有 SKILL.md + references/(3个文件) | 补 examples/ | — |
| **gitlink-branch** | 有 SKILL.md + examples/(1个文件) | 补 REFERENCE.md | — |
> **完善标准**:每个 Skill 至少有 SKILL.md + examples/(含真实命令输出)。REFERENCE.md 如果已有 references/ 目录可以不写。
### 待写文档 📄
- [ ] 《新需求构思报告》(子任务一 + 子任务二共用)
- [ ] 《变更影响分析及测试报告》(子任务一 + 子任务二共用)
---
## 八、小组分工
| 角色 | 负责人 | 负责内容 |
|------|--------|----------|
| 组长 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 提示)
7. 12 个 AI Agent Skills基础版
### 子任务二 🔄 进行中(截止 6月18日
#### 第一步:开发新 Skill ✅ 已完成
- [x] `gitlink-health` — 项目健康度报告 Skill
- [x] SKILL.md主文件 + CRITICAL 读 REFERENCE
- [x] REFERENCE.md评分算法 + fallback 策略 + 社区关注度标准)
- [x] examples/(健康度报告 + 周报2026-06-08 真实数据)
- [x] VERIFICATION.mdClaude Code 验证通过)
- [x] 公平评分验证77/100严格按公式计算
- [x] `gitlink-changelog` — Release Notes 自动生成 Skill
- [x] SKILL.mdCRITICAL 读 REFERENCE + 完整分类规则)
- [x] REFERENCE.mdAPI 路径修正 + PR 过滤注意事项)
- [x] examples/changelog 生成示例,真实数据)
- [x] VERIFICATION.mdClaude Code 验证通过)
- [x] `gitlink-triage` — Issue 智能分拣 + 新人引导 Skill
- [x] SKILL.mdCRITICAL 读 REFERENCE + subject 保留警告)
- [x] REFERENCE.md颜色码修正 + 标签映射说明)
- [x] examples/6 Issue 批量分拣示例,真实数据)
- [x] VERIFICATION.mdClaude Code 验证通过)
#### 第二步:完善已有 Skill
- [ ] gitlink-workflow 升级为独立完整 Skill当前仅为简单骨架
- [ ] 补充 gitlink-ci、gitlink-pm 的 REFERENCE.md + examples
- [ ] 为所有 Skill 补充 VERIFICATION.md
#### 第三步:文档撰写
- [ ] 向 gitlink-cli 主仓库提交 Skills PR
- [ ] 撰写《软件分析及建模报告》
- [ ] 撰写《新需求构思报告》
- [ ] 撰写《变更影响分析及测试报告》
- [ ] 变更说明文档

511
README.md
View File

@ -1,432 +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)
> This line is for PR workflow testing, can be reverted after merge.
- 上游仓库:<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

@ -1,14 +0,0 @@
package main
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/internal/config"
)
func main() {
cfg, _ := config.Load()
fmt.Printf("BaseURL: [%s]\n", cfg.BaseURL)
fmt.Printf("len: %d\n", len(cfg.BaseURL))
path := "/v1/chroe/gitlink-cli/issues/1"
fmt.Printf("fullURL: [%s]\n", cfg.BaseURL + path)
}

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,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,11 +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"`
AnthropicAPIKey string `yaml:"anthropic_api_key,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 {
@ -92,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
}
@ -111,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

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

View File

@ -19,9 +19,9 @@ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SRC="$PROJECT_DIR/skills"
DST="$HOME/.claude/skills"
# --- collect gitlink-* skills ---
# --- collect gitlink-* skills (top-level skills/ + workflow-bundled skills) ---
shopt -s nullglob
SKILLS=("$SRC"/gitlink-*/)
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

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

View File

@ -258,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) {

View File

@ -22,7 +22,8 @@ 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"
"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"
)
@ -47,7 +48,7 @@ func RegisterAll(root *cobra.Command) {
"watch": watch.Shortcuts(),
"star": star.Shortcuts(),
"wiki": wiki.Shortcuts(),
"workflow": workflow.Shortcuts(),
"workflow": cli.Shortcuts(),
}
descriptions := map[string]string{

View File

@ -58,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 == "" {
@ -226,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

@ -1,124 +0,0 @@
package workflow
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/gitlink-org/gitlink-cli/internal/config"
)
const anthropicBaseURL = "https://api.anthropic.com/v1/messages"
const defaultModel = "claude-sonnet-4-6"
// AIClient wraps the Anthropic Messages API for skill step execution.
type AIClient struct {
apiKey string
model string
http *http.Client
}
// 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"`
}
// NewAIClient resolves the API key (env → config) and returns a client, or nil if unavailable.
func NewAIClient() *AIClient {
key := os.Getenv("ANTHROPIC_API_KEY")
if key == "" {
cfg, err := config.Load()
if err == nil {
key = cfg.AnthropicAPIKey
}
}
if key == "" {
return nil
}
return &AIClient{
apiKey: key,
model: defaultModel,
http: &http.Client{Timeout: 60 * time.Second},
}
}
// Analyze sends the skill prompt + upstream data to the Anthropic API and parses the response.
func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
if c == nil {
return nil, fmt.Errorf("AI client not configured: set ANTHROPIC_API_KEY or configure anthropic_api_key")
}
body := map[string]interface{}{
"model": c.model,
"max_tokens": 4096,
"system": req.SystemPrompt,
"messages": []map[string]string{
{"role": "user", "content": req.UserData},
},
}
payload, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequest("POST", anthropicBaseURL, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-api-key", c.apiKey)
httpReq.Header.Set("anthropic-version", "2023-06-01")
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("Anthropic API returned %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if len(result.Content) == 0 {
return nil, fmt.Errorf("empty response from Anthropic API")
}
text := result.Content[0].Text
var aiResp AIResponse
if err := json.Unmarshal([]byte(text), &aiResp); err != nil {
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
}
return &aiResp, nil
}
// HasKey reports whether the AI client is configured.
func (c *AIClient) HasKey() bool {
return c != nil && c.apiKey != ""
}

View File

@ -1,45 +1,18 @@
package workflow
package cli
import (
"fmt"
"os"
"sort"
"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"
)
// --- Registry ---
var registry = map[string]*WorkflowDef{}
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]
}
// --- CLI Commands ---
// Shortcuts returns all workflow CLI commands.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
@ -51,8 +24,8 @@ func Shortcuts() []*common.Shortcut {
},
Run: func(ctx *common.RuntimeContext) error {
cat := ctx.Arg("category")
workflows := All()
filtered := make([]*WorkflowDef, 0)
workflows := wf.All()
filtered := make([]*wf.WorkflowDef, 0)
for _, w := range workflows {
if cat == "" || strings.EqualFold(w.Category, cat) {
filtered = append(filtered, w)
@ -87,11 +60,11 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
wf := Get(name)
if wf == nil {
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(wf)
return ctx.OutputData(w)
},
},
{
@ -102,6 +75,11 @@ func Shortcuts() []*common.Shortcut {
{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"},
},
@ -110,48 +88,38 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
wf := Get(name)
if wf == nil {
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
}
// Daemon loop mode (internal — forked by +start)
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 DaemonLoop(ctx, wf, interval)
return daemon.DaemonLoop(ctx, w, interval)
}
return runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
},
},
{
Name: "init",
Description: "Run project one-click initialization workflow",
Flags: []common.Flag{
{Name: "dry-run", Usage: "Preview initialization without 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},
},
Run: func(ctx *common.RuntimeContext) error {
wf := Get("project-init")
if wf == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", "project-init")
}
aiMode, err := resolveAIModeFromArgs(ctx)
if err != nil {
return err
}
return runWorkflowCommand(ctx, wf, ctx.Arg("dry-run") == "true", aiMode)
return runWorkflowCommand(ctx, w, ctx.Arg("dry-run") == "true", aiMode)
},
},
{
@ -169,10 +137,13 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
wf := Get(name)
if wf == nil {
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 {
@ -185,7 +156,7 @@ func Shortcuts() []*common.Shortcut {
}
ctx.AIMode = aiMode
return Watch(ctx, wf, interval, ctx.Arg("step"))
return daemon.Watch(ctx, w, interval, ctx.Arg("step"))
},
},
{
@ -196,14 +167,18 @@ func Shortcuts() []*common.Shortcut {
{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
}
wf := Get(name)
if wf == nil {
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")
@ -212,13 +187,21 @@ func Shortcuts() []*common.Shortcut {
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 Schedule(ctx, wf, interval)
return daemon.Schedule(ctx, w, interval)
},
},
{
@ -229,14 +212,18 @@ func Shortcuts() []*common.Shortcut {
{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
}
wf := Get(name)
if wf == nil {
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")
@ -245,12 +232,20 @@ func Shortcuts() []*common.Shortcut {
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 StartDaemon(ctx, wf, interval, aiMode)
return daemon.StartDaemon(ctx, w, interval, aiMode)
},
},
{
@ -264,7 +259,7 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
return StopDaemon(name)
return daemon.StopDaemon(name)
},
},
{
@ -278,7 +273,7 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
return StatusDaemon(name)
return daemon.StatusDaemon(name)
},
},
{
@ -293,7 +288,7 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
return tailDaemonLog(name, ctx.Arg("follow") == "true")
return daemon.TailDaemonLog(name, ctx.Arg("follow") == "true")
},
},
{
@ -310,8 +305,8 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
wf := Get(name)
if wf == nil {
w := wf.Get(name)
if w == nil {
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
}
@ -320,7 +315,7 @@ func Shortcuts() []*common.Shortcut {
return modeErr
}
return installSystemdUnit(ctx, wf, ctx.Arg("interval"), aiMode)
return daemon.InstallSystemdUnit(ctx, w, ctx.Arg("interval"), aiMode)
},
},
}
@ -341,8 +336,8 @@ func resolveAIModeFromArgs(ctx *common.RuntimeContext) (string, error) {
return "auto", nil
}
func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) error {
result, err := RunWithMode(ctx, wf, dryRun, aiMode)
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
}
@ -355,7 +350,7 @@ func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool
needsAI++
}
if v, _ := m["_ai_used"]; v == true {
ruleEngine++ // AI was used
ruleEngine++
}
}
}
@ -370,7 +365,7 @@ func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool
}
}
fmt.Fprintf(os.Stderr, "\n你可以:\n")
fmt.Fprintf(os.Stderr, " 1. 配置 API Key 启用全自动: gitlink-cli config set anthropic_api_key <key>\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)
@ -392,3 +387,11 @@ func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool
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

@ -1,23 +0,0 @@
package workflow
func registerCodeQuality() {
register(&WorkflowDef{
Name: "code-quality",
Category: "质量",
Description: "代码质量看门人PR 提交 → Review → CI 检查 → 结果汇总",
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 +list --limit 10"},
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取仓库保护规则配置", Target: "repo +info"},
{Type: StepTypeCommand, Name: "commits", Purpose: "获取最近提交供 CI 关联分析", 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"}},
},
})
}

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

@ -1,26 +0,0 @@
package workflow
func registerCommunityOps() {
register(&WorkflowDef{
Name: "community-ops",
Category: "运营",
Description: "社区运营自动化Issue 智能分拣 → 生成周报 → 生成 Release Notes",
Trigger: TriggerDef{
Type: "poll",
On: "issue.created",
Interval: "5m",
},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "open-issues", Purpose: "获取所有开放 Issue 供 AI 分类", Target: "issue +list --state open --limit 50"},
{Type: StepTypeCommand, Name: "labels", Purpose: "获取标签库供 AI 匹配", Target: "label +list"},
{Type: StepTypeCommand, Name: "members", Purpose: "获取成员列表供 AI 分配责任人", Target: "member +list"},
{Type: StepTypeSkill, Name: "triage", Purpose: "AI 分析前三步数据,输出分拣表格并执行打标签/分配", Target: "gitlink-triage", DependsOn: []string{"open-issues", "labels", "members"}},
{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"}},
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本发布记录", Target: "release +list"},
{Type: StepTypeSkill, Name: "changelog", Purpose: "AI 分类 commit 生成 Release Notes", Target: "gitlink-changelog", DependsOn: []string{"commits", "releases", "merged-prs"}},
},
})
}

View File

@ -1,23 +0,0 @@
package workflow
func registerContributorGrowth() {
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 统计 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: "项目基础数据Fork/Star/Watch", Target: "repo +info"},
{Type: StepTypeSkill, Name: "contributor-ranking", Purpose: "AI 分析贡献者排行并识别活跃与流失", Target: "gitlink-health", DependsOn: []string{"commits", "open-issues", "closed-issues", "merged-prs", "members"}},
},
})
}

View File

@ -1,43 +1,37 @@
package workflow
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, wf *WorkflowDef, interval time.Duration, aiMode string) error {
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 := []string{
"workflow", "+run", "--name", wf.Name,
"--owner", ctx.Owner, "--repo", ctx.Repo,
"--format", "json", "--daemon-loop",
"--interval", interval.String(),
}
if aiMode == "ai" {
args = append(args, "--ai")
} else if aiMode == "no-ai" {
args = append(args, "--no-ai")
}
args := BuildDaemonArgs(ctx, wfDef, interval, aiMode)
cmd := exec.Command(bin, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
applyDaemonAttrs(cmd)
// Redirect output to log file instead of discarding.
logPath := daemonLogPath(wf.Name)
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)
@ -50,18 +44,44 @@ func StartDaemon(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Dura
logFile.Close()
return fmt.Errorf("start daemon: %w", err)
}
// logFile is owned by child process; it will be closed when child exits.
pid := cmd.Process.Pid
if err := savePID(wf.Name, pid); err != nil {
if err := savePID(wfDef.Name, pid); err != nil {
return fmt.Errorf("save pid: %w", err)
}
fmt.Printf("Daemon started for %q (PID: %d)\n", wf.Name, pid)
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)
@ -76,7 +96,6 @@ func StopDaemon(name string) error {
}
if err := proc.Signal(os.Interrupt); err != nil {
// Process might already be dead; clean up pid file anyway.
cleanPID(name)
return fmt.Errorf("failed to stop daemon %q: %w", name, err)
}
@ -88,7 +107,7 @@ func StopDaemon(name string) error {
// StatusDaemon prints the current daemon status for a workflow.
func StatusDaemon(name string) error {
state, err := LoadState(name)
st, err := state.LoadState(name)
if err != nil {
return fmt.Errorf("load state: %w", err)
}
@ -102,66 +121,67 @@ func StatusDaemon(name string) error {
} else {
fmt.Println("状态: 已停止")
}
if state.LastRun != "" {
t, err := time.Parse(time.RFC3339, state.LastRun)
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", state.LastRun)
fmt.Printf("上次运行: %s\n", st.LastRun)
}
}
fmt.Printf("累计运行: %d 次\n", state.TotalRuns)
fmt.Printf("快照步骤: %d 个\n", len(state.Snapshots))
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 (used by the daemon subprocess).
func DaemonLoop(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration) error {
// 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()
// Run immediately on start (dry-run to establish baseline).
doDaemonCycle(ctx, wf)
doDaemonCycle(ctx, wfDef)
for {
select {
case <-tick.C:
doDaemonCycle(ctx, wf)
doDaemonCycle(ctx, wfDef)
}
}
}
func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
state, _ := LoadState(wf.Name)
func doDaemonCycle(ctx *common.RuntimeContext, wfDef *wf.WorkflowDef) {
st, _ := state.LoadState(wfDef.Name)
// Phase 1: cheap dry-run — collect data without AI.
dryResult, err := Run(ctx, wf, true)
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 := state.Diff(dryResult.Steps)
if len(changed) == 0 && state.TotalRuns > 0 {
// No data changes — save state, skip expensive run.
state.TotalRuns++
state.Save()
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)
}
fmt.Fprintf(os.Stderr, "[%s] 🔔 检测到变更: %v\n", time.Now().Format(time.RFC3339), changed)
// Phase 2: full run (AI or rules based on context.AIMode).
fullResult, err := Run(ctx, wf, false)
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
}
state.TotalRuns++
state.Diff(fullResult.Steps)
state.Save()
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 {
@ -170,15 +190,83 @@ func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
}
}
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)
}
// daemonLogPath returns the log file path for a workflow daemon.
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 {
// 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 {
@ -223,8 +311,8 @@ func tailDaemonLog(name string, follow bool) error {
}
}
// installSystemdUnit generates a systemd service unit file for a workflow daemon.
func installSystemdUnit(ctx *common.RuntimeContext, wf *WorkflowDef, interval, aiMode string) error {
// 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)
}
@ -252,12 +340,12 @@ StandardError=append:%s
[Install]
WantedBy=multi-user.target
`,
wf.Name, ctx.Owner, ctx.Repo,
bin, wf.Name, ctx.Owner, ctx.Repo, interval, extraArgs,
daemonLogPath(wf.Name), daemonLogPath(wf.Name),
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", wf.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)
}
@ -266,10 +354,10 @@ WantedBy=multi-user.target
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", wf.Name)
fmt.Printf(" sudo systemctl start workflow-%s\n", wf.Name)
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", wf.Name)
fmt.Printf("查看日志: journalctl -u workflow-%s -f\n", wfDef.Name)
return nil
}

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

@ -1,82 +0,0 @@
package workflow
import (
"encoding/json"
"fmt"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// 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"`
}
// Run executes every step in a workflow sequentially.
// Steps later in the sequence receive data from their DependsOn predecessors
// via ctx.Args (keyed by step name, stored as JSON).
// Set dryRun to true to skip AI API calls for skill steps.
func Run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowResult, error) {
return RunWithMode(ctx, wf, dryRun, "")
}
// RunWithMode executes a workflow with explicit AI mode control.
// aiMode must be "auto", "ai", "no-ai", or "" (equivalent to "auto").
func RunWithMode(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) (*WorkflowResult, error) {
if aiMode != "" {
ctx.AIMode = aiMode
}
return run(ctx, wf, dryRun)
}
func run(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool) (*WorkflowResult, error) {
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"
}
results := make([]StepResult, 0, len(wf.Steps))
for _, step := range wf.Steps {
sr := ExecuteStep(ctx, step, dryRun)
results = append(results, *sr)
// Feed output of this step as input to downstream steps via Args.
if sr.OK && 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)
}
}
}
return &WorkflowResult{
Workflow: wf.Name,
Owner: ctx.Owner,
Repo: ctx.Repo,
Steps: results,
}, nil
}
// resolvePath replaces template placeholders in a path string.
//
// {base} → /owner/repo
// {v1} → /v1/owner/repo
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,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

@ -1,16 +0,0 @@
package workflow
// manifest.go — registration center for all workflow definitions.
// Each scenario file defines a register*() function; init() calls them all.
// To add a new workflow:
// 1. Create a new file in this package (e.g., my_scenario.go)
// 2. Define func registerMyScenario() { register(&WorkflowDef{...}) }
// 3. Add registerMyScenario() to the init() list below
func init() {
registerCommunityOps()
registerCodeQuality()
registerProjectInit()
registerMultiRepo()
registerContributorGrowth()
}

View File

@ -1,23 +0,0 @@
package workflow
func registerMultiRepo() {
register(&WorkflowDef{
Name: "multi-repo",
Category: "协同",
Description: "多仓库协同:跨仓库 Issue/PR 状态看板、Release 协调发布",
Trigger: TriggerDef{
Type: "cron",
On: "0 9 * * 1",
Interval: "24h",
},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "repo-info", Purpose: "获取主仓库信息", Target: "repo +info"},
{Type: StepTypeCommand, Name: "open-issues", Purpose: "获取开放 Issue 列表", Target: "issue +list --state open --limit 50"},
{Type: StepTypeCommand, Name: "open-prs", Purpose: "获取开放 PR 列表", Target: "pr +list --state open --limit 20"},
{Type: StepTypeCommand, Name: "releases", Purpose: "获取版本信息协调跨仓库发布", Target: "release +list"},
{Type: StepTypeCommand, Name: "milestones", Purpose: "获取里程碑跨仓库对齐", Target: "milestone +list"},
{Type: StepTypeCommand, Name: "members", Purpose: "获取成员跨仓库协作", Target: "member +list"},
{Type: StepTypeSkill, Name: "repo-health", Purpose: "AI 综合评估多仓库健康与活跃度", Target: "gitlink-health", DependsOn: []string{"repo-info", "open-issues", "open-prs"}},
},
})
}

View File

@ -1,23 +0,0 @@
package workflow
func registerProjectInit() {
register(&WorkflowDef{
Name: "project-init",
Category: "初始化",
Description: "项目一键初始化:仓库检查 → 文件/Issue/里程碑初始 → CI 配置",
Trigger: TriggerDef{
Type: "manual",
On: "manual",
},
Steps: []StepDef{
{Type: StepTypeCommand, Name: "repo-info", Purpose: "确认仓库存在并获取基础信息", Target: "repo +info"},
{Type: StepTypeCommand, Name: "existing-files", Purpose: "检查 README/LICENSE 是否已存在", Target: "file +list"},
{Type: StepTypeCommand, Name: "labels", Purpose: "检查标签库是否齐全", Target: "label +list"},
{Type: StepTypeSkill, Name: "license-check", Purpose: "AI 检查许可证合规并扫描敏感信息泄露", 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: "AI 综合审计仓库健康度", Target: "gitlink-repo", DependsOn: []string{"repo-info", "labels", "milestones", "branches"}},
},
})
}

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

@ -173,3 +173,19 @@ func containsAny(s string, patterns ...string) bool {
}
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

@ -1,7 +1,10 @@
package rules
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
@ -125,7 +128,29 @@ func ContributorRankingRule(upstream map[string]interface{}, stepName string) (*
"churn_risk": filterByTag(rankings, "churn-risk"),
"new_stars": filterByTag(rankings, "new-star"),
}
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
// 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 {
@ -189,6 +214,21 @@ func extractMembers(upstream map[string]interface{}) map[string]string {
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 {
@ -200,9 +240,9 @@ func extractMembers(upstream map[string]interface{}) map[string]string {
continue
}
login := str(m, "login", "username", "name")
name := str(m, "name", "full_name", "display_name")
if login != "" {
members[login] = name
id := fmt.Sprint(m["id"])
if login != "" && id != "" && id != "0" && id != "<nil>" {
members[login] = id
}
}
return members
@ -219,6 +259,24 @@ func extractList(upstream map[string]interface{}, key string) []map[string]inter
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 {
@ -230,12 +288,39 @@ func extractList(upstream map[string]interface{}, key string) []map[string]inter
}
func authorLogin(m map[string]interface{}) string {
return str(m, "author", "login", "username", "committer", "user")
// 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 {
// Commits and issues may be nested under author/committer.
for _, key := range []string{"created_at", "committed_date", "updated_at", "authored_date"} {
// 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
}
@ -267,4 +352,59 @@ func str(m map[string]interface{}, keys ...string) string {
}
}
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

@ -93,8 +93,8 @@ func TestContributorOutputFormat(t *testing.T) {
if resp.Analysis == nil {
t.Fatal("expected non-nil Analysis")
}
if resp.Actions != nil {
t.Fatal("expected nil Actions (read-only report)")
if len(resp.Actions) < 1 {
t.Fatal("expected at least 1 wiki action")
}
}

View File

@ -1,7 +1,9 @@
package rules
import (
"fmt"
"math"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
@ -70,7 +72,186 @@ func HealthReportRule(upstream map[string]interface{}, stepName string) (*workfl
},
},
}
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
// 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 {
@ -199,8 +380,14 @@ func extractFirst(upstream map[string]interface{}, key string) map[string]interf
if len(list) > 0 {
return list[0]
}
// Try direct map.
// 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
@ -216,5 +403,11 @@ func grade(score float64) string {
}
func clamp(v float64) float64 {
return min(max(v, 0), 100)
if v < 0 {
return 0
}
if v > 100 {
return 100
}
return v
}

View File

@ -58,8 +58,18 @@ func TestHealthReportScoring(t *testing.T) {
}
}
}
if resp.Actions != nil {
t.Fatal("expected nil Actions (read-only report)")
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")
}
}

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

@ -5,9 +5,13 @@ 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

@ -15,6 +15,10 @@ func TestRegistryAllRegistered(t *testing.T) {
"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 {

View File

@ -24,19 +24,9 @@ func RepoAuditRule(upstream map[string]interface{}, stepName string) (*workflow.
var dims []dimScore
missing := []string{}
// Readme check.
desc := ""
hasReadme := false
if repoInfo != nil {
desc = str(repoInfo, "description")
if v, ok := repoInfo["has_readme"].(bool); ok {
hasReadme = v
}
}
readmeScore := 0.0
if hasReadme || desc != "" {
readmeScore = 100
dims = append(dims, dimScore{Name: "README", Score: readmeScore, Weight: 0.25, Status: "ok", Detail: "README 已存在"})
// 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")
@ -115,14 +105,20 @@ func RepoAuditRule(upstream map[string]interface{}, stepName string) (*workflow.
}
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")
if isLicenseFile(name) {
return true
for _, n := range names {
if strings.EqualFold(name, n) {
return true
}
}
}
return false

View File

@ -17,6 +17,7 @@ func TestRepoAuditComplete(t *testing.T) {
},
"existing-files": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"name": "README.md", "content": "# Project"},
map[string]interface{}{"name": "LICENSE", "content": "MIT"},
},
},

View File

@ -3,6 +3,7 @@ package rules
import (
"fmt"
"regexp"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
@ -31,21 +32,31 @@ func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow
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["id"])
prNum := interfaceToString(pr["pull_request_number"])
if prNum == "" {
prNum = interfaceToString(pr["number"])
prNum = interfaceToString(pr["id"])
if prNum == "" {
prNum = interfaceToString(pr["pull_request_id"])
prNum = interfaceToString(pr["number"])
if prNum == "" {
prNum = interfaceToString(pr["pull_request_id"])
}
}
}
if prNum == "" {
continue
}
text := title + " " + body
diffText := prDiffsMap[prNum]
text := title + " " + body + " " + diffText
var prFindings []finding
// Security scan.
for _, p := range reviewSecurityPatterns {
@ -59,19 +70,8 @@ func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow
Why: "PR 标题/描述中包含可能存在安全风险的代码模式",
Fix: p.fix,
}
prFindings = append(prFindings, f)
findings = append(findings, f)
if p.severity == "high" {
actions = append(actions, workflow.AIAction{
Type: "cli",
Module: "issue",
Command: "+comment",
Args: map[string]string{
"number": prNum,
"body": fmt.Sprintf("⚠️ **安全审查警告**: %s\n\n建议: %s", p.what, p.fix),
},
})
}
}
}
@ -90,6 +90,7 @@ func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow
Why: "大 PR 难以审查,增加合并风险和回滚难度",
Fix: "将改动按功能模块拆分为多个小 PR",
}
prFindings = append(prFindings, f)
findings = append(findings, f)
}
@ -103,8 +104,34 @@ func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow
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{}{
@ -162,6 +189,46 @@ var reviewSecurityPatterns = []reviewPattern{
},
}
// 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 ""
@ -177,3 +244,24 @@ func interfaceToString(v interface{}) string {
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

@ -9,16 +9,21 @@ func TestCodeReviewStaticAnalysis(t *testing.T) {
"open-prs": map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"id": "1",
"title": "add feature",
"body": "password = 'hardcoded12345678'",
"id": "1",
"title": "add feature",
"body": "password = 'hardcoded12345678'",
},
map[string]interface{}{
"id": "2",
"title": "wip",
"body": "",
"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",
},
},
},
}
@ -33,7 +38,6 @@ func TestCodeReviewStaticAnalysis(t *testing.T) {
if total == 0 {
t.Fatal("expected findings for hardcoded password and large PR")
}
// Should have at least one high-severity security finding.
findings := analysis["findings"].([]finding)
hasSecurity := false
hasMaint := false
@ -51,6 +55,25 @@ func TestCodeReviewStaticAnalysis(t *testing.T) {
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) {

View File

@ -31,6 +31,8 @@ func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIR
title := str(issue, "title")
body := str(issue, "body", "description")
text := title + " " + body
open := isIssueOpen(issue)
processed := isIssueProcessed(issue)
cat := classifyIssue(text)
pri := assignPriority(text)
@ -42,16 +44,26 @@ func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIR
}
result := map[string]interface{}{
"number": num,
"title": title,
"category": cat,
"priority": pri,
"assignee": assignee,
"number": num,
"title": title,
"category": cat,
"priority": pri,
"assignee": assignee,
"processed": processed,
}
classified = append(classified, result)
// Build PATCH action if we have labels or assignee.
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
}
@ -61,7 +73,7 @@ func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIR
if pri > 0 {
body2["priority_id"] = pri
}
if len(body2) > 0 && num != "" {
if open && len(body2) > 0 && num != "" {
actions = append(actions, workflow.AIAction{
Type: "api", Method: "PATCH",
Path: fmt.Sprintf("{v1}/issues/%s", num),
@ -69,13 +81,13 @@ func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIR
})
}
if isGoodFirstIssue(text) {
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",
"body": "👋 感谢提交 Issue这个 Issue 已被标记为 **Good First Issue**,适合新贡献者参与。欢迎提交 PR\n\n---\n*此评论由社区运营工作流自动生成*",
},
})
}
@ -99,8 +111,8 @@ var catPatterns = []struct {
{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)重构|代码质量|refactor|clean\s*up|tech\s*debt`), "refactor"},
{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"},
}
@ -144,6 +156,68 @@ func isGoodFirstIssue(text string) bool {
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{} {
@ -152,6 +226,7 @@ func extractLabels(upstream map[string]interface{}) []map[string]interface{} {
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"))
@ -159,7 +234,7 @@ func matchLabels(category string, labels []map[string]interface{}) []interface{}
continue
}
// Direct match or contains.
if name == catLower || strings.Contains(name, catLower) || strings.Contains(catLower, name) {
if matchesLabelName(catLower, aliases, name) {
if id := labelID(l); id != nil {
ids = append(ids, id)
}
@ -179,6 +254,40 @@ func matchLabels(category string, labels []map[string]interface{}) []interface{}
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 {
@ -196,11 +305,11 @@ func leastLoaded(load map[string]int, members map[string]string) string {
}
best := ""
bestN := -1
for login := range members {
for login, id := range members {
n := load[login]
if bestN < 0 || n < bestN {
bestN = n
best = login
best = id
}
}
return best
@ -217,3 +326,30 @@ func issueNumber(issue map[string]interface{}) string {
}
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

@ -110,6 +110,101 @@ func TestTriageRuleGoodFirstIssue(t *testing.T) {
}
}
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

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

@ -1,4 +1,4 @@
package workflow
package state
import (
"crypto/md5"
@ -9,8 +9,20 @@ import (
"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)
@ -18,8 +30,10 @@ func LoadState(name string) (*WorkflowState, error) {
if err != nil {
if os.IsNotExist(err) {
return &WorkflowState{
Workflow: name,
Snapshots: make(map[string]string),
Workflow: name,
Snapshots: make(map[string]string),
PhaseLastRun: make(map[string]string),
PhaseUpstream: make(map[string]string),
}, nil
}
return nil, err
@ -31,6 +45,12 @@ func LoadState(name string) (*WorkflowState, error) {
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
}
@ -50,24 +70,32 @@ func (s *WorkflowState) Save() error {
}
// Diff compares current step results against stored snapshots.
// Returns the names of steps whose data changed since the last run.
func (s *WorkflowState) Diff(results []StepResult) []string {
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)
hash := HashData(sr.Data)
if prev, ok := s.Snapshots[sr.Step]; ok && prev != hash {
changed = append(changed, sr.Step)
}
s.Snapshots[sr.Step] = hash
}
return changed
}
// hashData computes an MD5 hash of the JSON-encoded data.
func hashData(data interface{}) string {
// 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 ""

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")
}
}

View File

@ -1,381 +0,0 @@
package workflow
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// 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"`
}
// ExecuteStep dispatches a step to the right executor based on its Type.
func ExecuteStep(ctx *common.RuntimeContext, step StepDef, dryRun bool) *StepResult {
sr := &StepResult{
Step: step.Name,
Purpose: step.Purpose,
Type: step.Type,
}
switch step.Type {
case StepTypeAPI:
executeAPIStep(ctx, step, sr)
case StepTypeCommand:
executeCommandStep(ctx, step, sr)
case StepTypeSkill:
executeSkillStep(ctx, step, sr, dryRun)
default:
sr.OK = false
sr.Error = fmt.Sprintf("unknown step type: %q", step.Type)
}
return sr
}
// executeAPIStep makes an HTTP call through the API client.
func executeAPIStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
path := 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
}
}
// executeCommandStep runs a gitlink-cli subcommand as a subprocess.
func executeCommandStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult) {
parts := parseCommandTarget(step.Target)
if len(parts) == 0 {
sr.OK = false
sr.Error = fmt.Sprintf("empty command target: %q", step.Target)
return
}
bin, err := os.Executable()
if err != nil {
bin = "gitlink-cli"
}
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
}
}
// executeSkillStep runs a skill step. Depending on aiMode, it uses the AI API or
// falls back to a deterministic rule engine. Both paths produce the same AIResponse
// format, and actions from either source go through the same security whitelist.
func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult, dryRun bool) {
upstream := collectUpstream(ctx, step)
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 := NewAIClient()
var aiResp *AIResponse
var usedAI bool
switch aiMode {
case 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 AIModeAI:
if !client.HasKey() {
sr.OK = false
sr.Error = "AI 模式需要配置 API Key设置 ANTHROPIC_API_KEY 环境变量或 config set anthropic_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: // "auto"
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
}
}
executed := executeActions(ctx, aiResp.Actions)
sr.OK = true
sr.Data = map[string]interface{}{
"ok": true,
"analysis": aiResp.Analysis,
"executed": executed,
"_ai_used": usedAI,
"_skill": step.Target,
}
}
// executeActions runs allowed actions from an AIResponse. Returns count of
// successfully executed actions. Actions from both AI and rule engines pass
// through the same security whitelist.
func executeActions(ctx *common.RuntimeContext, actions []AIAction) int {
executed := 0
for _, action := range actions {
if !isActionAllowed(action) {
fmt.Fprintf(os.Stderr, "[workflow] blocked action: %s %s\n", action.Type, action.Command)
continue
}
if action.Type == "api" {
path := resolvePath(action.Path, ctx.Owner, ctx.Repo)
_, err := ctx.CallAPI(action.Method, path, action.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "[workflow] api action failed: %v\n", err)
continue
}
executed++
} else if action.Type == "cli" {
args := []string{action.Module, action.Command}
for k, v := range action.Args {
args = append(args, "--"+k, v)
}
args = append(args, "--owner", ctx.Owner, "--repo", ctx.Repo)
bin, _ := os.Executable()
if bin == "" {
bin = "gitlink-cli"
}
err := exec.Command(bin, args...).Run()
if err != nil {
fmt.Fprintf(os.Stderr, "[workflow] cli action failed: %v\n", err)
continue
}
executed++
}
}
return executed
}
// resolveAIMode determines the effective AI mode from the context.
func resolveAIMode(ctx *common.RuntimeContext) AIMode {
switch ctx.AIMode {
case "ai":
return AIModeAI
case "no-ai":
return AIModeNoAI
default:
return AIModeAuto
}
}
// callAI invokes the Anthropic API for a skill step.
func callAI(client *AIClient, step StepDef, upstream map[string]interface{}) (*AIResponse, error) {
skillMD := readSkillDoc(step.Target)
upstreamJSON, _ := json.MarshalIndent(upstream, "", " ")
return client.Analyze(&AIRequest{
SystemPrompt: skillMD,
UserData: string(upstreamJSON),
})
}
// runRuleEngine looks up and invokes the rule engine for a skill target.
func runRuleEngine(step StepDef, upstream map[string]interface{}) (*AIResponse, error) {
engine, ok := RuleEngines[step.Target]
if !ok {
return nil, ErrNoRuleEngine(step.Target)
}
return engine(upstream, step.Name)
}
// collectUpstream gathers data from steps declared in DependsOn.
func collectUpstream(ctx *common.RuntimeContext, step StepDef) map[string]interface{} {
upstream := make(map[string]interface{})
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 no DependsOn, collect all available upstream data.
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
}
// readSkillDoc reads the full SKILL.md for a given skill name.
func readSkillDoc(target string) string {
paths := []string{}
if exe, err := os.Executable(); err == nil {
paths = append(paths, filepath.Join(filepath.Dir(exe), "skills", target, "SKILL.md"))
}
paths = append(paths,
filepath.Join("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)
}
// Security whitelist for AI-generated actions.
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,
}
var blockedCLICommands = map[string]bool{
"+delete": true, "+remove": true, "+batch-delete": true,
"+fork": true, "+batch-fork": true,
}
func isActionAllowed(action 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
}
// 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
}

View File

@ -1,129 +0,0 @@
package workflow
import (
"fmt"
"os"
"os/signal"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// 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, wf *WorkflowDef, interval time.Duration, watchStep string) error {
if watchStep == "" && len(wf.Steps) > 0 {
watchStep = wf.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", wf.Trigger.Type, wf.Trigger.On)
fmt.Println(" Press Ctrl+C to stop")
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
state, _ := LoadState(wf.Name)
tick := time.NewTicker(interval)
defer tick.Stop()
for {
select {
case <-sig:
fmt.Println("\n👋 watch stopped")
return nil
case t := <-tick.C:
// Phase 1: cheap dry-run to check for changes
dryResult, err := Run(ctx, wf, true)
if err != nil {
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
continue
}
changed := state.Diff(dryResult.Steps)
if len(changed) == 0 && state.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)
// Phase 2: full run with AI
result, err := Run(ctx, wf, false)
if err != nil {
fmt.Printf("[%s] ❌ AI run error: %v\n", t.Format("15:04:05"), err)
continue
}
state.Diff(result.Steps)
state.TotalRuns++
state.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)
}
}
}
}
}
// Schedule runs the full workflow on a repeating interval. Blocks until Ctrl+C.
// Schedule always runs with AI (cron-style workflows like weekly reports always need fresh output).
func Schedule(ctx *common.RuntimeContext, wf *WorkflowDef, interval time.Duration) error {
fmt.Printf("⏰ Scheduled %q every %v on %s/%s\n", wf.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()
// Run immediately on start (dry-run to establish baseline)
state, _ := LoadState(wf.Name)
dryResult, _ := Run(ctx, wf, true)
state.Diff(dryResult.Steps)
state.TotalRuns++
state.Save()
for {
select {
case <-sig:
fmt.Println("\n👋 schedule stopped")
return nil
case t := <-tick.C:
// Phase 1: dry-run to check for changes
dryResult, err := Run(ctx, wf, true)
if err != nil {
fmt.Printf("[%s] ❌ error: %v\n", t.Format("15:04:05"), err)
continue
}
changed := state.Diff(dryResult.Steps)
state.TotalRuns++
state.Save()
if len(changed) == 0 {
fmt.Printf("[%s] ✓ no changes, skipped AI run\n", t.Format("15:04:05"))
continue
}
// Phase 2: full run with AI
fmt.Printf("[%s] ⏳ changes detected, running %q with AI...\n", t.Format("15:04:05"), wf.Name)
result, err := Run(ctx, wf, 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

@ -9,22 +9,18 @@ import (
type AIMode string
const (
AIModeAuto AIMode = "auto" // Use AI if API key available, else rules
AIModeAI AIMode = "ai" // Force AI (error if no key)
AIModeNoAI AIMode = "no-ai" // Force rule engine only
AIModeAuto AIMode = "auto"
AIModeAI AIMode = "ai"
AIModeNoAI AIMode = "no-ai"
)
// RuleEngineFunc is the signature for a deterministic rule engine.
// It receives upstream data (same JSON the AI would get) and the step name,
// and returns the same AIResponse format the AI would produce.
type RuleEngineFunc func(upstream map[string]interface{}, stepName string) (*AIResponse, error)
// RuleEngines is a registry of skill-target → rule-engine mappings.
// Populated by the rules/ package init().
var RuleEngines = map[string]RuleEngineFunc{}
// RegisterRuleEngine registers a rule engine function for a given skill target.
// Called by the rules package during init().
func RegisterRuleEngine(target string, fn RuleEngineFunc) {
RuleEngines[target] = fn
}
@ -43,26 +39,32 @@ const (
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.
//
// skill: Target = "gitlink-triage" → AI Agent reads the Skill doc
// command: Target = "issue +list --state open" → CLI subprocess
// api: Target = "{v1}/issues" → HTTP call, Method = GET/POST/...
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"` // "manual" | "poll" | "cron"
On string `json:"on"` // event description or cron expression
Interval string `json:"interval,omitempty"` // poll: "5m" cron: "0 9 * * 1"
Type string `json:"type"`
On string `json:"on"`
Interval string `json:"interval,omitempty"`
}
// WorkflowDef is a named, ordered sequence of steps with a trigger.
@ -76,19 +78,43 @@ type WorkflowDef struct {
// AIAction is a write instruction returned by an AI skill step.
type AIAction struct {
Type string `json:"type"` // "api" | "cli"
Method string `json:"method,omitempty"` // api: GET/PATCH/POST
Path string `json:"path,omitempty"` // api: /v1/{owner}/{repo}/issues/7
Body map[string]interface{} `json:"body,omitempty"` // api: request body
Module string `json:"module,omitempty"` // cli: "issue"
Command string `json:"command,omitempty"` // cli: "+comment"
Args map[string]string `json:"args,omitempty"` // cli: {"number":"10"}
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"`
}
// 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"` // stepName → md5(json)
// 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

@ -1,18 +1,89 @@
package workflow
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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"
)
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))
@ -43,57 +114,6 @@ func TestGetNonexistent(t *testing.T) {
}
}
func TestShortcutsCount(t *testing.T) {
sc := Shortcuts()
if len(sc) != 11 {
t.Fatalf("expected 11 shortcuts (list, info, run, init, watch, schedule, start, stop, status, logs, install-systemd), got %d", len(sc))
}
names := map[string]bool{
"list": false, "info": false, "run": false, "init": 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)
}
}
}
func TestProjectInitShortcut(t *testing.T) {
var initShortcut *common.Shortcut
for _, s := range Shortcuts() {
if s.Name == "init" {
initShortcut = s
break
}
}
if initShortcut == nil {
t.Fatal("missing init shortcut")
}
if initShortcut.Description == "" {
t.Fatal("init shortcut should have a description")
}
if len(initShortcut.Flags) != 3 {
t.Fatalf("init shortcut should have 3 flags (dry-run, ai, no-ai), got %d: %+v", len(initShortcut.Flags), initShortcut.Flags)
}
hasDryRun := false
for _, f := range initShortcut.Flags {
if f.Name == "dry-run" && f.Bool {
hasDryRun = true
}
}
if !hasDryRun {
t.Fatal("init shortcut should expose bool --dry-run flag")
}
}
func TestResolvePath(t *testing.T) {
cases := []struct {
template, owner, repo, expected string
@ -104,9 +124,9 @@ func TestResolvePath(t *testing.T) {
{"{v1}/issues?state=open", "x", "y", "/v1/x/y/issues?state=open"},
}
for _, tc := range cases {
got := resolvePath(tc.template, tc.owner, tc.repo)
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)
t.Fatalf("ResolvePath(%q, %s, %s) = %q, want %q", tc.template, tc.owner, tc.repo, got, tc.expected)
}
}
}
@ -179,6 +199,22 @@ func TestSkillStepDependsOn(t *testing.T) {
}
}
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
@ -190,306 +226,18 @@ func TestParseCommandTarget(t *testing.T) {
{"issue +list --state open --limit 50", []string{"issue", "+list", "--state", "open", "--limit", "50"}},
}
for _, tc := range cases {
got := parseCommandTarget(tc.input)
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)
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])
t.Fatalf("ParseCommandTarget(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.expected[i])
}
}
}
}
// --- Security whitelist tests ---
func TestActionAllowed(t *testing.T) {
cases := []struct {
name string
action AIAction
allowed bool
}{
{"api GET", AIAction{Type: "api", Method: "GET"}, true},
{"api POST", AIAction{Type: "api", Method: "POST"}, true},
{"api PATCH", AIAction{Type: "api", Method: "PATCH"}, true},
{"api DELETE blocked", AIAction{Type: "api", Method: "DELETE"}, false},
{"cli issue comment", AIAction{Type: "cli", Module: "issue", Command: "+comment"}, true},
{"cli delete blocked", AIAction{Type: "cli", Module: "repo", Command: "+delete"}, false},
{"cli fork blocked", AIAction{Type: "cli", Module: "repo", Command: "+fork"}, false},
{"cli repo module blocked", 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)
}
})
}
}
// --- State tests ---
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 := []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])
}
if _, ok := s.Snapshots["step2"]; !ok {
t.Fatal("step2 should be added to snapshots")
}
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")
}
}
// --- Engine integration tests ---
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)
wf := &WorkflowDef{
Name: "test-api",
Steps: []StepDef{
{Type: StepTypeAPI, Name: "fetch-issues", Purpose: "get issues", Method: "GET", Target: "{v1}/issues"},
{Type: StepTypeAPI, Name: "fetch-labels", Purpose: "get labels", Method: "GET", Target: "{v1}/labels"},
},
}
result, err := Run(ctx, wf, 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)
wf := &WorkflowDef{
Name: "test-skill-upstream",
Steps: []StepDef{
{Type: StepTypeAPI, Name: "get-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
{Type: StepTypeAPI, Name: "get-labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
{Type: StepTypeSkill, Name: "ai-triage", Purpose: "triage", Target: "gitlink-triage"},
},
}
result, err := Run(ctx, wf, false)
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")
}
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")
}
}
// TestSkillStepWithDependsOn verifies that when DependsOn is set,
// only those specific upstream steps are collected.
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)
wf := &WorkflowDef{
Name: "test-depends-on",
Steps: []StepDef{
{Type: StepTypeAPI, Name: "open-issues", Purpose: "issues", Method: "GET", Target: "{v1}/issues"},
{Type: StepTypeAPI, Name: "labels", Purpose: "labels", Method: "GET", Target: "{v1}/labels"},
{Type: StepTypeAPI, Name: "members", Purpose: "members", Method: "GET", Target: "{v1}/members"},
{Type: StepTypeSkill, Name: "triage", Purpose: "triage", Target: "gitlink-triage",
DependsOn: []string{"open-issues", "labels"}},
},
}
result, err := Run(ctx, wf, false)
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)
wf := &WorkflowDef{
Name: "test-fail",
Steps: []StepDef{
{Type: StepTypeAPI, Name: "bad-step", Purpose: "will fail", Method: "GET", Target: "{v1}/bad"},
},
}
result, err := Run(ctx, wf, 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)
wf := &WorkflowDef{
Name: "test-unknown",
Steps: []StepDef{
{Type: StepType("invalid"), Name: "bad", Purpose: "unknown", Target: "x"},
},
}
result, err := Run(ctx, wf, false)
if err != nil {
t.Fatalf("Run() returned error: %v", err)
}
if result.Steps[0].OK {
t.Fatal("unknown step type should fail")
}
}
func TestCodeQualityHasReviewStep(t *testing.T) {
wf := Get("code-quality")
if wf == nil {
@ -509,57 +257,3 @@ func TestCodeQualityHasReviewStep(t *testing.T) {
t.Fatal("code-quality missing gitlink-review skill step")
}
}
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)
wf := &WorkflowDef{
Name: "test-dry-run",
Steps: []StepDef{
{Type: StepTypeAPI, Name: "get-data", Purpose: "data", Method: "GET", Target: "{v1}/issues"},
{Type: StepTypeSkill, Name: "ai-step", Purpose: "AI analysis", Target: "gitlink-triage",
DependsOn: []string{"get-data"}},
},
}
result, err := Run(ctx, wf, 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")
}
}
// --- helpers ---
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

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM golang:1.26-alpine AS builder
@ -9,11 +10,12 @@ RUN go mod download
COPY . .
# Build gitlink-cli binary
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o gitlink-cli .
# Build showcase server binary
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -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

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)
})
@ -43,6 +45,7 @@ func main() {
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,35 +61,68 @@ func main() {
repo = "gitlink-cli"
}
}
if format == "" {
format = "json"
}
args := []string{module, "+" + command, "--owner", owner, "--repo", repo, "--format", format}
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())
}
}
@ -97,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

Binary file not shown.

View File

@ -162,7 +162,8 @@ skills/
| **gitlink-changelog** | Release Notes 自动生成 | 从 commit/PR/Issue 历史自动生成版本说明 |
| **gitlink-triage** | Issue 智能分拣 + 新人引导 | 自动分类、打标签、分配责任人、good-first-issue 引导 |
| **gitlink-review** | 智能代码审查 | 分析 PR diff多视角评审 + 对抗式自检,结构化 Review 意见自动评论 |
| **gitlink-research-fair** | 科研软件 FAIR 体检 | 5 轴 FAIR/可复现评分、体检报告卡、自动开 PR 修复缺口、SWH/commit 可复现证书 |
| **gitlink-research-fair** | 科研软件 X 光 | fair.py 抽论文/数据/复现/引用画像,四维裁决 + 真科研图谱 + 特有关键发现报告,可选处方 PR |
| **gitlink-spark** | 文献-代码语义缺口挖掘机 | arXiv×GitLink×GitHub 三源挖"理论无实现/需求无解答"缺口,出机会报告,一键 fork+issue 起跑 |
---

View File

@ -1,105 +1,81 @@
# gitlink-research-fair 参考文档
# gitlink-research-fair v2 参考文档(科研软件 X 光)
> 本文件为 SKILL.md 的深度参考。评分细则、KG schema、OpenAlex/SWH 技术细节、学术引用库与诚实边界均在此
> SKILL.md 的深度参考:四维裁决细则、真 KG schema、fair.py 抽取规则、数据源实测、学术锚
## 一、FAIR4RS 逐条映射
## 一、四维裁决细则
| 原则 | 含义 | 本 Skill 检查项 | GitLink 证据 | 兜底(无字段时) |
|------|------|----------------|--------------|------------------|
| F1 | 全局唯一持久标识 | A4 持久标识 | repo identifier/URL + commit SHA | commit-SHA 锚定 |
| F1.2 | 版本级唯一标识 | A3 有版本发布 | `release +list` | ✗(无 release tag |
| 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 |
每维 `✅/⚠️/❌`**必须引用 fair.py 画像字段作证据**。
## 二、评分算法
### 论文溯源F2/R1.2
- ✅ `paper.arxiv_id``paper.doi` 非空 + `paper.title`/`venue` 抽到
- ⚠️ 仅 `paper.title` 抽到("Code for..." 句式),无 arxiv/DOI
- ❌ `paper.in_readme` = False
- 每项状态:`✓`(1) / `⚠`(0.5) / `✗`(0) / `⊥`(跳过,不计入分母)
- 轴分 = Σ状态值 / (轴内项数 ⊥项数) × 100
- 总评 = 各轴分均值(全 ⊥ 轴记 N/A 排除)
- 等级A🟢≥80 / B🟡60-79 / C🔴<60
- `confidence=low`:标注"待人工确认",不计入分子分母
### 数据链FAIR 数据维度)
- ✅ `datasets` 非空 + 至少一个有 `download_script` + 数据 license 可考
- ⚠️ `datasets` 非空(命名)但无 download_script 或无 license
- ❌ `datasets` 为空
## 三、可复现性 checklist独立于 FAIR
### 复现就绪(独立于 FAIR
- ✅ `repro.deps_pinned`=True + `entry_points` 非空 + `env_spec`=True + `expected_results`=True四件齐全
- ⚠️ 有 `entry_points` 但缺依赖锁/环境/期望结果中任一
- ❌ 无 `entry_points` 或无 `deps_files`
Rep1 环境锁requirements 锁版本/Dockerfile/environment.yml· Rep2 数据集说明has_dataset/README 引用/下载脚本)· Rep3 复现步骤README "运行/复现/Quick Start" 章节)· Rep4 版本固定release tag 或 commit 锚定)· Rep5 入口可执行main.py/train.py/Makefile/CLI
### 引用就绪R1.1/R2
- ✅ `citation.cff``citation.codemeta` 为 True + 有版本/DOI
- ⚠️ 仅 `citation.readme_bibtex` 非空README 有引用文本,无机器可读文件)
- ❌ 三者皆 False
## 四、KG schematriples
## 二、真科研图谱 schemaMermaid
实体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)
**节点**(按 fair.py 画像实例化,每个 dataset/entry/framework 各一个节点,不合并):
- `Repo``:::anchor` 蓝灰锚点repo 名 + commit
- `Paper`venue + arxiv/DOI无则红色 `Paper: none`
- `Method`(从 methods[]
- `Code`(每个 entry_point 一个节点)
- `Dataset`(每个 dataset 一个节点;无则一个红色 `Dataset: none`
- `Framework`frameworks[];未知则黄色 `framework unknown`
- `Citation`cff/bibtex 状态;无则红色 `Citation: none`
- `License`(有/无/冲突)
输出:`triples.json`(数组 of {s,p,o}+ Mermaid `graph LR` 小图。
**边**
- `Repo --> Paper`、`Repo --> License`
- `Paper -->|proposes| Method`
- `Method -->|implements| Code`
- `Code -->|trains-on| Dataset`、`Code -->|depends-on| Framework`、`Code -->|built-by| Build(Makefile/CMake)`
- `Paper -->|cited-via| Citation`
- 推断/不确定的关系用虚边 `-.->`
## 五、OpenAlex 字段
**状态色**(按对应维度裁决):✅ `classDef ok fill:#cfe,stroke:#3a3` / ⚠️ `classDef warn fill:#ffe,stroke:#cc3` / ❌ `classDef bad fill:#fee,stroke:#c33` / 锚点 `classDef anchor fill:#eef,stroke:#336`
- 端点:`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/503/无结果 → 报告卡溯源栏标"未找到关联论文"
**语法纪律**(违反则渲染失败):节点 label 必须双引号 `P["..."]`label 内禁用 `<> ? () {} | "`,可用 `: , . / - _`;边 label 仅字母/连字符classDef 放最后。详见 SKILL.md「Mermaid 语法纪律」。
## 六、SWH-ID 说明
## 三、fair.py 抽取规则
- 完整 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
- **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
| 支撑点 | 文献 |
|--------|------|
| 科研软件 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 前复核) |
| 源 | 状态 | 备注 |
|---|---|---|
| `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
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 上复刻"是合理外推,须真机跑通闭环。
四维裁决对标 **FAIR4RS**Barker et al. 2022, Nature Sci Data论文溯源→F2/R1.2、数据链→FAIR-Data、复现就绪→独立轴FAIR 必要非充分、引用就绪→R1.1/R2。诚实声明这是适配版评分社区尚无认证级自动校验器非官方认证。
## 九、实测结论live probe2026-07-01 于 songhui18/ICCV2021
## 六、诚实边界
**目标仓库**`songhui18/ICCV2021`(显示名"ICCV2021论文复现"URL identifier = `ICCV2021`**注意 CLI 要用 identifier 不是中文显示名**19⭐/17fork3328 文件17 个论文复现子目录,最后更新约 4 年前。
**关键事实**
- `license_id` = None**根目录无 LICENSE**20 个 LICENSE 全在子目录随上游代码)→ **A2 ✗**
- releases = 0无任何版本发布→ **A3 ✗ / Rep4 ✗**
- 根级文件仅 `README.md`;无根级 requirements.txt / CITATION.cff / codemeta.json / .zenodo.json
- CITATION.cff=0、codemeta.json=0、.zenodo.json=0 → **F3 ✗ / I3 ✗ / R1 ✗**
- requirements.txt 13 处仅子目录、Dockerfile 5、Makefile 3、main.py 13、train.py 16、test.py 15 → 子目录复现较完整,但**根级无统一依赖锁**
- `has_dataset` = False → **Rep2 ✗**
- topics = [python, jupyter notebook, cuda] → **F2 ✓**description 详尽≥20 字)→ **F1 ✓**
- HEAD commit SHA = `e14ac625752171fd46c90778cf5c7b000d05307b`
**5 轴实测分**F 67 · A 50 · I 17 · R 50 · Repro 40 → **总评 C 🔴 45/100**
| 轴 | 分 | 关键依据 |
|----|----|----------|
| F 可发现 | 67 | F1✓ 详尽描述 · F2✓ 3 topics · F3✗ 无 codemeta/.zenodo |
| A 可访问 | 50 | A1✓ 公开 · A2✗ 无仓库级 license · A3✗ 无 release · A4✓ commit SHA |
| I 可互操作 | 17 | I1⚠ 依赖散落子目录无根级锁 · I2✗ 无机器可读 license · I3✗ 无标准元数据 |
| R 可复用 | 50 | R1✗ 无 CITATION · R2⚠ 根 README 是论文列表缺统一用法 · R4✓ 无明显敏感泄露 |
| Repro 可复现 | 40 | Rep1⚠ 子目录 requirements 无根级锁 · Rep2✗ 无数据集说明 · Rep3⚠ 子目录有步骤无统一复现章 · Rep4✗ 无 release · Rep5✓ 入口齐全 |
**结论**:一个 19⭐ 的"论文复现"合集FAIR/可复现维度仅得 C——无仓库级 license、无 release、无 CITATION、无数据集说明、依赖散落子目录。**正好印证可复现性危机**2024 顶会仅 19.5% 提供官方代码)。处方空间大:补根级 LICENSE + CITATION.cff + 统一 requirements + 打 v1.0 release + README 复现章节。
> **fork→PR 链路Task 7 实测2026-07-01**:✅ 跑通。`repo +fork` → `branch +create feat/fair-remediation --from master``file +create` 建 4 文件LICENSE / CITATION.cff / codemeta.json / REPRODUCIBILITY.md`pr +create`**PR #1**`caoweiqiong/ICCV2021#1`openid 145336。**全程纯 API 建文件,无需克隆 3328 文件大仓**`file +create` 支持 `--branch`)。
> **OpenAlex 溯源Task 7 实测)**:⚠️ 匿名搜索被限流HTTP 503 *"rate-limited due to heavy load, use free API key"*),溯源栏降级跳过——**正好印证 freemium 边界(§八-3**skill 优雅降级、报告卡标注"未命中"。
> **SWH-IDTask 7 实测)**:环境无 `swh.model`,证书用 `git+commit e14ac62` 锚定(符合 §六降级)。
1. fair.py 抽取覆盖度受 README 写法影响;非标准 README 可能漏(同时匹配多句式兜底)。
2. 框架/方法为推断,标"推断"/"未知",不肯定。
3. 复现就绪是**静态**判断(依赖/入口/环境/期望结果四件套),不实际跑代码。
4. v1 的 OpenAlex 溯源已砍(避免 503 弱环节)。

View File

@ -1,152 +1,140 @@
---
name: gitlink-research-fair
version: 1.0.0
description: "科研软件 FAIR 体检:分析 GitLink 科研仓库的可发现/可访问/可互操作/可复用/可复现性,输出体检报告卡,自动开 PR 修复缺口并签发 SWH/commit 锚定的可复现证书。当用户需要评估科研仓库的 FAIR 性与可复现性、生成科研软件体检报告时触发。"
version: 2.0.0
description: "科研软件 X 光:用 fair.py 真抽取 GitLink 科研仓库的论文/数据/复现/引用画像LLM 四维裁决,输出含真科研图谱与特有关键发现的洞察报告,可选处方 PR。当用户需要深挖科研仓库的科研产物、评估可复现/可引用性时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli repo --help"
cliHelp: "python skills/gitlink-research-fair/scripts/fair.py --help"
---
# gitlink-research-fair科研软件 FAIR 体检
# gitlink-research-fair科研软件 X 光
**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 资源。**
**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)。
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)四维裁决细则、真 KG schema、抽取规则见 [`REFERENCE.md`](REFERENCE.md)。
## 概述
本 Skill 引导 AI 对任意 GitLink 科研仓库做结构化、有学术标准依据、低误报的"FAIR + 可复现性"诊断,产出**体检报告卡**,并对缺口**自动开 PR 修复**、用 **SWH/commit 锚定**签发**可复现证书**。许可证/安全子项**复用 `gitlink-license`**。四幕剧本v1诊断 → 处方 → 证书
**科研软件 X 光**`scripts/fair.py` 真抽取仓库内容README/文件/依赖)→ 科研画像 JSONLLM 对 4 个科研专属维度裁决(论文溯源/数据链/复现就绪/引用就绪),渲染**每 repo 特有的洞察报告**(裁决总览 + 真科研图谱 + 关键发现),可选处方 PR。与 `gitlink-health`(项目过程健康)正交,与 `gitlink-spark`(跨仓挖缺口)互补——本 skill **单仓深挖科研产物**
## 命令接口skill 约定参数,非 CLI flag
## 命令接口
```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 解析 | 目标科研仓库 |
| `--lenses` | 全 5 轴 | 子集,如 `F,A,Repro` |
| `--auto` | 关 | 跳过预览直接开 PR仍受护栏 |
| `--no-fork` | 关 | 不开 PR处方物料落本地 |
| `--enrich` | 开 | 启用 OpenAlex 溯源(限流自动降级) |
| `--refresh` | 关 | 即使有旧报告哨兵也重评 |
| `--owner/--repo` | 自动从 cwd 解析 | 目标科研仓库(用 identifier |
| `--auto` | 关 | 跳过预览直接开处方 PR仍受护栏 |
| `--no-fork` | 关 | 只出 X 光报告,不开 PR |
| `--refresh` | 关 | 即使有旧哨兵也重评 |
## 管道8 步)
## 管道
### ① 取上下文
`repo +info`(元数据/license/topics/has_dataset· `file +list`(关键文件清单)
### ① 抽取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
### ② 深采
`file +get` 读 LICENSE/CITATION.cff/codemeta.json/README/requirements/Dockerfile · `release +list`(版本)· `commit +list`SHA· `issue/pr +list`(协作)· `member +list`(作者)
### ② 四维裁决LLM读 JSON
论文溯源 / 数据链 / 复现就绪 / 引用就绪。每维 `✅/⚠️/❌` + **引用画像字段的具体证据**。规则见 [`REFERENCE.md`](REFERENCE.md)。
### ③ FAIR 评分M1
逐项评 5 轴(许可证/安全子项委托 `gitlink-license` 流程)→ `✓/✗/⚠/⊥` + 证据 + 置信度
### ③ 真科研图谱(从画像生成 Mermaid
节点 `Paper↔Method↔Code↔Dataset↔Framework↔Citation`,带状态色(✅绿/⚠️黄/❌红。schema 见 REFERENCE。
### ④ KG 构建M2
四实体(Repo/Contributor/File/Commit)+Paper/Dataset/License 节点 → Mermaid 小图 + triples JSON
### ④ 渲染 X 光报告hero并落盘
裁决一行 + 真图谱 + **本 repo 特有关键发现** + 处方摘要 + 双裁决证书。**始终保存** `report-cards/<owner>-<repo>-xray.md`(绝不只在终端)。
### ⑤ 溯源富集M3
从 README/CITATION 抽论文 → OpenAlex REST 反查作者/机构best-effort
### ⑤ 处方(可选
对 ❌/⚠️ 项生成 CITATION.cff从 README 抽的引用)/ requirements.txt从 import 扫)/ Dockerfile → fork → PR默认预览
### ⑥ 渲染报告卡hero并落盘
等级 + 雷达图 + 5 轴逐项表 + 处方摘要 + 证书栏 + KG 小图 + 溯源栏。
**始终保存为本地文件** `report-cards/<owner>-<repo>-report-card.md`cwd 下,含哨兵)——报告卡是可归档/复查的核心产物,**无论后续是否开 PR 都必须落盘,绝不只在终端输出**。
## 四维裁决(速览,细则见 REFERENCE
### ⑦ 处方M4
`✗/⚠` 项生成 CITATION.cff/codemeta.json/LICENSE/复现章节 → 默认预览
| 维度 | ✅ | ⚠️ | ❌ |
|------|---|----|----|
| 论文溯源 | arxiv/DOI + 元数据全 | 仅 README 文字,无稳定链接 | 无论文线索 |
| 数据链 | 命名 + 下载脚本 + license | 命名但无脚本/无 license | 未提及数据集 |
| 复现就绪 | 依赖锁+入口+环境+期望结果齐全 | 有入口但缺依赖锁/环境/期望结果 | 无入口/无依赖 |
| 引用就绪 | CITATION.cff/codemeta + DOI + 版本 | 仅 README 引用文本 | 无引用信息 |
### ⑧ 发布
报告卡已在 ⑥ 落盘(`report-cards/<owner>-<repo>-report-card.md`)。
- **fork 模式**:确认后对 fork 开 PRPR body 带报告卡摘要)+ 出 SWH/commit 证书
- **`--no-fork` 或 PR 失败**:报告卡已在本地,告知用户路径,可手动粘贴或提交
- 哨兵内嵌于报告卡(及 PR body若开
## 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` 标"待人工确认",不进总评。
## 报告卡格式hero 产出)
## 报告格式混合主视觉hero
````markdown
🏥 **gitlink-research-fair 科研软件体检报告**
🔬 **科研软件 X 光 — <owner>/<repo>**
**总体**<等级> <>/100F · A · I · R · Repro版本锚定commit <sha><release >
**范围**:检查 19 项✓N · ✗N · ⚠N · ⊥N跳过<原因>
═══════════════════════════════════════
论文溯源 <V> 数据链 <V> 复现就绪 <V> 引用就绪 <V>
═══════════════════════════════════════
### 五轴雷达
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?>
graph LR
R["repo: Feature_Critic"]:::anchor
P["Paper: ICML 2019, arxiv:1901.11448"]:::ok
M["Method: Feature-Critic / meta-learning"]:::ok
C["Code: main_Feature_Critic.py"]:::ok
D1["Dataset: PACS"]:::warn
D2["Dataset: Visual Decathlon"]:::warn
D3["Dataset: ImageNet"]:::warn
F["Framework: PyTorch"]:::warn
Ci["Citation: README bibtex, no CITATION.cff"]:::bad
L["License: missing"]:::bad
R --> P
R --> L
P -->|proposes| M
M -->|implements| C
C -->|trains-on| D1
C -->|trains-on| D2
C -->|trains-on| D3
C -->|depends-on| F
P -->|cited-via| Ci
classDef ok fill:#cfe,stroke:#3a3
classDef warn fill:#ffe,stroke:#cc3
classDef bad fill:#fee,stroke:#c33
classDef anchor fill:#eef,stroke:#336
```
### 🔍 关键发现(本 repo 特有)
- <LLM 从画像抽出的 3 条具体发现每条引用 fair.py 字段>
### 🔧 处方(可选)
- < / 项的修复建议>
### 📜 双裁决证书
复现就绪 <V> 引用就绪 <V> 锚定 commit `<sha>`
---
<!-- gitlink-research-fair v1 | repo:<owner>/<repo> | grade:<G> | sha:<head-sha> -->
*由 gitlink-research-fair skill 生成。*
<!-- gitlink-research-fair v2 | repo:<owner>/<repo> | paper:<✅/⚠️/❌> | repro:<V> | cite:<V> | sha:<head> -->
*由 gitlink-research-fair v2科研软件 X 光)生成。*
````
哨兵 `<!-- gitlink-research-fair v1 | repo | grade | sha -->` 用于幂等与"基于哪个 commit"标识。重跑检测旧哨兵 → 默认提议更新(`--refresh` 才覆盖);`sha` 不匹配 → 提示"报告已过期,建议重评"。
## Mermaid 语法纪律(必读,否则图谱渲染失败)
## 处方M4惊艳闭环
- 节点 label **必须双引号**`P["Paper: ..."]`**禁止**裸 `P[<...>]``P[label with ?]`
- label 内**禁用** `<> ? () {} | "` 等特殊字符;可用:字母、数字、空格、`: , . / - _`
- 边 label 用 `-->|word|`word 仅字母/连字符(如 `trains-on`、`cited-via`),不要放 `?` 或中文标点
- **每个 dataset / entry / framework 各一个节点**(如 `D1["Dataset: PACS"]` `D2["Dataset: Visual Decathlon"]`),不要合并成一个 `D["datasets"]`
- 缺失项用红色节点(`:::bad`)显式标出(如 `L["License: missing"]:::bad`),不省略——"缺什么"也是图谱信息
- `classDef` 放在最后;锚点 repo 节点用 `:::anchor`(蓝灰)区分
| 缺口 | 生成物 |
|------|--------|
| 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` 反查作者/机构/载体。降级:限流或抽不到 → 跳过,标注"未找到关联论文"。
对 ❌/⚠️ 项生成修复:`CITATION.cff`(从 README 抽的引用文本构造)+ `requirements.txt`(从代码 import 扫)+ `Dockerfile`(模板)→ `repo +fork``pr +create`PR body 带报告摘要)。**护栏**:默认预览;`--auto` 跳过但**永不 force-push、永不碰原仓库、永不自动 merge**`--no-fork` 报告已落盘(④),不开 PR。
## 错误处理与降级
| 情况 | 处理 |
|------|------|
| `file +tree --recursive` 返回空 | 手动递归子目录 |
| 无 LICENSE | A2 ✗,处方建议模板 |
| OpenAlex 限流/无果 | 跳过溯源栏标注 |
| `swh.model` 不可用 | commit-SHA 锚定 + 标注 |
| 仓库过大 | 抽样文件 + "部分审查"标注 |
| fork 失败/无写权限 | 处方物料落本地(`--no-fork` |
| 二进制是 npm 旧版 | 强制 `./gitlink-cli``go build` 重建 |
| 仓库私有/无权限 | 清晰错误,不发报告 |
| 报告/PR 发布失败 | 报告卡已在 ⑥ 落盘,告知路径,用户可手动粘贴/提交 |
| README 读失败 | 降级用 file list + 元数据,标注"README 不可读,结论受限" |
| `file +list` data 为字符串 | json.loads 解套 |
| arxiv/DOI 抽不到 | 论文溯源判 ⚠️/❌,据实 |
| 框架无法推断 | frameworks 标"未知",不编造 |
| `--repo` 用中文显示名 404 | 提示用 identifier |
| fork/PR 失败 | 处方物料落本地,告知路径 |
| 报告/PR 发布失败 | 报告已在 ④ 落盘,告知路径 |

View File

@ -0,0 +1,88 @@
# 示例:科研软件 X 光 — Edge-Computing-Engine引擎类对照
> 基于 `fair.py --owner Edgedev --repo Edge-Computing-Engine` 于 2026-07-07 实跑fair.py v2.1,已修 arxiv 年份校验 + C++ 入口检测)。
> Edge 是一个 C++ 科学计算引擎autodiff + 神经网络模块),与 Feature_Criticpaper-code 类)形成**不同 profile 对照**,证明 X 光报告不千篇一律。
## Step 1运行 fair.py 抽取科研画像
```bash
python skills/gitlink-research-fair/scripts/fair.py --owner Edgedev --repo Edge-Computing-Engine
```
**画像摘要**
| 维度 | 抽取结果 |
|---|---|
| 论文 | arxiv **None**README 全文无有效 arxiv/DOI venue None 标题 "Edge-Engine"H1不计入 in_readme in_readme ❌ |
| 数据集 | **空**(未提及) |
| 复现 | 入口 **main.cpp**v2.1 已识别 C++ 入口)| deps **Makefile**deps_pinned ✓)| env ❌ expected_results ❌ |
| 引用 | 无 CITATION.cff 无 README bibtex |
| 方法/框架 | "graph" 命中 框架未知C++import 推断失效) |
| license | **LICENSE 文件存在**Apache 2.0 license_id None |
| 锚定 | commit `8678c7c7` 132 文件 |
## Step 2四维裁决LLM 读画像)
**论文溯源 ❌**(无论文链接)· **数据链 ❌** · **复现就绪 ⚠️**(有 C++ 入口+Makefile 构建,但缺环境锁/期望结果)· **引用就绪 ❌**
## Step 3X 光报告(全文,落盘 `report-cards/Edgedev-Edge-Computing-Engine-xray.md`
````markdown
🔬 **科研软件 X 光 — Edgedev/Edge-Computing-Engine**
═══════════════════════════════════════
论文溯源 ❌ 数据链 ❌ 复现就绪 ⚠️ 引用就绪 ❌
═══════════════════════════════════════
### 🧬 真科研图谱
```mermaid
graph LR
R["repo: Edge-Computing-Engine"]:::anchor
P["Paper: none, no arxiv or DOI"]:::bad
C["Code: main.cpp / C++"]:::warn
F["Build: Makefile, framework unknown"]:::warn
Ci["Citation: none"]:::bad
L["License: Apache 2.0 file but README forbids commercial use"]:::warn
R --> P
R --> L
P -.->|proposes| C
C -->|built-by| F
P -->|cited-via| Ci
classDef ok fill:#cfe,stroke:#3a3
classDef warn fill:#ffe,stroke:#cc3
classDef bad fill:#fee,stroke:#c33
classDef anchor fill:#eef,stroke:#336
```
### 🔍 关键发现(本 repo 特有)
- **无论文**README 全文无 arxiv/DOI 链接,论文溯源 ❌fair.py arxiv 候选经年份校验过滤,无有效命中)
- **LICENSE 自相矛盾**:根目录有 Apache 2.0 LICENSE但 README 声明"本项目禁止闭源商用"——与 Apache 2.0(允许商用)冲突,复用有法律风险
- **复现部分就绪**fair.py 已识别 C++ 入口 `main.cpp` + 构建文件 `Makefile`,但无 Dockerfile/environment 锁环境、README 无 expected_results → ⚠️ 而非 ❌
- 无数据集声明、无 CITATION.cff、无 README bibtex
- 132 文件,含 autodiff / 神经网络模块
### 🔧 处方(可选)
- 澄清许可证(去 README "禁止闭源商用" 或换 CC BY-NC
- 补 README论文/数据集/构建命令C++ make/make install 已有,缺期望结果)
- 补 `Dockerfile` 锁编译器/依赖环境
### 📜 双裁决证书
复现就绪 ⚠️ 部分 引用就绪 ❌ 锚定 commit `8678c7c7`
````
## Step 4与 Feature_Critic 对照(证明不千篇一律)
| 维度 | Feature_Criticpaper-code | Edge引擎类 |
|---|---|---|
| 论文溯源 | ✅ arxiv 1901.11448 真溯源 | ❌ 无论文链接 |
| 数据链 | ⚠️ PACS/Visual Decathlon 命名 | ❌ 无数据集 |
| 复现就绪 | ⚠️ 有 .py 入口,无依赖锁 | ⚠️ 有 C++ 入口+Makefile无环境/期望结果 |
| 引用就绪 | ⚠️ README bibtex | ❌ 无 |
| 真图谱 | 10 节点富图repo/论文/方法/代码/3数据集/PyTorch/引用/license | 6 节点多红黄repo/无论文/main.cpp/Makefile/无引用/license冲突 |
| 关键发现 | 论文可溯源但机器不可引用 | **LICENSE 自相矛盾** + 无论文 |
**两份报告内容截然不同**——X 光由 fair.py 抽取的真实画像驱动,每 repo 说出自己的话。
## 关键结论
- fair.py v2.1 已修复两个已知限制arxiv 候选加年份合法性校验(防 `5184.0000` 类伪阳性)、入口检测扩展到 `.cpp/.c/.cc/.cu` + `CMakeLists.txt`/`Makefile`(覆盖 C/C++ 科研代码——Edge 复现就绪从 ❌ 升到 ⚠️,论文溯源 ❌ 干净不再误报
- LICENSE 冲突这类"元数据扫描发现不了、需读 README 内容"的问题,由 LLM 裁决层补上fair.py 抽 file 存在LLM 读出冲突)
- 引擎类C++/无论文)与 paper-code 类Python/有论文)画像迥异,报告自然分化

View File

@ -1,154 +0,0 @@
# 示例:科研软件 FAIR 体检(真实数据)
> 基于 `songhui18/ICCV2021`(显示名"ICCV2021论文复现"URL identifier=`ICCV2021`)于 2026-07-01 在 Claude Code 中实跑。
> 这是 19⭐/17fork 的 ICCV 2021 论文复现合集3328 文件、17 个论文复现子目录。
> 一个"**可复现性**"工具去体检一个"**论文复现**"合集——主题共振。
---
## Step 1取上下文真实输出摘要
| 项 | 实测值 |
|---|---|
| URL identifier | `ICCV2021`**CLI 用 identifier不是中文显示名** |
| `license_id` | `None`**根目录无 LICENSE**20 个 LICENSE 全在子目录随上游代码) |
| releases | `0`(无任何版本发布) |
| 根级文件 | 仅 `README.md` |
| CITATION.cff / codemeta.json / .zenodo.json | 均无 |
| requirements.txt | 13 处(**仅子目录,无根级依赖锁** |
| `has_dataset` | `False` |
| topics | python, jupyter notebook, cuda |
| description | 详尽≥20 字) |
| HEAD commit | `e14ac625752171fd46c90778cf5c7b000d05307b` |
> ⚠️ **关键踩坑**`gitlink-cli --repo` 必须传 `identifier=ICCV2021`;用中文显示名"ICCV2021论文复现"会返回 **404**。identifier 从 `search +repos` 结果取。
采集命令:
```bash
gitlink-cli repo +info --owner songhui18 --repo ICCV2021 --format json
gitlink-cli file +list --owner songhui18 --repo ICCV2021 --format json # 3328 文件扁平列表
gitlink-cli release +list --owner songhui18 --repo ICCV2021 --format json
gitlink-cli commit +list --owner songhui18 --repo ICCV2021 --page 1 --format json
```
## Step 25 轴评分(真实)
| 轴 | 分 | 关键依据 |
|---|---|---|
| F 可发现 | 67 | F1✓ 详尽描述 · F2✓ 3 topics · F3✗ 无 codemeta/.zenodo |
| A 可访问 | 50 | A1✓ 公开 · A2✗ **无仓库级 license** · A3✗ 无 release · A4✓ commit SHA |
| I 可互操作 | 17 | I1⚠ 依赖散落子目录无根级锁 · I2✗ 无机器可读 license · I3✗ 无标准元数据 |
| R 可复用 | 50 | R1✗ 无 CITATION · R2⚠ 根 README 是论文列表 · R4✓ 无明显敏感泄露 |
| Repro 可复现 | 40 | Rep1⚠ · Rep2✗ 无数据集说明 · Rep3⚠ · Rep4✗ 无 release · Rep5✓ 入口齐全 |
**总评C 🔴 45/100** —— 一个 19⭐ 的论文复现合集FAIR/可复现仅得 C。
## Step 3报告卡真实全文
````markdown
🏥 **gitlink-research-fair 科研软件体检报告**
**总体**C 🔴 45/100F 67 · A 50 · I 17 · R 50 · Repro 40版本锚定commit `e14ac62`(无 release
**范围**:检查 19 项✓6 · ✗8 · ⚠4 · ⊥1R3 源码声明委托 gitlink-license 未在本轮跑)
### 逐项(节选)
| 轴 | 项 | 状态 | 证据 |
|---|---|:---:|---|
| A | A2 有许可证 | ✗ | `license_id` 为空,根目录无 LICENSE20 个 LICENSE 全在子目录) |
| F | F3 标准元数据 | ✗ | 无 codemeta.json / .zenodo.json |
| I | I1 依赖清单 | ⚠ | requirements.txt 仅在 13 个子目录,无根级统一锁 |
| R | R1 溯源/引用 | ✗ | 无 CITATION.cff |
| R | R3 源码声明 | ⊥ | 委托 gitlink-license本轮未跑深度源码声明扫描 |
| Repro | Rep2 数据集说明 | ✗ | `has_dataset=False`README 无数据集说明 |
| Repro | Rep5 入口可执行 | ✓ | 子目录含 train.py×16 / main.py×13 / test.py×15 |
| F | F1 清晰描述 | ✓ | description 详尽描述 CV 顶会论文复现合集 |
### 🔧 处方(可自动修复 4 项)
- [A2] 添加 LICENSEMIT / 木兰 PSL v2
- [R1] 生成 CITATION.cff
- [F3/I3] 生成 codemeta.json
- [Rep3] 补 REPRODUCIBILITY.md 复现指南
### 📜 可复现证书
锚定版本:`git+commit e14ac625752171fd46c90778cf5c7b000d05307b` FAIR: C 🔴 Repro: ⚠ 部分可复现
(完整 SWH-ID 需 `swh-identify`;环境无 `swh.model`,本次用 commit-SHA 锚定)
### 🔗 溯源OpenAlex
⚠️ 未命中——匿名搜索被限流HTTP 503需 free API key已降级跳过符合 freemium 边界)。
### 🧬 科研关系图KG
```mermaid
graph LR
R[Repo: ICCV2021合集] -->|contributes-to| C1[Contributor: 宋辉/songhui18]
R -->|has| D{Dataset?}:::miss
R -->|licensed-under| L{License?}:::miss
R -->|version-at| H[commit e14ac62]
classDef miss fill:#fee,stroke:#c33;
```
---
<!-- gitlink-research-fair v1 | repo:songhui18/ICCV2021 | grade:C | sha:e14ac625 -->
*由 gitlink-research-fair skill 生成。*
````
## Step 4处方 PR真实闭环 ✅)
**全自动跑通**(无需克隆 3328 文件大仓,纯 API 建文件):
```bash
gitlink-cli repo +fork --owner songhui18 --repo ICCV2021 # → caoweiqiong/ICCV2021
gitlink-cli branch +create --owner caoweiqiong --repo ICCV2021 --name feat/fair-remediation --from master
gitlink-cli file +create --owner caoweiqiong --repo ICCV2021 --branch feat/fair-remediation \
--path LICENSE --message "Add MIT LICENSE (fix A2)" --content "$(cat LICENSE)"
# … 同样建 CITATION.cff / codemeta.json / REPRODUCIBILITY.md
gitlink-cli pr +create --owner caoweiqiong --repo ICCV2021 --head feat/fair-remediation --base master \
--title "Improve research FAIRness & reproducibility (gitlink-research-fair 处方)" \
-b "$(cat pr_body.md)"
```
**PR**https://www.gitlink.org.cn/caoweiqiong/ICCV2021/pulls/1 `pull_request_number: 1`, id `145336`, open
PR body 含体检报告卡摘要 + 修复表。新增 4 文件:
| 文件 | 修复项 | FAIR 原则 |
|------|--------|-----------|
| `LICENSE` | 补 MIT 许可证 | R1.1 / A2 |
| `CITATION.cff` | 补引用信息 | R1.2 / R1 |
| `codemeta.json` | 补标准元数据 | F2 / F3 / I3 |
| `REPRODUCIBILITY.md` | 补复现指南 | Rep3 |
**预期效果**:合并后 A2/F3/I3/R1/Rep3 由 ✗/⚠ → ✓,总分 **C 45 → B ~75**
## Step 5证书
- **锚定版本**`git+commit e14ac625752171fd46c90778cf5c7b000d05307b`HEAD无 release
- **SWH-ID**:环境无 `swh.model`,用 commit-SHA 锚定(完整 SWH-ID 需 `swh-identify`
- **结论**FAIR C 🔴 · Repro ⚠ 部分可复现 —— 可作为"待改进科研软件"的基线快照,供后续 release 后重评对比。
---
## 对照:有 license 的仓库(证明评分区分度)
`leejt/GraphGallery`图神经网络多框架开发工具Python有 license跑同一 rubric
| 轴 | songhui18/ICCV2021 | leejt/GraphGallery |
|---|---|---|
| F | 67 | 67 |
| A | **50**(无 license | **75**(✓ 根级 LICENSE |
| I | 17无根级依赖锁 | 67✓ 根级 requirements.txt + setup.py |
| R | 50 | 67 |
| Repro | 40 | 70✓ setup.py 可安装) |
| **总评** | **C 🔴 45** | **B 🟡 69** |
**区分度 +24 分**,主要由 **A2 license + I1/Rep1 打包可安装** 拉开。rubric 正确奖励"有 license + 可安装"的仓库。GraphGallery 仍缺 CITATION/release故是 B 非 A——评分诚实不虚高。
---
## 关键结论
1. **主题共振**:用"可复现性"工具体检"论文复现"合集——一个 19⭐ 的真实科研仓库竟只得 C**正好印证可复现性危机**2024 顶会仅 19.5% 提供官方代码)。
2. **闭环可用**fork → 建分支 → `file +create` 建修复文件 → 开 PR**全程纯 API、无需克隆大仓**,处方 PR 真实落地。
3. **诚实降级**OpenAlex 匿名限流(503)→溯源跳过;无 `swh.model`→commit-SHA 锚定——都是设计内的优雅降级,**不掩盖、不编造**。
4. **评分有区分度**C 45无 license 合集vs B 69有 license 工具rubric 行为正确。
5. **差异于 howfairis**GitLink 原生 + 报告卡 + **自动修复闭环** + SWH 证书 + OpenAlex 溯源howfairis 只跑 GitHub 且只打分不修复。

View File

@ -0,0 +1,168 @@
#!/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"]
def _valid_arxiv_id(aid):
"""Validate arxiv id YYMM.NNNNN: year 07-26 (2007-2026 arxiv new format), month 01-12.
Rejects false positives like 5184.0000 (mm=84)."""
m = re.match(r"(\d{2})(\d{2})\.\d{4,5}$", aid)
if not m:
return False
yy, mm = int(m.group(1)), int(m.group(2))
return (7 <= yy <= 26) and (1 <= mm <= 12)
_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:
for m in re.finditer(pat, readme):
cand = m.group(1)
if _valid_arxiv_id(cand):
arxiv_id = cand
arxiv_url = f"https://arxiv.org/abs/{cand}"
break
if arxiv_id:
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
code_for = (re.search(r"[Cc]ode (?:for|of)\s+'([^']+)'", readme)
or re.search(r'[Cc]ode (?:for|of)\s+"([^"]+)"', readme))
h1 = re.search(r"^\s*#\s+(.+)$", readme, re.M)
title = (code_for.group(1).strip() if code_for
else (h1.group(1).strip() if h1 else None))
return {"in_readme": bool(arxiv_id or doi or code_for), "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):
"""Static reproducibility readiness: deps + entry + env + expected results.
Supports Python (.py) AND C/C++ (.cpp/.c/.cc/.cu + Makefile/CMake) repos."""
name_set = {(f or "") for f in files}
deps_candidates = ["requirements.txt", "environment.yml", "go.mod", "package.json",
"Dockerfile", "setup.py", "pyproject.toml", "CMakeLists.txt", "Makefile"]
deps_files = [f for f in deps_candidates if f in name_set]
entry_re = re.compile(r"(main|train|run|demo)_?\w*\.(py|cpp|c|cc|cu)$", re.I)
entry_points = sorted([f for f in name_set if entry_re.match(f or "")])
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)"]}
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")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,82 @@
# -*- 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")
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")
def test_arxiv_validation():
p = extract_paper("see version 5184.0000 released")
assert p["arxiv_id"] is None, ("5184.0000 应被年份校验拒绝", p["arxiv_id"])
p2 = extract_paper("paper at https://arxiv.org/abs/1901.11448 ICML")
assert p2["arxiv_id"] == "1901.11448"
p3 = extract_paper("# MyRepo\njust a project")
assert p3["in_readme"] is False, ("H1 单独不应算论文证据", p3["in_readme"])
print("test_arxiv_validation OK")
def test_cpp_entry_detection():
r = assess_repro(["main.cpp", "Makefile", "utils.cpp"], "")
assert "main.cpp" in r["entry_points"], r["entry_points"]
assert "Makefile" in r["deps_files"], r["deps_files"]
print("test_cpp_entry_detection OK")
if __name__ == "__main__":
test_extract_paper(); test_extract_paper_none(); test_extract_datasets()
test_assess_repro(); test_assess_citation(); test_extract_methods_frameworks()
test_arxiv_validation(); test_cpp_entry_detection()
print("ALL TESTS PASSED")

View File

@ -0,0 +1,63 @@
# 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. "机会建议"为主观启发,标注"需研究者自行判断"。

View File

@ -0,0 +1,132 @@
---
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 → 语义匹配两类缺口(每张带实证三件套)→ 渲染机会报告。
**始终保存为本地文件** `report-cards/spark-<field>-report.md`cwd 下,含哨兵)——机会报告无论是否起跑都**必须落盘,绝不只在终端输出**。
可选fork+issue 起跑:见下方"起跑动作"。
## 两类缺口 + 实证三件套(信服核心)
每张缺口卡**必须**带齐三件套,否则丢弃(防 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 目标 + 伪代码文本供手动起跑 |

View File

@ -0,0 +1,79 @@
# 示例gitlink-spark GNN 缺口挖掘(真实数据)
> 基于 `python spark.py --field "graph neural network" --max-papers 8 --gap-type both` 于 2026-07-06 实跑。
> 一个"文献-代码语义缺口挖掘机"在 GNN 领域跑出真实研究机会。
---
## Step 1数据采集真实
| 源 | 结果 |
|---|---|
| arXivabs:"graph neural network",近期) | 8 篇 |
| GitLink 仓库search +repos | 20 个(多为通用 graph/networkGNN 专项如 `leejt/GraphGallery` 需定向) |
| GitLink open issues定向 GraphGallery | 5 条"图神经网络模型论文复现"请求 |
| GitHub 全球对照 | 每篇论文方法 total_count |
采集命令:
```bash
python skills/gitlink-spark/scripts/spark.py --field "graph neural network" --max-papers 8 --gap-type both
```
## Step 2机会报告真实全文
````markdown
⚡ **gitlink-spark 机会报告:图神经网络**
学术采arXiv 近期 8 篇 GitLink 仓库 20 个(+ 定向 GraphGallery GitHub 全球基线已对照
生成时间2026-07-06
### 🧩 缺口 1 · 有理论无实现 [全球稀缺·高价值]
**论文**[arxiv:2607.02063] "SA-HGNN: Sample-Adaptive Hyperbolic Graph Neural Networks"
**方法关键词**sample-adaptive, hyperbolic
**GitLink**search "hyperbolic graph neural network" → **0 命中**20 仓库无一实现双曲 GNN
**GitHub 全球**total_count = **0**"sample-adaptive hyperbolic")→ 全球稀缺
**机会建议**GitLink 生态空白 × 全球稀缺 → 复现并开源到 GitLink易成本平台首个双曲 GNN 实现
**起跑**:✅ `caoweiqiong/GraphGallery#1`(已 fork GraphGallery 基准 + 建复现 todo
### 🧩 缺口 2 · 有理论无实现 [新兴·中等价值]
**论文**[arxiv:2607.00671] "Multi-Label Node Classification with Label Influence"
**方法关键词**multi-label, node, classification
**GitLink**0 专项实现
**GitHub 全球**total_count = **16** → 新兴1050 tier
**机会建议**全球新兴方向GitLink 空白 → 可做中文生态较早的完整实现
### 🧩 缺口 3 · 有需求无解答 [应用机会]
**Issue**`leejt/GraphGallery#1` "图神经网络模型论文复现:节点分类任务" · `#2` 链路预测 · `#3` 节点嵌入(共 5 条 open均为复现请求状态"新增",讨论 0 人)
**痛点**GraphGallery 用户在 GitLink 上明确请求 GNN 多任务论文复现(节点分类 / 链路预测 / 嵌入),现有框架未覆盖这些专项
**GitLink / GitHub**GraphGallery 提供框架但无这些专项复现GitHub 零散有
**机会建议**:针对 GitLink 用户实际复现需求,补齐节点分类 / 链路预测论文复现专题
### ✅ 已诚实排除(非空白)
- **Graph Attention Network (GAT)**GitHub total_count = **1543**(含 PetarV-/GAT 3534⭐→ 全球已成熟,**不报为空白**
- 本轮 8 篇 arXiv 论文中 **3 篇离题**Cayley 图数学 / WavePID 中微子物理 / EO-Agents LLM—— arXiv 宽泛匹配所致,已过滤不计入
---
<!-- gitlink-spark v1 | field:图神经网络 | gaps:3 | date:2026-07-06 -->
*由 gitlink-spark skill 生成。*
````
## Step 3起跑真实闭环 ✅)
选定缺口 1SA-HGNN全球稀缺起跑
```bash
gitlink-cli repo +fork --owner leejt --repo GraphGallery # → caoweiqiong/GraphGallery
gitlink-cli issue +create --owner caoweiqiong --repo GraphGallery \
--title "Reproduction todo: SA-HGNN (Sample-Adaptive Hyperbolic GNN) [gitlink-spark 起跑]" \
--body "<缺口三件套 + 复现计划 + 论文 arxiv 链接>"
```
**issue**`caoweiqiong/GraphGallery#1`fork 基准 + 复现 todo含 SA-HGNN 论文方法 + 基于 GraphGallery 的复现步骤)
## Step 4关键结论
1. **三源融合真实可跑**arXiv8 篇)× GitLink20 仓库 + 定向 GraphGallery× GitHub每方法 total_count
2. **GitHub 阈值生效(诚实核心)**GAT(1543) → 已诚实排除SA-HGNN(0) → 高价值缺口Multi-Label Node Cls(16) → 新兴。三级分明。
3. **离题论文诚实过滤**arXiv 宽泛匹配混入 3 篇非 GNN数学/物理/LLM报告明示排除不滥竽充数。
4. **demand 侧诚实降级**spark.py 自动扫描的 20 个 GitLink 仓库多为通用 graph/network、0 研究 issue定向 GraphGallery 发现真实复现需求5 条)。报告如实标注"自动 0 / 定向发现"。
5. **起跑闭环对称 fair**fair 给已有仓库开修复 PRspark 给缺口方向 fork 基准 + 复现 todo issue——都是"诊断→行动"闭环。
6. **每条缺口可溯源**到 spark.py 的 JSONarxiv id / 查询串 / total_count 全可查)。
```

View File

@ -0,0 +1,157 @@
#!/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):
"""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
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
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
_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:
mk = p.get("method_keywords") or []
q = " ".join(mk[:3]) if mk else p["title"][:40] # method keywords = implementation prevalence (NOT exact-title)
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")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,51 @@
# -*- 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")

View File

@ -101,6 +101,53 @@ AI 判断 Issue 是否适合新贡献者:
| 不涉及核心逻辑 | 修改不影响主要功能流程 |
| 有足够上下文 | 新人无需深入了解整个项目 |
### Good First Issue 个性化评论
当启用 AI 且判断某个 Issue 适合新贡献者时,不要使用固定模板评论。必须根据 Issue 的标题、描述、分类和可推断的修改范围,生成一条个性化新人引导评论,并通过 `actions` 返回 `issue +comment` 动作。
评论要求:
| 要求 | 说明 |
|------|------|
| 说明适合新人的原因 | 例如范围小、上下文清晰、主要是文档/测试/局部修复 |
| 给出 2-4 个入手步骤 | 结合 Issue 内容说明先看什么、改什么、如何验证 |
| 保持事实边界 | 不要编造不存在的文件路径、接口或负责人;不确定时用“可以先从相关模块/文档入手” |
| 控制风险 | 涉及安全、核心架构、数据迁移、跨模块重构时不要标为 good-first-issue也不要生成新人引导评论 |
| 只评论开放 Issue | 只有 `state/status=open``status_id=1/2` 的 Issue 才能生成评论;已关闭、已解决或 `status_id=3/5` 的 Issue 禁止生成 `issue +comment` action |
| 避免重复 | 如果 Issue 已有社区运营/Good First Issue 引导评论,则不要重复添加;仅已有标签或责任人时仍可添加个性化新人引导评论 |
| 保持简洁友好 | 建议 150-500 字Markdown 格式,语气欢迎但不要夸大 |
评论应包含:
1. 感谢或欢迎语
2. 为什么这个 Issue 适合新贡献者
3. 建议的处理步骤
4. 如何求助或提交 PR 的简短提示
#### AI 模式动作要求
当工作流启用 AI 时AI 不能只输出分析文字。对每个开放 Issue若符合以下任一条件应生成一条个性化 `issue +comment` action
- 标题或描述包含 `good first issue`、`beginner`、`easy`、`简单`、`新手`、`入门`
- 分类为 docs / question / enhancement优先级为中或低且描述范围不明显涉及安全、核心架构、数据迁移、跨模块重构
- 规则引擎会将其视为 Good First Issue 的简单开放 Issue
已有标签、已有负责人、已有优先级不是跳过评论的理由;这些字段只表示分拣已完成。只有以下情况必须跳过评论:
- Issue 已关闭、已解决,或 `status_id` 为 3/5
- Issue 已有明确的社区运营/Good First Issue 引导评论
- Issue 涉及安全风险、核心架构、数据迁移、跨模块重构,明显不适合新人
评论 action 必须使用这个 JSON 形状:
```json
{"type":"cli","module":"issue","command":"+comment","args":{"number":"<issue-number>","body":"<personalized markdown>"}}
```
评论必须结合该 Issue 的标题/描述生成,不要使用固定模板。若信息较少,给出保守且可执行的入手建议,例如先阅读相关工作流/命令模块、补充测试、在本地运行对应命令验证。
不要编造具体文件或目录路径。只有当上游数据中明确出现了相关路径时,才在评论中引用路径;否则使用“相关工作流模块”“对应命令模块”“测试用例”等保守表述。
### 责任人分配策略
AI 根据以下信息分配责任人:
@ -197,6 +244,38 @@ gitlink-cli issue +comment --owner <owner> --repo <repo> \
如果遇到问题,可以在这里回复,我们会尽快帮助你!"
```
### AI 模式下的个性化评论 Action
在工作流 AI 模式中,必须把个性化评论放入 JSON 输出的 `actions` 数组,让执行器自动调用 `issue +comment`。动作格式必须是:
```json
{
"type": "cli",
"module": "issue",
"command": "+comment",
"args": {
"number": "<issue-number>",
"body": "<根据该 Issue 内容生成的个性化 Markdown 评论>"
}
}
```
示例:
```json
{
"type": "cli",
"module": "issue",
"command": "+comment",
"args": {
"number": "10",
"body": "欢迎参与这个 Issue从描述看这个任务主要是补充 README 中的安装说明,范围比较清晰,不需要改动核心逻辑,因此适合作为 Good First Issue。\\n\\n建议可以从这几步开始\\n1. 先复现当前 README 中的安装流程,记录缺失或不清楚的地方。\\n2. 补充对应平台的命令示例,并保持和现有文档格式一致。\\n3. 本地检查 Markdown 渲染效果,确认命令块和链接都正常。\\n\\n如果推进过程中不确定写法可以在这个 Issue 下留言讨论;完成后欢迎提交 PR。"
}
}
```
如果同一个 Issue 同时需要打标签/分配责任人和新人引导评论,`actions` 中可以同时包含 PATCH action 和上述 comment action但只有确认是开放状态、低风险 good-first-issue 时才添加 comment action。已关闭或已解决的 Issue 即使内容适合新人,也只能在分析中说明,不得返回 `issue +comment` action。
## 分拣输出格式
AI 对每个 Issue 生成分拣建议:
@ -226,7 +305,8 @@ AI 对每个 Issue 生成分拣建议:
| **建议标签** | enhancement, good first issue |
| **建议责任人** | 未分配(适合新人) |
| **Good First Issue** | ✅ 是 |
| **AI 分析** | 功能建议明确,改动范围小(仅需修改帮助文档格式),适合新贡献者。建议添加引导评论。 |
| **AI 分析** | 功能建议明确,改动范围小(仅需修改帮助文档格式),适合新贡献者。需要生成与该 Issue 内容匹配的个性化引导评论,并在 actions 中返回 `issue +comment`。 |
| **个性化评论摘要** | 说明为什么适合新人,并给出 2-4 个可执行入手步骤。 |
```
## 使用场景

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