forked from chroe/gitlink-cli
test: security scan - hardcoded API key #2
|
|
@ -0,0 +1,153 @@
|
|||
# 实验踩坑记录
|
||||
|
||||
> 课程:《软件演化与运维》进阶任务 — 子任务二:编写和丰富 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` 列表包含已关闭的 Issue(status_id=5),但 `opened_count` 字段是准确的
|
||||
**影响**:AI 执行 triage 时可能处理已关闭的 Issue
|
||||
**解决方案**:客户端过滤 `status.id !== 5`(status_id=5 是关闭状态)
|
||||
|
||||
---
|
||||
|
||||
### 🟡 坑 4:compare API 全面返回 HTML
|
||||
|
||||
**发现时间**:2026-06-09
|
||||
**现象**:不仅是部分项目,所有项目的 compare API 都返回 HTML 而非 JSON
|
||||
**影响**:changelog Skill 无法使用 compare API 对比版本差异
|
||||
**解决方案**:改用 `commit +list` 按时间戳筛选版本区间内的提交
|
||||
|
||||
---
|
||||
|
||||
### 🟡 坑 5:journals API 全面返回 HTML
|
||||
|
||||
**发现时间**:2026-06-09(之前以为是"部分项目")
|
||||
**现象**:所有项目的 journals API(`/v1/:owner/:repo/issues/:number/journals`)都返回 HTML
|
||||
**影响**:无法获取 Issue 评论的详细内容和时间
|
||||
**解决方案**:统一使用 `comment_journals_count` 字段判断是否有响应(>0 表示有人响应)
|
||||
|
||||
---
|
||||
|
||||
### 🟢 坑 6:changelog 样式分类与输出格式不一致
|
||||
|
||||
**发现时间**: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 看着合理」≠「实际能跑」。必须真机验证。
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# GitLink-CLI 项目状态总结
|
||||
|
||||
> 最后更新:2026-06-03
|
||||
> 最后更新:2026-06-08
|
||||
> 仓库:`D:\自用\self\word\大三下\软件演化\gitlink-cli`
|
||||
> 远程:`https://gitlink.org.cn/chroe/gitlink-cli.git`
|
||||
|
||||
|
|
@ -10,14 +10,20 @@
|
|||
|
||||
**课程**:《软件演化与运维》课程实践
|
||||
**进阶任务**:GitLink 智能化能力提升项目
|
||||
**当前阶段**:子任务一 — 增加和完善 GitLink-CLI 能力(50%,截止 6月4日)
|
||||
**当前阶段**:子任务二 — 编写和丰富 GitLink Skills(20%,截止 6月18日)
|
||||
|
||||
### 子任务一交付要求
|
||||
### 子任务一交付要求(已完成)
|
||||
- 向 gitlink-cli 主仓库提交 PR(可多个)
|
||||
- 每个 PR 包含:功能代码 + 单元测试 + 命令帮助文档更新
|
||||
- 提供变更说明文档
|
||||
- 撰写《软件分析及建模报告》、《新需求构思报告》、《变更影响分析及测试报告》
|
||||
|
||||
### 子任务二交付要求(进行中)
|
||||
- 遵循 gitlink-cli Skills 规范(参考 `skills/README.md`)
|
||||
- 每个 Skill 包含:SKILL.md + 使用示例 + 至少在一个 Agent 平台上验证通过
|
||||
- 兼容至少一个主流 AI Agent(如 Claude Code、OpenClaw、Cursor 等)
|
||||
- 撰写《新需求构思报告》和《变更影响分析及测试报告》
|
||||
|
||||
---
|
||||
|
||||
## 二、技术栈与架构
|
||||
|
|
@ -215,7 +221,7 @@ Dockerfile 已配置 `ENV GOPROXY=https://goproxy.cn,direct`,解决国内网
|
|||
- [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] 12 个 AI Agent Skills(基础版,部分需要丰富)
|
||||
- [x] Showcase Dashboard 在线展示(12 个模块卡片,支持真实运行)
|
||||
- [x] GitHub CI/CD(release + npm publish + FreeBSD 支持)
|
||||
- [x] GitLink DevOps 流水线(push 自动部署,已配置密钥,正常运行)
|
||||
|
|
@ -223,10 +229,103 @@ Dockerfile 已配置 `ENV GOPROXY=https://goproxy.cn,direct`,解决国内网
|
|||
- [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)
|
||||
- 必须有的内容:
|
||||
· frontmatter(name/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/ 目录可以不写。
|
||||
|
||||
### 待写文档 📄
|
||||
- [ ] 《软件分析及建模报告》
|
||||
- [ ] 《新需求构思报告》
|
||||
- [ ] 《变更影响分析及测试报告》
|
||||
- [ ] 《新需求构思报告》(子任务一 + 子任务二共用)
|
||||
- [ ] 《变更影响分析及测试报告》(子任务一 + 子任务二共用)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -263,17 +362,43 @@ go test ./shortcuts/... -v
|
|||
|
||||
## 十、当前进度 & 下一步
|
||||
|
||||
### 已完成 ✅
|
||||
### 子任务一 ✅ 已完成
|
||||
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(基础版)
|
||||
|
||||
### 下一步 📋
|
||||
1. 向 gitlink-cli 主仓库提交 PR
|
||||
2. 撰写《软件分析及建模报告》
|
||||
3. 撰写《新需求构思报告》
|
||||
4. 撰写《变更影响分析及测试报告》
|
||||
5. 变更说明文档
|
||||
### 子任务二 🔄 进行中(截止 6月18日)
|
||||
|
||||
#### 第一步:开发新 Skill ✅ 已完成
|
||||
- [x] `gitlink-health` — 项目健康度报告 Skill
|
||||
- [x] SKILL.md(主文件 + CRITICAL 读 REFERENCE)
|
||||
- [x] REFERENCE.md(评分算法 + fallback 策略 + 社区关注度标准)
|
||||
- [x] examples/(健康度报告 + 周报,2026-06-08 真实数据)
|
||||
- [x] VERIFICATION.md(Claude Code 验证通过)
|
||||
- [x] 公平评分验证:77/100(严格按公式计算)
|
||||
- [x] `gitlink-changelog` — Release Notes 自动生成 Skill
|
||||
- [x] SKILL.md(CRITICAL 读 REFERENCE + 完整分类规则)
|
||||
- [x] REFERENCE.md(API 路径修正 + PR 过滤注意事项)
|
||||
- [x] examples/(changelog 生成示例,真实数据)
|
||||
- [x] VERIFICATION.md(Claude Code 验证通过)
|
||||
- [x] `gitlink-triage` — Issue 智能分拣 + 新人引导 Skill
|
||||
- [x] SKILL.md(CRITICAL 读 REFERENCE + subject 保留警告)
|
||||
- [x] REFERENCE.md(颜色码修正 + 标签映射说明)
|
||||
- [x] examples/(6 Issue 批量分拣示例,真实数据)
|
||||
- [x] VERIFICATION.md(Claude Code 验证通过)
|
||||
|
||||
#### 第二步:完善已有 Skill
|
||||
- [ ] gitlink-workflow 升级为独立完整 Skill(当前仅为简单骨架)
|
||||
- [ ] 补充 gitlink-ci、gitlink-pm 的 REFERENCE.md + examples
|
||||
- [ ] 为所有 Skill 补充 VERIFICATION.md
|
||||
|
||||
#### 第三步:文档撰写
|
||||
- [ ] 向 gitlink-cli 主仓库提交 Skills PR
|
||||
- [ ] 撰写《软件分析及建模报告》
|
||||
- [ ] 撰写《新需求构思报告》
|
||||
- [ ] 撰写《变更影响分析及测试报告》
|
||||
- [ ] 变更说明文档
|
||||
|
|
|
|||
|
|
@ -428,3 +428,5 @@ 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.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -13,6 +14,12 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
)
|
||||
|
||||
// msysPathRe matches Windows drive paths like "D:/something" that MSYS2
|
||||
// auto-converts from Unix paths like "/something". When the api command
|
||||
// receives "/v1/owner/repo", Git Bash on Windows may convert it to
|
||||
// "C:/Program Files/Git/v1/owner/repo". We detect and revert this.
|
||||
var msysPathRe = regexp.MustCompile(`^[A-Za-z]:/`)
|
||||
|
||||
func NewAPICmd() *cobra.Command {
|
||||
apiCmd := &cobra.Command{
|
||||
Use: "api <METHOD> <PATH>",
|
||||
|
|
@ -36,6 +43,20 @@ func runAPI(c *cobra.Command, args []string) error {
|
|||
method := strings.ToUpper(args[0])
|
||||
path := args[1]
|
||||
|
||||
// Fix MSYS2/Git Bash path auto-conversion on Windows.
|
||||
// "/v1/owner/repo" gets converted to "C:/Program Files/Git/v1/owner/repo".
|
||||
// Detect Windows drive letter prefix and strip it, then restore leading "/".
|
||||
if msysPathRe.MatchString(path) {
|
||||
// Find where the original API path starts (after the git install dir)
|
||||
// by looking for common API prefixes like /v1/, /api/, /users/, etc.
|
||||
for _, prefix := range []string{"/v1/", "/v2/", "/api/", "/users/", "/projects/"} {
|
||||
if idx := strings.Index(path, prefix); idx >= 0 {
|
||||
path = path[idx:]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -0,0 +1,767 @@
|
|||
# gitlink-review(智能代码审查)Skill 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 新增一个纯 Markdown skill `gitlink-review`,引导 AI agent 对指定 GitLink PR 做多视角 + 对抗式自检的结构化代码审查,并把报告卡作为评论发布。
|
||||
|
||||
**Architecture:** 交付物是 `skills/gitlink-review/` 下的 SKILL.md + REFERENCE.md + examples/,无 Go 代码。智能来自 agent 按管道执行(取 diff → 降噪 → 6 视角 → 对抗自检 → 综合 → 预览 → 发布)。发布主路径用已验证可用的 `pr +comment`;行内评论为"reviews API 可用时增强"。用"植入缺陷测试 PR"做端到端真实验证。
|
||||
|
||||
**Tech Stack:** Markdown + gitlink-cli(`pr +view` / `pr +files` / `pr +diff` / `pr +comment` / `api POST /pulls/:id/reviews`)。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 前置约束(所有任务适用)
|
||||
|
||||
- **`master` 受保护**:所有改动在特性分支 `feat/gitlink-review-skill` 上提交;测试用 fixture 在独立分支 `test/review-fixture` 上(仅供测试,不合并)。
|
||||
- **平台**:Windows + Git Bash。路径用正斜杠。
|
||||
- **认证**:`gitlink-cli` 已登录(`user +me` 可用)。
|
||||
|
||||
---
|
||||
|
||||
## Task 1:建立特性分支并纳入 spec/plan
|
||||
|
||||
**Files:**
|
||||
- Track: `docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md`
|
||||
- Track: `docs/superpowers/plans/2026-06-18-gitlink-review-skill.md`
|
||||
|
||||
- [ ] **Step 1:从最新 master 建分支**
|
||||
|
||||
```bash
|
||||
cd "C:/Users/CWQ98/Desktop/演化与运维/gitlink-cli"
|
||||
git fetch origin
|
||||
git checkout master
|
||||
git pull --ff-only origin master
|
||||
git checkout -b feat/gitlink-review-skill
|
||||
```
|
||||
|
||||
预期:位于 `feat/gitlink-review-skill`,与 master 同步。
|
||||
|
||||
- [ ] **Step 2:确认 CLI 可用**
|
||||
|
||||
```bash
|
||||
gitlink-cli version
|
||||
gitlink-cli user +me --format json
|
||||
```
|
||||
|
||||
预期:打印版本;`user +me` 返回 `login: caoweiqiong`。
|
||||
|
||||
- [ ] **Step 3:把 spec 和 plan 纳入版本控制**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md \
|
||||
docs/superpowers/plans/2026-06-18-gitlink-review-skill.md
|
||||
git commit -m "docs(review): 添加 gitlink-review 设计 spec 与实现计划"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2:创建"植入缺陷"测试 PR(端到端验证载体)
|
||||
|
||||
**Files:**
|
||||
- Create(在 `test/review-fixture` 分支上,不合并):`review_fixture.go`
|
||||
|
||||
- [ ] **Step 1:从 master 建测试分支**
|
||||
|
||||
```bash
|
||||
git checkout master
|
||||
git checkout -b test/review-fixture
|
||||
```
|
||||
|
||||
- [ ] **Step 2:写 fixture 文件(含 4 个植入点)**
|
||||
|
||||
创建 `review_fixture.go`:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
// 本文件为 gitlink-review skill 的测试夹具,故意植入缺陷。DO NOT MERGE。
|
||||
package reviewtest
|
||||
|
||||
import "os"
|
||||
|
||||
var _ = os.Getenv // 仅占位引入,避免 unused 报错(build ignore 下不影响)
|
||||
|
||||
// User 占位类型
|
||||
type User struct{ Name string }
|
||||
|
||||
// 1. 正确性:空指针未判
|
||||
func CurrentUser(token string) *User {
|
||||
if token == "" {
|
||||
return nil // token 无效返回 nil
|
||||
}
|
||||
return &User{Name: "cwq"}
|
||||
}
|
||||
|
||||
func Greet(token string) string {
|
||||
u := CurrentUser(token)
|
||||
return "hello " + u.Name // ← BUG: u 可能为 nil,解引用会 panic
|
||||
}
|
||||
|
||||
// 2. 安全:硬编码 Token(明显伪造字符串,避免触发真实密钥扫描)
|
||||
var AdminToken = "glpat-FAKEFAKEFAKE0000000000"
|
||||
|
||||
// 3. 测试:新函数 Welcome,本 PR 无对应 _test.go
|
||||
func Welcome(name string) string {
|
||||
if name == "" {
|
||||
return "guest"
|
||||
}
|
||||
return "hi " + name
|
||||
}
|
||||
|
||||
// 4. 伪阳性:看似除零,但调用方 DoMath 已保证除数非 0
|
||||
func Divide(a, b int) int {
|
||||
return a / b
|
||||
}
|
||||
|
||||
func DoMath(x int) int {
|
||||
if x == 0 {
|
||||
return 0 // 上游保证 x != 0
|
||||
}
|
||||
return Divide(100, x) // 因此 Divide 的除零在真实调用路径上是伪阳性
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:提交并推送**
|
||||
|
||||
```bash
|
||||
git add review_fixture.go
|
||||
git commit -m "test(review): 植入缺陷夹具(正确性/安全/测试/伪阳性)"
|
||||
git push origin test/review-fixture
|
||||
```
|
||||
|
||||
- [ ] **Step 4:创建 PR**
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +create \
|
||||
--owner chroe --repo gitlink-cli \
|
||||
--head test/review-fixture --base master \
|
||||
--title "test: gitlink-review 植入缺陷夹具(请勿合并)" \
|
||||
--body "gitlink-review skill 端到端验证用 PR。含 4 个植入点:空指针、硬编码 Token、缺测试、伪阳性。验证完成后将关闭不合并。" \
|
||||
--format json
|
||||
```
|
||||
|
||||
预期:`pull_request_number` 返回一个数(记为 `<PRN>`,如 3)。记录到 `review_fixture_notes.txt`(本地临时):
|
||||
|
||||
```
|
||||
TEST_PR_NUMBER=<PRN>
|
||||
```
|
||||
|
||||
- [ ] **Step 5:验证 diff 可取**
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +files --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json | head -c 800
|
||||
```
|
||||
|
||||
预期:files 含 `review_fixture.go`;diff 含上述代码片段。
|
||||
|
||||
---
|
||||
|
||||
## Task 3:实测 reviews API(决定行内评论能力)
|
||||
|
||||
**Files:** 无(结论写入 Task 5 的 REFERENCE.md)
|
||||
|
||||
- [ ] **Step 1:在测试 PR 上探测 POST reviews**
|
||||
|
||||
```bash
|
||||
gitlink-cli api POST /chroe/gitlink-cli/pulls/<PRN>/reviews \
|
||||
--body '{"body":"gitlink-review probe: 行内评论能力探测","event":"COMMENT"}' 2>&1
|
||||
```
|
||||
|
||||
- [ ] **Step 2:判断并记录结论**
|
||||
|
||||
- 若返回 `{"ok":true,...}`(或含 review id)→ **行内评论可用**,记录实际请求体格式。
|
||||
- 若返回 404 / URL 被注入 git exec-path → **不可用(与 triage 记录的 `api POST` bug 一致)**。
|
||||
|
||||
把结论写入本地 `review_api_probe.txt`:
|
||||
|
||||
```
|
||||
REVIEWS_API=<available|unavailable>
|
||||
NOTES=<观察到的响应或错误>
|
||||
```
|
||||
|
||||
- [ ] **Step 3:若可用,再试带行号的行内评论**
|
||||
|
||||
```bash
|
||||
gitlink-cli api POST /chroe/gitlink-cli/pulls/<PRN>/reviews \
|
||||
--body '{"event":"COMMENT","body":"行内探测","line":22,"path":"review_fixture.go","side":"RIGHT"}' 2>&1
|
||||
```
|
||||
|
||||
记录是否支持 `line/path/side`(行内定位)。结果并入 `review_api_probe.txt`。
|
||||
|
||||
> 此 task 无需 commit(结论是数据,将在 Task 5 固化进 REFERENCE.md)。
|
||||
|
||||
---
|
||||
|
||||
## Task 4:写 SKILL.md(主管道与方法论)
|
||||
|
||||
**Files:**
|
||||
- Create: `skills/gitlink-review/SKILL.md`
|
||||
|
||||
- [ ] **Step 1:切回特性分支并建目录**
|
||||
|
||||
```bash
|
||||
git checkout feat/gitlink-review-skill
|
||||
```
|
||||
|
||||
- [ ] **Step 2:写 SKILL.md**
|
||||
|
||||
创建 `skills/gitlink-review/SKILL.md`,**完整内容**如下:
|
||||
|
||||
````markdown
|
||||
---
|
||||
name: gitlink-review
|
||||
version: 1.0.0
|
||||
description: "智能代码审查:分析 PR diff,多视角评审 + 对抗式自检,输出结构化 Review 意见并作为评论发布。当用户需要审查 GitLink PR、做代码 review、自动生成审查意见时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli pr --help"
|
||||
---
|
||||
|
||||
# gitlink-review(智能代码审查)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有写入操作(发布评论)前默认先预览、确认后再执行;`--auto` 跳过确认但仍受置信度门控。**
|
||||
**CRITICAL — 绝不自动 approve / merge PR。审查只是评论,不做合并决策。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md);详细检查清单与字段映射见 [`REFERENCE.md`](REFERENCE.md)。
|
||||
|
||||
## 概述
|
||||
|
||||
本 Skill 引导 AI 对指定 GitLink PR 做结构化、多视角、低误报的代码审查,并把"报告卡"作为评论发布。核心特点:
|
||||
|
||||
1. **多视角评审团**:6 个视角并行扫 diff
|
||||
2. **对抗式自检**:每条候选发现先自我反驳,成立的才留下(直击 AI 审查误报老大难)
|
||||
3. **降噪**:自动跳过生成代码 / vendor / lock / 纯重命名
|
||||
4. **可执行**:每条发现带 `file:line` + 修复建议 + 理由
|
||||
|
||||
## 命令接口(skill 约定参数,非 gitlink-cli 新增 flag)
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `--owner/--repo` | 自动从 cwd 解析 | 目标仓库 |
|
||||
| `--id` | 必填 | PR 编号(`pull_request_number`,网页 `/pulls/N`) |
|
||||
| `--lenses` | 全 6 视角 | 子集,如 `correctness,security` |
|
||||
| `--auto` | 关 | 跳过预览确认直接发布(仍受置信度门控) |
|
||||
| `--inline` | 关 | 尝试附行内评论(reviews API 可用时) |
|
||||
| `--max-findings` | 12 | 报告卡上限 |
|
||||
| `--refresh` | 关 | 即使存在旧审查哨兵也重审并更新 |
|
||||
|
||||
## 管道(8 步)
|
||||
|
||||
### ① 取上下文
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +view --owner <owner> --repo <repo> --id <N> --format json # 元数据
|
||||
gitlink-cli pr +files --owner <owner> --repo <repo> --id <N> --format json # 文件 +/-
|
||||
gitlink-cli pr +diff --owner <owner> --repo <repo> --id <N> --format json # 核心:diff 内容
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json # 语言校准
|
||||
```
|
||||
|
||||
### ② 降噪过滤
|
||||
|
||||
按下述"降噪规则"跳过噪音文件;在报告卡"范围"行注明跳过数量,不静默吞掉。
|
||||
|
||||
### ③ 多视角分析
|
||||
|
||||
对每个启用的视角扫 diff,产出候选发现,每条含:
|
||||
|
||||
```json
|
||||
{ "lens": "correctness", "severity": "high", "likelihood": "likely",
|
||||
"file": "auth/login.go", "line": 42, "what": "空指针未判",
|
||||
"why": "...", "fix": "...", "confidence": "high" }
|
||||
```
|
||||
|
||||
### ④ 对抗式自检(质量门)
|
||||
|
||||
逐条自问(任一成立即丢弃或降级):
|
||||
|
||||
1. 语境里是否已有保护(外层判空、上游校验、框架机制)?
|
||||
2. 是真缺陷还是风格偏好?偏好 → 降级 🔵 nit 或丢弃。
|
||||
3. 引用的 API/签名/语言行为是否真实存在?(不确定则不报)
|
||||
4. 是否与另一视角重复?
|
||||
|
||||
**置信度门控**:`confidence=low` 且 `severity≠high` → 丢弃。
|
||||
|
||||
### ⑤ 综合
|
||||
|
||||
跨视角去重 → 按 `严重度×likelihood` 排序 → 封顶 `--max-findings`。
|
||||
|
||||
### ⑥ 预览(默认)
|
||||
|
||||
展示报告卡给用户;确认后发布。`--auto` 跳过。
|
||||
|
||||
### ⑦ 发布
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +comment --owner <owner> --repo <repo> --id <N> \
|
||||
--body "<报告卡全文,含哨兵>"
|
||||
```
|
||||
|
||||
`--inline` 且 reviews API 可用 → 对 🔴/🟡 发现附行内评论(见 REFERENCE.md 的 API 结论)。
|
||||
|
||||
### ⑧ 幂等
|
||||
|
||||
发布前检查该 PR 是否已有哨兵 `<!-- gitlink-review v1 ... -->`;有则默认提议"更新"(`--refresh` 才覆盖)。
|
||||
|
||||
## 评审团 6 视角
|
||||
|
||||
| 视角 | 盯什么 |
|
||||
|------|--------|
|
||||
| 🔴 正确性 (correctness) | 逻辑错、边界、空值、并发竞态、资源泄漏、错误处理、类型转换 |
|
||||
| 🔒 安全 (security) | 注入(SQL/命令/XSS)、鉴权越权、密钥/Token 泄露、路径穿越、不安全反序列化、弱加密 |
|
||||
| ⚡ 性能 (performance) | N+1 查询、无谓拷贝、O(n²)/嵌套循环、热路径分配、缺索引 |
|
||||
| 🧪 测试 (tests) | 新增/改动代码有无测试、边界用例、断言有效性 |
|
||||
| 🧹 可维护性 (maintainability) | 命名、重复、圈复杂度、抽象边界、与既有约定一致 |
|
||||
| 🚨 生产风险 (prod-risk) | "合并后凌晨3点哪会炸"——可观测性/日志、回滚、破坏性变更、配置依赖、降级 |
|
||||
|
||||
> 各视角的展开检查清单见 [`REFERENCE.md`](REFERENCE.md)。
|
||||
|
||||
## 报告卡格式(作为评论发布)
|
||||
|
||||
```markdown
|
||||
🤖 **gitlink-review 报告**
|
||||
|
||||
**结论**:<总体:✅LGTM / ⚠️建议修改 / 🛑有阻塞>(A 🔴阻塞 / B 🟡建议 / C 🔵nit)
|
||||
**范围**:<X> 文件,+<A>/-<D>(跳过 <Y> 噪音文件)|视角:<L>|自检丢弃:<R>
|
||||
|
||||
| 严重度 | 视角 | 位置 | 问题 |
|
||||
|:---:|---|---|---|
|
||||
| 🔴 | correctness | path/file.go:42 | <一句话> |
|
||||
|
||||
### 🔴 阻塞(合并前需处理)
|
||||
1. **[correctness] path/file.go:42** — <what>
|
||||
- **为什么**:<why>
|
||||
- **建议**:<fix>
|
||||
|
||||
### 🟡 建议
|
||||
…
|
||||
|
||||
### 🔵 nit
|
||||
…
|
||||
|
||||
### ✅ 未发现问题的方面
|
||||
- <视角>:<结论>
|
||||
|
||||
---
|
||||
<!-- gitlink-review v1 | pr:<N> | lenses:<L> | refuted:<R> | sha:<head-sha> -->
|
||||
*由 gitlink-review skill 生成。*
|
||||
```
|
||||
|
||||
## 降噪规则(自动跳过)
|
||||
|
||||
生成代码(`*.gen.go`/`*.pb.go`/`*_generated.*`/`*.min.js`/dist//build//target/)、第三方(vendor//third_party//node_modules/)、锁文件(go.sum/package-lock.json/yarn.lock/pnpm-lock.yaml/Cargo.lock)、纯重命名/移动、二进制/图片/字体。
|
||||
|
||||
## 安全护栏
|
||||
|
||||
- 默认预览确认;`--auto` 跳过但仍受置信度门控
|
||||
- **绝不自动 approve / merge**
|
||||
- 行内评论仅 `--inline` 且 reviews API 可用时发
|
||||
- `--max-findings` 封顶防刷屏
|
||||
- 评论失败 → 把报告卡原文交用户手动粘贴
|
||||
|
||||
## 错误处理与降级
|
||||
|
||||
| 情况 | 处理 |
|
||||
|------|------|
|
||||
| 无 diff | 报"无可审查变更",不发评论 |
|
||||
| PR 不存在/无权限 | 清晰错误,不发评论 |
|
||||
| reviews API 404 | 跳过行内,总结评论照发 |
|
||||
| diff 过大 | 抽样 + 标注"部分审查(仅 X/Y 文件)" |
|
||||
| 评论发布失败 | 输出报告卡原文供手动粘贴 |
|
||||
| 全部 low 置信 | 报"未发现高置信问题",列待人工确认项 |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- **先预览再发布**(默认),降低对外噪音
|
||||
- **置信度优先**:宁可少报,不要误报
|
||||
- **可执行**:每条发现必须带"建议修复"
|
||||
- **不越权**:只评论,不合并
|
||||
````
|
||||
|
||||
- [ ] **Step 3:提交**
|
||||
|
||||
```bash
|
||||
git add skills/gitlink-review/SKILL.md
|
||||
git commit -m "feat(review): 新增 gitlink-review SKILL.md(管道+6视角+对抗自检+报告卡)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5:写 REFERENCE.md(清单 + 评级 + API 结论)
|
||||
|
||||
**Files:**
|
||||
- Create: `skills/gitlink-review/REFERENCE.md`
|
||||
|
||||
- [ ] **Step 1:读 Task 3 的探测结论**
|
||||
|
||||
```bash
|
||||
cat review_api_probe.txt
|
||||
```
|
||||
|
||||
把 `<REVIEWS_API>` 与 `<REVIEWS_NOTES>`(下文占位)替换为真实结论。
|
||||
|
||||
- [ ] **Step 2:写 REFERENCE.md**
|
||||
|
||||
创建 `skills/gitlink-review/REFERENCE.md`,完整内容(**把 `<REVIEWS_API>` 等占位替换为 Task 3 实测结果**):
|
||||
|
||||
````markdown
|
||||
# gitlink-review 参考手册
|
||||
|
||||
> 各视角检查清单、严重度评级、降噪规则、reviews API 实测结论、字段映射。
|
||||
|
||||
---
|
||||
|
||||
## 一、各视角检查清单
|
||||
|
||||
### 🔴 正确性 (correctness)
|
||||
- 空值/nil 解引用未判
|
||||
- 边界:off-by-one、数组越界、空集合
|
||||
- 错误未处理或被吞(`err != nil` 缺失、`_ = err`)
|
||||
- 并发:数据竞态、缺锁、死锁
|
||||
- 资源泄漏:文件/连接/goroutine 未关闭或回收
|
||||
- 类型转换/断言未检查
|
||||
- 逻辑分支遗漏、return 路径不全
|
||||
|
||||
### 🔒 安全 (security)
|
||||
- 注入:SQL 拼接、命令、XSS、模板未转义
|
||||
- 鉴权/越权:缺权限校验、IDOR
|
||||
- 密钥/Token 硬编码或写入日志
|
||||
- 路径穿越(`../`、用户输入拼路径)
|
||||
- 不安全反序列化、弱加密/弱随机
|
||||
- 危险默认值(debug 开关、CORS *)
|
||||
|
||||
### ⚡ 性能 (performance)
|
||||
- N+1 查询、循环内 IO/查询
|
||||
- 无谓拷贝大对象、字符串反复拼接
|
||||
- O(n²)/深层嵌套循环
|
||||
- 热路径分配、缺缓存
|
||||
- 缺索引/全表扫描
|
||||
|
||||
### 🧪 测试 (tests)
|
||||
- 新增/改动函数有无对应测试
|
||||
- 边界与异常用例覆盖
|
||||
- 断言是否有效(非 `assert true`)
|
||||
- mock 是否合理、是否过度
|
||||
|
||||
### 🧹 可维护性 (maintainability)
|
||||
- 命名是否达意
|
||||
- 重复代码(DRY)
|
||||
- 圈复杂度过高、函数过长
|
||||
- 抽象边界模糊、职责混杂
|
||||
- 与既有代码风格/约定不一致
|
||||
|
||||
### 🚨 生产风险 (prod-risk)
|
||||
- 破坏性变更(API/DB schema/配置格式)
|
||||
- 可观测性:关键路径有无日志/指标
|
||||
- 回滚能力:是否可安全回退
|
||||
- 配置/环境依赖、启动顺序
|
||||
- 降级与限流缺失
|
||||
|
||||
---
|
||||
|
||||
## 二、严重度 × likelihood 评级
|
||||
|
||||
| severity | 含义 | 处理 |
|
||||
|----------|------|------|
|
||||
| 🔴 high | 阻塞:会导致 bug/安全问题/线上故障 | 合并前需处理 |
|
||||
| 🟡 medium | 建议:应修复但不强制阻塞 | 建议处理 |
|
||||
| 🔵 low | nit:风格/可读性 | 可选 |
|
||||
|
||||
| likelihood | 含义 |
|
||||
|------------|------|
|
||||
| likely | 真实路径上会发生 |
|
||||
| possible | 特定条件下发生 |
|
||||
| unlikely | 罕见但可能 |
|
||||
|
||||
排序权重:`high×likely` > `high×possible` > `medium×likely` > …。
|
||||
|
||||
置信度 `confidence`:high/medium/low。**门控**:`confidence=low && severity≠high → 丢弃`。
|
||||
|
||||
---
|
||||
|
||||
## 三、降噪规则
|
||||
|
||||
跳过:`*.gen.go`、`*.pb.go`、`*_generated.*`、`*.min.js`、`dist/`、`build/`、`target/`、`vendor/`、`third_party/`、`node_modules/`、`go.sum`、`package-lock.json`、`yarn.lock`、`pnpm-lock.yaml`、`Cargo.lock`、纯重命名/移动、二进制/图片/字体。跳过时在报告卡"范围"行计数。
|
||||
|
||||
---
|
||||
|
||||
## 四、reviews API 实测结论(决定行内评论)
|
||||
|
||||
**实测命令**(于测试 PR `<PRN>`):
|
||||
|
||||
```bash
|
||||
gitlink-cli api POST /<owner>/<repo>/pulls/<N>/reviews \
|
||||
--body '{"body":"...","event":"COMMENT"}'
|
||||
```
|
||||
|
||||
**结论**:<REVIEWS_API>(可用 / 不可用)
|
||||
|
||||
<REVIEWS_NOTES>(实测观察到的响应或错误)
|
||||
|
||||
**设计影响**:
|
||||
- 若 **不可用**(如返回 404 / git exec-path 注入 URL)→ 行内评论关闭,仅用 `pr +comment` 发总结评论。`--inline` 被忽略并提示原因。
|
||||
- 若 **可用** → `--inline` 时对 🔴/🟡 发现调用上述 API 发行内评论;请求体按实测支持的格式(是否含 `line/path/side`)构造。
|
||||
|
||||
---
|
||||
|
||||
## 五、字段映射
|
||||
|
||||
### pr +view 关键字段
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `issue.subject` / `issue.description` | 理解 PR 意图,校准审查重点 |
|
||||
| `pull_request.base` / `pull_request.head` | 目标/源分支 |
|
||||
| `author.login` | 作者(自审盲区提示) |
|
||||
| `files_count` / `commits_count` | 范围概览 |
|
||||
|
||||
### pr +diff 关键字段
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `files[].name` | 文件路径 |
|
||||
| `files[].addition` / `deletion` | 增删行数 |
|
||||
| `files[].sha` | 文件 sha(哨兵可选) |
|
||||
| diff 正文 | 分析输入 |
|
||||
|
||||
**行号映射规则**:报告卡里的 `file:line` 用**文件中的实际行号**(非 diff hunks 的 `+n` 相对行号)。从 diff hunk 头(`@@ -a,b +c,d @@`)推算:实际行号 = `c + (hunk 内相对行)`。
|
||||
|
||||
---
|
||||
|
||||
## 六、幂等哨兵
|
||||
|
||||
```
|
||||
<!-- gitlink-review v1 | pr:<N> | lenses:<L> | refuted:<R> | sha:<head-sha> -->
|
||||
```
|
||||
|
||||
- 发布前用 `pr +view`(或取评论)检查是否已含该哨兵
|
||||
- `sha` = 审查所基于的 head commit;PR 有新提交时提示"审查已过期,建议重审"
|
||||
- 默认不覆盖;`--refresh` 才重发
|
||||
````
|
||||
|
||||
- [ ] **Step 3:提交**
|
||||
|
||||
```bash
|
||||
git add skills/gitlink-review/REFERENCE.md
|
||||
git commit -m "docs(review): 新增 REFERENCE.md(视角清单+评级+API实测结论+字段映射)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6:端到端验证——对测试 PR 跑审查
|
||||
|
||||
**Files:** 无(验证 + 捕获输出)
|
||||
|
||||
- [ ] **Step 1:按 SKILL.md 对 `<PRN>` 执行审查**
|
||||
|
||||
人工/agent 依 SKILL.md 管道执行:取 view/files/diff → 降噪 → 6 视角 → 对抗自检 → 综合 → 生成报告卡。把生成的报告卡全文存到本地 `review_output_<PRN>.md`。
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +view --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
gitlink-cli pr +files --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
```
|
||||
|
||||
- [ ] **Step 2:验证 4 个植入点**
|
||||
|
||||
断言(必须全部满足,否则回改 SKILL.md/REFERENCE.md 后重跑):
|
||||
|
||||
| # | 植入点 | 期望 | 校验 |
|
||||
|---|--------|------|------|
|
||||
| 1 | `Greet` 空指针 | 🔴 correctness 命中,含 `review_fixture.go:<行>` 与 `u.Name` | 报告卡中能找到 |
|
||||
| 2 | `AdminToken` 硬编码 | 🔒 security 命中 | 报告卡中能找到 |
|
||||
| 3 | `Welcome` 缺测试 | 🧪 tests 命中(无 _test.go) | 报告卡中能找到 |
|
||||
| 4 | `Divide` 除零(DoMath 已保护) | **被对抗自检丢弃**,`refuted` 计数 ≥1,**不应**出现在发现列表 | 报告卡中无此项 |
|
||||
|
||||
- [ ] **Step 3:发布到测试 PR 并验证哨兵**
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +comment --owner chroe --repo gitlink-cli --id <PRN> \
|
||||
--body "$(cat review_output_<PRN>.md)" --format json
|
||||
```
|
||||
|
||||
预期:`ok: true`。再 `pr +view` 确认 `comments_count` 增加。
|
||||
|
||||
- [ ] **Step 4:若验证不通过,回改并重跑**
|
||||
|
||||
任何断言失败 → 修正 SKILL.md 的检查清单/自检规则 → 重新执行 Step 1-3,直到 4 项全过。
|
||||
|
||||
---
|
||||
|
||||
## Task 7:写 examples/review-workflow.md(真实走查)
|
||||
|
||||
**Files:**
|
||||
- Create: `skills/gitlink-review/examples/review-workflow.md`
|
||||
|
||||
- [ ] **Step 1:基于 Task 6 的真实输出写示例**
|
||||
|
||||
创建 `skills/gitlink-review/examples/review-workflow.md`:
|
||||
|
||||
````markdown
|
||||
# 示例:对植入缺陷 PR 的智能审查(真实数据)
|
||||
|
||||
> 基于 `chroe/gitlink-cli` 测试 PR #<PRN>(`test/review-fixture`)于 2026-06-18 实际执行。
|
||||
> 该 PR 故意植入 4 个问题用于验证 gitlink-review skill。
|
||||
|
||||
---
|
||||
|
||||
## Step 1:取上下文
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +view --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id <PRN> --format json
|
||||
```
|
||||
|
||||
**范围**:1 文件 `review_fixture.go`,+38/-0,无噪音文件需跳过。
|
||||
|
||||
## Step 2:6 视角扫描 + 对抗自检(关键过程)
|
||||
|
||||
| 候选发现 | 视角 | 自检结果 |
|
||||
|----------|------|----------|
|
||||
| `Greet` 中 `u.Name` 解引用 nil | correctness | ✅ 成立(无外层判空)→ 保留 🔴 |
|
||||
| `AdminToken` 硬编码 | security | ✅ 成立 → 保留 🔴 |
|
||||
| `Welcome` 无对应测试 | tests | ✅ 成立(本 PR 无 _test.go)→ 保留 🟡 |
|
||||
| `Divide(a,b)` 除零 | correctness | ❌ **被反驳**:`DoMath` 已保证 `x≠0`,真实调用路径除数非 0 → 丢弃 |
|
||||
|
||||
**自检丢弃:1 条(refuted=1)**。
|
||||
|
||||
## Step 3:发布报告卡(真实输出)
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +comment --owner chroe --repo gitlink-cli --id <PRN> \
|
||||
--body "<报告卡全文>"
|
||||
```
|
||||
|
||||
<此处粘贴 Task 6 生成的真实报告卡全文>
|
||||
|
||||
**发布结果**:`ok: true`,PR 评论数 +1。
|
||||
|
||||
## Step 4:幂等验证
|
||||
|
||||
再次运行(不带 `--refresh`)→ 检测到哨兵 `<!-- gitlink-review v1 | pr:<PRN> | ... -->` → 提示"已存在审查,使用 --refresh 更新",未重复发布。
|
||||
|
||||
## 关键结论
|
||||
|
||||
- 3 个真实缺陷被对应视角命中,1 个伪阳性被对抗自检丢弃(refuted=1)
|
||||
- 行内评论:<根据 Task 3 结论写"可用已附行内" 或 "API 不可用,仅总结评论">
|
||||
````
|
||||
|
||||
- [ ] **Step 2:提交**
|
||||
|
||||
```bash
|
||||
git add skills/gitlink-review/examples/review-workflow.md
|
||||
git commit -m "docs(review): 新增真实走查示例 review-workflow.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8:更新 README 与 workflow 链接
|
||||
|
||||
**Files:**
|
||||
- Modify: `skills/README.md`
|
||||
- Modify: `skills/gitlink-workflow/SKILL.md`
|
||||
|
||||
- [ ] **Step 1:README 智能 skill 表加一行**
|
||||
|
||||
在 `skills/README.md` 的"智能 Skills"表追加:
|
||||
|
||||
```markdown
|
||||
| **gitlink-review** | 智能代码审查 | 分析 PR diff,多视角评审 + 对抗式自检,结构化 Review 意见自动评论 |
|
||||
```
|
||||
|
||||
并在文档导航/按需处补一行指向 `gitlink-review/SKILL.md`。
|
||||
|
||||
- [ ] **Step 2:workflow 的浅层 Code Review 链回本 skill**
|
||||
|
||||
在 `skills/gitlink-workflow/SKILL.md` 的"AI 在 PR 流程中的角色 → Code Review"处补一句:
|
||||
|
||||
```markdown
|
||||
> 深度代码审查请使用 [`../gitlink-review/SKILL.md`](../gitlink-review/SKILL.md)(多视角 + 对抗式自检 + 自动评论)。
|
||||
```
|
||||
|
||||
- [ ] **Step 3:提交**
|
||||
|
||||
```bash
|
||||
git add skills/README.md skills/gitlink-workflow/SKILL.md
|
||||
git commit -m "docs(review): README 智能表登记 gitlink-review,workflow 链回深度审查"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9:清理测试 PR + 推送特性分支 + 建 PR
|
||||
|
||||
**Files:** 无
|
||||
|
||||
- [ ] **Step 1:关闭测试 PR(不合并)**
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +close --owner chroe --repo gitlink-cli --id <PRN>
|
||||
```
|
||||
|
||||
预期:`ok: true`,PR 状态变 closed(未合并,植入缺陷不进 master)。
|
||||
|
||||
- [ ] **Step 2:删本地/远端测试分支**
|
||||
|
||||
```bash
|
||||
git branch -D test/review-fixture
|
||||
git push origin --delete test/review-fixture
|
||||
```
|
||||
|
||||
- [ ] **Step 3:清理本地临时文件**
|
||||
|
||||
```bash
|
||||
rm -f review_fixture_notes.txt review_api_probe.txt review_output_*.md
|
||||
```
|
||||
|
||||
- [ ] **Step 4:推送特性分支**
|
||||
|
||||
```bash
|
||||
git checkout feat/gitlink-review-skill
|
||||
git push origin feat/gitlink-review-skill
|
||||
```
|
||||
|
||||
- [ ] **Step 5:创建合并到 master 的 PR**
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +create \
|
||||
--owner chroe --repo gitlink-cli \
|
||||
--head feat/gitlink-review-skill --base master \
|
||||
--title "feat: 新增 gitlink-review 智能代码审查 Skill" \
|
||||
--body "## 目的
|
||||
子任务二核心场景:智能代码审查(分析 PR diff,多视角+对抗自检,结构化 Review 自动评论)。
|
||||
|
||||
## 交付
|
||||
- skills/gitlink-review/:SKILL.md + REFERENCE.md + examples/review-workflow.md
|
||||
- skills/README.md、skills/gitlink-workflow/SKILL.md:登记与链接
|
||||
- docs/superpowers/:设计 spec + 实现计划
|
||||
|
||||
## 验证(真实数据)
|
||||
植入缺陷测试 PR 已验证:3 个真实缺陷被对应视角命中,1 个伪阳性被对抗自检丢弃(refuted=1),哨兵幂等,pr +comment 发布成功。reviews API:<填 Task 3 结论>。
|
||||
|
||||
## 设计要点
|
||||
- 多视角评审团(正确性/安全/性能/测试/可维护/生产风险)
|
||||
- 对抗式自检质量门(直击误报)
|
||||
- 默认预览确认,绝不自动合并" \
|
||||
--format json
|
||||
```
|
||||
|
||||
- [ ] **Step 6:记录 PR 号并通知用户**
|
||||
|
||||
把返回的 `pull_request_number` 报告给用户,等待其合并(master 受保护)。
|
||||
|
||||
---
|
||||
|
||||
## 验收(全部满足才算完成)
|
||||
|
||||
- [ ] `skills/gitlink-review/` 三件套齐全,结构与 triage/health 一致
|
||||
- [ ] Task 6 四个植入点断言全过(3 命中 + 1 丢弃)
|
||||
- [ ] REFERENCE.md 的 reviews API 结论为实测结果(非猜测)
|
||||
- [ ] 报告卡含哨兵,幂等生效
|
||||
- [ ] README 与 workflow 链接已更新
|
||||
- [ ] 测试 PR 已关闭未合并,测试分支已删
|
||||
- [ ] 特性分支已推送,PR 已创建
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
# 智能代码审查 Skill(gitlink-review)设计
|
||||
|
||||
- **日期**:2026-06-18
|
||||
- **状态**:已批准,待编写实现计划
|
||||
- **作者**:CWQ + Claude
|
||||
- **定位**:子任务二核心场景之一——智能代码审查;纯 Markdown skill(SKILL.md + REFERENCE.md + examples/),无需 Go 代码
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
gitlink-cli 现有 16 个 skill 中**没有专门的代码审查 skill**:`gitlink-workflow` 的"PR 全流程"仅在 Step 5 浅层提及(`pr +files` + "给出审查意见")。README 列的智能 skill 只有 health / changelog / triage 三件套。
|
||||
|
||||
本 skill 填补该缺口:引导 AI agent 对指定 GitLink PR 执行**结构化、多视角、低误报**的代码审查,并把结构化审查意见作为评论发布到 PR。
|
||||
|
||||
**实用性目标**:信噪比高、误报少、每条发现可执行(带 `file:line` + 修复建议 + 理由)、能稳定落地到 GitLink(`pr +comment` 已验证可用)。
|
||||
|
||||
**创意性目标**:多视角"评审团" + 对抗式自检(自带质量门,直击 AI code review 的误报老大难)+ 生产风险(oncall)视角。
|
||||
|
||||
## 2. 非目标(YAGNI)
|
||||
|
||||
- 不做 CI / webhook 自动触发——按需由 agent 调用
|
||||
- **绝不自动 approve / merge**
|
||||
- 不做跨仓库批量(单 PR 为主;批量留作可选入口,不在首版实现)
|
||||
- 不修改 gitlink-cli 的 Go 代码——纯 skill 交付物
|
||||
|
||||
## 3. 关键决策(用户确认)
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|--------|------|
|
||||
| 评论形式 | 总结评论为主(`pr +comment`,可靠)+ 行内评论增强(reviews API 可用时附加,否则降级) |
|
||||
| 发表策略 | 默认预览、确认后发;`--auto` 跳过确认(仍受置信度门控) |
|
||||
| 审查引擎 | 多视角评审团(6 视角全量)+ 对抗式自检 |
|
||||
|
||||
## 4. 文件清单与改动范围
|
||||
|
||||
| 文件 | 动作 | 内容 |
|
||||
|------|------|------|
|
||||
| `skills/gitlink-review/SKILL.md` | 新增 | 主管道、6 视角、对抗自检、降噪、输出格式、安全护栏、命令接口 |
|
||||
| `skills/gitlink-review/REFERENCE.md` | 新增 | 各视角检查清单、严重度评级表、字段映射、reviews API 实测结论与降级、噪音文件规则 |
|
||||
| `skills/gitlink-review/examples/review-workflow.md` | 新增 | 真实 PR 走查(含植入缺陷验证、真实输出) |
|
||||
| `skills/README.md` | 修改 | 智能技能表新增 gitlink-review |
|
||||
| `skills/gitlink-workflow/SKILL.md` | 修改 | 浅层 Code Review 步骤链回本 skill |
|
||||
|
||||
## 5. 命令接口
|
||||
|
||||
skill 由 agent 调用,参数(约定,非 CLI 子命令):
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `--owner/--repo` | 自动从 cwd 解析 | 目标仓库 |
|
||||
| `--id` | 必填 | PR 编号(`pull_request_number`,网页 URL `/pulls/N`) |
|
||||
| `--lenses` | 全 6 视角 | 指定子集,如 `correctness,security` |
|
||||
| `--auto` | 关 | 跳过预览确认,直接发布(仍受置信度门控) |
|
||||
| `--inline` | 关 | 尝试附行内评论(reviews API 可用时) |
|
||||
| `--max-findings` | 12 | 报告卡上限,防刷屏 |
|
||||
| `--refresh` | 关 | 即使检测到旧审查哨兵也重审并更新 |
|
||||
|
||||
## 6. 数据流(8 步管道)
|
||||
|
||||
```
|
||||
① 取上下文 pr +view / pr +files / pr +diff(核心输入)/ repo +info(语言校准、约定)
|
||||
② 降噪过滤 跳过生成代码 / vendor / lock / 纯重命名 / 二进制 / minified
|
||||
③ 多视角分析 6 视角并行扫 diff → 候选发现
|
||||
④ 对抗式自检 逐条尝试反驳 → 丢弃不成立/低置信
|
||||
⑤ 综合 跨视角去重、按 严重度×likelihood 排序、封顶 → 报告卡
|
||||
⑥ 预览 默认展示给用户,确认后发(--auto 跳过)
|
||||
⑦ 发布 pr +comment 发总结评论(可靠);--inline 且 reviews API 可用 → 附行内评论,否则降级
|
||||
⑧ 幂等 评论内嵌哨兵 <!-- gitlink-review v1 -->,重跑检测旧审查 → 提议更新而非刷屏
|
||||
```
|
||||
|
||||
每条候选发现的数据结构(内部):
|
||||
|
||||
```json
|
||||
{
|
||||
"lens": "correctness",
|
||||
"severity": "high", // high(🔴) | medium(🟡) | low(🔵)
|
||||
"likelihood": "likely", // likely | possible | unlikely
|
||||
"file": "auth/login.go",
|
||||
"line": 42,
|
||||
"what": "空指针未判",
|
||||
"why": "GetUser 在 token 无效时返回 nil,此处直接解引用",
|
||||
"fix": "if u := GetUser(t); u != nil { ... }",
|
||||
"confidence": "high" // high | medium | low
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 评审团 6 视角
|
||||
|
||||
| 视角 | 盯什么 |
|
||||
|------|--------|
|
||||
| 🔴 正确性 (correctness) | 逻辑错、边界、空值、并发竞态、资源泄漏、错误处理、类型转换 |
|
||||
| 🔒 安全 (security) | 注入(SQL/命令/XSS)、鉴权越权、密钥/Token 泄露、路径穿越、不安全反序列化、弱加密 |
|
||||
| ⚡ 性能 (performance) | N+1 查询、无谓拷贝、O(n²)/嵌套循环、热路径分配、缺索引、大对象常驻 |
|
||||
| 🧪 测试 (tests) | 新增/改动代码有无对应测试、边界用例、断言是否有效、mock 是否合理 |
|
||||
| 🧹 可维护性 (maintainability) | 命名、重复代码、圈复杂度、抽象边界、与既有约定一致性 |
|
||||
| 🚨 生产风险 (prod-risk) | "合并后凌晨3点哪会炸"——可观测性/日志、回滚能力、破坏性变更(API/DB/schema)、配置依赖、降级路径 |
|
||||
|
||||
各视角的展开检查清单写入 `REFERENCE.md`。
|
||||
|
||||
## 8. 对抗式自检(质量门,创意核心)
|
||||
|
||||
对每条候选发现,agent 自我问以下问题,任一成立即丢弃或降级:
|
||||
|
||||
1. **语境已处理**:完整上下文里是否已有保护(外层判空、上游校验、框架机制)?
|
||||
2. **风格冒充 bug**:这是真缺陷还是个人风格偏好?若是偏好,降级为 🔵 nit 或丢弃。
|
||||
3. **幻觉检查**:我引用的 API/函数签名/语言行为是否真实存在?(不确定则不报,或标注"待确认")
|
||||
4. **重复/已被覆盖**:是否与另一视角的发现其实是同一问题?
|
||||
|
||||
**置信度门控**:`confidence=low` 且 `severity≠high` → 丢弃。这保证只发高信号发现。
|
||||
|
||||
## 9. 降噪规则(自动跳过,不审查)
|
||||
|
||||
- 生成代码:`*.gen.go`、`*.pb.go`、`*_generated.*`、`*.min.js`、dist/、build/、target/
|
||||
- 第三方:`vendor/`、`third_party/`、`node_modules/`
|
||||
- 锁文件:`go.sum`、`package-lock.json`、`yarn.lock`、`pnpm-lock.yaml`、`Cargo.lock`
|
||||
- 纯重命名/移动(无内容变更)
|
||||
- 二进制文件、图片、字体
|
||||
|
||||
跳过时在报告卡"范围"行注明跳过数量,不静默吞掉。
|
||||
|
||||
## 10. 输出格式(报告卡,作为 PR 评论发布)
|
||||
|
||||
```markdown
|
||||
🤖 **gitlink-review 报告**
|
||||
|
||||
**结论**:⚠️ 建议修改(2 🔴阻塞 / 4 🟡建议 / 3 🔵nit)
|
||||
**范围**:5 文件,+120/-30(跳过 2 噪音文件)|视角:6|自检丢弃:3
|
||||
|
||||
| 严重度 | 视角 | 位置 | 问题 |
|
||||
|:---:|---|---|---|
|
||||
| 🔴 | 正确性 | auth/login.go:42 | 空指针未判 |
|
||||
| 🔴 | 安全 | config.go:8 | 硬编码 Token |
|
||||
| 🟡 | 性能 | list.go:88 | 循环内重复查询 |
|
||||
|
||||
### 🔴 阻塞(合并前需处理)
|
||||
1. **[正确性] auth/login.go:42** — `GetUser(token)` 在 token 无效时返回 nil,此处直接解引用会 panic。
|
||||
- **建议**:`if u := GetUser(t); u != nil { ... }`
|
||||
2. **[安全] config.go:8** — 硬编码 Token,存在泄露风险。
|
||||
- **建议**:改从环境变量读取 `os.Getenv("GITLINK_TOKEN")`
|
||||
|
||||
### 🟡 建议
|
||||
…
|
||||
|
||||
### 🔵 nit
|
||||
…
|
||||
|
||||
### ✅ 未发现问题的方面
|
||||
- 安全:未发现注入点
|
||||
- 生产风险:无破坏性 API 变更
|
||||
|
||||
---
|
||||
<!-- gitlink-review v1 | pr:5 | lenses:6 | refuted:3 | sha:<head-sha> -->
|
||||
*由 gitlink-review skill 生成;本评论为预览确认后发布。*
|
||||
```
|
||||
|
||||
哨兵 `<!-- gitlink-review v1 | pr:N | lenses | refuted | sha -->` 用于幂等与"针对哪个 commit"标识。
|
||||
|
||||
## 11. 安全护栏
|
||||
|
||||
- 默认预览确认;`--auto` 跳过确认但**仍受置信度门控**
|
||||
- **绝不自动 approve / merge**(即使 `--auto`)
|
||||
- 行内评论仅在显式 `--inline` 且 reviews API 实测可用时发,否则不发
|
||||
- `--max-findings` 封顶,防刷屏
|
||||
- 所有写操作需认证(遵循 `gitlink-shared`)
|
||||
- 跳过自己作者本人的 PR 时可提示(避免自审盲区),但不强制阻断
|
||||
|
||||
## 12. 错误处理与降级
|
||||
|
||||
| 情况 | 处理 |
|
||||
|------|------|
|
||||
| `pr +diff` 无差异 | 报告"无可审查变更",不发评论 |
|
||||
| PR 不存在/无权限 | 清晰错误信息,不发评论 |
|
||||
| reviews API 404(已知 `api POST` bug) | 记录、跳过行内、总结评论照发 |
|
||||
| diff 过大(> 阈值) | 抽样审查 + 标注"部分审查(仅 X/Y 文件)" |
|
||||
| `pr +comment` 发布失败 | 把报告卡原文输出给用户手动粘贴 |
|
||||
| 置信度全部 low | 报告"未发现高置信问题",列出待人工确认项 |
|
||||
|
||||
## 13. 幂等与去重
|
||||
|
||||
- 评论内嵌哨兵;重跑时先 `pr +view`/取评论检测哨兵
|
||||
- 已存在旧审查:默认提议"更新"(`--refresh` 才覆盖),避免重复发
|
||||
- `sha` 字段记录审查所基于的 head commit;PR 有新提交时提示"审查已过期,建议重审"
|
||||
|
||||
## 14. 验证计划(真实数据,沿用其他 skill 惯例)
|
||||
|
||||
建一个**植入已知缺陷的小测试 PR**,包含:
|
||||
|
||||
1. 一处空指针/越界(验证 🔴 正确性视角命中)
|
||||
2. 一处硬编码密钥(验证 🔒 安全视角命中)
|
||||
3. 一处缺测试的新函数(验证 🧪 测试视角命中)
|
||||
4. 一处"看起来像 bug 但语境已处理"的伪阳性(验证对抗自检丢弃)
|
||||
|
||||
验证项:
|
||||
- [ ] 每个植入缺陷被对应视角命中
|
||||
- [ ] 伪阳性被对抗自检丢弃(refuted 计数 +1)
|
||||
- [ ] 哨兵正确内嵌,重跑不刷屏
|
||||
- [ ] 总结评论通过 `pr +comment` 成功发布
|
||||
- [ ] reviews API 实测:记录可用/不可用结论写入 REFERENCE.md
|
||||
- [ ] `--auto` 与预览两种模式均验证
|
||||
|
||||
结果(真实输出)写入 `examples/review-workflow.md`。
|
||||
|
||||
## 15. 风险与未决
|
||||
|
||||
- **reviews API 可用性**:`gitlink-triage` 记录 `api POST` 有 URL 注入 bug 导致 404。本 skill 的行内评论依赖 `POST /:owner/:repo/pulls/:id/reviews`,实现时**必须实测**;不可用则设计已内建降级(仅总结评论)。结论写入 REFERENCE.md。
|
||||
- **token 消耗**:6 视角全量较重;通过 `--lenses` 子集和 `--max-findings` 控制。
|
||||
- **行号偏移**:diff 行号 vs 文件行号的映射需在 REFERENCE.md 给出规则,保证 `file:line` 准确。
|
||||
|
||||
## 16. 验收标准
|
||||
|
||||
1. `skills/gitlink-review/` 三件套齐全,结构与 triage/health 等智能 skill 一致
|
||||
2. 测试 PR 的 4 个植入点验证全部通过(3 命中 + 1 丢弃)
|
||||
3. 总结评论在真实 PR 成功发布,含哨兵
|
||||
4. reviews API 可用性有明确实测结论
|
||||
5. README 与 workflow 链接更新
|
||||
Binary file not shown.
|
|
@ -14,10 +14,11 @@ const (
|
|||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Format string `yaml:"default_format"`
|
||||
Editor string `yaml:"editor,omitempty"`
|
||||
Pager string `yaml:"pager,omitempty"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Format string `yaml:"default_format"`
|
||||
Editor string `yaml:"editor,omitempty"`
|
||||
Pager string `yaml:"pager,omitempty"`
|
||||
AnthropicAPIKey string `yaml:"anthropic_api_key,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,19 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
switch data := envelope.Data.(type) {
|
||||
// Convert struct types to generic map/slice via JSON round-trip
|
||||
data := envelope.Data
|
||||
if _, ok := data.(map[string]interface{}); !ok {
|
||||
if _, ok := data.([]interface{}); !ok {
|
||||
generic, err := toGeneric(data)
|
||||
if err != nil {
|
||||
return printJSON(w, envelope)
|
||||
}
|
||||
data = generic
|
||||
}
|
||||
}
|
||||
|
||||
switch data := data.(type) {
|
||||
case []interface{}:
|
||||
return printSliceTable(w, data)
|
||||
case map[string]interface{}:
|
||||
|
|
@ -82,11 +94,23 @@ func printTable(w io.Writer, envelope *Envelope) error {
|
|||
}
|
||||
}
|
||||
|
||||
func toGeneric(v interface{}) (interface{}, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(b, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func findSliceInMap(m map[string]interface{}) []interface{} {
|
||||
for _, key := range []string{
|
||||
"issues", "pull_requests", "milestones", "webhooks", "issue_tags",
|
||||
"commits", "files", "members", "collaborators", "users", "branches",
|
||||
"releases", "entries", "tags", "watchers",
|
||||
"releases", "entries", "tags", "watchers", "results",
|
||||
} {
|
||||
if v, ok := m[key]; ok {
|
||||
if slice, ok := v.([]interface{}); ok && len(slice) > 0 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
#!/bin/bash
|
||||
# Link skills/gitlink-* into ~/.claude/skills/ so Claude Code can load and
|
||||
# invoke them through the Skill tool (e.g. `Skill gitlink-issue`).
|
||||
#
|
||||
# Cross-platform:
|
||||
# - Windows (Git Bash / MSYS): directory junction (mklink /J, no admin needed)
|
||||
# - macOS / Linux: symlink
|
||||
#
|
||||
# Links point at the in-repo skills/ directory, so updating the repo keeps the
|
||||
# Skill content in sync. The script is idempotent and safe to re-run.
|
||||
#
|
||||
# CRITICAL (Windows): an existing link is removed with `rmdir` (no /s), never
|
||||
# `rm -rf` — `rm -rf` would follow the junction and DELETE THE SOURCE FILES.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SRC="$PROJECT_DIR/skills"
|
||||
DST="$HOME/.claude/skills"
|
||||
|
||||
# --- collect gitlink-* skills ---
|
||||
shopt -s nullglob
|
||||
SKILLS=("$SRC"/gitlink-*/)
|
||||
shopt -u nullglob
|
||||
if [ ${#SKILLS[@]} -eq 0 ]; then
|
||||
echo "No gitlink-* skills found under $SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DST"
|
||||
|
||||
# --- detect platform ---
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;;
|
||||
*) PLATFORM=unix ;;
|
||||
esac
|
||||
|
||||
OK=0
|
||||
FAIL=0
|
||||
|
||||
for skill in "${SKILLS[@]}"; do
|
||||
skill="${skill%/}" # strip trailing slash for clean paths
|
||||
name="$(basename "$skill")"
|
||||
link="$DST/$name"
|
||||
|
||||
if [ "$PLATFORM" = "windows" ]; then
|
||||
win_link="$(cygpath -w "$link")"
|
||||
win_src="$(cygpath -w "$skill")"
|
||||
|
||||
if [ -e "$link" ] || [ -L "$link" ]; then
|
||||
# rmdir (no /s) removes only the junction/symlink, never the target.
|
||||
if ! cmd //c rmdir "$win_link" >/dev/null 2>&1; then
|
||||
echo "SKIP $name (existing path is not a link — left untouched)"
|
||||
FAIL=$((FAIL+1)); continue
|
||||
fi
|
||||
fi
|
||||
|
||||
if powershell -NoProfile -Command \
|
||||
"New-Item -ItemType Junction -Path '$win_link' -Target '$win_src' -ErrorAction Stop" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "OK $name"; OK=$((OK+1))
|
||||
else
|
||||
echo "FAIL $name"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
else
|
||||
# ln -sfn replaces an existing symlink safely (does not follow it).
|
||||
if ln -sfn "$skill" "$link"; then
|
||||
echo "OK $name"; OK=$((OK+1))
|
||||
else
|
||||
echo "FAIL $name"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done: $OK linked, $FAIL failed."
|
||||
echo "Target: $DST"
|
||||
echo "Skills are now invocable via the Claude Code Skill tool."
|
||||
|
|
@ -36,6 +36,7 @@ type RuntimeContext struct {
|
|||
Repo string
|
||||
Format string
|
||||
Args map[string]string
|
||||
AIMode string
|
||||
}
|
||||
|
||||
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
|
||||
|
|
|
|||
|
|
@ -474,6 +474,89 @@ func setMilestone(ctx *common.RuntimeContext, number, milestoneID string) error
|
|||
return nil
|
||||
}
|
||||
|
||||
func newBatchCommentShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-comment",
|
||||
Description: "Add a comment to multiple issues by issue numbers or a CSV file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
|
||||
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
|
||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview the issues that would be commented on without changing them", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchComment,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchComment(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(numbers) == 0 {
|
||||
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]batchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := batchResult{Number: number, Action: "comment"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := commentIssue(ctx, number, body); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "commented"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
summary.Duration = time.Since(start).String()
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个 Issue 评论失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func commentIssue(ctx *common.RuntimeContext, number, body string) error {
|
||||
payload := map[string]interface{}{
|
||||
"notes": body,
|
||||
}
|
||||
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload); err != nil {
|
||||
return fmt.Errorf("添加评论: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCommaSeparated(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newBatchAssignShortcut(),
|
||||
newBatchLabelShortcut(),
|
||||
newBatchMilestoneShortcut(),
|
||||
newBatchCommentShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List issues",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
package member
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type batchAddResult struct {
|
||||
User string `json:"user" yaml:"user"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Status string `json:"status" yaml:"status"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
|
||||
type batchAddSummary struct {
|
||||
Owner string `json:"owner" yaml:"owner"`
|
||||
Repo string `json:"repo" yaml:"repo"`
|
||||
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
||||
Total int `json:"total" yaml:"total"`
|
||||
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
||||
Failed int `json:"failed" yaml:"failed"`
|
||||
Duration string `json:"duration" yaml:"duration"`
|
||||
Results []batchAddResult `json:"results" yaml:"results"`
|
||||
}
|
||||
|
||||
func batchAddShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-add",
|
||||
Description: "批量添加成员到项目,支持逗号分隔列表或 CSV 文件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "users", Short: "u", Usage: "逗号分隔的用户数字 ID,例如: 42,99,105"},
|
||||
{Name: "from", Usage: "从 CSV 文件读取用户 ID。支持 user_id/id/user 列名或无表头首列"},
|
||||
{Name: "dry-run", Usage: "仅预览将要添加的成员,不实际执行", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchAdd,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchAdd(ctx *common.RuntimeContext) error {
|
||||
start := time.Now()
|
||||
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userIDs, err := collectUserIDs(ctx.Arg("users"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return fmt.Errorf("未提供用户 ID,请使用 --users 42,99 或 --from users.csv")
|
||||
}
|
||||
|
||||
dryRun := parseMemberBool(ctx.Arg("dry-run"))
|
||||
|
||||
summary := batchAddSummary{
|
||||
Owner: ctx.Owner,
|
||||
Repo: ctx.Repo,
|
||||
DryRun: dryRun,
|
||||
Total: len(userIDs),
|
||||
Results: make([]batchAddResult, 0, len(userIDs)),
|
||||
}
|
||||
|
||||
for _, uid := range userIDs {
|
||||
result := batchAddResult{User: uid, Action: "add"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
id, _ := strconv.ParseInt(uid, 10, 64)
|
||||
body := map[string]interface{}{"user_id": id}
|
||||
if _, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "added"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
summary.Duration = time.Since(start).String()
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个成员添加失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectUserIDs(usersValue, csvPath string) ([]string, error) {
|
||||
ids, err := parseUserIDList(usersValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if csvPath == "" {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
csvIDs, err := readUserIDsFromCSV(csvPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mergeUserIDLists(ids, csvIDs), nil
|
||||
}
|
||||
|
||||
func parseUserIDList(value string) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return normalizeUserIDs(strings.Split(value, ","))
|
||||
}
|
||||
|
||||
func readUserIDsFromCSV(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
idCol := -1
|
||||
startRow := 0
|
||||
for i, cell := range records[0] {
|
||||
switch strings.ToLower(strings.TrimSpace(cell)) {
|
||||
case "user_id", "id", "user", "uid":
|
||||
idCol = i
|
||||
startRow = 1
|
||||
}
|
||||
}
|
||||
if idCol == -1 {
|
||||
idCol = 0
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(records)-startRow)
|
||||
for _, record := range records[startRow:] {
|
||||
if idCol >= len(record) {
|
||||
continue
|
||||
}
|
||||
values = append(values, record[idCol])
|
||||
}
|
||||
return normalizeUserIDs(values)
|
||||
}
|
||||
|
||||
func normalizeUserIDs(values []string) ([]string, error) {
|
||||
ids := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
id := strings.TrimSpace(value)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
|
||||
return nil, fmt.Errorf("无效的用户 ID %q: 必须是整数", id)
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func mergeUserIDLists(values ...[]string) []string {
|
||||
merged := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, ids := range values {
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
merged = append(merged, id)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func parseMemberBool(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
batchAddShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List project members",
|
||||
|
|
|
|||
|
|
@ -22,6 +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/rules"
|
||||
)
|
||||
|
||||
// RegisterAll mounts all shortcut groups onto the root command.
|
||||
|
|
@ -45,6 +47,7 @@ func RegisterAll(root *cobra.Command) {
|
|||
"watch": watch.Shortcuts(),
|
||||
"star": star.Shortcuts(),
|
||||
"wiki": wiki.Shortcuts(),
|
||||
"workflow": workflow.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
|
|
@ -66,6 +69,7 @@ func RegisterAll(root *cobra.Command) {
|
|||
"watch": "Watch repository operations",
|
||||
"star": "Star repository operations",
|
||||
"wiki": "Wiki page operations",
|
||||
"workflow": "Automated workflow operations",
|
||||
}
|
||||
|
||||
for name, shortcuts := range groups {
|
||||
|
|
|
|||
|
|
@ -25,20 +25,22 @@ type batchRepoSummary struct {
|
|||
Results []batchRepoResult `json:"results" yaml:"results"`
|
||||
}
|
||||
|
||||
func newBatchDeleteShortcut() *common.Shortcut {
|
||||
func newBatchCreateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-delete",
|
||||
Description: "批量删除仓库,支持逗号分隔列表或 CSV 文件",
|
||||
Name: "batch-create",
|
||||
Description: "批量创建仓库,支持逗号分隔列表或 CSV 文件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo,例如: alice/proj1,bob/proj2"},
|
||||
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo、owner,repo 双列或 owner/repo 单列格式"},
|
||||
{Name: "dry-run", Usage: "仅预览将要删除的仓库,不实际执行", Bool: true, Default: "false"},
|
||||
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库名称,例如: repo1,repo2,repo3"},
|
||||
{Name: "from", Usage: "从 CSV 文件读取仓库名称。支持 name/repo/repository 列名或无表头首列"},
|
||||
{Name: "description", Short: "d", Usage: "仓库描述(所有仓库共用一个描述)"},
|
||||
{Name: "private", Usage: "设为私有仓库 (true/false)", Default: "false"},
|
||||
{Name: "dry-run", Usage: "仅预览将要创建的仓库,不实际执行", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchDelete,
|
||||
Run: runBatchCreate,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchDelete(ctx *common.RuntimeContext) error {
|
||||
func runBatchCreate(ctx *common.RuntimeContext) error {
|
||||
start := time.Now()
|
||||
|
||||
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
|
||||
|
|
@ -46,24 +48,44 @@ func runBatchDelete(ctx *common.RuntimeContext) error {
|
|||
return err
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv")
|
||||
return fmt.Errorf("未提供仓库名称,请使用 --repos repo1,repo2 或 --from repos.csv")
|
||||
}
|
||||
|
||||
// 仅取仓库名,不需要 owner/repo 格式
|
||||
names := make([]string, len(repos))
|
||||
for i, r := range repos {
|
||||
parts := strings.SplitN(r, "/", 2)
|
||||
names[i] = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
dryRunVal := false
|
||||
if ctx.Arg("dry-run") != "" {
|
||||
dryRunVal = dryRun
|
||||
}
|
||||
_ = dryRunVal
|
||||
|
||||
summary := batchRepoSummary{
|
||||
Total: len(repos),
|
||||
Results: make([]batchRepoResult, 0, len(repos)),
|
||||
Total: len(names),
|
||||
Results: make([]batchRepoResult, 0, len(names)),
|
||||
}
|
||||
|
||||
for _, repoID := range repos {
|
||||
parts := strings.SplitN(repoID, "/", 2)
|
||||
result := batchRepoResult{Repo: repoID, Action: "delete"}
|
||||
var userLogin string
|
||||
var userID int
|
||||
if !dryRun {
|
||||
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取当前用户信息失败: %w", err)
|
||||
}
|
||||
userData, _ := userEnv.Data.(map[string]interface{})
|
||||
login, _ := userData["login"].(string)
|
||||
if login == "" {
|
||||
return fmt.Errorf("无法获取当前用户名")
|
||||
}
|
||||
userLogin = login
|
||||
uid, _ := userData["user_id"].(float64)
|
||||
userID = int(uid)
|
||||
}
|
||||
|
||||
private := ctx.Arg("private") == "true"
|
||||
desc := ctx.Arg("description")
|
||||
|
||||
for _, name := range names {
|
||||
result := batchRepoResult{Repo: name, Action: "create"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
|
|
@ -71,13 +93,25 @@ func runBatchDelete(ctx *common.RuntimeContext) error {
|
|||
continue
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/%s/%s", parts[0], parts[1])
|
||||
if _, err := ctx.CallAPI("DELETE", path, nil); err != nil {
|
||||
body := map[string]interface{}{
|
||||
"name": name,
|
||||
"repository_name": name,
|
||||
"user_id": userID,
|
||||
}
|
||||
if desc != "" {
|
||||
body["description"] = desc
|
||||
}
|
||||
if private {
|
||||
body["private"] = true
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/%s/%s", userLogin, name)
|
||||
if _, err := ctx.CallAPI("POST", path, body); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "deleted"
|
||||
result.Status = "created"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
|
|
@ -89,7 +123,7 @@ func runBatchDelete(ctx *common.RuntimeContext) error {
|
|||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个仓库删除失败", summary.Failed, summary.Total)
|
||||
return fmt.Errorf("%d / %d 个仓库创建失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -100,7 +134,7 @@ func newBatchForkShortcut() *common.Shortcut {
|
|||
Description: "批量 Fork 仓库,支持逗号分隔列表或 CSV 文件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo,例如: alice/proj1,bob/proj2"},
|
||||
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo、owner,repo 双列或 owner/repo 单列格式"},
|
||||
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo 单列或 owner、repo 双列格式"},
|
||||
{Name: "dry-run", Usage: "仅预览将要 Fork 的仓库,不实际执行", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchFork,
|
||||
|
|
@ -119,7 +153,6 @@ func runBatchFork(ctx *common.RuntimeContext) error {
|
|||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
|
||||
summary := batchRepoSummary{
|
||||
Total: len(repos),
|
||||
Results: make([]batchRepoResult, 0, len(repos)),
|
||||
|
|
@ -158,6 +191,71 @@ func runBatchFork(ctx *common.RuntimeContext) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func newBatchDeleteShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-delete",
|
||||
Description: "批量删除仓库,支持逗号分隔列表或 CSV 文件",
|
||||
Flags: []common.Flag{
|
||||
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo,例如: alice/proj1,bob/proj2"},
|
||||
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo 单列或 owner、repo 双列格式"},
|
||||
{Name: "dry-run", Usage: "仅预览将要删除的仓库,不实际执行", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchDelete,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchDelete(ctx *common.RuntimeContext) error {
|
||||
start := time.Now()
|
||||
|
||||
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv")
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
summary := batchRepoSummary{
|
||||
Total: len(repos),
|
||||
Results: make([]batchRepoResult, 0, len(repos)),
|
||||
}
|
||||
|
||||
for _, repoID := range repos {
|
||||
parts := strings.SplitN(repoID, "/", 2)
|
||||
result := batchRepoResult{Repo: repoID, Action: "delete"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
summary.Results = append(summary.Results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/%s/%s", parts[0], parts[1])
|
||||
if _, err := ctx.CallAPI("DELETE", path, nil); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
summary.Failed++
|
||||
} else {
|
||||
result.Status = "deleted"
|
||||
summary.Succeeded++
|
||||
}
|
||||
summary.Results = append(summary.Results, result)
|
||||
}
|
||||
|
||||
summary.Duration = time.Since(start).String()
|
||||
|
||||
if err := ctx.OutputData(summary); err != nil {
|
||||
return err
|
||||
}
|
||||
if summary.Failed > 0 {
|
||||
return fmt.Errorf("%d / %d 个仓库删除失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- CSV / list helpers ---
|
||||
|
||||
func collectRepos(reposValue, csvPath string) ([]string, error) {
|
||||
repos, err := parseRepoList(reposValue)
|
||||
if err != nil {
|
||||
|
|
@ -171,7 +269,7 @@ func collectRepos(reposValue, csvPath string) ([]string, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mergeRepoLists(repos, csvRepos), nil
|
||||
return mergeRepoStrings(repos, csvRepos), nil
|
||||
}
|
||||
|
||||
func parseRepoList(value string) ([]string, error) {
|
||||
|
|
@ -198,7 +296,6 @@ func readReposFromCSV(path string) ([]string, error) {
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
// Detect column layout: single owner/repo column, or owner+repo dual columns
|
||||
singleCol, ownerCol, repoCol := -1, -1, -1
|
||||
startRow := 0
|
||||
for i, cell := range records[0] {
|
||||
|
|
@ -210,14 +307,20 @@ func readReposFromCSV(path string) ([]string, error) {
|
|||
ownerCol = i
|
||||
startRow = 1
|
||||
case "repo", "repository", "name":
|
||||
repoCol = i
|
||||
if repoCol == -1 {
|
||||
repoCol = i
|
||||
}
|
||||
startRow = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no header — first column is owner/repo
|
||||
if singleCol == -1 && ownerCol == -1 && repoCol == -1 {
|
||||
singleCol = 0
|
||||
if ownerCol == -1 || repoCol == -1 {
|
||||
// Not dual-column: use single-column mode
|
||||
if singleCol == -1 {
|
||||
singleCol = 0
|
||||
}
|
||||
ownerCol = -1
|
||||
repoCol = -1
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(records)-startRow)
|
||||
|
|
@ -243,10 +346,6 @@ func normalizeRepoIDs(values []string) ([]string, error) {
|
|||
if repoID == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(repoID, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, fmt.Errorf("无效的仓库标识 %q: 请使用 owner/repo 格式", repoID)
|
||||
}
|
||||
if seen[repoID] {
|
||||
continue
|
||||
}
|
||||
|
|
@ -256,7 +355,7 @@ func normalizeRepoIDs(values []string) ([]string, error) {
|
|||
return repos, nil
|
||||
}
|
||||
|
||||
func mergeRepoLists(values ...[]string) []string {
|
||||
func mergeRepoStrings(values ...[]string) []string {
|
||||
merged := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, repos := range values {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchDeleteShortcut(),
|
||||
newBatchCreateShortcut(),
|
||||
newBatchForkShortcut(),
|
||||
newBatchDeleteShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repositories for a user or organization",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
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 != ""
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
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"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// StartDaemon launches a workflow as a background daemon process.
|
||||
func StartDaemon(ctx *common.RuntimeContext, 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")
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
// Redirect output to log file instead of discarding.
|
||||
logPath := daemonLogPath(wf.Name)
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create log file: %w", err)
|
||||
}
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.Stdin = nil
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("start daemon: %w", err)
|
||||
}
|
||||
// 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 {
|
||||
return fmt.Errorf("save pid: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Daemon started for %q (PID: %d)\n", wf.Name, pid)
|
||||
fmt.Printf("Log: %s\n", logPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopDaemon stops a running workflow daemon by name.
|
||||
func StopDaemon(name string) error {
|
||||
pid, err := readPID(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
cleanPID(name)
|
||||
return fmt.Errorf("daemon %q not running (PID %d not found)", name, pid)
|
||||
}
|
||||
|
||||
if err := proc.Signal(os.Interrupt); err != nil {
|
||||
// Process might already be dead; clean up pid file anyway.
|
||||
cleanPID(name)
|
||||
return fmt.Errorf("failed to stop daemon %q: %w", name, err)
|
||||
}
|
||||
|
||||
cleanPID(name)
|
||||
fmt.Printf("Daemon %q stopped (PID %d)\n", name, pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StatusDaemon prints the current daemon status for a workflow.
|
||||
func StatusDaemon(name string) error {
|
||||
state, err := LoadState(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load state: %w", err)
|
||||
}
|
||||
|
||||
pid, pidErr := readPID(name)
|
||||
running := pidErr == nil && processRunning(pid)
|
||||
|
||||
fmt.Printf("工作流: %s\n", name)
|
||||
if running {
|
||||
fmt.Printf("状态: 运行中 (PID: %d)\n", pid)
|
||||
} else {
|
||||
fmt.Println("状态: 已停止")
|
||||
}
|
||||
if state.LastRun != "" {
|
||||
t, err := time.Parse(time.RFC3339, state.LastRun)
|
||||
if err == nil {
|
||||
fmt.Printf("上次运行: %s\n", t.Format("2006-01-02 15:04"))
|
||||
} else {
|
||||
fmt.Printf("上次运行: %s\n", state.LastRun)
|
||||
}
|
||||
}
|
||||
fmt.Printf("累计运行: %d 次\n", state.TotalRuns)
|
||||
fmt.Printf("快照步骤: %d 个\n", len(state.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 {
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
|
||||
// Run immediately on start (dry-run to establish baseline).
|
||||
doDaemonCycle(ctx, wf)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-tick.C:
|
||||
doDaemonCycle(ctx, wf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func doDaemonCycle(ctx *common.RuntimeContext, wf *WorkflowDef) {
|
||||
state, _ := LoadState(wf.Name)
|
||||
|
||||
// Phase 1: cheap dry-run — collect data without AI.
|
||||
dryResult, err := Run(ctx, wf, 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
|
||||
}
|
||||
|
||||
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)
|
||||
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()
|
||||
|
||||
ok, total := 0, len(fullResult.Steps)
|
||||
for _, sr := range fullResult.Steps {
|
||||
if sr.OK {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[%s] ✅ %d/%d steps OK\n", time.Now().Format(time.RFC3339), ok, total)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
path := daemonLogPath(name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取日志文件 %s: %w (daemon 可能尚未启动)", path, err)
|
||||
}
|
||||
fmt.Print(string(data))
|
||||
|
||||
if !follow {
|
||||
return nil
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
offset := int64(len(data))
|
||||
for {
|
||||
select {
|
||||
case <-sig:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fi.Size() > offset {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
f.Seek(offset, 0)
|
||||
buf := make([]byte, fi.Size()-offset)
|
||||
n, _ := f.Read(buf)
|
||||
if n > 0 {
|
||||
fmt.Print(string(buf[:n]))
|
||||
}
|
||||
offset = fi.Size()
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installSystemdUnit generates a systemd service unit file for a workflow daemon.
|
||||
func installSystemdUnit(ctx *common.RuntimeContext, wf *WorkflowDef, interval, aiMode string) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("resolve repo: %w", err)
|
||||
}
|
||||
|
||||
bin, _ := os.Executable()
|
||||
extraArgs := ""
|
||||
if aiMode == "ai" {
|
||||
extraArgs = " --ai"
|
||||
} else if aiMode == "no-ai" {
|
||||
extraArgs = " --no-ai"
|
||||
}
|
||||
|
||||
unit := fmt.Sprintf(`[Unit]
|
||||
Description=GitLink CLI Workflow: %s (%s/%s)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%s workflow +run --name %s --owner %s --repo %s --format json --daemon-loop --interval %s%s
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
StandardOutput=append:%s
|
||||
StandardError=append:%s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`,
|
||||
wf.Name, ctx.Owner, ctx.Repo,
|
||||
bin, wf.Name, ctx.Owner, ctx.Repo, interval, extraArgs,
|
||||
daemonLogPath(wf.Name), daemonLogPath(wf.Name),
|
||||
)
|
||||
|
||||
unitPath := filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.service", wf.Name))
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
|
||||
return fmt.Errorf("写入 unit 文件: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Systemd unit 已写入: %s\n\n", unitPath)
|
||||
fmt.Println("安装步骤:")
|
||||
fmt.Printf(" sudo cp %s /etc/systemd/system/\n", unitPath)
|
||||
fmt.Println(" sudo systemctl daemon-reload")
|
||||
fmt.Printf(" sudo systemctl enable workflow-%s\n", wf.Name)
|
||||
fmt.Printf(" sudo systemctl start workflow-%s\n", wf.Name)
|
||||
fmt.Println()
|
||||
fmt.Printf("查看日志: journalctl -u workflow-%s -f\n", wf.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func savePID(name string, pid int) error {
|
||||
dir := config.ConfigDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(pidPath(name), []byte(strconv.Itoa(pid)), 0600)
|
||||
}
|
||||
|
||||
func readPID(name string) (int, error) {
|
||||
data, err := os.ReadFile(pidPath(name))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, fmt.Errorf("daemon %q is not running (no PID file)", name)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(string(data))
|
||||
}
|
||||
|
||||
func cleanPID(name string) {
|
||||
os.Remove(pidPath(name))
|
||||
}
|
||||
|
||||
func processRunning(pid int) bool {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return proc.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func pidPath(name string) string {
|
||||
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s.pid", name))
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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"}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// commitGroup holds category → commits mapping for changelog generation.
|
||||
type commitGroup struct {
|
||||
Category string
|
||||
Emoji string
|
||||
Commits []map[string]interface{}
|
||||
}
|
||||
|
||||
var changelogRules = []struct {
|
||||
emoji string
|
||||
name string
|
||||
patterns []*regexp.Regexp
|
||||
}{
|
||||
{"✨", "新功能", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(feat|feature|add|新增|支持)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🐛", "Bug 修复", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(fix|bugfix|hotfix|修复|解决)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🔧", "改进优化", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(refactor|perf|improve|enhance|style|fmt|optimize|优化|增强|完善|调整|格式化)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"📚", "文档", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(docs|doc|文档|README|注释)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🧪", "测试", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(test|tests|测试)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
{"🏗️", "构建/CI", []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(build|ci|chore|构建|部署|Docker)(\(.+\))?!?[::]`),
|
||||
}},
|
||||
}
|
||||
|
||||
var breakingRe = regexp.MustCompile(`!:`)
|
||||
var breakingBodyRe = regexp.MustCompile(`BREAKING[ -]CHANGE`)
|
||||
|
||||
// ChangelogRule classifies commits and generates release notes.
|
||||
func ChangelogRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
commits := extractCommits(upstream)
|
||||
releases := extractList(upstream, "releases")
|
||||
mergedPRs := extractPRs(upstream, "merged-prs")
|
||||
|
||||
groups := []commitGroup{}
|
||||
breaking := []map[string]interface{}{}
|
||||
uncategorized := []map[string]interface{}{}
|
||||
|
||||
for _, c := range commits {
|
||||
msg := str(c, "title", "message", "commit", "subject")
|
||||
body := str(c, "body", "description")
|
||||
if msg == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check breaking change.
|
||||
if breakingRe.MatchString(msg) || breakingBodyRe.MatchString(body) {
|
||||
breaking = append(breaking, c)
|
||||
continue
|
||||
}
|
||||
|
||||
categorized := false
|
||||
for i, rule := range changelogRules {
|
||||
for _, re := range rule.patterns {
|
||||
if re.MatchString(msg) {
|
||||
// Extend existing group or create new.
|
||||
found := false
|
||||
for j, g := range groups {
|
||||
if g.Category == rule.name {
|
||||
groups[j].Commits = append(groups[j].Commits, c)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
groups = append(groups, commitGroup{
|
||||
Category: rule.name,
|
||||
Emoji: rule.emoji,
|
||||
Commits: []map[string]interface{}{c},
|
||||
})
|
||||
}
|
||||
_ = i // suppress unused
|
||||
categorized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if categorized {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !categorized {
|
||||
uncategorized = append(uncategorized, c)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort groups: features first, then bug fixes, then rest.
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
return orderOf(groups[i].Category) < orderOf(groups[j].Category)
|
||||
})
|
||||
|
||||
// Add breaking changes group at top if present.
|
||||
if len(breaking) > 0 {
|
||||
groups = append([]commitGroup{{
|
||||
Category: "破坏性变更",
|
||||
Emoji: "⚠️",
|
||||
Commits: breaking,
|
||||
}}, groups...)
|
||||
}
|
||||
|
||||
// Add uncategorized at end.
|
||||
if len(uncategorized) > 0 {
|
||||
groups = append(groups, commitGroup{
|
||||
Category: "其他",
|
||||
Emoji: "🔀",
|
||||
Commits: uncategorized,
|
||||
})
|
||||
}
|
||||
|
||||
// Build analysis.
|
||||
sections := []map[string]interface{}{}
|
||||
for _, g := range groups {
|
||||
items := []string{}
|
||||
for _, c := range g.Commits {
|
||||
msg := str(c, "title", "message", "commit", "subject")
|
||||
sha := str(c, "sha", "id", "commit_id")
|
||||
if sha != "" && len(sha) > 7 {
|
||||
sha = sha[:7]
|
||||
}
|
||||
items = append(items, fmt.Sprintf("%s %s", sha, msg))
|
||||
}
|
||||
sections = append(sections, map[string]interface{}{
|
||||
"category": g.Emoji + " " + g.Category,
|
||||
"count": len(g.Commits),
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"total_commits": len(commits),
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
// Build release creation action if there are categorized commits.
|
||||
var actions []workflow.AIAction
|
||||
if len(commits) > 0 {
|
||||
// Determine next version tag.
|
||||
latestTag := "v0.0.0"
|
||||
for _, rel := range releases {
|
||||
if t := str(rel, "tag_name", "tag", "name"); t != "" {
|
||||
if compareTags(t, latestTag) > 0 {
|
||||
latestTag = t
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check merged PRs for version hints.
|
||||
for _, pr := range mergedPRs {
|
||||
labels := str(pr, "labels")
|
||||
if strings.Contains(labels, "release") || strings.Contains(labels, "version") {
|
||||
// PR merged with release label — bump version.
|
||||
}
|
||||
}
|
||||
|
||||
nextTag := bumpTag(latestTag)
|
||||
if len(breaking) > 0 {
|
||||
nextTag = bumpMajor(latestTag)
|
||||
}
|
||||
|
||||
body := buildChangelogBody(sections, nextTag)
|
||||
|
||||
if nextTag != latestTag {
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "cli",
|
||||
Module: "release",
|
||||
Command: "+create",
|
||||
Args: map[string]string{
|
||||
"tag": nextTag,
|
||||
"name": nextTag,
|
||||
"body": body,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
func orderOf(cat string) int {
|
||||
order := map[string]int{
|
||||
"破坏性变更": 0,
|
||||
"新功能": 1,
|
||||
"Bug 修复": 2,
|
||||
"改进优化": 3,
|
||||
"文档": 4,
|
||||
"测试": 5,
|
||||
"构建/CI": 6,
|
||||
"其他": 7,
|
||||
}
|
||||
if o, ok := order[cat]; ok {
|
||||
return o
|
||||
}
|
||||
return 99
|
||||
}
|
||||
|
||||
func compareTags(a, b string) int {
|
||||
an := normalizeTag(a)
|
||||
bn := normalizeTag(b)
|
||||
if an > bn {
|
||||
return 1
|
||||
} else if an < bn {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeTag(t string) string {
|
||||
t = strings.TrimPrefix(t, "v")
|
||||
parts := strings.Split(t, ".")
|
||||
for len(parts) < 3 {
|
||||
parts = append(parts, "0")
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
func bumpTag(tag string) string {
|
||||
parts := strings.Split(normalizeTag(tag), ".")
|
||||
if len(parts) < 3 {
|
||||
return "v0.1.0"
|
||||
}
|
||||
minor := atoi(parts[1])
|
||||
return fmt.Sprintf("v%s.%d.0", parts[0], minor+1)
|
||||
}
|
||||
|
||||
func bumpMajor(tag string) string {
|
||||
parts := strings.Split(normalizeTag(tag), ".")
|
||||
if len(parts) < 1 {
|
||||
return "v1.0.0"
|
||||
}
|
||||
major := atoi(parts[0])
|
||||
return fmt.Sprintf("v%d.0.0", major+1)
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
var n int
|
||||
fmt.Sscanf(s, "%d", &n)
|
||||
return n
|
||||
}
|
||||
|
||||
func buildChangelogBody(sections []map[string]interface{}, tag string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n\n", tag)
|
||||
for _, sec := range sections {
|
||||
fmt.Fprintf(&b, "## %s (%d)\n\n", sec["category"], sec["count"])
|
||||
if items, ok := sec["items"].([]string); ok {
|
||||
for _, item := range items {
|
||||
fmt.Fprintf(&b, "- %s\n", item)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestChangelogConventionalCommits(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"sha": "abc12345", "title": "feat: add user login"},
|
||||
map[string]interface{}{"sha": "def12345", "title": "fix: resolve null pointer"},
|
||||
map[string]interface{}{"sha": "ghi12345", "title": "docs: update README"},
|
||||
},
|
||||
},
|
||||
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := ChangelogRule(upstream, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
sections := analysis["sections"].([]map[string]interface{})
|
||||
if len(sections) < 3 {
|
||||
t.Fatalf("expected at least 3 sections (features, bugs, docs), got %d", len(sections))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogBreakingChange(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"sha": "abc12345", "title": "feat!: drop support for v1"},
|
||||
},
|
||||
},
|
||||
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := ChangelogRule(upstream, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
sections := analysis["sections"].([]map[string]interface{})
|
||||
if len(sections) == 0 {
|
||||
t.Fatal("expected breaking changes section")
|
||||
}
|
||||
first := sections[0]
|
||||
if cat := first["category"]; cat == nil {
|
||||
t.Fatal("first section missing category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogChineseKeywords(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"sha": "aaa11111", "title": "新增:用户管理模块"},
|
||||
map[string]interface{}{"sha": "bbb11111", "title": "修复:登录页面报错"},
|
||||
},
|
||||
},
|
||||
"releases": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := ChangelogRule(upstream, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
// Should have at least 2 sections.
|
||||
sections := analysis["sections"].([]map[string]interface{})
|
||||
if len(sections) < 2 {
|
||||
t.Fatalf("expected at least 2 sections, got %d", len(sections))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogNoCommits(t *testing.T) {
|
||||
resp, err := ChangelogRule(map[string]interface{}{}, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["total_commits"]; v.(int) != 0 {
|
||||
t.Fatalf("expected 0 total_commits, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogOutputFormat(t *testing.T) {
|
||||
resp, err := ChangelogRule(map[string]interface{}{}, "changelog")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangelogRule failed: %v", err)
|
||||
}
|
||||
var _ *workflow.AIResponse = resp
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// CIDiagnosisRule matches CI build error logs against known patterns.
|
||||
func CIDiagnosisRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
builds := extractList(upstream, "ci-builds")
|
||||
commits := extractCommits(upstream)
|
||||
|
||||
type diagnosis struct {
|
||||
BuildNumber interface{} `json:"build_number"`
|
||||
Status string `json:"status"`
|
||||
Pattern string `json:"matched_pattern"`
|
||||
Diagnosis string `json:"diagnosis"`
|
||||
Suggestion string `json:"suggestion"`
|
||||
RelatedSHA string `json:"related_commit"`
|
||||
}
|
||||
var diagnoses []diagnosis
|
||||
var actions []workflow.AIAction
|
||||
|
||||
for _, build := range builds {
|
||||
status := str(build, "status", "state", "result")
|
||||
if status != "failed" && status != "failure" && status != "error" && status != "3" {
|
||||
// Check nested: some APIs use "build" wrapper.
|
||||
if inner, ok := build["build"].(map[string]interface{}); ok {
|
||||
build = inner
|
||||
status = str(build, "status", "state", "result")
|
||||
if status != "failed" && status != "failure" && status != "error" && status != "3" {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
log := str(build, "log", "logs", "output", "build_log")
|
||||
if log == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
d := diagnoseLog(log)
|
||||
buildNum := build["build_number"]
|
||||
if buildNum == nil {
|
||||
buildNum = build["id"]
|
||||
}
|
||||
|
||||
// Find related commit.
|
||||
relatedSHA := ""
|
||||
for _, c := range commits {
|
||||
cSha := str(c, "sha", "id", "commit_id")
|
||||
if cSha != "" && containsAny(str(c, "title", "message", "commit"), d.Pattern) {
|
||||
relatedSHA = cSha
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
diagnoses = append(diagnoses, diagnosis{
|
||||
BuildNumber: buildNum,
|
||||
Status: status,
|
||||
Pattern: d.Pattern,
|
||||
Diagnosis: d.Diagnosis,
|
||||
Suggestion: d.Suggestion,
|
||||
RelatedSHA: relatedSHA,
|
||||
})
|
||||
|
||||
// Auto-retry for transient failures.
|
||||
if d.Transient {
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "api",
|
||||
Method: "POST",
|
||||
Path: fmt.Sprintf("{v1}/builds/%v/retry", buildNum),
|
||||
Body: map[string]interface{}{},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(diagnoses) == 0 {
|
||||
return &workflow.AIResponse{
|
||||
Analysis: map[string]interface{}{"diagnoses": nil, "message": "no failed builds found"},
|
||||
Actions: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"diagnoses": diagnoses,
|
||||
"total_failures": len(diagnoses),
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
type logPattern struct {
|
||||
Re *regexp.Regexp
|
||||
Pattern string
|
||||
Diagnosis string
|
||||
Suggestion string
|
||||
Transient bool
|
||||
}
|
||||
|
||||
var ciPatterns = []logPattern{
|
||||
{regexp.MustCompile(`cannot find package|package .* is not in`), "cannot find package",
|
||||
"依赖缺失",
|
||||
"检查 go.mod/package.json 确认依赖已声明",
|
||||
false},
|
||||
{regexp.MustCompile(`syntax error|unexpected token|unexpected EOF`), "syntax error",
|
||||
"语法错误",
|
||||
"检查最近提交中的语法问题",
|
||||
false},
|
||||
{regexp.MustCompile(`permission denied|access denied|forbidden|401|403`), "permission denied",
|
||||
"权限不足",
|
||||
"检查密钥配置和访问权限",
|
||||
false},
|
||||
{regexp.MustCompile(`connection refused|connection reset|no route to host|dial tcp`), "connection refused",
|
||||
"服务不可达",
|
||||
"检查外部服务状态和网络连接",
|
||||
true},
|
||||
{regexp.MustCompile(`out of memory|OOM|killed|signal: killed`), "out of memory",
|
||||
"资源不足(内存溢出)",
|
||||
"优化内存使用或增加构建资源",
|
||||
false},
|
||||
{regexp.MustCompile(`No such file|file not found|not found`), "No such file",
|
||||
"文件缺失",
|
||||
"检查 .devops/ 路径和依赖文件配置",
|
||||
false},
|
||||
{regexp.MustCompile(`docker:.*not found|docker.*command not found`), "docker not found",
|
||||
"Docker 环境缺失",
|
||||
"构建环境未配置 Docker,检查 CI 配置",
|
||||
false},
|
||||
{regexp.MustCompile(`FAIL|exit status [1-9]|Test.*failed`), "exit status 1",
|
||||
"测试失败",
|
||||
"查看测试输出定位失败用例",
|
||||
false},
|
||||
{regexp.MustCompile(`timeout|timed out|deadline exceeded`), "timeout",
|
||||
"构建超时",
|
||||
"优化构建脚本或增加超时时间",
|
||||
true},
|
||||
{regexp.MustCompile(`undefined:|undefined symbol|cannot use|type mismatch`), "undefined:",
|
||||
"编译错误(未定义符号)",
|
||||
"检查导入和类型定义",
|
||||
false},
|
||||
}
|
||||
|
||||
func diagnoseLog(log string) logPattern {
|
||||
for _, p := range ciPatterns {
|
||||
if p.Re.MatchString(log) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return logPattern{
|
||||
Pattern: "unknown",
|
||||
Diagnosis: "未知错误",
|
||||
Suggestion: "请人工查看 CI 日志进行诊断",
|
||||
Transient: false,
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(s string, patterns ...string) bool {
|
||||
for _, p := range patterns {
|
||||
if p != "" && len(s) > 0 && len(p) > 0 {
|
||||
// Simple substring check.
|
||||
if len(s) >= len(p) {
|
||||
for i := 0; i <= len(s)-len(p); i++ {
|
||||
if s[i:i+len(p)] == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCIDiagnosisPatterns(t *testing.T) {
|
||||
tests := []struct {
|
||||
log string
|
||||
pattern string
|
||||
transient bool
|
||||
}{
|
||||
{"cannot find package github.com/foo/bar", "cannot find package", false},
|
||||
{"syntax error: unexpected token at line 42", "syntax error", false},
|
||||
{"permission denied: unable to access /tmp/build", "permission denied", false},
|
||||
{"connection refused: dial tcp 10.0.0.1:8080", "connection refused", true},
|
||||
{"out of memory: process killed", "out of memory", false},
|
||||
{"No such file or directory: .devops/build.yml", "No such file", false},
|
||||
{"FAIL: TestLogin (0.23s)", "exit status 1", false},
|
||||
{"timeout: deadline exceeded after 300s", "timeout", true},
|
||||
{"undefined: UserService in main.go:15", "undefined:", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
d := diagnoseLog(tc.log)
|
||||
if d.Pattern != tc.pattern {
|
||||
t.Errorf("log=%q: expected pattern %q, got %q", tc.log, tc.pattern, d.Pattern)
|
||||
}
|
||||
if d.Transient != tc.transient {
|
||||
t.Errorf("log=%q: expected transient=%v, got %v", tc.log, tc.transient, d.Transient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDiagnosisNoFailures(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"ci-builds": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": "1", "status": "success", "log": "build passed"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := CIDiagnosisRule(upstream, "ci-diagnosis")
|
||||
if err != nil {
|
||||
t.Fatalf("CIDiagnosisRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if msg := analysis["message"]; msg != "no failed builds found" {
|
||||
t.Fatalf("expected 'no failed builds found', got %v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDiagnosisWithFailures(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"ci-builds": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": "1", "status": "failed", "log": "connection refused"},
|
||||
},
|
||||
},
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := CIDiagnosisRule(upstream, "ci-diagnosis")
|
||||
if err != nil {
|
||||
t.Fatalf("CIDiagnosisRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["total_failures"]; v.(int) != 1 {
|
||||
t.Fatalf("expected 1 failure, got %v", v)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// contributorEntry holds per-contributor aggregate data.
|
||||
type contributorEntry struct {
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Commits int `json:"commits"`
|
||||
Issues int `json:"issues"`
|
||||
PRs int `json:"prs"`
|
||||
Total int `json:"total"`
|
||||
Trend float64 `json:"trend"`
|
||||
LastActivity string `json:"last_activity"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// ContributorRankingRule produces a contributor ranking report.
|
||||
func ContributorRankingRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
commits := extractCommits(upstream)
|
||||
issues := append(extractIssues(upstream, "open-issues"), extractIssues(upstream, "closed-issues")...)
|
||||
prs := extractPRs(upstream, "merged-prs")
|
||||
members := extractMembers(upstream)
|
||||
|
||||
// Aggregate per login.
|
||||
stats := map[string]*contributorEntry{}
|
||||
for _, c := range commits {
|
||||
login := authorLogin(c)
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
e := ensureEntry(stats, login, members)
|
||||
e.Commits++
|
||||
e.Total++
|
||||
if ts := commitTimestamp(c); ts != "" && ts > e.LastActivity {
|
||||
e.LastActivity = ts
|
||||
}
|
||||
}
|
||||
|
||||
for _, i := range issues {
|
||||
login := authorLogin(i)
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
e := ensureEntry(stats, login, members)
|
||||
e.Issues++
|
||||
e.Total++
|
||||
if ts := issueTimestamp(i); ts != "" && ts > e.LastActivity {
|
||||
e.LastActivity = ts
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range prs {
|
||||
login := authorLogin(p)
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
e := ensureEntry(stats, login, members)
|
||||
e.PRs++
|
||||
e.Total++
|
||||
if ts := prTimestamp(p); ts != "" && ts > e.LastActivity {
|
||||
e.LastActivity = ts
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate trends using 30-day windows.
|
||||
now := time.Now()
|
||||
cutoff30 := now.Add(-30 * 24 * time.Hour)
|
||||
cutoff60 := now.Add(-60 * 24 * time.Hour)
|
||||
|
||||
recent := countInWindow(commits, cutoff30, now)
|
||||
prev := countInWindow(commits, cutoff60, cutoff30)
|
||||
for login := range stats {
|
||||
rc := recent[login]
|
||||
pc := prev[login]
|
||||
if pc > 0 {
|
||||
stats[login].Trend = float64(rc-pc) / float64(pc) * 100
|
||||
} else if rc > 0 {
|
||||
stats[login].Trend = 100
|
||||
}
|
||||
// Tagging.
|
||||
if stats[login].Trend > 50 {
|
||||
stats[login].Tags = append(stats[login].Tags, "new-star")
|
||||
}
|
||||
if stats[login].LastActivity != "" {
|
||||
t, err := time.Parse(time.RFC3339, stats[login].LastActivity)
|
||||
if err == nil && now.Sub(t) > 30*24*time.Hour {
|
||||
stats[login].Tags = append(stats[login].Tags, "churn-risk")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by total desc.
|
||||
entries := make([]contributorEntry, 0, len(stats))
|
||||
for _, e := range stats {
|
||||
entries = append(entries, *e)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Total > entries[j].Total })
|
||||
|
||||
// Build analysis.
|
||||
rankings := make([]map[string]interface{}, len(entries))
|
||||
for i, e := range entries {
|
||||
rankings[i] = map[string]interface{}{
|
||||
"rank": i + 1,
|
||||
"login": e.Login,
|
||||
"name": e.Name,
|
||||
"commits": e.Commits,
|
||||
"issues": e.Issues,
|
||||
"prs": e.PRs,
|
||||
"total": e.Total,
|
||||
"trend": e.Trend,
|
||||
"last_activity": e.LastActivity,
|
||||
"tags": e.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"title": "贡献者排行榜",
|
||||
"rankings": rankings,
|
||||
"churn_risk": filterByTag(rankings, "churn-risk"),
|
||||
"new_stars": filterByTag(rankings, "new-star"),
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
func ensureEntry(stats map[string]*contributorEntry, login string, members map[string]string) *contributorEntry {
|
||||
if e, ok := stats[login]; ok {
|
||||
return e
|
||||
}
|
||||
e := &contributorEntry{Login: login, Name: members[login]}
|
||||
stats[login] = e
|
||||
return e
|
||||
}
|
||||
|
||||
func filterByTag(rankings []map[string]interface{}, tag string) []map[string]interface{} {
|
||||
var out []map[string]interface{}
|
||||
for _, r := range rankings {
|
||||
if tags, ok := r["tags"].([]string); ok {
|
||||
for _, t := range tags {
|
||||
if t == tag {
|
||||
out = append(out, r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countInWindow(commits []map[string]interface{}, start, end time.Time) map[string]int {
|
||||
m := map[string]int{}
|
||||
for _, c := range commits {
|
||||
ts := commitTimestamp(c)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if t.After(start) && t.Before(end) {
|
||||
m[authorLogin(c)]++
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func extractCommits(upstream map[string]interface{}) []map[string]interface{} {
|
||||
return extractList(upstream, "commits")
|
||||
}
|
||||
|
||||
func extractIssues(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||
return extractList(upstream, key)
|
||||
}
|
||||
|
||||
func extractPRs(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||
return extractList(upstream, key)
|
||||
}
|
||||
|
||||
func extractMembers(upstream map[string]interface{}) map[string]string {
|
||||
raw, ok := upstream["members"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
members := map[string]string{}
|
||||
list, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return members
|
||||
}
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
login := str(m, "login", "username", "name")
|
||||
name := str(m, "name", "full_name", "display_name")
|
||||
if login != "" {
|
||||
members[login] = name
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
func extractList(upstream map[string]interface{}, key string) []map[string]interface{} {
|
||||
raw, ok := upstream[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// upstream values may be stored as an envelope: {"ok": true, "data": [...]}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if data, ok := m["data"]; ok {
|
||||
raw = data
|
||||
}
|
||||
}
|
||||
list, _ := raw.([]interface{})
|
||||
var out []map[string]interface{}
|
||||
for _, item := range list {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func authorLogin(m map[string]interface{}) string {
|
||||
return str(m, "author", "login", "username", "committer", "user")
|
||||
}
|
||||
|
||||
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"} {
|
||||
if s := str(m, key); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
// Try nested author.
|
||||
if a, ok := m["author"].(map[string]interface{}); ok {
|
||||
return str(a, "date", "created_at")
|
||||
}
|
||||
if a, ok := m["committer"].(map[string]interface{}); ok {
|
||||
return str(a, "date", "created_at")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func issueTimestamp(m map[string]interface{}) string {
|
||||
return str(m, "created_at", "updated_at", "closed_at")
|
||||
}
|
||||
|
||||
func prTimestamp(m map[string]interface{}) string {
|
||||
return str(m, "created_at", "merged_at", "updated_at")
|
||||
}
|
||||
|
||||
// str returns the first non-empty string value for the given keys.
|
||||
func str(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
v, _ := m[k].(string)
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContributorRanking(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": "2025-06-01T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-05T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-10T00:00:00Z", "author": "dev2"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
map[string]interface{}{"login": "dev2", "name": "Dev Two"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
rankings := analysis["rankings"].([]map[string]interface{})
|
||||
if len(rankings) != 2 {
|
||||
t.Fatalf("expected 2 rankings, got %d", len(rankings))
|
||||
}
|
||||
|
||||
// dev1 should be ranked #1 (2 commits vs 1).
|
||||
first := rankings[0]
|
||||
if first["login"] != "dev1" {
|
||||
t.Errorf("expected dev1 as #1, got %v", first["login"])
|
||||
}
|
||||
if first["rank"] != 1 {
|
||||
t.Errorf("expected rank 1, got %v", first["rank"])
|
||||
}
|
||||
if first["commits"] != 2 {
|
||||
t.Errorf("expected 2 commits, got %v", first["commits"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChurnRiskDetection(t *testing.T) {
|
||||
// 35 days ago — should trigger churn risk.
|
||||
oldDate := "2025-01-01T00:00:00Z"
|
||||
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": oldDate, "author": "dev1"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
rankings := analysis["rankings"].([]map[string]interface{})
|
||||
if len(rankings) > 0 {
|
||||
tags := rankings[0]["tags"].([]string)
|
||||
for _, tag := range tags {
|
||||
if tag == "churn-risk" {
|
||||
return // success
|
||||
}
|
||||
}
|
||||
t.Errorf("expected churn-risk tag for old activity, got tags: %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContributorOutputFormat(t *testing.T) {
|
||||
resp, err := ContributorRankingRule(map[string]interface{}{}, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
if resp.Actions != nil {
|
||||
t.Fatal("expected nil Actions (read-only report)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStarDetection(t *testing.T) {
|
||||
// Use very recent dates so the commits appear in the last 30 days.
|
||||
now := time.Now()
|
||||
d1 := now.Add(-2 * 24 * time.Hour).Format(time.RFC3339)
|
||||
d2 := now.Add(-3 * 24 * time.Hour).Format(time.RFC3339)
|
||||
d3 := now.Add(-4 * 24 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
upstream := map[string]interface{}{
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": d1, "author": "dev1"},
|
||||
map[string]interface{}{"created_at": d2, "author": "dev1"},
|
||||
map[string]interface{}{"created_at": d3, "author": "dev1"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"closed-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"merged-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ContributorRankingRule(upstream, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("ContributorRankingRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
// Should have new-star or at least ranking.
|
||||
rankings := analysis["rankings"].([]map[string]interface{})
|
||||
if len(rankings) == 0 {
|
||||
t.Fatal("expected at least 1 ranking entry")
|
||||
}
|
||||
tags, _ := rankings[0]["tags"].([]string)
|
||||
t.Logf("tags for dev1: %v", tags)
|
||||
// With only recent commits (no previous period), trend should be 100%, triggering new-star.
|
||||
found := false
|
||||
for _, tag := range tags {
|
||||
if tag == "new-star" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected new-star tag, got: %v", tags)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package rules
|
||||
|
||||
import "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
|
||||
// HealthDispatchRule routes "gitlink-health" skill calls to the correct engine
|
||||
// based on step name and upstream data shape.
|
||||
func HealthDispatchRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
// contributor-ranking: from contributor_growth workflow.
|
||||
if stepName == "contributor-ranking" {
|
||||
return ContributorRankingRule(upstream, stepName)
|
||||
}
|
||||
|
||||
// repo-health: from multi_repo workflow.
|
||||
if stepName == "repo-health" {
|
||||
return HealthReportRule(upstream, stepName)
|
||||
}
|
||||
|
||||
// health-report: from community_ops workflow.
|
||||
if stepName == "health-report" {
|
||||
return HealthReportRule(upstream, stepName)
|
||||
}
|
||||
|
||||
// Default: inspect upstream shape to decide.
|
||||
// If upstream has "members" and "merged-prs" but no "repo-info", it's contributor ranking.
|
||||
_, hasRepoInfo := upstream["repo-info"]
|
||||
_, hasMembers := upstream["members"]
|
||||
if hasMembers && !hasRepoInfo {
|
||||
return ContributorRankingRule(upstream, stepName)
|
||||
}
|
||||
return HealthReportRule(upstream, stepName)
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// HealthReportRule computes a 4-dimension weighted health score.
|
||||
func HealthReportRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
repoInfo := extractFirst(upstream, "repo-info")
|
||||
mergedPRs := extractPRs(upstream, "merged-prs")
|
||||
commits := extractCommits(upstream)
|
||||
openIssues := extractIssues(upstream, "open-issues")
|
||||
|
||||
// Compute raw metrics.
|
||||
totalIssues := len(openIssues) // approximation
|
||||
totalPRs := len(mergedPRs)
|
||||
now := time.Now()
|
||||
recentCommits := countRecent(commits, now, 30)
|
||||
releaseCount := 0
|
||||
if repoInfo != nil {
|
||||
if v, ok := repoInfo["release_count"].(float64); ok {
|
||||
releaseCount = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Dimension 1: Issue Health (30%)
|
||||
issueScore := scoreIssueHealth(totalIssues, openIssues, now)
|
||||
|
||||
// Dimension 2: PR Health (30%)
|
||||
prScore := scorePRHealth(totalPRs, mergedPRs, now)
|
||||
|
||||
// Dimension 3: Contributor Health (20%)
|
||||
contributorScore := scoreContributorHealth(commits, now)
|
||||
|
||||
// Dimension 4: Activity (20%)
|
||||
activityScore := scoreActivity(recentCommits, releaseCount, repoInfo)
|
||||
|
||||
// Composite score.
|
||||
composite := issueScore*0.30 + prScore*0.30 + contributorScore*0.20 + activityScore*0.20
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"title": "项目健康度报告",
|
||||
"composite": math.Round(composite*10) / 10,
|
||||
"grade": grade(composite),
|
||||
"dimensions": map[string]interface{}{
|
||||
"issue_health": map[string]interface{}{
|
||||
"score": math.Round(issueScore*10) / 10,
|
||||
"weight": 0.30,
|
||||
"grade": grade(issueScore),
|
||||
},
|
||||
"pr_health": map[string]interface{}{
|
||||
"score": math.Round(prScore*10) / 10,
|
||||
"weight": 0.30,
|
||||
"grade": grade(prScore),
|
||||
},
|
||||
"contributor_health": map[string]interface{}{
|
||||
"score": math.Round(contributorScore*10) / 10,
|
||||
"weight": 0.20,
|
||||
"grade": grade(contributorScore),
|
||||
},
|
||||
"activity": map[string]interface{}{
|
||||
"score": math.Round(activityScore*10) / 10,
|
||||
"weight": 0.20,
|
||||
"grade": grade(activityScore),
|
||||
"recent_commits": recentCommits,
|
||||
"releases": releaseCount,
|
||||
},
|
||||
},
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
func scoreIssueHealth(total int, openIssues []map[string]interface{}, now time.Time) float64 {
|
||||
if total == 0 {
|
||||
return 80 // neutral
|
||||
}
|
||||
// Stale issues: open for >30 days.
|
||||
stale := 0
|
||||
for _, iss := range openIssues {
|
||||
ts := issueTimestamp(iss)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(t) > 30*24*time.Hour {
|
||||
stale++
|
||||
}
|
||||
}
|
||||
ratio := float64(stale) / float64(max(total, 1))
|
||||
score := (1 - ratio) * 100
|
||||
return clamp(score)
|
||||
}
|
||||
|
||||
func scorePRHealth(total int, mergedPRs []map[string]interface{}, now time.Time) float64 {
|
||||
if total == 0 {
|
||||
return 80 // neutral
|
||||
}
|
||||
// Avg merge time from creation.
|
||||
var totalHours float64
|
||||
count := 0
|
||||
for _, pr := range mergedPRs {
|
||||
created := prTimestamp(pr)
|
||||
merged := str(pr, "merged_at")
|
||||
if created == "" || merged == "" {
|
||||
continue
|
||||
}
|
||||
ct, err1 := time.Parse(time.RFC3339, created)
|
||||
mt, err2 := time.Parse(time.RFC3339, merged)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
totalHours += mt.Sub(ct).Hours()
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return 80
|
||||
}
|
||||
avgDays := totalHours / float64(count) / 24
|
||||
// <3 days = excellent (100), 3-7 = good (80), >7 = needs improvement (50).
|
||||
if avgDays < 3 {
|
||||
return 100
|
||||
} else if avgDays < 7 {
|
||||
return 80
|
||||
}
|
||||
return 50
|
||||
}
|
||||
|
||||
func scoreContributorHealth(commits []map[string]interface{}, now time.Time) float64 {
|
||||
// Unique authors in last 30 days.
|
||||
authors := map[string]bool{}
|
||||
for _, c := range commits {
|
||||
ts := commitTimestamp(c)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(t) <= 30*24*time.Hour {
|
||||
authors[authorLogin(c)] = true
|
||||
}
|
||||
}
|
||||
n := len(authors)
|
||||
// >3 active = 100, 1-3 = 60, 0 = 30.
|
||||
if n > 3 {
|
||||
return 100
|
||||
} else if n >= 1 {
|
||||
return 60
|
||||
}
|
||||
return 30
|
||||
}
|
||||
|
||||
func scoreActivity(recentCommits int, releaseCount int, repoInfo map[string]interface{}) float64 {
|
||||
score := 0.0
|
||||
if recentCommits >= 10 {
|
||||
score += 50
|
||||
} else if recentCommits > 0 {
|
||||
score += float64(recentCommits) / 10 * 50
|
||||
}
|
||||
if releaseCount >= 3 {
|
||||
score += 50
|
||||
} else if releaseCount > 0 {
|
||||
score += float64(releaseCount) / 3 * 50
|
||||
}
|
||||
if score == 0 {
|
||||
score = 30 // bare minimum if repo exists
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func countRecent(commits []map[string]interface{}, now time.Time, days int) int {
|
||||
n := 0
|
||||
for _, c := range commits {
|
||||
ts := commitTimestamp(c)
|
||||
if ts == "" {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(t) <= time.Duration(days)*24*time.Hour {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// extractFirst returns the first map from upstream by key (some upstream data is wrapped).
|
||||
func extractFirst(upstream map[string]interface{}, key string) map[string]interface{} {
|
||||
list := extractList(upstream, key)
|
||||
if len(list) > 0 {
|
||||
return list[0]
|
||||
}
|
||||
// Try direct map.
|
||||
if m, ok := upstream[key].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func grade(score float64) string {
|
||||
if score >= 80 {
|
||||
return "优秀"
|
||||
} else if score >= 60 {
|
||||
return "良好"
|
||||
}
|
||||
return "需改进"
|
||||
}
|
||||
|
||||
func clamp(v float64) float64 {
|
||||
return min(max(v, 0), 100)
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestHealthReportScoring(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"repo-info": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"description": "test repo", "open_issues_count": 5, "release_count": 2},
|
||||
},
|
||||
},
|
||||
"merged-prs": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"merged_at": "2025-01-03T00:00:00Z",
|
||||
"author": "dev1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"commits": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"created_at": "2025-06-01T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-05T00:00:00Z", "author": "dev1"},
|
||||
map[string]interface{}{"created_at": "2025-06-10T00:00:00Z", "author": "dev2"},
|
||||
map[string]interface{}{"created_at": "2025-06-15T00:00:00Z", "author": "dev3"},
|
||||
},
|
||||
},
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := HealthReportRule(upstream, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthReportRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if _, ok := analysis["composite"]; !ok {
|
||||
t.Fatal("missing composite score")
|
||||
}
|
||||
if _, ok := analysis["grade"]; !ok {
|
||||
t.Fatal("missing grade")
|
||||
}
|
||||
if dims, ok := analysis["dimensions"].(map[string]interface{}); !ok {
|
||||
t.Fatal("missing dimensions")
|
||||
} else {
|
||||
for _, dim := range []string{"issue_health", "pr_health", "contributor_health", "activity"} {
|
||||
if _, ok := dims[dim]; !ok {
|
||||
t.Errorf("missing dimension: %s", dim)
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.Actions != nil {
|
||||
t.Fatal("expected nil Actions (read-only report)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthReportNoData(t *testing.T) {
|
||||
resp, err := HealthReportRule(map[string]interface{}{}, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthReportRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
// Should still produce a score (neutral defaults).
|
||||
if v := analysis["composite"]; v == nil {
|
||||
t.Fatal("expected composite score even with no data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthReportOutputFormat(t *testing.T) {
|
||||
resp, err := HealthReportRule(map[string]interface{}{}, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthReportRule failed: %v", err)
|
||||
}
|
||||
var _ *workflow.AIResponse = resp
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// riskEntry describes a single license/security risk finding.
|
||||
type riskEntry struct {
|
||||
File string `json:"file"`
|
||||
Risk string `json:"risk"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// LicenseCheckRule scans file lists and content for license compliance and sensitive data.
|
||||
func LicenseCheckRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
files := extractList(upstream, "existing-files")
|
||||
if len(files) == 0 {
|
||||
files = extractList(upstream, "files")
|
||||
}
|
||||
|
||||
var findings []riskEntry
|
||||
hasLicense := false
|
||||
licenseType := ""
|
||||
|
||||
for _, f := range files {
|
||||
name := str(f, "name", "filename", "path", "file_name")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// License file detection.
|
||||
if isLicenseFile(name) {
|
||||
hasLicense = true
|
||||
content := str(f, "content", "body", "text")
|
||||
if content != "" {
|
||||
licenseType = detectLicenseType(content)
|
||||
}
|
||||
}
|
||||
|
||||
// Sensitive file name detection.
|
||||
for _, fp := range filePatterns {
|
||||
if fp.re.MatchString(strings.ToLower(name)) {
|
||||
findings = append(findings, riskEntry{
|
||||
File: name,
|
||||
Risk: fp.risk,
|
||||
Message: fp.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sensitive content detection.
|
||||
content := str(f, "content", "body", "text")
|
||||
if content != "" {
|
||||
for _, cp := range contentPatterns {
|
||||
if cp.re.MatchString(content) {
|
||||
// Apply exclusion rules.
|
||||
matches := cp.re.FindAllString(content, -1)
|
||||
for _, match := range matches {
|
||||
if isPlaceholder(match) {
|
||||
continue
|
||||
}
|
||||
findings = append(findings, riskEntry{
|
||||
File: name,
|
||||
Risk: "high",
|
||||
Message: cp.message + " → `" + truncate(match, 40) + "`",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute scores.
|
||||
licenseScore := 0.0
|
||||
if hasLicense {
|
||||
licenseScore = 100
|
||||
if licenseType != "" {
|
||||
licenseScore = 100
|
||||
} else {
|
||||
licenseScore = 70
|
||||
}
|
||||
}
|
||||
|
||||
sensitiveScore := 100.0
|
||||
highCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Risk == "high" {
|
||||
highCount++
|
||||
}
|
||||
}
|
||||
if highCount > 0 {
|
||||
sensitiveScore = max(0, 100-float64(highCount)*20)
|
||||
}
|
||||
|
||||
composite := licenseScore*0.35 + sensitiveScore*0.40 + 50*0.15 + 50*0.10
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"has_license": hasLicense,
|
||||
"license_type": licenseType,
|
||||
"license_score": licenseScore,
|
||||
"sensitive_score": sensitiveScore,
|
||||
"composite_score": composite,
|
||||
"grade": grade(composite),
|
||||
"findings": findings,
|
||||
"total_findings": len(findings),
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
// --- license file detection ---
|
||||
|
||||
func isLicenseFile(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
for _, pattern := range []string{"license", "copying", "notice", "licence"} {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func detectLicenseType(content string) string {
|
||||
for _, lp := range licensePatterns {
|
||||
if lp.re.MatchString(content) {
|
||||
return lp.name
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
var licensePatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
name string
|
||||
}{
|
||||
{regexp.MustCompile(`(?i)MIT\s+License|Permission is hereby granted`), "MIT"},
|
||||
{regexp.MustCompile(`(?i)Apache\s+License.*Version\s+2\.0|http://www\.apache\.org/licenses`), "Apache 2.0"},
|
||||
{regexp.MustCompile(`(?i)GNU GENERAL PUBLIC LICENSE.*Version 3|GPL\s*v3`), "GPL v3"},
|
||||
{regexp.MustCompile(`(?i)GNU GENERAL PUBLIC LICENSE.*Version 2|GPL\s*v2`), "GPL v2"},
|
||||
{regexp.MustCompile(`(?i)BSD\s+(3-Clause|2-Clause|License)`), "BSD"},
|
||||
{regexp.MustCompile(`(?i)Mulan\s+Permissive|木兰宽松许可证`), "Mulan PSL v2"},
|
||||
{regexp.MustCompile(`(?i)Mozilla Public License|MPL`), "MPL"},
|
||||
{regexp.MustCompile(`(?i)ISC\s+License`), "ISC"},
|
||||
{regexp.MustCompile(`(?i)Creative Commons|CC-BY`), "Creative Commons"},
|
||||
{regexp.MustCompile(`(?i)Unlicense|public\s+domain`), "Unlicense"},
|
||||
}
|
||||
|
||||
// --- file pattern scanning ---
|
||||
|
||||
type fileRiskPattern struct {
|
||||
re *regexp.Regexp
|
||||
risk string
|
||||
message string
|
||||
}
|
||||
|
||||
var filePatterns = []fileRiskPattern{
|
||||
{regexp.MustCompile(`\.pem$|\.key$|\.p12$|\.pfx$`), "high", "私钥/证书文件,确认是否应纳入版本控制"},
|
||||
{regexp.MustCompile(`id_rsa|id_dsa|id_ecdsa|id_ed25519`), "high", "SSH 私钥文件,不应提交到仓库"},
|
||||
{regexp.MustCompile(`^\.env$|\.env\.`), "high", "环境变量文件,可能包含敏感凭据"},
|
||||
{regexp.MustCompile(`credentials\.|\.secret$|secret\.yml`), "high", "凭据文件,可能包含敏感信息"},
|
||||
{regexp.MustCompile(`serviceAccount\.json|\.service-account\.json`), "high", "服务账号密钥文件"},
|
||||
{regexp.MustCompile(`.*token.*|.*secret.*`), "medium", "文件名包含 token/secret,检查内容"},
|
||||
{regexp.MustCompile(`coverage\.out$`), "low", "覆盖率输出文件,建议添加到 .gitignore"},
|
||||
{regexp.MustCompile(`\.exe$|\.bin$|\.dll$|\.so$`), "low", "二进制文件,检查是否应纳入版本控制"},
|
||||
{regexp.MustCompile(`\.log$|\.tmp$`), "low", "日志/临时文件,建议添加到 .gitignore"},
|
||||
}
|
||||
|
||||
// --- content pattern scanning ---
|
||||
|
||||
type contentRiskPattern struct {
|
||||
re *regexp.Regexp
|
||||
message string
|
||||
}
|
||||
|
||||
var contentPatterns = []contentRiskPattern{
|
||||
{regexp.MustCompile(`(?i)(token|api[_-]?key|apikey|secret|password|passwd|authorization)\s*[:=]\s*['"][^\s'"]{8,}['"]`),
|
||||
"检测到硬编码凭据赋值"},
|
||||
{regexp.MustCompile(`-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----`),
|
||||
"检测到私钥头部"},
|
||||
{regexp.MustCompile(`(?i)GITLINK_TOKEN\s*[:=]\s*['"][^\s'"]+['"]`),
|
||||
"检测到 GitLink Token"},
|
||||
{regexp.MustCompile(`(?i)(mongodb|mysql|postgres|redis|jdbc)://[^\s'"]+@`),
|
||||
"检测到数据库连接字符串"},
|
||||
{regexp.MustCompile(`AKIA[0-9A-Z]{16}`),
|
||||
"检测到 AWS Access Key"},
|
||||
{regexp.MustCompile(`ghp_[a-zA-Z0-9]{36}`),
|
||||
"检测到 GitHub 个人访问令牌"},
|
||||
{regexp.MustCompile(`(?i)eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}`),
|
||||
"检测到 JWT 令牌格式"},
|
||||
{regexp.MustCompile(`(?i)(\d{1,3}\.){3}\d{1,3}`),
|
||||
"检测到硬编码 IP 地址"},
|
||||
{regexp.MustCompile(`(?i)password\s*[:=]\s*['"]['"]`),
|
||||
"检测到空密码"},
|
||||
}
|
||||
|
||||
func isPlaceholder(s string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
for _, p := range []string{"((variable))", "<token>", "your_token_here", "xxx", "replace_me", "<your", "placeholder"} {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Skip if it looks like a format variable: {{.Var}} or ${VAR}.
|
||||
if matched, _ := regexp.MatchString(`\{\{\.?\w+\}\}|\$\{\w+\}`, s); matched {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLicenseFileDetection(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "src/main.go", "content": "package main"},
|
||||
map[string]interface{}{"name": "LICENSE", "content": "MIT License\n\nPermission is hereby granted..."},
|
||||
map[string]interface{}{"name": "README.md", "content": "# Project"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["has_license"]; v != true {
|
||||
t.Fatal("expected has_license=true")
|
||||
}
|
||||
if v := analysis["license_type"]; v != "MIT" {
|
||||
t.Fatalf("expected license_type=MIT, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensitiveContentDetection(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "config.go", "content": `api_key = "sk-1234567890abcdef"`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
findings := analysis["findings"].([]riskEntry)
|
||||
if len(findings) == 0 {
|
||||
t.Fatal("expected findings for hardcoded key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholderExclusion(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "config.go", "content": `api_key = "your_token_here"`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
findings := analysis["findings"].([]riskEntry)
|
||||
// Placeholder should not generate riskEntry findings.
|
||||
// But the api_key pattern may still match — check that it's excluded.
|
||||
for _, f := range findings {
|
||||
if f.File == "config.go" {
|
||||
t.Logf("finding: %+v", f)
|
||||
// Should NOT be a high risk for the api_key pattern.
|
||||
}
|
||||
}
|
||||
// The placeholder exclusion should filter out the match from content patterns.
|
||||
// But filename-based detections may still fire. Let's just check there are no
|
||||
// high risk findings for the placeholder content.
|
||||
for _, f := range findings {
|
||||
if f.Risk == "high" && f.File == "config.go" {
|
||||
t.Errorf("placeholder should be excluded, but got high risk finding: %s", f.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseFileMissing(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "README.md"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := LicenseCheckRule(upstream, "license-check")
|
||||
if err != nil {
|
||||
t.Fatalf("LicenseCheckRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["has_license"]; v != false {
|
||||
t.Fatal("expected has_license=false")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package rules
|
||||
|
||||
import "github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
|
||||
func init() {
|
||||
workflow.RegisterRuleEngine("gitlink-triage", TriageRule)
|
||||
workflow.RegisterRuleEngine("gitlink-health", HealthDispatchRule)
|
||||
workflow.RegisterRuleEngine("gitlink-changelog", ChangelogRule)
|
||||
workflow.RegisterRuleEngine("gitlink-review", CodeReviewRule)
|
||||
workflow.RegisterRuleEngine("gitlink-ci", CIDiagnosisRule)
|
||||
workflow.RegisterRuleEngine("gitlink-license", LicenseCheckRule)
|
||||
workflow.RegisterRuleEngine("gitlink-repo", RepoAuditRule)
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestRegistryAllRegistered(t *testing.T) {
|
||||
expected := []string{
|
||||
"gitlink-triage",
|
||||
"gitlink-health",
|
||||
"gitlink-changelog",
|
||||
"gitlink-review",
|
||||
"gitlink-ci",
|
||||
"gitlink-license",
|
||||
"gitlink-repo",
|
||||
}
|
||||
for _, target := range expected {
|
||||
if _, ok := workflow.RuleEngines[target]; !ok {
|
||||
t.Errorf("rule engine not registered for target %q", target)
|
||||
}
|
||||
}
|
||||
if len(workflow.RuleEngines) != len(expected) {
|
||||
t.Fatalf("expected %d rule engines, got %d", len(expected), len(workflow.RuleEngines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthDispatchRule(t *testing.T) {
|
||||
// contributor-ranking step should delegate to ContributorRankingRule.
|
||||
resp, err := HealthDispatchRule(map[string]interface{}{}, "contributor-ranking")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthDispatchRule failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response")
|
||||
}
|
||||
|
||||
// health-report step should delegate to HealthReportRule.
|
||||
resp, err = HealthDispatchRule(map[string]interface{}{}, "health-report")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthDispatchRule for health-report failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response for health-report")
|
||||
}
|
||||
|
||||
// repo-health step should delegate to HealthReportRule too.
|
||||
resp, err = HealthDispatchRule(map[string]interface{}{}, "repo-health")
|
||||
if err != nil {
|
||||
t.Fatalf("HealthDispatchRule for repo-health failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response for repo-health")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// RepoAuditRule evaluates a repository against a 6-dimension checklist.
|
||||
func RepoAuditRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
repoInfo := extractFirst(upstream, "repo-info")
|
||||
labels := extractLabels(upstream)
|
||||
milestones := extractList(upstream, "milestones")
|
||||
branches := extractList(upstream, "branches")
|
||||
|
||||
type dimScore struct {
|
||||
Name string `json:"name"`
|
||||
Score float64 `json:"score"`
|
||||
Weight float64 `json:"weight"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
var dims []dimScore
|
||||
missing := []string{}
|
||||
|
||||
// Readme check.
|
||||
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 已存在"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "README", Score: 0, Weight: 0.25, Status: "missing", Detail: "缺少 README 文件"})
|
||||
missing = append(missing, "README")
|
||||
}
|
||||
|
||||
// License check.
|
||||
if detectLicenseInFiles(upstream) {
|
||||
dims = append(dims, dimScore{Name: "LICENSE", Score: 100, Weight: 0.20, Status: "ok", Detail: "LICENSE 文件已存在"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "LICENSE", Score: 0, Weight: 0.20, Status: "missing", Detail: "缺少 LICENSE 文件"})
|
||||
missing = append(missing, "LICENSE")
|
||||
}
|
||||
|
||||
// Labels check.
|
||||
if len(labels) > 5 {
|
||||
dims = append(dims, dimScore{Name: "标签库", Score: 100, Weight: 0.15, Status: "ok", Detail: "标签配置完善"})
|
||||
} else if len(labels) > 0 {
|
||||
dims = append(dims, dimScore{Name: "标签库", Score: 50, Weight: 0.15, Status: "partial", Detail: "标签较少,建议补充"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "标签库", Score: 0, Weight: 0.15, Status: "missing", Detail: "未配置标签"})
|
||||
missing = append(missing, "labels")
|
||||
}
|
||||
|
||||
// Milestones check.
|
||||
if len(milestones) > 0 {
|
||||
dims = append(dims, dimScore{Name: "里程碑", Score: 100, Weight: 0.15, Status: "ok", Detail: "已配置里程碑"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "里程碑", Score: 0, Weight: 0.15, Status: "missing", Detail: "未配置里程碑"})
|
||||
missing = append(missing, "milestones")
|
||||
}
|
||||
|
||||
// Branches check.
|
||||
if len(branches) > 2 {
|
||||
dims = append(dims, dimScore{Name: "分支结构", Score: 100, Weight: 0.10, Status: "ok", Detail: "分支结构完善"})
|
||||
} else if len(branches) > 1 {
|
||||
dims = append(dims, dimScore{Name: "分支结构", Score: 70, Weight: 0.10, Status: "ok", Detail: "至少有一个开发分支"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "分支结构", Score: 40, Weight: 0.10, Status: "partial", Detail: "仅主分支,建议创建 develop 分支"})
|
||||
}
|
||||
|
||||
// DevOps check.
|
||||
devops := false
|
||||
if repoInfo != nil {
|
||||
if v, ok := repoInfo["open_devops"].(bool); ok {
|
||||
devops = v
|
||||
}
|
||||
if v, ok := repoInfo["devops_enabled"].(bool); ok {
|
||||
devops = v
|
||||
}
|
||||
}
|
||||
if devops {
|
||||
dims = append(dims, dimScore{Name: "DevOps", Score: 100, Weight: 0.15, Status: "ok", Detail: "DevOps 已开启"})
|
||||
} else {
|
||||
dims = append(dims, dimScore{Name: "DevOps", Score: 0, Weight: 0.15, Status: "missing", Detail: "DevOps 未开启"})
|
||||
missing = append(missing, "DevOps")
|
||||
}
|
||||
|
||||
// Composite.
|
||||
var composite float64
|
||||
for _, d := range dims {
|
||||
composite += d.Score * d.Weight
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"composite_score": composite,
|
||||
"grade": grade(composite),
|
||||
"dimensions": dims,
|
||||
"missing_items": missing,
|
||||
"recommendation": strings.Join(missing, "、") + " 需要补充",
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
analysis["recommendation"] = "项目初始化完善,所有检查项均已通过"
|
||||
}
|
||||
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||||
}
|
||||
|
||||
func detectLicenseInFiles(upstream map[string]interface{}) bool {
|
||||
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
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepoAuditComplete(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"repo-info": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{
|
||||
"description": "A well-maintained project",
|
||||
"has_readme": true,
|
||||
"open_devops": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"existing-files": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "LICENSE", "content": "MIT"},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "bug"},
|
||||
map[string]interface{}{"name": "enhancement"},
|
||||
map[string]interface{}{"name": "question"},
|
||||
map[string]interface{}{"name": "docs"},
|
||||
map[string]interface{}{"name": "security"},
|
||||
map[string]interface{}{"name": "refactor"},
|
||||
},
|
||||
},
|
||||
"milestones": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"title": "v1.0"},
|
||||
},
|
||||
},
|
||||
"branches": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"name": "master"},
|
||||
map[string]interface{}{"name": "develop"},
|
||||
map[string]interface{}{"name": "feature/x"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := RepoAuditRule(upstream, "repo-audit")
|
||||
if err != nil {
|
||||
t.Fatalf("RepoAuditRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
|
||||
// Fully complete repo should have a high score.
|
||||
composite := analysis["composite_score"].(float64)
|
||||
if composite < 80 {
|
||||
t.Errorf("expected composite >= 80 for complete repo, got %.1f", composite)
|
||||
}
|
||||
|
||||
// Should have no missing items.
|
||||
missing := analysis["missing_items"].([]string)
|
||||
if len(missing) != 0 {
|
||||
t.Errorf("expected 0 missing items, got %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoAuditEmpty(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"repo-info": map[string]interface{}{"data": []interface{}{}},
|
||||
"labels": map[string]interface{}{"data": []interface{}{}},
|
||||
"milestones": map[string]interface{}{"data": []interface{}{}},
|
||||
"branches": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := RepoAuditRule(upstream, "repo-audit")
|
||||
if err != nil {
|
||||
t.Fatalf("RepoAuditRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
|
||||
// Empty repo should have a low score.
|
||||
composite := analysis["composite_score"].(float64)
|
||||
if composite >= 50 {
|
||||
t.Errorf("expected composite < 50 for empty repo, got %.1f", composite)
|
||||
}
|
||||
|
||||
// Should have missing items.
|
||||
missing := analysis["missing_items"].([]string)
|
||||
if len(missing) == 0 {
|
||||
t.Fatal("expected missing items for empty repo")
|
||||
}
|
||||
t.Logf("missing items: %v", missing)
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// finding holds a single code review finding.
|
||||
type finding struct {
|
||||
PRNumber interface{} `json:"pr_number"`
|
||||
PRTitle string `json:"pr_title"`
|
||||
Lens string `json:"lens"`
|
||||
Severity string `json:"severity"`
|
||||
What string `json:"what"`
|
||||
Why string `json:"why"`
|
||||
Fix string `json:"fix"`
|
||||
}
|
||||
|
||||
// CodeReviewRule performs static analysis on PR metadata.
|
||||
func CodeReviewRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
prs := extractPRs(upstream, "open-prs")
|
||||
if len(prs) == 0 {
|
||||
return &workflow.AIResponse{
|
||||
Analysis: map[string]interface{}{"findings": nil, "message": "no open PRs to review"},
|
||||
Actions: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var findings []finding
|
||||
var actions []workflow.AIAction
|
||||
|
||||
for _, pr := range prs {
|
||||
title := str(pr, "title", "name")
|
||||
body := str(pr, "body", "description")
|
||||
prNum := interfaceToString(pr["id"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["number"])
|
||||
if prNum == "" {
|
||||
prNum = interfaceToString(pr["pull_request_id"])
|
||||
}
|
||||
}
|
||||
if prNum == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
text := title + " " + body
|
||||
|
||||
// Security scan.
|
||||
for _, p := range reviewSecurityPatterns {
|
||||
if p.re.MatchString(text) {
|
||||
f := finding{
|
||||
PRNumber: prNum,
|
||||
PRTitle: title,
|
||||
Lens: "security",
|
||||
Severity: p.severity,
|
||||
What: p.what,
|
||||
Why: "PR 标题/描述中包含可能存在安全风险的代码模式",
|
||||
Fix: p.fix,
|
||||
}
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainability scan.
|
||||
filesCount := 0
|
||||
if v, ok := pr["files_count"].(float64); ok {
|
||||
filesCount = int(v)
|
||||
}
|
||||
if filesCount > 50 {
|
||||
f := finding{
|
||||
PRNumber: prNum,
|
||||
PRTitle: title,
|
||||
Lens: "maintainability",
|
||||
Severity: "medium",
|
||||
What: fmt.Sprintf("PR 包含 %d 个文件,建议拆分为更小的 PR", filesCount),
|
||||
Why: "大 PR 难以审查,增加合并风险和回滚难度",
|
||||
Fix: "将改动按功能模块拆分为多个小 PR",
|
||||
}
|
||||
findings = append(findings, f)
|
||||
}
|
||||
|
||||
if body == "" && len(title) < 10 {
|
||||
f := finding{
|
||||
PRNumber: prNum,
|
||||
PRTitle: title,
|
||||
Lens: "maintainability",
|
||||
Severity: "low",
|
||||
What: "PR 缺少描述信息",
|
||||
Why: "不清晰的 PR 描述增加审查时间,降低代码质量",
|
||||
Fix: "添加 PR 描述,说明改动原因、影响范围和测试方式",
|
||||
}
|
||||
findings = append(findings, f)
|
||||
}
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"reviewed_prs": len(prs),
|
||||
"total_findings": len(findings),
|
||||
"findings": findings,
|
||||
"summary": fmt.Sprintf("审查了 %d 个 PR,发现 %d 个问题", len(prs), len(findings)),
|
||||
}
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
type reviewPattern struct {
|
||||
re *regexp.Regexp
|
||||
severity string
|
||||
what string
|
||||
fix string
|
||||
}
|
||||
|
||||
var reviewSecurityPatterns = []reviewPattern{
|
||||
{
|
||||
regexp.MustCompile(`(?i)(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*['"][^\s'"]{8,}['"]`),
|
||||
"high",
|
||||
"检测到硬编码凭据(密码/Token/密钥)",
|
||||
"将凭据移至环境变量或密钥管理服务,使用占位符替换",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)SELECT\s.*\sFROM\s.*WHERE\s.*\+`),
|
||||
"high",
|
||||
"检测到潜在 SQL 注入模式(字符串拼接构建 SQL)",
|
||||
"使用参数化查询或 ORM 框架",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)innerHTML\s*=|document\.write\(|eval\(`),
|
||||
"medium",
|
||||
"检测到潜在 XSS 风险(innerHTML / eval 使用)",
|
||||
"使用 textContent 替代 innerHTML,避免使用 eval",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----`),
|
||||
"high",
|
||||
"检测到私钥明文",
|
||||
"立即删除私钥,使用密钥管理服务",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)ghp_[a-zA-Z0-9]{36}`),
|
||||
"high",
|
||||
"检测到 GitHub 个人访问令牌",
|
||||
"撤销此令牌,使用环境变量存储新令牌",
|
||||
},
|
||||
{
|
||||
regexp.MustCompile(`(?i)os\.system\(|exec\(|subprocess\.call\(`),
|
||||
"medium",
|
||||
"检测到潜在命令注入风险",
|
||||
"避免将用户输入直接拼接到系统命令中,使用参数列表形式",
|
||||
},
|
||||
}
|
||||
|
||||
func interfaceToString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", val)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", val)
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCodeReviewStaticAnalysis(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-prs": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "1",
|
||||
"title": "add feature",
|
||||
"body": "password = 'hardcoded12345678'",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "2",
|
||||
"title": "wip",
|
||||
"body": "",
|
||||
"files_count": 60.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := CodeReviewRule(upstream, "review")
|
||||
if err != nil {
|
||||
t.Fatalf("CodeReviewRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
|
||||
total := analysis["total_findings"].(int)
|
||||
if total == 0 {
|
||||
t.Fatal("expected findings for hardcoded password and large PR")
|
||||
}
|
||||
// Should have at least one high-severity security finding.
|
||||
findings := analysis["findings"].([]finding)
|
||||
hasSecurity := false
|
||||
hasMaint := false
|
||||
for _, f := range findings {
|
||||
if f.Lens == "security" {
|
||||
hasSecurity = true
|
||||
}
|
||||
if f.Lens == "maintainability" {
|
||||
hasMaint = true
|
||||
}
|
||||
}
|
||||
if !hasSecurity {
|
||||
t.Error("expected security finding for hardcoded password")
|
||||
}
|
||||
if !hasMaint {
|
||||
t.Error("expected maintainability finding for large PR or missing body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeReviewNoPRs(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-prs": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
|
||||
resp, err := CodeReviewRule(upstream, "review")
|
||||
if err != nil {
|
||||
t.Fatalf("CodeReviewRule failed: %v", err)
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if msg := analysis["message"]; msg != "no open PRs to review" {
|
||||
t.Fatalf("expected 'no open PRs to review', got %v", msg)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// TriageRule classifies issues, assigns priorities, matches labels, and distributes work.
|
||||
func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||||
issues := extractIssues(upstream, "open-issues")
|
||||
labels := extractLabels(upstream)
|
||||
members := extractMembers(upstream)
|
||||
|
||||
if len(issues) == 0 {
|
||||
return &workflow.AIResponse{
|
||||
Analysis: map[string]interface{}{"classified": 0, "message": "no open issues to triage"},
|
||||
Actions: nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var actions []workflow.AIAction
|
||||
memberLoad := map[string]int{}
|
||||
classified := []map[string]interface{}{}
|
||||
gfi := []map[string]interface{}{}
|
||||
|
||||
for _, issue := range issues {
|
||||
num := issueNumber(issue)
|
||||
title := str(issue, "title")
|
||||
body := str(issue, "body", "description")
|
||||
text := title + " " + body
|
||||
|
||||
cat := classifyIssue(text)
|
||||
pri := assignPriority(text)
|
||||
labelIDs := matchLabels(cat, labels)
|
||||
assignee := leastLoaded(memberLoad, members)
|
||||
|
||||
if assignee != "" {
|
||||
memberLoad[assignee]++
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"number": num,
|
||||
"title": title,
|
||||
"category": cat,
|
||||
"priority": pri,
|
||||
"assignee": assignee,
|
||||
}
|
||||
classified = append(classified, result)
|
||||
|
||||
// Build PATCH action if we have labels or assignee.
|
||||
body2 := map[string]interface{}{}
|
||||
if len(labelIDs) > 0 {
|
||||
body2["issue_tag_ids"] = labelIDs
|
||||
}
|
||||
if assignee != "" {
|
||||
body2["assigner_ids"] = []string{assignee}
|
||||
}
|
||||
if pri > 0 {
|
||||
body2["priority_id"] = pri
|
||||
}
|
||||
if len(body2) > 0 && num != "" {
|
||||
actions = append(actions, workflow.AIAction{
|
||||
Type: "api", Method: "PATCH",
|
||||
Path: fmt.Sprintf("{v1}/issues/%s", num),
|
||||
Body: body2,
|
||||
})
|
||||
}
|
||||
|
||||
if 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!",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
analysis := map[string]interface{}{
|
||||
"classified": len(classified),
|
||||
"results": classified,
|
||||
"good_first_issues": gfi,
|
||||
}
|
||||
|
||||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||||
}
|
||||
|
||||
// --- classification ---
|
||||
|
||||
var catPatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
category string
|
||||
}{
|
||||
{regexp.MustCompile(`(?i)错误|失败|异常|崩溃|crash|error|bug|broken|404|500`), "bug"},
|
||||
{regexp.MustCompile(`(?i)安全|漏洞|泄露|vulnerability|CVE|敏感`), "security"},
|
||||
{regexp.MustCompile(`(?i)性能|慢|卡顿|优化|performance|speed`), "performance"},
|
||||
{regexp.MustCompile(`(?i)重构|代码质量|refactor|clean\s*up|tech\s*debt`), "refactor"},
|
||||
{regexp.MustCompile(`(?i)建议|希望|新增|支持|feature|enhancement|add|improve`), "enhancement"},
|
||||
{regexp.MustCompile(`(?i)文档|README|帮助|doc|documentation|typo`), "docs"},
|
||||
{regexp.MustCompile(`(?i)如何|怎么|请问|how\s*to|question|help|求助`), "question"},
|
||||
}
|
||||
|
||||
var priorityPatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
pri int
|
||||
}{
|
||||
{regexp.MustCompile(`(?i)紧急|urgent|critical|崩溃|crash|严重|安全|漏洞|CVE|P0`), 4},
|
||||
{regexp.MustCompile(`(?i)重要|high|important|P1|阻断`), 3},
|
||||
{regexp.MustCompile(`(?i)低|low|trivial|minor|P3`), 1},
|
||||
}
|
||||
|
||||
func classifyIssue(text string) string {
|
||||
for _, p := range catPatterns {
|
||||
if p.re.MatchString(text) {
|
||||
return p.category
|
||||
}
|
||||
}
|
||||
return "enhancement" // default
|
||||
}
|
||||
|
||||
func assignPriority(text string) int {
|
||||
for _, p := range priorityPatterns {
|
||||
if p.re.MatchString(text) {
|
||||
return p.pri
|
||||
}
|
||||
}
|
||||
return 2 // default: medium
|
||||
}
|
||||
|
||||
func isGoodFirstIssue(text string) bool {
|
||||
gfiRe := regexp.MustCompile(`(?i)good\s*first\s*issue|beginner|easy|简单|新手|入门`)
|
||||
if gfiRe.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
// Also mark simple enhancements/docs as GFI.
|
||||
cat := classifyIssue(text)
|
||||
pri := assignPriority(text)
|
||||
return (cat == "docs" || cat == "enhancement") && pri <= 2 &&
|
||||
len(strings.Fields(text)) < 200
|
||||
}
|
||||
|
||||
// --- label matching ---
|
||||
|
||||
func extractLabels(upstream map[string]interface{}) []map[string]interface{} {
|
||||
return extractList(upstream, "labels")
|
||||
}
|
||||
|
||||
func matchLabels(category string, labels []map[string]interface{}) []interface{} {
|
||||
catLower := strings.ToLower(category)
|
||||
var ids []interface{}
|
||||
for _, l := range labels {
|
||||
name := strings.ToLower(str(l, "name", "title", "label"))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// Direct match or contains.
|
||||
if name == catLower || strings.Contains(name, catLower) || strings.Contains(catLower, name) {
|
||||
if id := labelID(l); id != nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also match sub-categories for bug.
|
||||
if catLower == "bug" {
|
||||
for _, l := range labels {
|
||||
name := strings.ToLower(str(l, "name", "title", "label"))
|
||||
if strings.Contains(name, "bug") || strings.Contains(name, "fix") {
|
||||
if id := labelID(l); id != nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func labelID(l map[string]interface{}) interface{} {
|
||||
for _, k := range []string{"id", "tag_id", "label_id"} {
|
||||
if v := l[k]; v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- assignment ---
|
||||
|
||||
func leastLoaded(load map[string]int, members map[string]string) string {
|
||||
if len(members) == 0 {
|
||||
return ""
|
||||
}
|
||||
best := ""
|
||||
bestN := -1
|
||||
for login := range members {
|
||||
n := load[login]
|
||||
if bestN < 0 || n < bestN {
|
||||
bestN = n
|
||||
best = login
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func issueNumber(issue map[string]interface{}) string {
|
||||
for _, k := range []string{"project_issues_index", "number", "iid", "id"} {
|
||||
s := fmt.Sprint(issue[k])
|
||||
if s != "" && s != "0" && s != "<nil>" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
func TestTriageRuleClassifiesByKeyword(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"project_issues_index": "1", "title": "fix crash on startup", "body": "应用启动时崩溃"},
|
||||
map[string]interface{}{"project_issues_index": "2", "title": "新增导出功能", "body": ""},
|
||||
map[string]interface{}{"project_issues_index": "3", "title": "如何配置SSO", "body": "请问怎么配"},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": 1, "name": "bug"},
|
||||
map[string]interface{}{"id": 2, "name": "enhancement"},
|
||||
map[string]interface{}{"id": 3, "name": "question"},
|
||||
},
|
||||
},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev One"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
if resp.Analysis == nil {
|
||||
t.Fatal("expected non-nil Analysis")
|
||||
}
|
||||
|
||||
analysis, ok := resp.Analysis.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Analysis is not a map")
|
||||
}
|
||||
if v := analysis["classified"]; v.(int) != 3 {
|
||||
t.Fatalf("expected 3 classified, got %v", v)
|
||||
}
|
||||
if len(resp.Actions) == 0 {
|
||||
t.Fatal("expected actions for issue triage")
|
||||
}
|
||||
|
||||
// Verify each action type.
|
||||
for _, a := range resp.Actions {
|
||||
if a.Type == "api" && a.Method != "PATCH" {
|
||||
t.Errorf("unexpected API method: %s", a.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRuleEmptyIssues(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{"data": []interface{}{}},
|
||||
"labels": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
if len(resp.Actions) != 0 {
|
||||
t.Fatalf("expected 0 actions for empty issues, got %d", len(resp.Actions))
|
||||
}
|
||||
analysis := resp.Analysis.(map[string]interface{})
|
||||
if v := analysis["classified"]; v.(int) != 0 {
|
||||
t.Fatalf("expected 0 classified, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRuleGoodFirstIssue(t *testing.T) {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"project_issues_index": "10", "title": "good first issue: add docs", "body": "easy task for beginners"},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"id": 1, "name": "docs"},
|
||||
},
|
||||
},
|
||||
"members": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"login": "dev1", "name": "Dev"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
hasComment := false
|
||||
for _, a := range resp.Actions {
|
||||
if a.Type == "cli" && a.Module == "issue" && a.Command == "+comment" {
|
||||
hasComment = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasComment {
|
||||
t.Fatal("expected a cli comment action for good first issue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRulePriority(t *testing.T) {
|
||||
cases := []struct {
|
||||
title string
|
||||
expected int
|
||||
}{
|
||||
{"紧急: 安全漏洞", 4},
|
||||
{"重要功能", 3},
|
||||
{"普通建议", 2},
|
||||
{"低优先级改进", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
upstream := map[string]interface{}{
|
||||
"open-issues": map[string]interface{}{
|
||||
"data": []interface{}{
|
||||
map[string]interface{}{"project_issues_index": "1", "title": tc.title, "body": ""},
|
||||
},
|
||||
},
|
||||
"labels": map[string]interface{}{"data": []interface{}{}},
|
||||
"members": map[string]interface{}{"data": []interface{}{}},
|
||||
}
|
||||
resp, err := TriageRule(upstream, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed for %q: %v", tc.title, err)
|
||||
}
|
||||
if len(resp.Actions) > 0 {
|
||||
body := resp.Actions[0].Body
|
||||
if v, ok := body["priority_id"]; ok {
|
||||
if v.(int) != tc.expected {
|
||||
t.Errorf("title=%q: priority_id=%v, want %d", tc.title, v, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriageRuleOutputFormat(t *testing.T) {
|
||||
resp, err := TriageRule(map[string]interface{}{}, "triage")
|
||||
if err != nil {
|
||||
t.Fatalf("TriageRule failed: %v", err)
|
||||
}
|
||||
if resp == nil {
|
||||
t.Fatal("expected non-nil response")
|
||||
}
|
||||
// Verify it's a valid workflow.AIResponse.
|
||||
var _ *workflow.AIResponse = resp
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
)
|
||||
|
||||
// LoadState reads the persisted workflow state from disk.
|
||||
func LoadState(name string) (*WorkflowState, error) {
|
||||
path := statePath(name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &WorkflowState{
|
||||
Workflow: name,
|
||||
Snapshots: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var s WorkflowState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("parse state file %s: %w", path, err)
|
||||
}
|
||||
if s.Snapshots == nil {
|
||||
s.Snapshots = make(map[string]string)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// Save persists the workflow state to disk.
|
||||
func (s *WorkflowState) Save() error {
|
||||
s.LastRun = time.Now().Format(time.RFC3339)
|
||||
path := statePath(s.Workflow)
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
// Diff compares current step results against stored snapshots.
|
||||
// Returns the names of steps whose data changed since the last run.
|
||||
func (s *WorkflowState) Diff(results []StepResult) []string {
|
||||
changed := []string{}
|
||||
for _, sr := range results {
|
||||
if !sr.OK || sr.Data == nil {
|
||||
continue
|
||||
}
|
||||
hash := hashData(sr.Data)
|
||||
if prev, ok := s.Snapshots[sr.Step]; ok && prev != hash {
|
||||
changed = append(changed, sr.Step)
|
||||
}
|
||||
s.Snapshots[sr.Step] = hash
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// hashData computes an MD5 hash of the JSON-encoded data.
|
||||
func hashData(data interface{}) string {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%x", md5.Sum(b))
|
||||
}
|
||||
|
||||
// statePath returns the file path for a workflow's state file.
|
||||
func statePath(name string) string {
|
||||
return filepath.Join(config.ConfigDir(), fmt.Sprintf("workflow-%s-state.json", name))
|
||||
}
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
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
|
||||
}
|
||||
// HARDCODED_SECRET: api_key='sk-1234567890abcdef'
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// AIMode controls whether AI is used for skill steps.
|
||||
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
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ErrNoRuleEngine is returned when no rule engine is registered for a target.
|
||||
func ErrNoRuleEngine(target string) error {
|
||||
return fmt.Errorf("no rule engine registered for skill target %q", target)
|
||||
}
|
||||
|
||||
// StepType classifies what mechanism executes a step.
|
||||
type StepType string
|
||||
|
||||
const (
|
||||
StepTypeSkill StepType = "skill"
|
||||
StepTypeCommand StepType = "command"
|
||||
StepTypeAPI StepType = "api"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
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"
|
||||
}
|
||||
|
||||
// WorkflowDef is a named, ordered sequence of steps with a trigger.
|
||||
type WorkflowDef struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Trigger TriggerDef `json:"trigger"`
|
||||
Steps []StepDef `json:"steps"`
|
||||
}
|
||||
|
||||
// AIAction is a write instruction returned by an AI skill step.
|
||||
type AIAction struct {
|
||||
Type string `json:"type"` // "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"}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// --- 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{
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List available workflows",
|
||||
Flags: []common.Flag{
|
||||
{Name: "category", Short: "c", Usage: "Filter by category", Default: ""},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
cat := ctx.Arg("category")
|
||||
workflows := All()
|
||||
filtered := make([]*WorkflowDef, 0)
|
||||
for _, w := range workflows {
|
||||
if cat == "" || strings.EqualFold(w.Category, cat) {
|
||||
filtered = append(filtered, w)
|
||||
}
|
||||
}
|
||||
type listItem struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
StepCount int `json:"step_count"`
|
||||
}
|
||||
items := make([]listItem, len(filtered))
|
||||
for i, w := range filtered {
|
||||
items[i] = listItem{
|
||||
Name: w.Name,
|
||||
Category: w.Category,
|
||||
Description: w.Description,
|
||||
StepCount: len(w.Steps),
|
||||
}
|
||||
}
|
||||
return ctx.OutputData(items)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "info",
|
||||
Description: "Show workflow detail (steps and trigger)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
return ctx.OutputData(wf)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "run",
|
||||
Description: "Execute a workflow manually",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview mode (no AI calls)", Bool: true},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
{Name: "daemon-loop", Usage: "Internal: run in loop mode", Bool: true},
|
||||
{Name: "interval", Usage: "Internal: loop interval", Default: "5m"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
|
||||
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 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)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "watch",
|
||||
Description: "Poll for changes and trigger workflow on delta",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 30s, 5m, 1h)", Default: "5m"},
|
||||
{Name: "step", Short: "s", Usage: "Step name to watch for changes (default: first step)", Default: ""},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
interval, err := time.ParseDuration(intervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
ctx.AIMode = aiMode
|
||||
|
||||
return Watch(ctx, wf, interval, ctx.Arg("step"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "schedule",
|
||||
Description: "Run a workflow on a repeating schedule",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Run interval (e.g. 1h, 24h)", Default: "24h"},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
interval, err := time.ParseDuration(intervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
ctx.AIMode = aiMode
|
||||
|
||||
return Schedule(ctx, wf, interval)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "start",
|
||||
Description: "Start workflow as background daemon",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Poll interval (e.g. 5m, 1h)", Default: "5m"},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
intervalStr := ctx.Arg("interval")
|
||||
interval, err := time.ParseDuration(intervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval %q: %w", intervalStr, err)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
|
||||
return StartDaemon(ctx, wf, interval, aiMode)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "stop",
|
||||
Description: "Stop workflow daemon",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return StopDaemon(name)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "status",
|
||||
Description: "Show daemon status",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return StatusDaemon(name)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "logs",
|
||||
Description: "View daemon log output",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "follow", Short: "f", Usage: "Follow log output (like tail -f)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tailDaemonLog(name, ctx.Arg("follow") == "true")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "install-systemd",
|
||||
Description: "Generate systemd service unit for a workflow daemon",
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Short: "n", Usage: "Workflow name", Required: true},
|
||||
{Name: "interval", Short: "i", Usage: "Poll interval", Default: "5m"},
|
||||
{Name: "ai", Usage: "Force AI mode (requires API key)", Bool: true},
|
||||
{Name: "no-ai", Usage: "Force rule engine mode (no AI)", Bool: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
return fmt.Errorf("workflow %q not found; use workflow +list to see available workflows", name)
|
||||
}
|
||||
|
||||
aiMode, modeErr := resolveAIModeFromArgs(ctx)
|
||||
if modeErr != nil {
|
||||
return modeErr
|
||||
}
|
||||
|
||||
return installSystemdUnit(ctx, wf, ctx.Arg("interval"), aiMode)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveAIModeFromArgs(ctx *common.RuntimeContext) (string, error) {
|
||||
ai := ctx.Arg("ai") == "true"
|
||||
noAI := ctx.Arg("no-ai") == "true"
|
||||
if ai && noAI {
|
||||
return "", fmt.Errorf("--ai 和 --no-ai 互斥,只能指定其中一个")
|
||||
}
|
||||
if ai {
|
||||
return "ai", nil
|
||||
}
|
||||
if noAI {
|
||||
return "no-ai", nil
|
||||
}
|
||||
return "auto", nil
|
||||
}
|
||||
|
||||
func runWorkflowCommand(ctx *common.RuntimeContext, wf *WorkflowDef, dryRun bool, aiMode string) error {
|
||||
result, err := RunWithMode(ctx, wf, dryRun, aiMode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
needsAI := 0
|
||||
ruleEngine := 0
|
||||
for _, sr := range result.Steps {
|
||||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||
if v, _ := m["_needs_ai"]; v == true {
|
||||
needsAI++
|
||||
}
|
||||
if v, _ := m["_ai_used"]; v == true {
|
||||
ruleEngine++ // AI was used
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if needsAI > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\n⚠ %d 个 skill 步骤需要 AI 处理:\n", needsAI)
|
||||
for _, sr := range result.Steps {
|
||||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||
if v, _ := m["_needs_ai"]; v == true {
|
||||
fmt.Fprintf(os.Stderr, " - %s (%s)\n", sr.Step, m["_skill"])
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n你可以:\n")
|
||||
fmt.Fprintf(os.Stderr, " 1. 配置 API Key 启用全自动: gitlink-cli config set anthropic_api_key <key>\n")
|
||||
fmt.Fprintf(os.Stderr, " 2. 将以上完整 JSON 输出交给 AI Agent 继续处理\n")
|
||||
} else if ruleEngine > 0 {
|
||||
fmt.Fprintf(os.Stderr, "🤖 AI 已处理 %d 个 skill 步骤\n", ruleEngine)
|
||||
} else {
|
||||
noAI := 0
|
||||
for _, sr := range result.Steps {
|
||||
if m, ok := sr.Data.(map[string]interface{}); ok {
|
||||
if v, _ := m["_ai_used"]; v == false {
|
||||
if _, hasSkill := m["_skill"]; hasSkill {
|
||||
noAI++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if noAI > 0 {
|
||||
fmt.Fprintf(os.Stderr, "⚙️ 规则引擎已处理 %d 个 skill 步骤 (未使用 AI)\n", noAI)
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.Output(output.SuccessEnvelope(result, nil))
|
||||
}
|
||||
|
|
@ -0,0 +1,565 @@
|
|||
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 TestRegistry(t *testing.T) {
|
||||
if len(registry) != 5 {
|
||||
t.Fatalf("expected 5 workflows, got %d", len(registry))
|
||||
}
|
||||
|
||||
for _, name := range []string{"community-ops", "code-quality", "project-init", "multi-repo", "contributor-growth"} {
|
||||
wf := Get(name)
|
||||
if wf == nil {
|
||||
t.Fatalf("workflow %q not found", name)
|
||||
}
|
||||
if len(wf.Steps) == 0 {
|
||||
t.Fatalf("workflow %q has no steps", name)
|
||||
}
|
||||
if wf.Trigger.Type == "" {
|
||||
t.Fatalf("workflow %q has no trigger.type", name)
|
||||
}
|
||||
}
|
||||
|
||||
all := All()
|
||||
if len(all) != 5 {
|
||||
t.Fatalf("All() returned %d workflows, expected 5", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNonexistent(t *testing.T) {
|
||||
if Get("nonexistent") != nil {
|
||||
t.Fatal("expected nil for nonexistent workflow")
|
||||
}
|
||||
}
|
||||
|
||||
func 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
|
||||
}{
|
||||
{"{v1}/issues", "chroe", "gitlink-cli", "/v1/chroe/gitlink-cli/issues"},
|
||||
{"{base}/pulls", "chroe", "gitlink-cli", "/chroe/gitlink-cli/pulls"},
|
||||
{"{base}", "org", "proj", "/org/proj"},
|
||||
{"{v1}/issues?state=open", "x", "y", "/v1/x/y/issues?state=open"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := resolvePath(tc.template, tc.owner, tc.repo)
|
||||
if got != tc.expected {
|
||||
t.Fatalf("resolvePath(%q, %s, %s) = %q, want %q", tc.template, tc.owner, tc.repo, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggers(t *testing.T) {
|
||||
expected := map[string]struct {
|
||||
on string
|
||||
typ string
|
||||
}{
|
||||
"community-ops": {"issue.created", "poll"},
|
||||
"code-quality": {"pr.opened", "poll"},
|
||||
"project-init": {"manual", "manual"},
|
||||
"multi-repo": {"0 9 * * 1", "cron"},
|
||||
"contributor-growth": {"0 9 * * 1", "cron"},
|
||||
}
|
||||
for name, want := range expected {
|
||||
wf := Get(name)
|
||||
if wf.Trigger.On != want.on {
|
||||
t.Fatalf("%s: trigger.on = %q, want %q", name, wf.Trigger.On, want.on)
|
||||
}
|
||||
if wf.Trigger.Type != want.typ {
|
||||
t.Fatalf("%s: trigger.type = %q, want %q", name, wf.Trigger.Type, want.typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepTypes(t *testing.T) {
|
||||
wf := Get("community-ops")
|
||||
if wf == nil {
|
||||
t.Fatal("community-ops not found")
|
||||
}
|
||||
|
||||
typeCounts := map[StepType]int{}
|
||||
for _, s := range wf.Steps {
|
||||
typeCounts[s.Type]++
|
||||
}
|
||||
if typeCounts[StepTypeCommand] < 1 {
|
||||
t.Fatal("community-ops should have at least one command step")
|
||||
}
|
||||
if typeCounts[StepTypeSkill] < 1 {
|
||||
t.Fatal("community-ops should have at least one skill step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillStepDependsOn(t *testing.T) {
|
||||
wf := Get("community-ops")
|
||||
if wf == nil {
|
||||
t.Fatal("community-ops not found")
|
||||
}
|
||||
|
||||
var triage *StepDef
|
||||
for i := range wf.Steps {
|
||||
if wf.Steps[i].Name == "triage" {
|
||||
triage = &wf.Steps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if triage == nil {
|
||||
t.Fatal("triage step not found")
|
||||
}
|
||||
if len(triage.DependsOn) != 3 {
|
||||
t.Fatalf("triage step should have 3 dependencies, got %d: %v", len(triage.DependsOn), triage.DependsOn)
|
||||
}
|
||||
expectedDeps := map[string]bool{"open-issues": false, "labels": false, "members": false}
|
||||
for _, dep := range triage.DependsOn {
|
||||
if _, ok := expectedDeps[dep]; !ok {
|
||||
t.Fatalf("unexpected dependency: %s", dep)
|
||||
}
|
||||
expectedDeps[dep] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"issue +list --state open", []string{"issue", "+list", "--state", "open"}},
|
||||
{"repo +info", []string{"repo", "+info"}},
|
||||
{"pr +list --state merged --limit 50", []string{"pr", "+list", "--state", "merged", "--limit", "50"}},
|
||||
{"issue +list --state open --limit 50", []string{"issue", "+list", "--state", "open", "--limit", "50"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := parseCommandTarget(tc.input)
|
||||
if len(got) != len(tc.expected) {
|
||||
t.Fatalf("parseCommandTarget(%q): len=%d, want len=%d (got=%v)", tc.input, len(got), len(tc.expected), got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.expected[i] {
|
||||
t.Fatalf("parseCommandTarget(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
t.Fatal("code-quality not found")
|
||||
}
|
||||
if len(wf.Steps) < 7 {
|
||||
t.Fatalf("code-quality should have at least 7 steps (including review), got %d", len(wf.Steps))
|
||||
}
|
||||
found := false
|
||||
for _, s := range wf.Steps {
|
||||
if s.Target == "gitlink-review" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("code-quality missing gitlink-review skill step")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,16 +10,18 @@ RUN go mod download
|
|||
COPY . .
|
||||
|
||||
# Build gitlink-cli binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o gitlink-cli .
|
||||
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 -o showcase-server ./showcase/
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o showcase-server ./showcase/
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache ca-certificates git
|
||||
|
||||
RUN mkdir -p /root/.config/gitlink-cli
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/gitlink-cli .
|
||||
|
|
|
|||
|
|
@ -3,21 +3,41 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>gitlink-cli 功能增强展示</title>
|
||||
<title>gitlink-cli 项目总览 — 软件演化与运维课程实践</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif; background: #f0f2f5; color: #333; }
|
||||
.header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: white; padding: 40px 20px; text-align: center; }
|
||||
.header h1 { font-size: 2em; margin-bottom: 8px; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif; background: #f0f2f5; color: #333; scroll-behavior: smooth; }
|
||||
|
||||
.header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: white; padding: 48px 20px 36px; text-align: center; }
|
||||
.header h1 { font-size: 2.2em; margin-bottom: 8px; letter-spacing: 2px; }
|
||||
.header p { color: #8892b0; font-size: 1.1em; }
|
||||
.header .repo-info { margin-top: 12px; font-size: 0.9em; color: #64ffda; }
|
||||
.header .repo-info code { background: rgba(255,255,255,0.1); padding: 4px 12px; border-radius: 4px; }
|
||||
.stats-bar { display: flex; justify-content: center; gap: 32px; padding: 20px; background: white; border-bottom: 1px solid #e8e8e8; flex-wrap: wrap; }
|
||||
|
||||
.nav-bar { display: flex; justify-content: center; background: white; border-bottom: 2px solid #e8e8e8; position: sticky; top: 0; z-index: 100; flex-wrap: wrap; }
|
||||
.nav-bar a { padding: 14px 24px; color: #666; font-size: 0.9em; font-weight: 500; border-bottom: 3px solid transparent; transition: all 0.2s; text-decoration: none; }
|
||||
.nav-bar a:hover, .nav-bar a.active { color: #1890ff; border-bottom-color: #1890ff; }
|
||||
|
||||
.stats-bar { display: flex; justify-content: center; gap: 40px; padding: 24px 20px; background: white; border-bottom: 1px solid #e8e8e8; flex-wrap: wrap; }
|
||||
.stat-item { text-align: center; }
|
||||
.stat-item .num { font-size: 2em; font-weight: bold; color: #1890ff; }
|
||||
.stat-item .num.green { color: #52c41a; }
|
||||
.stat-item .num.purple { color: #722ed1; }
|
||||
.stat-item .num.orange { color: #fa8c16; }
|
||||
.stat-item .label { color: #999; font-size: 0.85em; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
|
||||
.section { padding: 20px 0; }
|
||||
.section-header { max-width: 1200px; margin: 0 auto 20px; padding: 0 24px; }
|
||||
.section-header .badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 0.8em; font-weight: 600; color: white; margin-bottom: 8px; }
|
||||
.section-header .badge.p1 { background: #1890ff; }
|
||||
.section-header .badge.p2 { background: #52c41a; }
|
||||
.section-header .badge.p3 { background: #722ed1; }
|
||||
.section-header .badge.p4 { background: #fa8c16; }
|
||||
.section-header h2 { font-size: 1.6em; margin: 4px 0; }
|
||||
.section-header .subtitle { color: #999; font-size: 0.9em; }
|
||||
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
|
||||
|
||||
.module-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(550px, 1fr)); gap: 20px; }
|
||||
.module-card { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
|
||||
.module-header { padding: 16px 20px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #f0f0f0; }
|
||||
|
|
@ -26,7 +46,6 @@
|
|||
.module-header .tag { font-size: 0.75em; background: #e6f7ff; color: #1890ff; padding: 2px 8px; border-radius: 10px; }
|
||||
.module-header .arrow { transition: transform 0.3s; font-size: 0.8em; color: #999; }
|
||||
.module-header .arrow.open { transform: rotate(180deg); }
|
||||
.module-body { padding: 0; }
|
||||
.module-body.collapsed { display: none; }
|
||||
.cmd-section { padding: 10px 20px 4px; color: #1890ff; font-weight: 600; font-size: 0.88em; background: #f0f5ff; border-bottom: 1px solid #e6f0ff; border-top: 1px solid #e6f0ff; }
|
||||
.cmd-section:first-child { border-top: none; }
|
||||
|
|
@ -44,44 +63,193 @@
|
|||
.cmd-params.show { display: flex; }
|
||||
.param-group { display: flex; flex-direction: column; gap: 2px; }
|
||||
.param-group label { font-size: 0.75em; color: #999; }
|
||||
.param-group input { border: 1px solid #d9d9d9; border-radius: 4px; padding: 4px 8px; font-size: 0.85em; width: 140px; }
|
||||
.param-group input:focus { border-color: #1890ff; outline: none; box-shadow: 0 0 0 2px rgba(24,144,255,0.2); }
|
||||
.param-group input, .param-group select { border: 1px solid #d9d9d9; border-radius: 4px; padding: 4px 8px; font-size: 0.85em; width: 140px; }
|
||||
.param-group input:focus, .param-group select:focus { border-color: #1890ff; outline: none; box-shadow: 0 0 0 2px rgba(24,144,255,0.2); }
|
||||
.run-btn { background: #1890ff; color: white; border: none; padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85em; white-space: nowrap; align-self: flex-end; }
|
||||
.run-btn:hover { background: #40a9ff; }
|
||||
.run-btn:disabled { background: #d9d9d9; cursor: not-allowed; }
|
||||
.result-panel { background: #1e1e1e; margin: 4px 20px 14px; border-radius: 8px; overflow: hidden; display: none; }
|
||||
.result-panel.show { display: block; }
|
||||
.result-toolbar { background: #2d2d2d; padding: 8px 12px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.result-toolbar .cmd-text { color: #4ec9b0; font-family: monospace; font-size: 0.85em; }
|
||||
.result-toolbar .status { font-size: 0.8em; padding: 2px 8px; border-radius: 4px; }
|
||||
.result-toolbar .cmd-text { color: #4ec9b0; font-family: monospace; font-size: 0.85em; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.result-toolbar .status { font-size: 0.8em; padding: 2px 8px; border-radius: 4px; margin: 0 8px; }
|
||||
.result-toolbar .status.ok { background: #2ea04380; color: #4ec9b0; }
|
||||
.result-toolbar .status.err { background: #da363380; color: #f85149; }
|
||||
.result-toolbar .close-btn { color: #666; cursor: pointer; font-size: 1.2em; }
|
||||
.result-toolbar .close-btn:hover { color: #999; }
|
||||
.result-body { padding: 12px; max-height: 400px; overflow: auto; }
|
||||
.result-body pre { color: #d4d4d4; font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.82em; line-height: 1.5; white-space: pre-wrap; word-break: break-all; margin: 0; }
|
||||
.footer { text-align: center; color: #999; padding: 40px 20px; font-size: 0.85em; }
|
||||
.result-body table { width: 100%; border-collapse: collapse; color: #d4d4d4; font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.82em; }
|
||||
.result-body thead th { background: #2d2d2d; color: #4ec9b0; text-align: left; padding: 6px 10px; border-bottom: 2px solid #4ec9b0; white-space: nowrap; position: sticky; top: 0; }
|
||||
.result-body tbody td { padding: 4px 10px; border-bottom: 1px solid #333; white-space: nowrap; }
|
||||
.result-body tbody tr:hover { background: #2a2a2a; }
|
||||
.result-body tbody tr:nth-child(even) { background: #252525; }
|
||||
|
||||
.skills-category { margin-bottom: 24px; }
|
||||
.skills-category h3 { font-size: 1.1em; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #f0f0f0; display: flex; align-items: center; gap: 8px; }
|
||||
.skills-category h3 .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||
.skills-category h3 .dot.core { background: #1890ff; }
|
||||
.skills-category h3 .dot.smart { background: #52c41a; }
|
||||
.skills-category h3 .dot.aux { background: #fa8c16; }
|
||||
.skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||
.skill-card { background: white; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); padding: 16px 18px; border-left: 4px solid transparent; transition: box-shadow 0.2s; }
|
||||
.skill-card:hover { box-shadow: 0 3px 12px rgba(0,0,0,0.1); }
|
||||
.skill-card.core { border-left-color: #1890ff; }
|
||||
.skill-card.smart { border-left-color: #52c41a; }
|
||||
.skill-card.aux { border-left-color: #fa8c16; }
|
||||
.skill-card .skill-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 6px; }
|
||||
.skill-card .skill-name { font-weight: 700; font-size: 1em; font-family: 'SFMono-Regular', Consolas, monospace; }
|
||||
.skill-card .skill-ver { font-size: 0.7em; color: #bbb; background: #f5f5f5; padding: 2px 6px; border-radius: 4px; }
|
||||
.skill-card .skill-desc { color: #666; font-size: 0.85em; margin-bottom: 10px; line-height: 1.4; }
|
||||
.skill-card .skill-meta { display: flex; gap: 12px; flex-wrap: wrap; font-size: 0.78em; color: #999; }
|
||||
.skill-card .skill-files { margin-top: 6px; font-size: 0.75em; color: #bbb; font-family: monospace; }
|
||||
.skill-card .skill-example { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #e8e8e8; display: none; }
|
||||
.skill-card .skill-example.show { display: block; }
|
||||
.skill-card .skill-example .ex-title { font-size: 0.8em; font-weight: 600; color: #52c41a; margin-bottom: 4px; }
|
||||
.skill-card .skill-example .ex-text { font-size: 0.78em; color: #888; line-height: 1.5; background: #fafbfc; padding: 8px; border-radius: 4px; max-height: 160px; overflow-y: auto; }
|
||||
.example-btn { background: none; border: 1px solid #52c41a; color: #52c41a; padding: 2px 10px; border-radius: 4px; cursor: pointer; font-size: 0.75em; margin-top: 4px; }
|
||||
.example-btn:hover { background: #52c41a; color: white; }
|
||||
|
||||
.wf-panel { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 24px; overflow: hidden; }
|
||||
.wf-panel-header { padding: 16px 20px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #f0f0f0; background: #faf6ff; }
|
||||
.wf-panel-header:hover { background: #f3e8ff; }
|
||||
.wf-panel-header .wf-name { font-weight: 700; font-size: 1.05em; font-family: 'SFMono-Regular', Consolas, monospace; color: #722ed1; }
|
||||
.wf-panel-header .wf-meta { display: flex; align-items: center; gap: 10px; }
|
||||
.wf-panel-header .wf-cat { font-size: 0.7em; background: #f3e8ff; color: #722ed1; padding: 2px 8px; border-radius: 10px; }
|
||||
.wf-panel-header .wf-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.wf-panel-header .wf-dot.running { background: #52c41a; box-shadow: 0 0 4px #52c41a; }
|
||||
.wf-panel-header .wf-dot.stopped { background: #d9d9d9; }
|
||||
.wf-panel-header .arrow { transition: transform 0.3s; font-size: 0.8em; color: #999; }
|
||||
.wf-panel-header .arrow.open { transform: rotate(180deg); }
|
||||
.wf-panel-body.collapsed { display: none; }
|
||||
.wf-panel-body { padding: 0; }
|
||||
.wf-panel-desc { padding: 12px 20px; color: #666; font-size: 0.9em; border-bottom: 1px solid #f0f0f0; background: #fcfcfc; }
|
||||
.wf-panel-trigger { padding: 8px 20px; font-size: 0.8em; color: #999; border-bottom: 1px solid #f0f0f0; }
|
||||
.wf-panel-trigger code { background: #f5f5f5; padding: 1px 6px; border-radius: 3px; }
|
||||
.wf-panel-steps { padding: 8px 20px; border-bottom: 1px solid #f0f0f0; }
|
||||
.wf-panel-steps summary { cursor: pointer; color: #722ed1; font-weight: 500; font-size: 0.85em; }
|
||||
.wf-panel-steps ol { padding-left: 18px; line-height: 1.6; font-size: 0.8em; color: #888; }
|
||||
.wf-actions { padding: 12px 20px; display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
|
||||
.wf-actions .action-btn { color: white; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85em; white-space: nowrap; }
|
||||
.wf-actions .action-btn:hover { opacity: 0.85; }
|
||||
.wf-actions .action-btn:disabled { background: #d9d9d9; cursor: not-allowed; }
|
||||
.wf-actions .btn-run { background: #1890ff; }
|
||||
.wf-actions .btn-start { background: #52c41a; }
|
||||
.wf-actions .btn-stop { background: #ff4d4f; }
|
||||
.wf-actions .btn-info { background: #722ed1; }
|
||||
.wf-actions .btn-status { background: #faad14; color: #333; }
|
||||
.wf-actions .btn-logs { background: #666; }
|
||||
.wf-actions .action-select { border: 1px solid #d9d9d9; border-radius: 4px; padding: 5px 8px; font-size: 0.85em; }
|
||||
.wf-global { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 12px 20px; margin-bottom: 24px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.wf-global span { font-weight: 600; color: #722ed1; font-size: 0.95em; }
|
||||
.wf-global .action-btn { color: white; border: none; padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 0.85em; white-space: nowrap; }
|
||||
|
||||
.placeholder-card { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 60px 40px; text-align: center; color: #999; }
|
||||
.placeholder-card .ph-icon { font-size: 3em; margin-bottom: 16px; }
|
||||
.placeholder-card h3 { font-size: 1.2em; color: #666; margin-bottom: 8px; }
|
||||
.placeholder-card p { font-size: 0.9em; }
|
||||
|
||||
.footer { text-align: center; color: #999; padding: 40px 20px; font-size: 0.85em; border-top: 1px solid #e8e8e8; margin-top: 30px; }
|
||||
.divider { height: 1px; background: linear-gradient(to right, transparent, #e0e0e0, transparent); margin: 0 24px; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.module-grid { grid-template-columns: 1fr; }
|
||||
.module-grid, .skill-grid, .wf-def-grid { grid-template-columns: 1fr; }
|
||||
.cmd-params { padding-left: 20px; }
|
||||
.stats-bar { gap: 18px; }
|
||||
.nav-bar a { padding: 10px 14px; font-size: 0.8em; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>gitlink-cli 功能增强</h1>
|
||||
<p>软件演化与运维 课程实践 — 进阶任务 子任务一</p>
|
||||
<h1>gitlink-cli 项目总览</h1>
|
||||
<p>软件演化与运维 课程实践 — 进阶任务成果汇报</p>
|
||||
<div class="repo-info">演示仓库:<code>chroe/gitlink-cli</code> — 展开命令、填入参数、点击运行查看真实结果</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav-bar" id="nav">
|
||||
<a href="#p1">一 · CLI 能力增强</a>
|
||||
<a href="#p2">二 · AI Skills</a>
|
||||
<a href="#p3">三 · 自动化工作流</a>
|
||||
<a href="#p4">四 · 辅助科研</a>
|
||||
</nav>
|
||||
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item"><div class="num">9</div><div class="label">新增模块</div></div>
|
||||
<div class="stat-item"><div class="num green">47</div><div class="label">新增命令</div></div>
|
||||
<div class="stat-item"><div class="num">7</div><div class="label">批量操作</div></div>
|
||||
<div class="stat-item"><div class="num">47+</div><div class="label">单元测试</div></div>
|
||||
<div class="stat-item"><div class="num">11</div><div class="label">CLI 模块</div></div>
|
||||
<div class="stat-item"><div class="num green">47+</div><div class="label">新增命令</div></div>
|
||||
<div class="stat-item"><div class="num green">17</div><div class="label">AI Skills</div></div>
|
||||
<div class="stat-item"><div class="num purple">5</div><div class="label">自动化工作流</div></div>
|
||||
<div class="stat-item"><div class="num purple">11</div><div class="label">工作流命令</div></div>
|
||||
<div class="stat-item"><div class="num orange">47+</div><div class="label">单元测试</div></div>
|
||||
<div class="stat-item" style="cursor:pointer" onclick="toggleFormat()" title="点击切换输出格式">
|
||||
<div class="num" id="format-label" style="font-size:1.2em;transition:color 0.3s;">JSON</div>
|
||||
<div class="label">输出格式 ▾</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container"><div class="module-grid" id="modules"></div></div>
|
||||
<div class="footer">gitlink-cli 功能增强 — 软件演化与运维课程实践</div>
|
||||
|
||||
<div class="section" id="p1">
|
||||
<div class="section-header">
|
||||
<span class="badge p1">子任务一</span>
|
||||
<h2>增加和完善 GitLink-CLI 能力</h2>
|
||||
<p class="subtitle">11 个模块 · 47+ 命令 · 7 个批量操作 · 全部可交互运行</p>
|
||||
</div>
|
||||
<div class="container"><div class="module-grid" id="modules"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="section" id="p2">
|
||||
<div class="section-header">
|
||||
<span class="badge p2">子任务二</span>
|
||||
<h2>编写和丰富 GitLink Skills</h2>
|
||||
<p class="subtitle">17 个 AI Agent Skill · 覆盖核心/智能/辅助三大类 · 兼容 Claude Code 等主流平台</p>
|
||||
</div>
|
||||
<div class="container" id="skills-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="section" id="p3">
|
||||
<div class="section-header">
|
||||
<span class="badge p3">子任务三</span>
|
||||
<h2>构建端到端自动化工作流</h2>
|
||||
<p class="subtitle">5 个预置工作流 · 11 个 CLI 命令 · 规则引擎 + AI 双模式 · 支持守护进程/systemd 部署</p>
|
||||
</div>
|
||||
<div class="container" id="workflow-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="section" id="p4">
|
||||
<div class="section-header">
|
||||
<span class="badge p4">子任务四</span>
|
||||
<h2>应用 GitLink 辅助科研</h2>
|
||||
<p class="subtitle">利用 GitLink 平台能力支撑科研项目管理和学术协作</p>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="placeholder-card">
|
||||
<div class="ph-icon">🔬</div>
|
||||
<h3>即将上线</h3>
|
||||
<p>科研项目管理、学术协作工具集成等功能正在规划中,敬请期待。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">gitlink-cli 项目总览 — 软件演化与运维课程实践 — 进阶任务成果汇报</div>
|
||||
|
||||
<script>
|
||||
let currentFormat = 'json';
|
||||
|
||||
function toggleFormat() {
|
||||
currentFormat = currentFormat === 'json' ? 'table' : 'json';
|
||||
const label = document.getElementById('format-label');
|
||||
if (label) {
|
||||
label.textContent = currentFormat.toUpperCase();
|
||||
label.style.color = currentFormat === 'table' ? '#52c41a' : '';
|
||||
}
|
||||
}
|
||||
|
||||
const MODULES = [
|
||||
{
|
||||
name: "milestone", title: "里程碑管理",
|
||||
|
|
@ -157,14 +325,20 @@ const MODULES = [
|
|||
},
|
||||
{
|
||||
name: "member", title: "成员管理",
|
||||
desc: "管理项目成员,支持添加、移除、修改角色和查看成员列表。用户ID 可用 search +users 查找",
|
||||
desc: "管理项目成员和组织成员,支持添加、移除、修改角色、批量组织邀请",
|
||||
color: "#f5222d",
|
||||
repoSelector: true,
|
||||
commands: [
|
||||
{ name: "list", desc: "列出项目成员", detail: "获取项目中所有协作者的列表,返回结果中包含每位成员的 id(数字)和 login(用户名),可按关键词搜索", params: [{k:"keyword",l:"搜索关键词",v:""}] },
|
||||
{ name: "users", desc: "🔍 按用户名搜索ID", module: "search", detail: "输入 GitLink 用户名搜索,返回的 user_id 就是添加/移除成员时需要的数字 ID", params: [{k:"keyword",l:"用户名",v:""}] },
|
||||
{ name: "add", desc: "添加成员", detail: "邀请用户加入项目成为协作者。user-id 是数字 ID,先点「按用户名搜索ID」查找", params: [{k:"user-id",l:"用户数字ID",v:""}] },
|
||||
{ name: "remove", desc: "移除成员", detail: "将指定用户移出项目。先点 +list 查看成员的数字 ID", params: [{k:"user-id",l:"用户数字ID",v:""}] },
|
||||
{ name: "update", desc: "修改成员角色", detail: "更改成员的项目角色:Manager(管理员)、Developer(开发者)、Reporter(报告者)。先点 +list 查看成员 ID", params: [{k:"user-id",l:"用户数字ID",v:""},{k:"role",l:"角色",v:"Developer"}] },
|
||||
{ name: "list", desc: "列出项目成员", detail: "获取项目中所有协作者的列表,返回每位成员的 id(数字)和 login(用户名)", params: [{k:"keyword",l:"搜索关键词",v:""}] },
|
||||
{ name: "users", desc: "按用户名搜索ID", module: "search", detail: "输入 GitLink 用户名搜索,返回的 user_id 就是添加/移除成员时需要的数字 ID", params: [{k:"keyword",l:"用户名",v:""}] },
|
||||
{ name: "add", desc: "添加成员", detail: "邀请用户加入项目成为协作者,user-id 为数字 ID(先点「搜索ID」查找)", params: [{k:"user-id",l:"用户数字ID",v:""}] },
|
||||
{ name: "remove", desc: "移除成员", detail: "将指定用户移出项目", params: [{k:"user-id",l:"用户数字ID",v:""}] },
|
||||
{ name: "update", desc: "修改成员角色", detail: "更改成员角色:Manager / Developer / Reporter", params: [{k:"user-id",l:"用户数字ID",v:""},{k:"role",l:"角色",v:"Developer"}] },
|
||||
{ name: "batch-add", desc: "批量添加成员", detail: "批量添加成员到项目,支持 --users 逗号分隔数字 ID 或 --from CSV 文件。建议先用预览模式确认", params: [{k:"users",l:"用户ID列表",v:"42,99"},{k:"dry-run",l:"预览模式",v:"true",bool:true}] },
|
||||
{ section: "────────── 组织成员 ──────────" },
|
||||
{ name: "info", desc: "输入组织名查ID", module: "org", detail: "输入组织的 name(如 e2e-test-org-1780389982),返回结果中的 id 字段就是组织 ID", params: [{k:"id",l:"组织名称(name)",v:"e2e-test-org-1780389982"}] },
|
||||
{ name: "members", desc: "查看组织成员", module: "org", detail: "查看指定组织的成员列表", params: [{k:"id",l:"组织ID",v:"151783"}] },
|
||||
{ name: "batch-invite", desc: "批量邀请加入组织", module: "org", detail: "批量邀请用户加入组织,支持用户名或数字ID,建议先开预览模式确认", params: [{k:"id",l:"组织ID(先用「查ID」获取)",v:"151783"},{k:"users",l:"用户名/ID列表",v:"chroe"},{k:"role",l:"角色",v:"member"},{k:"dry-run",l:"预览模式",v:"true",bool:true}] },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -197,161 +371,332 @@ const MODULES = [
|
|||
{ name: "reopen", desc: "重新打开 Issue", detail: "重新打开已关闭的 Issue", params: [{k:"number",l:"Issue编号",v:"1"}] },
|
||||
{ name: "batch-close", desc: "批量关闭 Issue", detail: "按编号批量关闭 Issue,支持 --numbers 逗号分隔或 --from CSV 文件。开启预览模式可先查看将执行的操作而不实际执行", params: [{k:"numbers",l:"Issue编号",v:"1,2,3"},{k:"dry-run",l:"预览模式",v:"false",bool:true}] },
|
||||
{ name: "batch-reopen", desc: "批量重新打开 Issue", detail: "批量重新打开已关闭的 Issue,支持 --numbers 逗号分隔或 --from CSV 文件", params: [{k:"numbers",l:"Issue编号",v:"1,2,3"},{k:"dry-run",l:"预览模式",v:"false",bool:true}] },
|
||||
|
||||
{ name: "batch-comment", desc: "批量评论 Issue", detail: "对多个 Issue 批量添加同一条评论,支持 --numbers 逗号分隔或 --from CSV 文件", params: [{k:"numbers",l:"Issue编号",v:"1,2,3"},{k:"body",l:"评论内容",v:"统一回复"},{k:"dry-run",l:"预览模式",v:"false",bool:true}] },
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
name: "repo", title: "批量仓库管理",
|
||||
desc: "批量删除或 Fork 仓库,支持逗号分隔列表或 CSV 文件批量导入,建议先用预览模式确认",
|
||||
desc: "批量创建、Fork 和删除仓库,支持逗号分隔列表或 CSV 文件批量导入。创建用仓库名,Fork/删除用 owner/repo 格式",
|
||||
color: "#722ed1",
|
||||
commands: [
|
||||
{ name: "batch-delete", desc: "批量删除仓库", detail: "按 owner/repo 格式批量删除仓库,支持 --repos 逗号分隔或 --from CSV 文件。⚠️ 危险操作,建议先开启预览模式确认", params: [{k:"repos",l:"仓库列表",v:"chroe/calc-framework,chroe/tutoring-prep"},{k:"dry-run",l:"预览模式",v:"true",bool:true}] },
|
||||
{ name: "batch-fork", desc: "批量 Fork 仓库", detail: "批量 Fork 多个仓库到自己的账号下", params: [{k:"repos",l:"仓库列表",v:"wxhwwla/calc-framework,kaysen/tutoring-prep"},{k:"dry-run",l:"预览模式",v:"true",bool:true}] },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "org", title: "批量组织邀请",
|
||||
desc: "批量邀请成员加入组织,支持逗号分隔或 CSV 文件批量导入,建议先用预览模式确认",
|
||||
color: "#fa8c16",
|
||||
commands: [
|
||||
{ name: "batch-invite", desc: "批量邀请成员", detail: "批量邀请用户加入组织,支持用户名或数字ID,支持设置角色(member/admin),支持 --from CSV 文件", params: [{k:"id",l:"组织ID(用 org +list 查看)",v:"151783"},{k:"users",l:"用户名/ID列表",v:"chroe"},{k:"role",l:"角色",v:"member"},{k:"dry-run",l:"预览模式",v:"true",bool:true}] },
|
||||
{ name: "batch-create", desc: "批量创建仓库", detail: "批量创建仓库到你自己的账号下,支持 --repos 逗号分隔仓库名或 --from CSV 文件。可统一设置描述和私有性", params: [{k:"repos",l:"仓库名称",v:"repo1,repo2"},{k:"description",l:"描述",v:"批量创建的仓库"},{k:"private",l:"私有",v:"false"},{k:"dry-run",l:"预览模式",v:"true",bool:true}] },
|
||||
{ name: "batch-fork", desc: "批量 Fork 仓库", detail: "批量 Fork 其他人的仓库到你的账号下,使用 owner/repo 格式", params: [{k:"repos",l:"仓库标识",v:"wxhwwla/calc-framework,kaysen/tutoring-prep"},{k:"dry-run",l:"预览模式",v:"true",bool:true}] },
|
||||
{ name: "batch-delete", desc: "批量删除仓库", detail: "批量删除你名下的仓库,使用 owner/repo 格式。⚠ 危险操作,不可恢复", params: [{k:"repos",l:"仓库标识",v:"yetja/repo1,yetja/repo2"},{k:"dry-run",l:"预览模式",v:"true",bool:true}] },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const container = document.getElementById('modules');
|
||||
const SKILLS = [
|
||||
{ id:"gitlink-shared", name:"Shared", displayName:"共享基础", cat:"core",
|
||||
desc:"认证、全局参数、API 参考、安全规则、错误处理 — 所有其他 Skill 的前置依赖",
|
||||
ver:"1.0.0", files:["SKILL.md","references/api-reference.md","references/troubleshooting.md"] },
|
||||
{ id:"gitlink-repo", name:"Repo", displayName:"仓库管理", cat:"core",
|
||||
desc:"仓库增删改查、批量创建/Fork/删除、Fork 协作流程、仓库健康度检查",
|
||||
ver:"1.0.0", files:["SKILL.md","references/(9个文件)","examples/repo-health-check.md"] },
|
||||
{ id:"gitlink-issue", name:"Issue", displayName:"Issue 管理", cat:"core",
|
||||
desc:"Issue 创建/查看/更新/关闭、批量操作、评论、标签和里程碑分配",
|
||||
ver:"1.0.0", files:["SKILL.md","references/(7个文件)","examples/issue-workflow.md"] },
|
||||
{ id:"gitlink-pr", name:"PR", displayName:"Pull Request", cat:"core",
|
||||
desc:"PR 创建/查看/合并/关闭、变更文件 Diff、代码审查、批量操作",
|
||||
ver:"1.0.0", files:["SKILL.md","references/(6个文件)","examples/pr-workflow.md"] },
|
||||
{ id:"gitlink-branch", name:"Branch", displayName:"分支管理", cat:"core",
|
||||
desc:"分支创建/删除/保护、查看分支列表、批量操作",
|
||||
ver:"1.0.0", files:["SKILL.md","REFERENCE.md","examples/branch-workflow.md"] },
|
||||
{ id:"gitlink-release", name:"Release", displayName:"版本发布", cat:"core",
|
||||
desc:"Release 创建/查看/删除/列表、版本管理",
|
||||
ver:"1.0.0", files:["SKILL.md","references/(4个文件)","examples/release-workflow.md"] },
|
||||
{ id:"gitlink-health", name:"Health", displayName:"项目健康度报告", cat:"smart",
|
||||
desc:"Issue 响应时间、PR 合并效率、贡献者活跃度统计 → 结构化健康度评分与报告",
|
||||
ver:"1.0.0", files:["SKILL.md","REFERENCE.md","examples/health-report.md","examples/weekly-report.md"],
|
||||
example:{title:"健康度评分算法",text:"4 维度加权评分:Issue 关闭率(25%) + 平均响应时间(25%) + PR 合并率(25%) + 平均合并耗时(25%)。每维度 0-100 分,加权求和得总分。优秀≥80,良好60-79,需改进<60。示例:chroe/gitlink-cli = 77/100(良好)。支持 fallback 策略:部分 API 不可用时自动降级评分。"} },
|
||||
{ id:"gitlink-changelog", name:"Changelog", displayName:"Release Notes 自动生成", cat:"smart",
|
||||
desc:"从 commit、PR、Issue 历史自动分析并生成结构化版本说明",
|
||||
ver:"1.0.0", files:["SKILL.md","REFERENCE.md","examples/generate-release-notes.md"],
|
||||
example:{title:"Commit 分类规则",text:"支持中英文关键词:✨ feat/add/新增 → 新功能 | 🐛 fix/修复 → Bug 修复 | 🔧 refactor/perf/优化 → 改进 | 📚 docs/文档 → 文档 | 🧪 test/测试 → 测试 | 🏗️ build/ci/chore → 构建/CI。按 Conventional Commits 规范解析,合并同类提交,去重后生成结构化 Release Notes。"} },
|
||||
{ id:"gitlink-triage", name:"Triage", displayName:"Issue 智能分拣", cat:"smart",
|
||||
desc:"自动为新 Issue 分类、打标签、分配责任人,并为 good-first-issue 添加引导评论",
|
||||
ver:"1.0.0", files:["SKILL.md","REFERENCE.md","examples/batch-triage.md"],
|
||||
example:{title:"分拣流程",text:"规则引擎:关键词正则匹配分类(bug/enhancement/question/docs) → 优先级评估(1-4) → 标签名自动匹配项目已有标签 → 轮询分配给合适成员。可批量处理多个 Issue,支持预览模式(--dry-run)。注意:GitLink 标签通常为中文(缺陷/功能/疑问),需先用 label +list 获取真实标签名匹配。"} },
|
||||
{ id:"gitlink-review", name:"Review", displayName:"智能代码审查", cat:"smart",
|
||||
desc:"分析 PR diff,多视角评审 + 对抗式自检,结构化 Review 意见自动评论",
|
||||
ver:"1.0.0", files:["SKILL.md","REFERENCE.md","examples/review-workflow.md"],
|
||||
example:{title:"Review 检查清单",text:"安全性:SQL 注入/XSS/密钥泄露检测。性能:N+1 查询/阻塞循环模式识别。可读性:命名清晰度/逻辑简洁度评估。测试:新增代码对应测试覆盖率检查。CI:构建状态验证。支持多视角评审(安全专家/性能工程师/维护者)和对抗式自检(生成 Review → 自己反驳 → 修正后输出)。"} },
|
||||
{ id:"gitlink-search", name:"Search", displayName:"搜索", cat:"aux",
|
||||
desc:"搜索仓库、用户,支持关键词和高级过滤",
|
||||
ver:"1.0.0", files:["SKILL.md","REFERENCE.md","references/(2个文件)","examples/search-workflow.md"] },
|
||||
{ id:"gitlink-user", name:"User", displayName:"用户信息", cat:"aux",
|
||||
desc:"查看当前登录用户、查询用户详情、获取用户项目统计",
|
||||
ver:"1.0.0", files:["SKILL.md","REFERENCE.md","references/(2个文件)","examples/user-workflow.md"] },
|
||||
{ id:"gitlink-org", name:"Org", displayName:"组织管理", cat:"aux",
|
||||
desc:"组织治理与成员管理:扫描成员活跃度、批量邀请/移除成员、团队结构分析",
|
||||
ver:"1.0.0", files:["SKILL.md","REFERENCE.md","examples/org-audit.md"] },
|
||||
{ id:"gitlink-ci", name:"CI", displayName:"CI/CD 诊断", cat:"aux",
|
||||
desc:"检查 DevOps 状态、诊断构建失败、分析 CI 日志、管理构建生命周期",
|
||||
ver:"1.0.0", files:["SKILL.md","REFERENCE.md","examples/ci-devops-check.md"] },
|
||||
{ id:"gitlink-pm", name:"PM", displayName:"项目管理", cat:"aux",
|
||||
desc:"里程碑、看板、周报等项目管理功能",
|
||||
ver:"1.0.0", files:["SKILL.md","REFERENCE.md","examples/pm-workflow.md"] },
|
||||
{ id:"gitlink-workflow", name:"Workflow", displayName:"自动化工作流引擎", cat:"aux",
|
||||
desc:"注册、浏览、执行 5 个预置端到端自动化工作流,规则引擎 + AI 双模式",
|
||||
ver:"1.4.0", files:["SKILL.md","REFERENCE.md","examples/deploy.md","examples/pr-workflow.md"] },
|
||||
{ id:"gitlink-license", name:"License", displayName:"许可证合规检查", cat:"aux",
|
||||
desc:"扫描仓库许可证完整性、依赖许可证合规性、敏感信息泄露风险",
|
||||
ver:"1.0.0", files:["SKILL.md","REFERENCE.md","examples/license-check.md"] },
|
||||
];
|
||||
|
||||
MODULES.forEach(mod => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'module-card';
|
||||
const cmdHTML = mod.commands.map(cmd => {
|
||||
if (cmd.section) {
|
||||
return `<div class="cmd-section">${cmd.section}</div>`;
|
||||
}
|
||||
const uid = `${mod.name}-${cmd.name}`;
|
||||
const paramsHTML = cmd.params.map(p => {
|
||||
if (p.bool) {
|
||||
return `<div class="param-group"><label>${p.l}</label><select id="p-${uid}-${p.k}"><option value="false"${p.v!=='true'?' selected':''}>否</option><option value="true"${p.v==='true'?' selected':''}>是</option></select></div>`;
|
||||
}
|
||||
return `<div class="param-group"><label>${p.l}</label><input id="p-${uid}-${p.k}" value="${p.v}" placeholder="${p.l}"></div>`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="cmd-row">
|
||||
<div class="cmd-top">
|
||||
<span class="cmd-name">+${cmd.name}</span>
|
||||
<span class="cmd-desc">${cmd.desc}</span>
|
||||
<button class="cmd-toggle" onclick="toggleDetail('${uid}')">详情</button>
|
||||
</div>
|
||||
<div class="cmd-detail" id="detail-${uid}">${cmd.detail}</div>
|
||||
<div class="cmd-params" id="params-${uid}">
|
||||
${paramsHTML}
|
||||
<button class="run-btn" onclick="runCommand('${mod.name}','${cmd.name}','${uid}')" id="btn-${uid}">▶ 运行</button>
|
||||
</div>
|
||||
<div class="result-panel" id="result-${uid}"></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const WORKFLOWS = [
|
||||
{ name:"community-ops", category:"运营", triggerType:"poll", triggerOn:"issue.created",
|
||||
desc:"社区运营自动化全链路:Issue 智能分拣 → 生成周报 → 生成 Release Notes",
|
||||
steps:["获取所有开放 Issue","获取标签库","获取成员列表","[AI] 分拣表格并执行打标签/分配","获取项目基础信息","获取已合并 PR","获取提交历史","[AI] 根据指标生成周报","获取版本发布记录","[AI] 分类 commit 生成 Release Notes"] },
|
||||
{ name:"code-quality", category:"质量", triggerType:"poll", triggerOn:"pr.opened",
|
||||
desc:"PR 提交 → Review → CI 检查 → 结果汇总的全自动代码质量门禁",
|
||||
steps:["获取开放 PR 列表","获取 CI 构建状态","获取 PR 变更文件列表","获取 PR 详情","[AI] 代码审查(安全/性能/可读性/测试/CI)","[AI] 生成 Review 意见","[AI] 质量达标则自动提交 Review 评论"] },
|
||||
{ name:"project-init", category:"初始化", triggerType:"manual", triggerOn:"manual",
|
||||
desc:"输入仓库名 → 检查现状 → 补齐缺失(README/LICENSE/标签/里程碑/Issue)",
|
||||
steps:["获取仓库基本信息","检查文件结构","获取已有标签和里程碑","检查分支保护状态","[AI] 对比初始化清单,识别缺失项","[AI] 生成建议的初始化内容","[AI] 自动创建缺失的标签/里程碑/Issue","[AI] 生成项目初始化报告"] },
|
||||
{ name:"multi-repo", category:"协同", triggerType:"cron", triggerOn:"0 9 * * 1",
|
||||
desc:"跨多个仓库的统一 Issue 追踪、PR 状态看板、Release 协调",
|
||||
steps:["获取目标仓库列表","获取每个仓库的开放 Issue","获取每个仓库的开放 PR","获取每个仓库的最新 Release","获取每个仓库的里程碑进度","[AI] 生成跨仓库状态看板"] },
|
||||
{ name:"contributor-growth", category:"成长", triggerType:"cron", triggerOn:"0 9 * * 1",
|
||||
desc:"追踪贡献者活动 → 排行 → 识别新星与流失风险",
|
||||
steps:["获取提交历史","获取 Issue/PR 贡献数据","获取成员列表","[AI] 统计贡献排行","[AI] 计算 30 天活跃度趋势","[AI] 识别新星贡献者和流失风险"] },
|
||||
];
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="module-header" onclick="toggleModule(this)">
|
||||
<h3><span style="color:${mod.color};font-size:1.3em;">■</span> ${mod.title} <span class="tag">${mod.commands.length} 个命令</span></h3>
|
||||
<span class="arrow">▼</span>
|
||||
</div>
|
||||
<div class="module-body collapsed">
|
||||
<p style="padding:12px 20px;color:#666;font-size:0.9em;border-bottom:1px solid #f0f0f0;background:#fafbfc;">${mod.desc}</p>
|
||||
${cmdHTML}
|
||||
</div>`;
|
||||
container.appendChild(card);
|
||||
});
|
||||
const WORKFLOW_COMMANDS = []; // unused
|
||||
|
||||
(function renderPart3() {
|
||||
var container = document.getElementById("workflow-container");
|
||||
|
||||
// Global top bar
|
||||
var globalHTML = '<div class="wf-global">' +
|
||||
'<span>全局命令 </span>' +
|
||||
'<button class="action-btn btn-info" onclick="runWfGlobal(\'list\',\'wf-global-list\')" id="btn-wf-global-list">📋 列出所有工作流</button>' +
|
||||
'<button class="action-btn btn-logs" onclick="runWfGlobal(\'install-systemd\',\'wf-global-install\')" id="btn-wf-global-install">⚙ 生成 systemd 服务</button>' +
|
||||
'</div>' +
|
||||
'<div class="result-panel" id="result-wf-global-list"></div>' +
|
||||
'<div class="result-panel" id="result-wf-global-install"></div>';
|
||||
container.insertAdjacentHTML("beforeend", globalHTML);
|
||||
|
||||
// Each workflow as a panel
|
||||
WORKFLOWS.forEach(function(w) {
|
||||
var stepsHTML = w.steps.map(function(s,i) { return "<li>" + s + "</li>"; }).join("");
|
||||
var triggerLabel = w.triggerType === "manual" ? "手动触发" : w.triggerType === "poll" ? "事件触发: <code>" + w.triggerOn + "</code>" : "定时触发: <code>" + w.triggerOn + "</code>";
|
||||
var uid = "wfpanel-" + w.name;
|
||||
|
||||
var panelHTML =
|
||||
'<div class="wf-panel">' +
|
||||
'<div class="wf-panel-header" onclick="toggleWfPanel(this)">' +
|
||||
'<div class="wf-meta">' +
|
||||
'<span class="wf-dot stopped" id="dot-' + uid + '"></span>' +
|
||||
'<span class="wf-name">' + w.name + '</span>' +
|
||||
'<span class="wf-cat">' + w.category + '</span>' +
|
||||
'</div>' +
|
||||
'<span class="arrow">▼</span>' +
|
||||
'</div>' +
|
||||
'<div class="wf-panel-body collapsed">' +
|
||||
'<div class="wf-panel-desc">' + w.desc + '</div>' +
|
||||
'<div class="wf-panel-trigger">⚡ ' + triggerLabel + '</div>' +
|
||||
'<details class="wf-panel-steps"><summary>' + w.steps.length + ' 个步骤</summary><ol>' + stepsHTML + '</ol></details>' +
|
||||
'<div class="wf-actions">' +
|
||||
'<select class="action-select" id="mode-' + uid + '">' +
|
||||
'<option value="no-ai">规则引擎</option>' +
|
||||
'<option value="ai">AI 增强</option>' +
|
||||
'<option value="auto">自动检测</option>' +
|
||||
'</select>' +
|
||||
'<button class="action-btn btn-run" onclick="runWfOp(\'" + w.name + '"\',\'run\',\'" + uid + '"\')" id="btn-run-' + uid + '">▶ 执行</button>' +
|
||||
'<button class="action-btn btn-start" onclick="runWfOp(\'" + w.name + '"\',\'start\',\'" + uid + '"\')" id="btn-start-' + uid + '">▶ 启动守护</button>' +
|
||||
'<button class="action-btn btn-stop" onclick="runWfOp(\'" + w.name + '"\',\'stop\',\'" + uid + '"\')" id="btn-stop-' + uid + '">⏹ 停止</button>' +
|
||||
'<button class="action-btn btn-status" onclick="runWfOp(\'" + w.name + '"\',\'status\',\'" + uid + '"\')" id="btn-status-' + uid + '">📊 状态</button>' +
|
||||
'<button class="action-btn btn-logs" onclick="runWfOp(\'" + w.name + '"\',\'logs\',\'" + uid + '"\')" id="btn-logs-' + uid + '">📋 日志</button>' +
|
||||
'<button class="action-btn btn-info" onclick="runWfOp(\'" + w.name + '"\',\'info\',\'" + uid + '"\')" id="btn-info-' + uid + '">ℹ 详情</button>' +
|
||||
'</div>' +
|
||||
'<div class="result-panel" id="result-' + uid + '"></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
container.insertAdjacentHTML("beforeend", panelHTML);
|
||||
});
|
||||
})();
|
||||
|
||||
function toggleWfPanel(header) {
|
||||
var body = header.nextElementSibling;
|
||||
var arrow = header.querySelector(".arrow");
|
||||
body.classList.toggle("collapsed");
|
||||
arrow.classList.toggle("open");
|
||||
}
|
||||
|
||||
function toggleModule(header) {
|
||||
const body = header.nextElementSibling;
|
||||
const arrow = header.querySelector('.arrow');
|
||||
var body = header.nextElementSibling;
|
||||
var arrow = header.querySelector('.arrow');
|
||||
body.classList.toggle('collapsed');
|
||||
arrow.classList.toggle('open');
|
||||
}
|
||||
|
||||
function toggleDetail(uid) {
|
||||
const detail = document.getElementById(`detail-${uid}`);
|
||||
const params = document.getElementById(`params-${uid}`);
|
||||
detail.classList.toggle('show');
|
||||
params.classList.toggle('show');
|
||||
var detail = document.getElementById('detail-' + uid);
|
||||
var params = document.getElementById('params-' + uid);
|
||||
if (detail) detail.classList.toggle('show');
|
||||
if (params) params.classList.toggle('show');
|
||||
}
|
||||
|
||||
function getArgs(uid, params) {
|
||||
return params.map(p => {
|
||||
const val = document.getElementById(`p-${uid}-${p.k}`).value.trim();
|
||||
// Boolean flag: only pass --flag when value is "true", omit for false
|
||||
if (p.bool) {
|
||||
return val === 'true' ? `--${p.k}` : '';
|
||||
}
|
||||
return params.map(function(p) {
|
||||
var el = document.getElementById('p-' + uid + '-' + p.k);
|
||||
if (!el) return '';
|
||||
var val = el.value.trim();
|
||||
if (p.bool) return val === 'true' ? '--' + p.k : '';
|
||||
if (!val) return '';
|
||||
// Use = syntax for bool values (pflag requires it)
|
||||
if (val === 'true' || val === 'false') {
|
||||
return `--${p.k}=${val}`;
|
||||
}
|
||||
if (val.includes(' ')) {
|
||||
return `--${p.k} "${val}"`;
|
||||
}
|
||||
return `--${p.k} ${val}`;
|
||||
if (val === 'true' || val === 'false') return '--' + p.k + '=' + val;
|
||||
if (val.indexOf(' ') >= 0) return '--' + p.k + ' "' + val + '"';
|
||||
return '--' + p.k + ' ' + val;
|
||||
}).filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
async function runCommand(module, command, uid) {
|
||||
const btn = document.getElementById(`btn-${uid}`);
|
||||
const panel = document.getElementById(`result-${uid}`);
|
||||
const mod = MODULES.find(m => m.name === module);
|
||||
const cmd = mod.commands.find(c => c.name === command);
|
||||
const args = getArgs(uid, cmd.params);
|
||||
const apiModule = cmd.module || module;
|
||||
|
||||
var btn = document.getElementById('btn-' + uid);
|
||||
var panel = document.getElementById('result-' + uid);
|
||||
var mod = MODULES.find(function(m) { return m.name === module; });
|
||||
var cmd = mod.commands.find(function(c) {
|
||||
var expectedUid = c.module ? module + '-' + c.module + '-' + c.name : module + '-' + c.name;
|
||||
return expectedUid === uid;
|
||||
});
|
||||
if (!cmd) return;
|
||||
var args = getArgs(uid, cmd.params);
|
||||
var apiModule = cmd.module || module;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '运行中...';
|
||||
|
||||
let url = `/api/run?module=${apiModule}&command=${command}`;
|
||||
if (args) url += `&args=${encodeURIComponent(args)}`;
|
||||
|
||||
var url = '/api/run?module=' + apiModule + '&command=' + command + '&format=' + currentFormat;
|
||||
if (mod.repoSelector) {
|
||||
var ownerEl = document.getElementById('repo-owner-' + module);
|
||||
var repoEl = document.getElementById('repo-name-' + module);
|
||||
if (ownerEl && repoEl) {
|
||||
url += '&owner=' + encodeURIComponent(ownerEl.value.trim());
|
||||
url += '&repo=' + encodeURIComponent(repoEl.value.trim());
|
||||
}
|
||||
}
|
||||
if (args) url += '&args=' + encodeURIComponent(args);
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
const statusClass = data.ok ? 'ok' : 'err';
|
||||
const statusText = data.ok ? '成功' : '失败';
|
||||
|
||||
let output;
|
||||
var resp = await fetch(url);
|
||||
var data = await resp.json();
|
||||
var statusClass = data.ok ? 'ok' : 'err';
|
||||
var statusText = data.ok ? '成功' : '失败';
|
||||
var output, isTable = false;
|
||||
if (!data.ok && data.error) {
|
||||
output = data.error;
|
||||
} else if (data.output === null || data.output === undefined) {
|
||||
output = '(操作完成,无返回数据)';
|
||||
} else if (typeof data.output === 'string') {
|
||||
output = data.output || '(操作完成,无返回数据)';
|
||||
if (currentFormat === 'table' && /^.+\n-{2,}/.test(output)) isTable = true;
|
||||
} else {
|
||||
const s = JSON.stringify(data.output, null, 2);
|
||||
var s = JSON.stringify(data.output, null, 2);
|
||||
output = (s === 'null' || s === '{}' || s === '""') ? '(操作完成,无返回数据)' : s;
|
||||
}
|
||||
panel.innerHTML = `
|
||||
<div class="result-toolbar">
|
||||
<span class="cmd-text">$ ${data.command || module + ' +' + command}</span>
|
||||
<span class="status ${statusClass}">${statusText}</span>
|
||||
<span class="close-btn" onclick="panel.classList.remove('show')">×</span>
|
||||
</div>
|
||||
<div class="result-body"><pre>${escapeHtml(output)}</pre></div>`;
|
||||
var bodyHTML = isTable ? '<div class="result-body" style="overflow-x:auto">' + tableToHtml(output) + '</div>' : '<div class="result-body"><pre>' + escapeHtml(output) + '</pre></div>';
|
||||
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ ' + (data.command || module + ' +' + command) + '</span><span class="status ' + statusClass + '">' + statusText + '</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">×</span></div>' + bodyHTML;
|
||||
panel.classList.add('show');
|
||||
} catch (e) {
|
||||
panel.innerHTML = `
|
||||
<div class="result-toolbar">
|
||||
<span class="cmd-text">$ ${module} +${command}</span>
|
||||
<span class="status err">请求失败</span>
|
||||
<span class="close-btn" onclick="panel.classList.remove('show')">×</span>
|
||||
</div>
|
||||
<div class="result-body"><pre>${escapeHtml(e.message)}</pre></div>`;
|
||||
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ ' + module + ' +' + command + '</span><span class="status err">请求失败</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">×</span></div><div class="result-body"><pre>' + escapeHtml(e.message) + '</pre></div>';
|
||||
panel.classList.add('show');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = '▶ 运行';
|
||||
}
|
||||
|
||||
async function runWfGlobal(command, uid) {
|
||||
var btn = document.getElementById('btn-' + uid);
|
||||
var panel = document.getElementById('result-' + uid);
|
||||
btn.disabled = true;
|
||||
var url = '/api/run?module=workflow&command=' + command + '&format=' + currentFormat;
|
||||
try {
|
||||
var resp = await fetch(url);
|
||||
var data = await resp.json();
|
||||
var sc = data.ok ? 'ok' : 'err', st = data.ok ? '成功' : '失败';
|
||||
var out = formatOutput(data);
|
||||
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ ' + (data.command || 'workflow +' + command) + '</span><span class="status ' + sc + '">' + st + '</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">×</span></div><div class="result-body"><pre>' + escapeHtml(out) + '</pre></div>';
|
||||
panel.classList.add('show');
|
||||
} catch(e) {
|
||||
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ workflow +' + command + '</span><span class="status err">请求失败</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">×</span></div><div class="result-body"><pre>' + escapeHtml(e.message) + '</pre></div>';
|
||||
panel.classList.add('show');
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
async function runWfOp(wfName, op, uid) {
|
||||
var btn = document.getElementById('btn-' + op + '-' + uid);
|
||||
var panel = document.getElementById('result-' + uid);
|
||||
btn.disabled = true;
|
||||
|
||||
var url = '/api/run?module=workflow&command=' + op + '&format=' + currentFormat;
|
||||
if (op === 'run') {
|
||||
var mode = document.getElementById('mode-' + uid).value;
|
||||
if (mode === 'no-ai') url += '&args=--name%20' + wfName + '%20--no-ai=true';
|
||||
else if (mode === 'ai') url += '&args=--name%20' + wfName + '%20--ai=true';
|
||||
else url += '&args=--name%20' + wfName;
|
||||
} else if (op === 'start') {
|
||||
url += '&args=--name%20' + wfName + '%20--interval%205m';
|
||||
} else {
|
||||
url += '&args=--name%20' + wfName;
|
||||
}
|
||||
|
||||
try {
|
||||
var resp = await fetch(url);
|
||||
var data = await resp.json();
|
||||
var sc = data.ok ? 'ok' : 'err', st = data.ok ? '成功' : '失败';
|
||||
var out = formatOutput(data);
|
||||
|
||||
// Update dot indicator
|
||||
if (op === 'start' && data.ok) {
|
||||
var dot = document.getElementById('dot-' + uid);
|
||||
if (dot) { dot.classList.remove('stopped'); dot.classList.add('running'); }
|
||||
} else if (op === 'stop' && data.ok) {
|
||||
var dot = document.getElementById('dot-' + uid);
|
||||
if (dot) { dot.classList.remove('running'); dot.classList.add('stopped'); }
|
||||
}
|
||||
|
||||
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ ' + (data.command || 'workflow +' + op + ' --name ' + wfName) + '</span><span class="status ' + sc + '">' + st + '</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">×</span></div><div class="result-body"><pre>' + escapeHtml(out) + '</pre></div>';
|
||||
panel.classList.add('show');
|
||||
} catch(e) {
|
||||
panel.innerHTML = '<div class="result-toolbar"><span class="cmd-text">$ workflow +' + op + ' --name ' + wfName + '</span><span class="status err">请求失败</span><span class="close-btn" onclick="this.closest(\'.result-panel\').classList.remove(\'show\')">×</span></div><div class="result-body"><pre>' + escapeHtml(e.message) + '</pre></div>';
|
||||
panel.classList.add('show');
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function formatOutput(data) {
|
||||
if (!data.ok && data.error) return data.error;
|
||||
if (data.output === null || data.output === undefined) return '(操作完成,无返回数据)';
|
||||
if (typeof data.output === 'string') return data.output || '(操作完成,无返回数据)';
|
||||
var s = JSON.stringify(data.output, null, 2);
|
||||
return (s === 'null' || s === '{}' || s === '""') ? '(操作完成,无返回数据)' : s;
|
||||
}
|
||||
|
||||
function tableToHtml(text) {
|
||||
var lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return '<pre>' + escapeHtml(text) + '</pre>';
|
||||
var splitRow = function(line) { return line.split(/\s{2,}/).map(function(c) { return c.trim(); }).filter(function(c) { return c !== ''; }); };
|
||||
var headers = splitRow(lines[0]);
|
||||
if (headers.length < 2) return '<pre>' + escapeHtml(text) + '</pre>';
|
||||
var thead = '<thead><tr>' + headers.map(function(h) { return '<th>' + escapeHtml(h) + '</th>'; }).join('') + '</tr></thead>';
|
||||
var tbody = ['<tbody>'];
|
||||
for (var i = 2; i < lines.length; i++) {
|
||||
var cells = splitRow(lines[i]);
|
||||
tbody.push('<tr>' + cells.map(function(c) { return '<td>' + escapeHtml(c) + '</td>'; }).join('') + '</tr>');
|
||||
}
|
||||
tbody.push('</tbody>');
|
||||
return '<table>' + thead + tbody.join('') + '</table>';
|
||||
}
|
||||
|
||||
function escapeHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
(function setupNav() {
|
||||
var sections = ['p1','p2','p3','p4'].map(function(id) { return document.getElementById(id); });
|
||||
var links = document.querySelectorAll('.nav-bar a');
|
||||
window.addEventListener('scroll', function() {
|
||||
var current = sections[0].id;
|
||||
sections.forEach(function(s) { if (window.scrollY >= s.offsetTop - 100) current = s.id; });
|
||||
links.forEach(function(a) { a.classList.toggle('active', a.getAttribute('href') === '#' + current); });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ func main() {
|
|||
command := r.URL.Query().Get("command")
|
||||
owner := r.URL.Query().Get("owner")
|
||||
repo := r.URL.Query().Get("repo")
|
||||
format := r.URL.Query().Get("format")
|
||||
extraArgs := r.URL.Query().Get("args")
|
||||
|
||||
if module == "" || command == "" {
|
||||
|
|
@ -57,8 +58,11 @@ func main() {
|
|||
repo = "gitlink-cli"
|
||||
}
|
||||
}
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
args := []string{module, "+" + command, "--owner", owner, "--repo", repo, "--format", "json"}
|
||||
args := []string{module, "+" + command, "--owner", owner, "--repo", repo, "--format", format}
|
||||
if extraArgs != "" {
|
||||
args = append(args, parseShellArgs(extraArgs)...)
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -108,8 +108,24 @@ skills/
|
|||
│ └── ci-workflow.md # CI 工作流
|
||||
├── gitlink-pm/ # 项目管理
|
||||
│ └── SKILL.md # PM 操作指南
|
||||
└── gitlink-workflow/ # AI 自动化工作流
|
||||
└── SKILL.md # 工作流模板(Issue 分类、PR Review、Release Notes)
|
||||
├── gitlink-workflow/ # AI 自动化工作流
|
||||
│ └── SKILL.md # 工作流模板(Issue 分类、PR Review、Release Notes)
|
||||
├── gitlink-health/ # 项目健康度报告
|
||||
│ ├── SKILL.md # 健康度报告操作指南
|
||||
│ ├── REFERENCE.md # 指标体系与评分算法
|
||||
│ └── examples/
|
||||
│ ├── health-report.md # 完整健康度报告示例
|
||||
│ └── weekly-report.md # 周报模式示例
|
||||
├── gitlink-changelog/ # Release Notes 自动生成
|
||||
│ ├── SKILL.md # Changelog 操作指南
|
||||
│ ├── REFERENCE.md # Commit 分类规则与字段映射
|
||||
│ └── examples/
|
||||
│ └── generate-release-notes.md # Release Notes 生成示例
|
||||
└── gitlink-triage/ # Issue 智能分拣与新人引导
|
||||
├── SKILL.md # 分拣操作指南
|
||||
├── REFERENCE.md # 标签体系与分配算法
|
||||
└── examples/
|
||||
└── batch-triage.md # 批量分拣示例
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -138,6 +154,15 @@ skills/
|
|||
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
|
||||
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
|
||||
|
||||
### 🆕 智能 Skills(v1.2 新增)
|
||||
|
||||
| Skill | 说明 | 场景 |
|
||||
|-------|------|------|
|
||||
| **gitlink-health** | 项目健康度报告 | Issue 响应时间、PR 合并效率、贡献者活跃度统计 |
|
||||
| **gitlink-changelog** | Release Notes 自动生成 | 从 commit/PR/Issue 历史自动生成版本说明 |
|
||||
| **gitlink-triage** | Issue 智能分拣 + 新人引导 | 自动分类、打标签、分配责任人、good-first-issue 引导 |
|
||||
| **gitlink-review** | 智能代码审查 | 分析 PR diff,多视角评审 + 对抗式自检,结构化 Review 意见自动评论 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 使用场景
|
||||
|
|
@ -304,6 +329,23 @@ AI 代理可以:
|
|||
|
||||
---
|
||||
|
||||
## 🤖 在 Claude Code 中通过 Skill 工具调用
|
||||
|
||||
默认情况下,这些 Skill 以**文档驱动**方式使用:AI 代理读取 `SKILL.md` 后执行 `gitlink-cli` 命令(见上文流程图)。
|
||||
|
||||
若希望像标准 Skill 一样,由 Claude Code 的 **Skill 工具**直接加载并调用(例如 `Skill gitlink-issue`),需要把 `skills/gitlink-*` 链接到 Claude Code 扫描的 `~/.claude/skills/` 目录。
|
||||
|
||||
仓库提供一键脚本,自动检测平台并创建链接(**Windows 用目录联接 junction,无需管理员权限;macOS/Linux 用 symlink**),均指向仓库内的 `skills/`,更新仓库后内容自动同步:
|
||||
|
||||
```bash
|
||||
# 在仓库根目录执行(Windows 需 Git Bash 或 WSL)
|
||||
bash scripts/setup-skills.sh
|
||||
```
|
||||
|
||||
执行后即可在 Claude Code 中通过 Skill 工具调用任意 `gitlink-*` Skill。脚本可重复执行,会安全更新已有链接——移除时只删 junction/symlink 本身,**不会影响 `skills/` 下的源文件**。
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试状态
|
||||
|
||||
✅ **生产就绪** (8.5/10)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
# gitlink-branch 参考手册
|
||||
|
||||
> 本文档定义分支管理命令的参数说明、API 字段映射和注意事项。
|
||||
|
||||
---
|
||||
|
||||
## 一、命令参考
|
||||
|
||||
### branch +list
|
||||
|
||||
列出仓库所有分支。
|
||||
|
||||
```bash
|
||||
gitlink-cli branch +list --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
返回字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` | 分支名称 |
|
||||
| `protected` | 是否受保护(true/false) |
|
||||
|
||||
实测返回(chroe/gitlink-cli):
|
||||
|
||||
```
|
||||
master, test/pr-workflow-demo, pr/showcase-dashboard, pr/shortcuts-modules,
|
||||
fix/showcase-member-v2, fix/showcase-member-org, fix/batch-invite-userid,
|
||||
feat/task-a-shortcuts, fix/windows-npm-install
|
||||
共 9 个分支
|
||||
```
|
||||
|
||||
### branch +create
|
||||
|
||||
从指定分支或 commit 创建新分支。
|
||||
|
||||
```bash
|
||||
gitlink-cli branch +create --owner <owner> --repo <repo> \
|
||||
--name <新分支名> --from <源分支> --format json
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name, -n` | 是 | 新分支名称 |
|
||||
| `--from, -f` | 否 | 源分支或 commit(默认 master) |
|
||||
|
||||
API:
|
||||
|
||||
```
|
||||
POST /v1/{owner}/{repo}/branches.json
|
||||
Body: { "branch_name": name, "ref": from }
|
||||
```
|
||||
|
||||
### branch +delete
|
||||
|
||||
删除指定分支。
|
||||
|
||||
```bash
|
||||
gitlink-cli branch +delete --owner <owner> --repo <repo> \
|
||||
--name <分支名> --format json
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name, -n` | 是 | 要删除的分支名称 |
|
||||
|
||||
> ⚠️ **写入操作** — 删除分支不可恢复,执行前确认用户意图。不能删除默认分支。
|
||||
|
||||
### branch +protect
|
||||
|
||||
设置分支保护。
|
||||
|
||||
```bash
|
||||
gitlink-cli branch +protect --owner <owner> --repo <repo> \
|
||||
--name <分支名> --format json
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name, -n` | 是 | 要保护的分支名称 |
|
||||
|
||||
> ⚠️ 受保护的分支不允许直接 push 和强制推送。
|
||||
|
||||
---
|
||||
|
||||
## 二、分支命名约定
|
||||
|
||||
| 前缀 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `feat/` | 新功能 | `feat/task-a-shortcuts` |
|
||||
| `fix/` | Bug 修复 | `fix/showcase-member-v2` |
|
||||
| `pr/` | PR 相关 | `pr/showcase-dashboard` |
|
||||
| `test/` | 测试 | `test/pr-workflow-demo` |
|
||||
|
||||
---
|
||||
|
||||
## 三、常见问题
|
||||
|
||||
### Q: branch +list 返回的分支数量很多怎么办?
|
||||
|
||||
A: 正常现象,开发过程中会积累功能分支。定期清理已合并的分支。
|
||||
|
||||
### Q: 能删除 master 分支吗?
|
||||
|
||||
A: 不能。GitLink 不允许删除默认分支。
|
||||
|
||||
### Q: branch +protect 和仓库设置里的保护有什么区别?
|
||||
|
||||
A: 效果相同,CLI 命令是仓库设置的快捷方式。
|
||||
|
|
@ -1,70 +1,44 @@
|
|||
# 分支管理完整工作流
|
||||
# 示例:分支管理(真实数据)
|
||||
|
||||
本示例演示一个完整的分支管理流程:查看分支 → 创建分支 → 开发推送 → 保护分支 → 清理旧分支。
|
||||
> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-09 在 Claude Code 中实际执行。
|
||||
|
||||
## 场景描述
|
||||
---
|
||||
|
||||
你正在维护一个 GitLink 仓库,需要开发一个新功能。开发完成后需要保护主分支,并清理已完成的功能分支。
|
||||
|
||||
## 步骤
|
||||
|
||||
### 1. 查看当前所有分支
|
||||
## 执行命令序列
|
||||
|
||||
```bash
|
||||
gitlink-cli branch +list
|
||||
# Step 1: 查看所有分支
|
||||
gitlink-cli branch +list --owner chroe --repo gitlink-cli --format json
|
||||
# 结果: 9 个分支
|
||||
# master protected=False
|
||||
# test/pr-workflow-demo protected=False
|
||||
# pr/showcase-dashboard protected=False
|
||||
# pr/shortcuts-modules protected=False
|
||||
# fix/showcase-member-v2 protected=False
|
||||
# fix/showcase-member-org protected=False
|
||||
# fix/batch-invite-userid protected=False
|
||||
# feat/task-a-shortcuts protected=False
|
||||
# fix/windows-npm-install protected=False
|
||||
|
||||
# Step 2: 保护 master 分支
|
||||
gitlink-cli branch +protect --owner chroe --repo gitlink-cli --name master --format json
|
||||
# 结果: ok=True
|
||||
# branch_name=master, created_at=2026-06-13 10:47
|
||||
```
|
||||
|
||||
输出示例:
|
||||
```
|
||||
Branch Protected
|
||||
master Yes
|
||||
develop No
|
||||
feature/old-login No
|
||||
## 完整工作流
|
||||
|
||||
```
|
||||
用户:"帮我看看仓库有哪些分支,把 master 保护一下"
|
||||
|
||||
### 2. 创建新功能分支
|
||||
|
||||
```bash
|
||||
gitlink-cli branch +create --name feature/new-auth --from develop
|
||||
```
|
||||
|
||||
输出确认分支创建成功。
|
||||
|
||||
### 3. 本地开发并推送
|
||||
|
||||
```bash
|
||||
git checkout -b feature/new-auth
|
||||
# ... 编写代码 ...
|
||||
git add .
|
||||
git commit -m "feat: add new auth module"
|
||||
git push gitlink feature/new-auth
|
||||
```
|
||||
|
||||
### 4. 创建 PR 并合并
|
||||
|
||||
通过 `gitlink-cli pr +create` 创建 PR,审查后合并。合并后,功能分支 `feature/new-auth` 将自动删除(取决于仓库设置)。
|
||||
|
||||
### 5. 保护主分支
|
||||
|
||||
确保 `master` 分支有保护规则,防止误删或直接推送:
|
||||
|
||||
```bash
|
||||
gitlink-cli branch +protect --name master
|
||||
```
|
||||
|
||||
### 6. 清理旧分支(可选)
|
||||
|
||||
开发完成后,删除不再需要的旧分支:
|
||||
|
||||
```bash
|
||||
# 确认旧分支已合并或无保留价值后
|
||||
gitlink-cli branch +delete --name feature/old-login
|
||||
AI 执行流程:
|
||||
1. branch +list → 列出所有分支,发现 9 个分支,master 未保护
|
||||
2. branch +protect --name master → 保护 master 分支
|
||||
3. 报告结果:master 已保护,其他 8 个功能分支可以按需清理
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `branch +delete` 是 **Destructive Operation**,操作前请确认分支内容已合并或无保留价值
|
||||
- `branch +protect` 会对分支施加保护规则,影响后续的推送和合并流程
|
||||
- `branch +unprotect` 仅支持简单分支名(如 `main`),含 `/` 的路径需通过 Web 页面操作
|
||||
- 建议使用 `branch +list` 先查看分支状态,再执行写入/删除操作
|
||||
- 建议使用 `branch +list` 先查看分支状态,再执行写入/删除操作
|
||||
- `branch +delete` 是写入操作,执行前确认用户意图
|
||||
- 不能删除默认分支(master)
|
||||
- 建议先 `branch +list` 查看状态再执行写入操作
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
# gitlink-changelog 参考手册
|
||||
|
||||
> 本文档定义 Release Notes 自动生成的分类规则、数据字段和 API 注意事项。
|
||||
|
||||
---
|
||||
|
||||
## 一、Commit 分类规则(详细版)
|
||||
|
||||
### 主分类
|
||||
|
||||
| 分类 | 图标 | 前缀关键词 | 优先级 |
|
||||
|------|------|-----------|--------|
|
||||
| 新功能 | ✨ | `feat`, `feature`, `add`, `新增`, `支持` | 高 |
|
||||
| Bug 修复 | 🐛 | `fix`, `bugfix`, `hotfix`, `修复`, `解决` | 高 |
|
||||
| 破坏性变更 | ⚠️ | 标题含 `!`(如 `feat!:`)或 body 含 `BREAKING CHANGE` | 最高 |
|
||||
| 改进优化 | 🔧 | `refactor`, `perf`, `improve`, `优化`, `增强`, `完善`, `enhance` | 中 |
|
||||
| 文档更新 | 📚 | `docs`, `doc`, `文档`, `README`, `注释` | 低 |
|
||||
| 测试 | 🧪 | `test`, `tests`, `测试` | 低 |
|
||||
| 构建/CI | 🏗️ | `build`, `ci`, `chore`, `构建`, `部署`, `Docker` | 低 |
|
||||
| 样式 | 💄 | `style`, `fmt`, `格式化` | 最低 |
|
||||
|
||||
### 分类逻辑
|
||||
|
||||
```
|
||||
1. 优先检测破坏性变更(!后缀 或 BREAKING CHANGE body)
|
||||
2. 检查前缀是否匹配已知分类
|
||||
3. 无前缀时,AI 根据 commit message 语义判断:
|
||||
- 包含"新增"、"添加" → 新功能
|
||||
- 包含"修复"、"解决"、"fix" → Bug 修复
|
||||
- 包含"优化"、"改进"、"重构" → 改进优化
|
||||
- 其他 → 归入"其他变更"分类
|
||||
```
|
||||
|
||||
### 模块识别
|
||||
|
||||
从 commit message 或 PR 标题中提取涉及的模块:
|
||||
|
||||
| 模式 | 示例 | 提取模块 |
|
||||
|------|------|---------|
|
||||
| `feat(<module>):` | `feat(wiki): add create` | wiki |
|
||||
| `fix <module>:` | `fix batch operations:` | batch |
|
||||
| 中文括号 | `新增 wiki 模块` | wiki |
|
||||
| 文件路径 | `shortcuts/wiki/wiki.go` | wiki |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据字段映射
|
||||
|
||||
### commit +list 关键字段
|
||||
|
||||
```json
|
||||
{
|
||||
"sha": "abc123def456",
|
||||
"message": "feat(wiki): add create and update commands",
|
||||
"author": {
|
||||
"login": "username",
|
||||
"name": "用户名",
|
||||
"image_url": "..."
|
||||
},
|
||||
"timestamp": "2026-06-01T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `message` | 分类依据,提取功能描述 |
|
||||
| `author.login` | 贡献者归属 |
|
||||
| `timestamp` | 判断是否属于当前版本区间 |
|
||||
| `sha` | 可作为变更追溯引用 |
|
||||
|
||||
### pr +list / pr +view 关键字段
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 42,
|
||||
"title": "feat(wiki): add wiki management commands",
|
||||
"body": "## 变更说明\n...",
|
||||
"pull_request_status": 1,
|
||||
"created_at": "2026-06-01T10:00:00+08:00",
|
||||
"updated_at": "2026-06-02T15:00:00+08:00",
|
||||
"merged_at": "2026-06-02T15:00:00+08:00",
|
||||
"user": { "login": "username", "name": "用户名" },
|
||||
"head": "feature/wiki",
|
||||
"base": "master"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `title` | 分类依据 |
|
||||
| `body` | 提取详细变更说明 |
|
||||
| `merged_at` | 判断是否属于当前版本区间 |
|
||||
| `user.login` | 贡献者归属 |
|
||||
| `id` | PR 编号引用 |
|
||||
|
||||
### issue +list 关键字段
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"project_issues_index": 15,
|
||||
"subject": "Wiki 中文路径乱码",
|
||||
"description": "...",
|
||||
"created_on": "2026-05-28T10:00:00+08:00",
|
||||
"updated_on": "2026-06-01T15:00:00+08:00",
|
||||
"status": { "id": 5, "name": "关闭" },
|
||||
"author": { "login": "username" },
|
||||
"tags": [{ "id": 1, "name": "bug" }]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `subject` | Issue 标题引用 |
|
||||
| `status.id === 5` | 已关闭 |
|
||||
| `updated_on` | 判断关闭时间是否在版本区间内 |
|
||||
| `tags` | 辅助分类(bug 标签 → Bug 修复) |
|
||||
| `project_issues_index` | Issue 编号引用(#15) |
|
||||
|
||||
### release +list 关键字段
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 3,
|
||||
"tag_name": "v1.1.0",
|
||||
"name": "v1.1.0",
|
||||
"body": "## ✨ 新功能\n...",
|
||||
"created_at": "2026-05-15T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `tag_name` | 版本号,确定版本基线 |
|
||||
| `created_at` | 版本发布时间,作为时间基线 |
|
||||
|
||||
---
|
||||
|
||||
## 三、版本区间计算
|
||||
|
||||
### 方法 1:基于上一个 Release 的时间戳
|
||||
|
||||
```
|
||||
版本区间开始 = release +list 中最新一条的 created_at
|
||||
版本区间结束 = 当前时间
|
||||
```
|
||||
|
||||
### 方法 2:基于 Git Tag 对比(已废弃)
|
||||
|
||||
> ⚠️ **此方法不可用。** compare API 实测返回 HTML 而非 JSON,无法使用。请改用方法 1 或方法 3。
|
||||
|
||||
### 方法 3:无历史版本时
|
||||
|
||||
```
|
||||
版本区间开始 = repo +info 的 created_on(项目创建时间)
|
||||
版本区间结束 = 当前时间
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Release Notes 发布注意事项
|
||||
|
||||
1. **`release +create` 需要 `--tag` 参数**:即 Git tag 名称(如 `v1.2.0`)
|
||||
2. **`--body` 中的换行**:使用 `\n` 转义,或由 CLI 内部处理多行文本
|
||||
3. **`release +view` 使用 `version_id` 而非 tag_name**:创建后从 `release +list` 获取 `version_id`
|
||||
4. **发布前确认**:写入操作前必须确认用户意图
|
||||
5. **Tag 必须存在**:如果 tag 不存在于仓库,需要先通过 git 创建 tag 并推送
|
||||
|
||||
---
|
||||
|
||||
## 五、常见问题
|
||||
|
||||
### Q: Commit message 不规范怎么办?
|
||||
|
||||
> ⚠️ **注意**:compare API (`api GET /:owner/:repo/compare/...`) 实测返回 HTML 而非 JSON,不可使用。请改用 `commit +list` 按时间戳筛选版本区间内的提交。
|
||||
|
||||
A: AI 根据 message 内容语义自动判断分类,无需严格遵循 Conventional Commits。但规范的 message 能显著提高分类准确度。
|
||||
|
||||
### Q: 如何处理合并提交?
|
||||
|
||||
A: 合并提交(merge commit)的 message 通常为 `Merge pull request #xx`,AI 应跳过这些提交,改为从对应的 PR 记录获取变更信息。
|
||||
|
||||
### Q: 大量提交时如何处理?
|
||||
|
||||
A: 使用分页参数逐步采集,AI 可先统计总量再决定是否需要完整采集。对于超过 100 条提交的版本,建议按模块汇总而非逐条列出。
|
||||
|
||||
### Q: 如何生成英文 Release Notes?
|
||||
|
||||
A: 在提示 AI 时指定"Generate Release Notes in English"。Skill 支持中英文输出,由 AI 根据用户语言偏好决定。
|
||||
|
||||
### Q: PR `--state merged` 返回的数据准确吗?
|
||||
|
||||
A: 注意 GitLink 的 `--state` 参数仅影响统计计数,返回列表可能包含所有状态的 PR。需要通过 `pull_request_status` 字段客户端过滤:`pull_request_status === 1` 才是真正已合并的 PR。
|
||||
|
||||
### Q: 项目无历史 Release 时怎么确定版本区间?
|
||||
|
||||
A: 使用 `repo +info` 获取项目创建时间(`created_on`),作为版本区间的起始时间。无需指定 base 版本。
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
---
|
||||
name: gitlink-changelog
|
||||
version: 1.0.0
|
||||
description: "Release Notes 自动生成:从 commit、PR、Issue 历史自动分析并生成结构化版本说明。当用户需要生成 Changelog、Release Notes、版本发布说明时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli --help"
|
||||
---
|
||||
|
||||
# gitlink-changelog(Release Notes 自动生成)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 分类 commit 和生成 Release Notes 前,必须先阅读 [`REFERENCE.md`](REFERENCE.md),其中包含完整的分类规则、优先级逻辑和 API 字段映射。**
|
||||
**CRITICAL — 本 Skill 的数据采集阶段仅读取数据;最终发布 Release 时为写入操作,发布前务必确认用户意图。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## 概述
|
||||
|
||||
本 Skill 从 GitLink 项目的提交历史、PR 记录和 Issue 数据中自动提取版本变更信息,由 AI 分析分类后生成结构化的 Release Notes / Changelog。支持一键发布到 GitLink Release。
|
||||
|
||||
## 数据采集命令
|
||||
|
||||
### 第一步:获取版本基线
|
||||
|
||||
```bash
|
||||
# 获取上一个 Release 信息(确定版本基线)
|
||||
gitlink-cli release +list --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
### 第二步:获取提交历史
|
||||
|
||||
```bash
|
||||
# 获取提交列表(AI 根据上一个 Release 的时间筛选新增提交)
|
||||
gitlink-cli commit +list --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 注意:提交可能需要翻页,用 --page 参数获取全部
|
||||
gitlink-cli commit +list --owner <owner> --repo <repo> --page 2 --format json
|
||||
```
|
||||
|
||||
> ⚠️ **不要使用 compare API**。`api GET /:owner/:repo/compare/...` 实测会返回 HTML 而非 JSON,无法用于变更对比。请改用 `commit +list` 按时间戳筛选。
|
||||
|
||||
### 第三步:获取 PR 记录
|
||||
|
||||
```bash
|
||||
# 获取已合并的 PR 列表
|
||||
gitlink-cli pr +list --owner <owner> --repo <repo> --state merged --format json
|
||||
|
||||
# 查看具体 PR 详情(获取关联 Issue 和变更内容)
|
||||
gitlink-cli pr +view --owner <owner> --repo <repo> --id <pr_id> --format json
|
||||
|
||||
# 查看 PR 变更文件
|
||||
gitlink-cli pr +files --owner <owner> --repo <repo> --id <pr_id> --format json
|
||||
```
|
||||
|
||||
### 第四步:获取已关闭 Issue
|
||||
|
||||
```bash
|
||||
# 获取已关闭的 Issue
|
||||
gitlink-cli issue +list --owner <owner> --repo <repo> --state closed --format json
|
||||
```
|
||||
|
||||
## Release Notes 输出格式
|
||||
|
||||
AI 根据采集到的数据,按以下结构生成 Release Notes:
|
||||
|
||||
```markdown
|
||||
# 🚀 <版本号> (<发布日期>)
|
||||
|
||||
## 📝 版本摘要
|
||||
|
||||
<一段话总结本次版本的核心变更>
|
||||
|
||||
## ✨ 新功能 (Features)
|
||||
|
||||
- **<模块>**: <功能描述> (#<PR号>) by @<作者>
|
||||
- ...
|
||||
|
||||
## 🐛 问题修复 (Bug Fixes)
|
||||
|
||||
- **<模块>**: <修复描述> (#<Issue号>) by @<修复者>
|
||||
- ...
|
||||
|
||||
## 🔧 改进优化 (Improvements)
|
||||
|
||||
- **<模块>**: <改进描述> (#<PR号>)
|
||||
- ...
|
||||
|
||||
## 📚 文档更新 (Documentation)
|
||||
|
||||
- <文档变更描述>
|
||||
- ...
|
||||
|
||||
## 🧪 测试 (Tests)
|
||||
|
||||
- <新增/改进的测试描述>
|
||||
- ...
|
||||
|
||||
## 🏗️ 构建/CI (Build & CI)
|
||||
|
||||
- <构建或 CI 变更描述>
|
||||
- ...
|
||||
|
||||
## ⚠️ 破坏性变更 (Breaking Changes)
|
||||
|
||||
- <破坏性变更描述及迁移指南>
|
||||
> 如果没有破坏性变更,此节不显示
|
||||
|
||||
## 🙏 贡献者
|
||||
|
||||
感谢以下贡献者参与本版本开发:
|
||||
|
||||
@contributor-a, @contributor-b, @contributor-c
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
| 类型 | 数量 |
|
||||
|------|------|
|
||||
| 新功能 | N |
|
||||
| Bug 修复 | N |
|
||||
| 改进优化 | N |
|
||||
| 文档更新 | N |
|
||||
| 测试 | N |
|
||||
| 合计 | N |
|
||||
```
|
||||
|
||||
## Commit 分类规则
|
||||
|
||||
AI 根据 commit message 的关键词自动分类:
|
||||
|
||||
| 分类 | 关键词前缀 | 示例 |
|
||||
|------|-----------|------|
|
||||
| ✨ 新功能 | `feat`, `feature`, `add`, `新增`, `支持` | `feat: add wiki module` |
|
||||
| 🐛 Bug 修复 | `fix`, `bugfix`, `hotfix`, `修复`, `解决` | `fix: resolve wiki encoding issue` |
|
||||
| 🔧 改进优化 | `refactor`, `perf`, `improve`, `优化`, `增强`, `完善`, `enhance` | `refactor: simplify batch logic` |
|
||||
| 📚 文档更新 | `docs`, `doc`, `文档`, `README`, `注释` | `docs: update API reference` |
|
||||
| 🧪 测试 | `test`, `tests`, `测试` | `test: add wiki module tests` |
|
||||
| 🏗️ 构建/CI | `build`, `ci`, `chore`, `构建`, `部署`, `Docker` | `ci: add FreeBSD build target` |
|
||||
| 💄 样式 | `style`, `fmt`, `格式化` | `style: fix indentation`(归入改进优化) |
|
||||
| ⚠️ 破坏性变更 | commit body 含 `BREAKING CHANGE` 或标题含 `!` | `feat!: change API response format` |
|
||||
|
||||
> 完整分类规则和优先级逻辑见 [`REFERENCE.md`](REFERENCE.md)。
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:为指定版本生成 Release Notes
|
||||
|
||||
```
|
||||
用户:"为 v1.2.0 生成 Release Notes"
|
||||
|
||||
AI 执行流程:
|
||||
1. release +list → 找到上一个版本 v1.1.0 的发布时间
|
||||
2. commit +list → 筛选 v1.1.0 之后的所有提交
|
||||
3. pr +list --state merged → 筛选同一时间段的已合并 PR
|
||||
4. issue +list --state closed → 筛选已关闭的 Issue
|
||||
5. 按分类规则整理 → 生成结构化 Release Notes
|
||||
```
|
||||
|
||||
### 场景 2:生成完整 Changelog(所有版本)
|
||||
|
||||
```
|
||||
用户:"生成项目完整 Changelog"
|
||||
|
||||
AI 执行流程:
|
||||
1. release +list → 获取所有版本
|
||||
2. 对每个版本区间分别采集 commit/PR/Issue
|
||||
3. 按版本倒序排列,生成完整 Changelog
|
||||
```
|
||||
|
||||
### 场景 3:生成并一键发布
|
||||
|
||||
```
|
||||
用户:"生成 v1.2.0 Release Notes 并发布"
|
||||
|
||||
AI 执行流程:
|
||||
1-5. 同场景 1,生成 Release Notes
|
||||
6. 确认用户意图:"即将发布 v1.2.0 Release,内容如上,确认发布?"
|
||||
7. 用户确认后执行:
|
||||
gitlink-cli release +create --owner <owner> --repo <repo> \
|
||||
--tag v1.2.0 --name "v1.2.0" --body "<Release Notes 内容>"
|
||||
```
|
||||
|
||||
## 发布命令
|
||||
|
||||
```bash
|
||||
# 创建 Release(需要认证)
|
||||
gitlink-cli release +create --owner <owner> --repo <repo> \
|
||||
--tag v1.2.0 \
|
||||
--name "v1.2.0" \
|
||||
--body "## ✨ 新功能\n- feat: xxx\n\n## 🐛 Bug 修复\n- fix: yyy"
|
||||
|
||||
# 查看 Release 是否创建成功
|
||||
gitlink-cli release +view --owner <owner> --repo <repo> --id <version_id>
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- 所有数据采集命令使用 `--format json`
|
||||
- commit message 建议遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范,便于自动分类
|
||||
- 发布前让用户预览 Release Notes 并确认
|
||||
- 保留上一个版本的时间戳作为基线,避免遗漏或重复
|
||||
- 如果项目未发布过 Release,则从项目创建时间开始采集所有数据
|
||||
- 关联 PR 和 Issue 编号,增强 Release Notes 的可追溯性
|
||||
|
||||
## 详细参考
|
||||
|
||||
详见 [`REFERENCE.md`](REFERENCE.md) 了解完整的 commit 分类规则、数据字段映射和 API 注意事项。
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
# 示例:Release Notes 生成(真实数据)
|
||||
|
||||
> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-09 在 Claude Code 中实际执行。
|
||||
> 展示从 v0.1.13-freebsd(上一个 Release,2026-06-03 23:51)到当前时间段的变更。
|
||||
|
||||
---
|
||||
|
||||
## 执行命令序列
|
||||
|
||||
```bash
|
||||
# Step 1: 获取上一个版本(确定时间基线)
|
||||
gitlink-cli release +list --owner chroe --repo gitlink-cli --format json
|
||||
# 结果: 1 个版本, tag=v0.1.13-freebsd, created=2026-06-03 23:51
|
||||
|
||||
# Step 2: 获取提交历史(AI 按时间戳筛选基线之后的提交)
|
||||
gitlink-cli commit +list --owner chroe --repo gitlink-cli --format json
|
||||
# 结果: 本页 20 个提交,基线之后 20 个(含 2 个 merge commit 需跳过)
|
||||
# 如需更多: gitlink-cli commit +list --owner chroe --repo gitlink-cli --page 2 --format json
|
||||
|
||||
# Step 3: 获取 PR(本项目为 Fork,无 PR 记录)
|
||||
gitlink-cli pr +list --owner chroe --repo gitlink-cli --state merged --format json
|
||||
# 结果: merged_issues_size=0
|
||||
|
||||
# Step 4: 获取已关闭 Issue
|
||||
gitlink-cli issue +list --owner chroe --repo gitlink-cli --state closed --format json
|
||||
# 结果: 6 个已关闭 Issue(#1~#6)
|
||||
```
|
||||
|
||||
## Commit 分类过程(20 条提交)
|
||||
|
||||
| # | commit message(首行) | 作者 | 分类结果 |
|
||||
|---|----------------------|------|---------|
|
||||
| 1 | `fix(skills): gitlink-health 评分算法修复...` | chroe | 🐛 Bug 修复 |
|
||||
| 2 | `feat(skills): 新增 3 个 AI Agent Skill...` | chroe | ✨ 新功能 |
|
||||
| 3 | `fix(showcase): 移除 rebase 残留...` | chroe | 🐛 Bug 修复 |
|
||||
| 4 | `refactor(showcase): 成员管理卡片优化` | chroe | 🔧 改进优化 |
|
||||
| 5 | `删除不可用的批量操作...新增批量评论...` | yetja | 🔧 改进优化 |
|
||||
| 6 | `refactor(showcase): 批量组织邀请合并...` | chroe | 🔧 改进优化 |
|
||||
| 7 | `fix(org): batch-invite 用户名自动解析...` | chroe | 🐛 Bug 修复 |
|
||||
| 8 | `chore: batch-delete 默认改用 fork 后的路径` | chroe | 🏗️ 构建/CI |
|
||||
| 9 | `chore: 更新仓库管理默认示例为真实仓库` | chroe | 🏗️ 构建/CI |
|
||||
| 10 | `chore: 恢复 repo 批量管理卡片` | chroe | 🏗️ 构建/CI |
|
||||
| 11 | `fix: 移除 batch-milestone (v1 PATCH不生效)` | chroe | 🐛 Bug 修复 |
|
||||
| 12 | `fix: 移除 batch-label (API不支持标签关联)` | chroe | 🐛 Bug 修复 |
|
||||
| 13 | `fix: 移除 assign/batch-assign (API不支持)` | chroe | 🐛 Bug 修复 |
|
||||
| 14 | `fix: assign 传递数字 ID + 保留 status_id` | chroe | 🐛 Bug 修复 |
|
||||
| 15 | `Merge branch 'master' of...` | chroe | ⏭️ 跳过 |
|
||||
| 16 | `feat: 添加 reopen/batch-reopen 命令` | chroe | ✨ 新功能 |
|
||||
| 17 | `delete test file` | chroe | ⏭️ 跳过 |
|
||||
| 18 | `add test file` | chroe | ⏭️ 跳过 |
|
||||
| 19 | `fix: watch/star 模块 resolveProjectID...` | chroe | 🐛 Bug 修复 |
|
||||
| 20 | `Merge branch 'master' of...` | chroe | ⏭️ 跳过 |
|
||||
|
||||
## 生成的 Release Notes
|
||||
|
||||
```markdown
|
||||
# 🚀 v0.2.0 (2026-06-09)
|
||||
|
||||
> 基于 v0.1.13-freebsd (2026-06-03 23:51) 之后的变更生成
|
||||
|
||||
## 📝 版本摘要
|
||||
|
||||
本版本为 Skills 生态扩展版本,新增 3 个 AI Agent Skill(项目健康度报告、Release Notes 自动生成、Issue 智能分拣),
|
||||
新增 reopen/batch-reopen 命令,清理了多个 GitLink API 不支持的批量操作命令,并修复了 Showcase 前端和模块兼容性问题。
|
||||
|
||||
## ✨ 新功能 (Features)
|
||||
|
||||
- **skills**: 新增 `gitlink-health` Skill — 项目健康度报告(Issue/PR/贡献者/活跃度四维评分)(by @chroe)
|
||||
- **skills**: 新增 `gitlink-changelog` Skill — 从 commit/PR/Issue 历史自动生成 Release Notes (by @chroe)
|
||||
- **skills**: 新增 `gitlink-triage` Skill — Issue 智能分拣 + 新人引导 (by @chroe)
|
||||
- **issue**: 新增 reopen/batch-reopen 命令 (by @chroe)
|
||||
|
||||
## 🐛 问题修复 (Bug Fixes)
|
||||
|
||||
- **skills**: 修复 gitlink-health 评分算法 — fallback 策略和贡献集中度计算 (by @chroe)
|
||||
- **showcase**: 修复 rebase 残留 commit message 导致 JS 语法错误白屏 (by @chroe)
|
||||
- **org**: batch-invite 用户名自动解析为数字 ID,修正 Showcase 默认值 (by @chroe)
|
||||
- **watch/star**: 修复 resolveProjectID 兼容 project_id 字段 (by @chroe)
|
||||
- **batch**: 移除 batch-milestone(GitLink v1 PATCH 实际不生效)(by @chroe)
|
||||
- **batch**: 移除 batch-label(GitLink API 不支持 Issue 标签关联)(by @chroe)
|
||||
- **batch**: 移除 assign/batch-assign(GitLink API 不支持)(by @chroe)
|
||||
- **assign**: 传递数字 ID + 保留 status_id 防止状态被重置 (by @chroe)
|
||||
|
||||
## 🔧 改进优化 (Improvements)
|
||||
|
||||
- **showcase**: 成员管理卡片优化 — 组织 ID 查找改为 `org +info`,新增仓库选择器 (by @chroe)
|
||||
- **showcase**: 批量组织邀请合并到成员管理卡片 (by @chroe)
|
||||
- **batch**: 移除不可用的批量操作,新增批量评论、批量仓库管理 (by @yetja)
|
||||
|
||||
## 🏗️ 构建/CI (Build & CI)
|
||||
|
||||
- batch-delete 默认改用 fork 后的仓库路径 (by @chroe)
|
||||
- 更新仓库管理默认示例为真实仓库 (by @chroe)
|
||||
- 恢复 repo 批量管理卡片 (by @chroe)
|
||||
|
||||
## 🙏 贡献者
|
||||
|
||||
感谢以下贡献者参与本版本开发:
|
||||
|
||||
@chroe, @yetja, @caoweiqiong (CWQ)
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
| 类型 | 数量 |
|
||||
|------|------|
|
||||
| 新功能 | 4 |
|
||||
| Bug 修复 | 8 |
|
||||
| 改进优化 | 3 |
|
||||
| 构建/CI | 3 |
|
||||
| 跳过(merge/test) | 4 |
|
||||
| 合计 | 18(有效 16)|
|
||||
```
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
# gitlink-ci 参考手册
|
||||
|
||||
> 本文档定义 CI/CD 的 API 字段映射、错误诊断模式、流水线配置参考和已知问题。
|
||||
|
||||
---
|
||||
|
||||
## 一、前置条件:DevOps 状态
|
||||
|
||||
### 检查方法
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
# 关注字段: "open_devops": true/false
|
||||
```
|
||||
|
||||
| open_devops | CI 命令可用性 |
|
||||
|-------------|-------------|
|
||||
| `true` | `ci +builds/logs/restart/stop` 全部可用 |
|
||||
| `false` | 所有 CI 命令返回 `[-1] 接口数据异常` |
|
||||
|
||||
### 激活 DevOps
|
||||
|
||||
```bash
|
||||
# 通过 Raw API 激活
|
||||
gitlink-cli api POST /<owner>/<repo>/activate
|
||||
|
||||
# 或通过 Web 页面:
|
||||
# 仓库 → 设置 → DevOps → 开启
|
||||
```
|
||||
|
||||
### 实测数据
|
||||
|
||||
在 `chroe/gitlink-cli` 上(`open_devops: false`):
|
||||
```bash
|
||||
gitlink-cli ci +builds --owner chroe --repo gitlink-cli
|
||||
# 输出: 错误: 获取构建列表失败: [-1] 接口数据异常
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、CI API 字段映射
|
||||
|
||||
### ci +builds 预期响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"builds": [
|
||||
{
|
||||
"id": 42,
|
||||
"number": 42,
|
||||
"status": "failed",
|
||||
"branch": "feature/new-auth",
|
||||
"commit": "abc1234def",
|
||||
"commit_message": "feat: add new auth",
|
||||
"created_at": "2026-06-12T10:00:00+08:00",
|
||||
"duration": 120,
|
||||
"stages": [
|
||||
{ "number": 1, "name": "build", "status": "success" },
|
||||
{ "number": 2, "name": "test", "status": "failed" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"total_count": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `number` | 构建编号,用于 `ci +logs` / `ci +restart` / `ci +stop` |
|
||||
| `status` | `success` / `failed` / `running` / `stopped` |
|
||||
| `branch` | 触发构建的分支 |
|
||||
| `commit` | 触发构建的 commit SHA |
|
||||
| `stages` | 流水线阶段列表,每个 stage 有独立状态 |
|
||||
| `duration` | 构建耗时(秒) |
|
||||
|
||||
### ci +logs API
|
||||
|
||||
```
|
||||
GET /<owner>/<repo>/builds/<build-number>/logs/<stage>/<step>
|
||||
```
|
||||
|
||||
参数说明:
|
||||
- `build-number`:从 `ci +builds` 获取
|
||||
- `stage`:阶段编号,默认 1(编译阶段)
|
||||
- `step`:步骤编号,默认 1
|
||||
|
||||
---
|
||||
|
||||
## 三、构建错误诊断模式库
|
||||
|
||||
### 编译错误
|
||||
|
||||
| 模式 | 正则 | 常见原因 |
|
||||
|------|------|---------|
|
||||
| 依赖缺失 | `cannot find package` | `go.mod` 或 `package.json` 不完整 |
|
||||
| 语法错误 | `syntax error` | 代码语法问题 |
|
||||
| 类型错误 | `cannot use .* as type` | 类型不匹配 |
|
||||
| 未定义引用 | `undefined: ` | 缺少 import 或拼写错误 |
|
||||
| 导入路径错误 | `no required module provides package` | Go module 路径变更 |
|
||||
|
||||
### 测试失败
|
||||
|
||||
| 模式 | 常见原因 |
|
||||
|------|---------|
|
||||
| `FAIL: TestXxx` | 测试断言失败 |
|
||||
| `panic: runtime error` | 测试中空指针或越界 |
|
||||
| `--- FAIL: TestXxx (0.00s)` | 测试立即失败(setup 错误) |
|
||||
| `too many arguments` | 测试函数签名不匹配 |
|
||||
|
||||
### 环境/基础设施
|
||||
|
||||
| 模式 | 常见原因 |
|
||||
|------|---------|
|
||||
| `connection refused` | 数据库/外部服务不可用 |
|
||||
| `out of memory` | 构建内存不足 |
|
||||
| `permission denied` | 密钥或文件权限问题 |
|
||||
| `docker: not found` | 构建环境缺少 Docker |
|
||||
| `No space left on device` | 磁盘空间不足 |
|
||||
|
||||
### GitLink 特定错误
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| `[-1] 接口数据异常` | 仓库未开启 DevOps |
|
||||
| 返回 HTML 而非 JSON | API 路径错误(如缺少 `/v1/` 前缀) |
|
||||
|
||||
---
|
||||
|
||||
## 四、流水线配置文件
|
||||
|
||||
### 文件位置
|
||||
|
||||
```
|
||||
仓库根目录/
|
||||
└── .devops/
|
||||
└── <流水线名称>.yml
|
||||
```
|
||||
|
||||
### 示例配置(Go 项目)
|
||||
|
||||
```yaml
|
||||
name: 构建部署
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: 构建
|
||||
run: go build -o app .
|
||||
- name: 测试
|
||||
run: go test ./...
|
||||
- name: 部署
|
||||
run: |
|
||||
ssh root@server "cd /opt/app && git pull && docker build -t app . && docker-compose up -d"
|
||||
```
|
||||
|
||||
### 关键注意事项
|
||||
|
||||
- Docker 构建需配置 `GOPROXY=https://goproxy.cn,direct`(国内网络)
|
||||
- 服务器 Docker daemon 需配置国内镜像加速器(`/etc/docker/daemon.json`)
|
||||
- GitLink 密钥管理:敏感信息通过 `deploy_server.server_password` 注入
|
||||
- 使用 `git fetch + git reset --hard` 替代 `git pull` 避免本地修改冲突
|
||||
|
||||
---
|
||||
|
||||
## 五、已知限制
|
||||
|
||||
| 限制 | 说明 |
|
||||
|------|------|
|
||||
| DevOps 默认关闭 | 大部分仓库的 `open_devops` 为 `false`,需手动开启 |
|
||||
| 接口数据异常 | 通用错误码 `-1`,无结构化错误信息 |
|
||||
| 无构建触发 API | 无法通过 CLI 触发新构建,只能通过 git push 触发 |
|
||||
| 日志可能截断 | 长日志可能被分页或截断 |
|
||||
|
||||
---
|
||||
|
||||
## 六、常见问题
|
||||
|
||||
### Q: 所有 CI 命令都返回"接口数据异常"?
|
||||
|
||||
A: 99% 的情况是因为仓库未开启 DevOps。检查 `repo +info` 中的 `open_devops` 字段。
|
||||
|
||||
### Q: 如何触发一次新构建?
|
||||
|
||||
A: GitLink 没有"手动触发构建"的 API。只能通过 `git push` 到触发分支(如 master)来启动构建。
|
||||
|
||||
### Q: ci +logs 的 stage/step 是什么意思?
|
||||
|
||||
A: 每个流水线有多个 stage(阶段),每个 stage 有多个 step(步骤)。默认 stage=1, step=1 通常是第一个编译步骤。
|
||||
|
||||
### Q: CI 构建没有日志输出?
|
||||
|
||||
A: 尝试不同的 stage/step 组合。如果 stage=1,step=1 无输出,试试 stage=2,step=1。
|
||||
|
|
@ -1,55 +1,232 @@
|
|||
---
|
||||
name: gitlink-ci
|
||||
version: 1.0.0
|
||||
description: "CI/CD 操作:查看构建列表、构建日志、重启/停止构建。当用户需要操作 GitLink CI 时触发。"
|
||||
version: 2.0.0
|
||||
description: "CI/CD 构建诊断与监控:检查 DevOps 状态、诊断构建失败、分析 CI 日志、管理构建生命周期。当用户需要排查 CI 失败、监控构建状态、配置流水线时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli ci --help"
|
||||
---
|
||||
|
||||
# gitlink-ci(CI/CD 操作)
|
||||
# gitlink-ci(CI/CD 构建诊断与监控)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
|
||||
**CRITICAL — CI 功能依赖仓库开启 DevOps(`open_devops: true`)。未开启的仓库所有 CI 命令会返回"接口数据异常"。使用前务必检查 DevOps 状态。**
|
||||
**CRITICAL — `ci +restart` 和 `ci +stop` 是写入操作,执行前需确认用户意图。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## Shortcuts
|
||||
## 概述
|
||||
|
||||
| Shortcut | 说明 | 需要认证 |
|
||||
|----------|------|----------|
|
||||
| `ci +builds` | 构建列表 | 是 |
|
||||
| `ci +logs` | 构建日志 | 是 |
|
||||
| `ci +restart` | 重启构建 | 是 |
|
||||
| `ci +stop` | 停止构建 | 是 |
|
||||
本 Skill 通过 gitlink-cli 的 CI 命令和 Raw API,由 AI 分析后完成:
|
||||
1. **DevOps 状态检查**:扫描仓库是否开启 CI,未开启的引导激活
|
||||
2. **构建失败诊断**:获取失败构建的日志 → AI 分析错误 → 定位根因 → 建议修复
|
||||
3. **CI 健康监控**:查看近期构建成功率、平均构建时间
|
||||
4. **流水线配置审计**:检查 `.devops/` 目录下的流水线文件
|
||||
|
||||
## 使用示例
|
||||
## 前置检查命令
|
||||
|
||||
### 必须:确认 DevOps 状态
|
||||
|
||||
```bash
|
||||
# 查看构建列表
|
||||
gitlink-cli ci +builds --owner myuser --repo myrepo
|
||||
|
||||
# 查看构建日志
|
||||
gitlink-cli ci +logs --build 42 --stage 1 --step 1
|
||||
|
||||
# 重启构建
|
||||
gitlink-cli ci +restart --build 42
|
||||
|
||||
# 停止构建
|
||||
gitlink-cli ci +stop --build 42
|
||||
# 检查仓库是否开启 DevOps
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json | grep open_devops
|
||||
```
|
||||
|
||||
## Raw API 补充
|
||||
如果返回 `"open_devops": false`,后续 CI 命令将不可用。需要先激活:
|
||||
|
||||
```bash
|
||||
# 激活 CI
|
||||
gitlink-cli api POST /:owner/:repo/activate
|
||||
# 激活 DevOps
|
||||
gitlink-cli api POST /<owner>/<repo>/activate
|
||||
```
|
||||
|
||||
> ⚠️ **实测**:`chroe/gitlink-cli` 等仓库的 `open_devops` 为 `false`,调用 `ci +builds` 会返回"接口数据异常"。这不是 CLI 的 bug,而是 GitLink 平台的前置要求。
|
||||
|
||||
## 数据采集命令
|
||||
|
||||
### 第一步:获取构建列表
|
||||
|
||||
```bash
|
||||
# 查看构建历史
|
||||
gitlink-cli ci +builds --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 分页
|
||||
gitlink-cli ci +builds --owner <owner> --repo <repo> --page 2 --format json
|
||||
```
|
||||
|
||||
### 第二步:查看失败构建日志
|
||||
|
||||
```bash
|
||||
# 查看默认 stage=1, step=1 的日志
|
||||
gitlink-cli ci +logs --owner <owner> --repo <repo> --build <build-number>
|
||||
|
||||
# 查看特定 stage/step(如 stage 2 = 测试阶段)
|
||||
gitlink-cli ci +logs --owner <owner> --repo <repo> --build <build-number> --stage 2 --step 1
|
||||
```
|
||||
|
||||
### 第三步:查看 CI 配置
|
||||
|
||||
```bash
|
||||
# 检查 CI 授权状态
|
||||
gitlink-cli api GET /<owner>/<repo>/ci_authorize
|
||||
|
||||
# 查看构建详情
|
||||
gitlink-cli api GET /<owner>/<repo>/builds/<build_id>
|
||||
```
|
||||
|
||||
## AI 分析规则
|
||||
|
||||
### 构建失败诊断流程
|
||||
|
||||
```
|
||||
1. ci +builds → 筛选 status=failed 的构建
|
||||
2. ci +logs --build <n> → 获取日志
|
||||
3. AI 分析日志中的错误模式
|
||||
4. 匹配已知错误类型 → 给出修复建议
|
||||
5. 如有多个 stage,逐 stage 排查
|
||||
```
|
||||
|
||||
### 常见错误模式识别
|
||||
|
||||
| 日志关键词 | 诊断 | 建议修复 |
|
||||
|-----------|------|---------|
|
||||
| `cannot find package` | 依赖缺失 | 检查 `go.mod` 或 `package.json` |
|
||||
| `syntax error` / `unexpected token` | 语法错误 | 检查最近提交的代码 |
|
||||
| `permission denied` | 权限不足 | 检查密钥配置、文件权限 |
|
||||
| `connection refused` | 服务不可达 | 检查外部服务/数据库连接 |
|
||||
| `out of memory` / `killed` | 资源不足 | 优化内存使用或增加构建资源 |
|
||||
| `No such file` | 文件缺失 | 检查 `.devops/` 配置文件路径 |
|
||||
| `docker: command not found` | 环境缺失 | 确认构建环境有 Docker |
|
||||
| `exit status 1` / `FAIL` | 测试失败 | 查看具体测试输出 |
|
||||
| `timeout` | 构建超时 | 优化构建脚本或增加超时时间 |
|
||||
|
||||
### 构建健康评分
|
||||
|
||||
| 指标 | 计算方式 |
|
||||
|------|---------|
|
||||
| 成功率 | 成功构建 / 总构建 × 100% |
|
||||
| 平均耗时 | 所有构建的 `duration` 平均值 |
|
||||
| 失败趋势 | 最近 10 次构建中失败次数的变化方向 |
|
||||
|
||||
## 执行命令
|
||||
|
||||
### 重启失败的构建
|
||||
|
||||
```bash
|
||||
gitlink-cli ci +restart --owner <owner> --repo <repo> --build <build-number>
|
||||
```
|
||||
|
||||
### 停止异常构建
|
||||
|
||||
```bash
|
||||
gitlink-cli ci +stop --owner <owner> --repo <repo> --build <build-number>
|
||||
```
|
||||
|
||||
### 管理 CI 状态
|
||||
|
||||
```bash
|
||||
# 停用 CI
|
||||
gitlink-cli api DELETE /:owner/:repo/deactivate
|
||||
gitlink-cli api DELETE /<owner>/<repo>/deactivate
|
||||
|
||||
# CI 授权状态
|
||||
gitlink-cli api GET /:owner/:repo/ci_authorize
|
||||
# 激活 CI
|
||||
gitlink-cli api POST /<owner>/<repo>/activate
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
### 构建失败诊断报告
|
||||
|
||||
```markdown
|
||||
# 🔧 构建失败诊断报告
|
||||
|
||||
> 仓库:<owner/repo>
|
||||
> 构建编号:#<n>
|
||||
> 诊断时间:<timestamp>
|
||||
|
||||
## 失败概况
|
||||
|
||||
| 项目 | 详情 |
|
||||
|------|------|
|
||||
| 构建编号 | #42 |
|
||||
| 触发分支 | feature/new-auth |
|
||||
| 失败 Stage | stage 2 (测试) |
|
||||
| 失败时间 | 2026-06-12 10:30 |
|
||||
|
||||
## 错误日志(关键部分)
|
||||
|
||||
```
|
||||
[ERROR] cannot find package "github.com/example/lib"
|
||||
at main.go:5
|
||||
```
|
||||
|
||||
## AI 诊断
|
||||
|
||||
| 错误类型 | 依赖缺失 |
|
||||
|---------|---------|
|
||||
| **根因** | `go.mod` 中缺少 `github.com/example/lib` 依赖 |
|
||||
| **影响范围** | 所有 import 该包的 Go 文件 |
|
||||
| **修复建议** | 运行 `go get github.com/example/lib && go mod tidy` 后提交 |
|
||||
|
||||
## 修复步骤
|
||||
|
||||
1. 本地运行 `go get github.com/example/lib`
|
||||
2. 运行 `go mod tidy` 更新依赖
|
||||
3. 提交修改后的 `go.mod` 和 `go.sum`
|
||||
4. 运行 `gitlink-cli ci +restart --build 42` 重启构建
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:构建失败排查
|
||||
|
||||
```
|
||||
用户:"帮我看看 repo 的构建为什么失败了"
|
||||
|
||||
AI 执行流程:
|
||||
1. repo +info → 检查 open_devops
|
||||
2. ci +builds → 获取构建列表,找到失败构建
|
||||
3. ci +logs --build <n> → 获取失败日志
|
||||
4. AI 分析日志中的错误模式
|
||||
5. 匹配已知错误 → 生成诊断报告和修复建议
|
||||
6. 询问用户是否要重启构建
|
||||
```
|
||||
|
||||
### 场景 2:CI 配置审计
|
||||
|
||||
```
|
||||
用户:"帮我检查项目 CI 配置是否正常"
|
||||
|
||||
AI 执行流程:
|
||||
1. repo +info → 检查 open_devops
|
||||
2. ci +builds → 查看最近的构建记录
|
||||
3. raw api GET /<owner>/<repo>/ci_authorize → 检查授权状态
|
||||
4. 计算近期构建成功率
|
||||
5. 检查 .devops/ 目录下的流水线配置(如有本地代码)
|
||||
6. 生成 CI 健康度报告
|
||||
```
|
||||
|
||||
### 场景 3:批量 DevOps 检查
|
||||
|
||||
```
|
||||
用户:"检查我所有仓库的 DevOps 开启情况"
|
||||
|
||||
AI 执行流程:
|
||||
1. repo +list --category all → 获取所有仓库
|
||||
2. 筛选 open_devops = true 的仓库
|
||||
3. 对开启了 DevOps 的仓库,获取最近构建状态
|
||||
4. 对未开启的仓库,列出可激活的选项
|
||||
5. 生成 DevOps 覆盖率报告
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- **先查 DevOps 状态**:每次 CI 操作前检查 `open_devops`,避免"接口数据异常"错误
|
||||
- **逐 stage 排查**:多 stage 流水线中,从第一个失败的 stage 开始分析
|
||||
- **关联 commit**:失败构建通常与最近的代码变更相关,关联 `commit +list` 查看
|
||||
- **重启前确认**:确认已修复根因后再重启,避免反复失败
|
||||
- **日志截断**:CI 日志可能很长,AI 应提取错误关键词而非全文搬运
|
||||
|
||||
## 详细参考
|
||||
|
||||
详见 [`REFERENCE.md`](REFERENCE.md) 了解 CI API 字段映射、错误诊断模式和流水线配置。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
# 示例:CI DevOps 状态检查与构建诊断(真实数据)
|
||||
|
||||
> 本示例基于真实仓库于 2026-06-12 在 Claude Code 中实际执行。
|
||||
> 展示 DevOps 状态检查 → 发现未开启 → 给出激活方案的完整流程。
|
||||
|
||||
---
|
||||
|
||||
## 场景:发现 CI 不可用
|
||||
|
||||
用户想查看 `chroe/gitlink-cli` 的构建状态。
|
||||
|
||||
### Step 1: 检查 DevOps 状态
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner chroe --repo gitlink-cli --format json | python3 -c "
|
||||
import sys,json
|
||||
d = json.load(sys.stdin)['data']
|
||||
print(f'DevOps: {d[\"open_devops\"]}')
|
||||
print(f'项目ID: {d[\"project_id\"]}')
|
||||
"
|
||||
```
|
||||
|
||||
真实输出:
|
||||
```
|
||||
DevOps: False
|
||||
项目ID: 1547045
|
||||
```
|
||||
|
||||
### Step 2: 尝试 CI 命令(预期失败)
|
||||
|
||||
```bash
|
||||
gitlink-cli ci +builds --owner chroe --repo gitlink-cli
|
||||
```
|
||||
|
||||
真实输出:
|
||||
```
|
||||
错误: 获取构建列表失败: [-1] 接口数据异常
|
||||
```
|
||||
|
||||
### Step 3: 检查是否有其他仓库开启了 DevOps
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +list --category all --format json | python3 -c "
|
||||
import sys,json
|
||||
for p in json.load(sys.stdin)['data']['projects']:
|
||||
if p.get('open_devops'):
|
||||
print(f\" ✅ {p['identifier']} (devops enabled)\")
|
||||
"
|
||||
```
|
||||
|
||||
真实输出(在 yetja 用户下):
|
||||
```
|
||||
(无输出 — 没有任何仓库开启 DevOps)
|
||||
```
|
||||
|
||||
甚至 Gitlink 组织的热门仓库也是关闭的:
|
||||
```
|
||||
Gitlink/build → devops: True ← 仅有此仓库开启
|
||||
Gitlink/gitlink-cli → devops: False
|
||||
Gitlink/forgeplus → devops: False
|
||||
```
|
||||
|
||||
## AI 分析
|
||||
|
||||
```markdown
|
||||
# 🔧 CI DevOps 状态检查报告
|
||||
|
||||
> 检查时间:2026-06-12
|
||||
> 检查范围:yetja 用户下的仓库 + Gitlink 组织热门仓库
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 检查仓库数 | 20 |
|
||||
| DevOps 已开启 | 1 (5%) |
|
||||
| DevOps 未开启 | 19 (95%) |
|
||||
|
||||
## DevOps 已开启的仓库
|
||||
|
||||
| 仓库 | 说明 |
|
||||
|------|------|
|
||||
| Gitlink/build | GitLink 官方 CI/CD 项目 |
|
||||
|
||||
## 关于 chroe/gitlink-cli
|
||||
|
||||
- **DevOps 状态**:未开启(`open_devops: false`)
|
||||
- **CI 命令**:全部不可用,返回 `[-1] 接口数据异常`
|
||||
- **项目类型**:Fork 自 `Gitlink/gitlink-cli`
|
||||
- **项目 ID**:1547045
|
||||
|
||||
## 如何开启 DevOps
|
||||
|
||||
### 方法 1:Web 页面(推荐)
|
||||
|
||||
1. 打开 https://gitlink.org.cn/chroe/gitlink-cli
|
||||
2. 进入 设置 → DevOps
|
||||
3. 点击"开启 DevOps"
|
||||
|
||||
### 方法 2:CLI Raw API
|
||||
|
||||
```bash
|
||||
gitlink-cli api POST /chroe/gitlink-cli/activate
|
||||
```
|
||||
|
||||
> ⚠️ `api POST` 存在已知 URL 拼接 bug,可能返回 404。建议使用 Web 页面。
|
||||
|
||||
## 开启后的流水线配置
|
||||
|
||||
本项目已有 `.devops/构建部署Showcase.yml` 配置文件:
|
||||
|
||||
- **触发条件**:push 到 master 分支
|
||||
- **构建环境**:Docker 容器
|
||||
- **部署目标**:118.31.4.168:9090
|
||||
- **容器名**:gitlink-cli-showcase
|
||||
|
||||
开启 DevOps 后,每次 push 将自动触发构建和部署。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 补充示例:开启 DevOps 后的工作流(预期)
|
||||
|
||||
如果 DevOps 已开启,典型的构建诊断流程如下:
|
||||
|
||||
```bash
|
||||
# 1. 获取近期构建
|
||||
gitlink-cli ci +builds --owner chroe --repo gitlink-cli --format json
|
||||
|
||||
# 2. 找到失败构建,查看日志
|
||||
gitlink-cli ci +logs --build 42 --stage 1 --step 1
|
||||
|
||||
# 3. AI 分析日志中的错误
|
||||
# (日志内容示例)
|
||||
# [ERROR] cannot find package "github.com/example/lib"
|
||||
|
||||
# 4. AI 诊断:依赖缺失 → 建议 go get + go mod tidy
|
||||
|
||||
# 5. 修复后重启构建
|
||||
gitlink-cli ci +restart --build 42
|
||||
|
||||
# 6. 验证修复
|
||||
gitlink-cli ci +logs --build 42 --stage 1 --step 1
|
||||
```
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
# gitlink-health 参考手册
|
||||
|
||||
> 本文档定义项目健康度报告的指标体系、评分算法和数据字段映射。
|
||||
|
||||
---
|
||||
|
||||
## 一、指标体系
|
||||
|
||||
### 1. Issue 健康度(权重 30%)
|
||||
|
||||
| 指标 | 权重 | 计算方法 | 数据来源 |
|
||||
|------|------|---------|---------|
|
||||
| Issue 关闭率 | 30% | `closed_count / (open_count + closed_count) * 100` | `issue +list --state open/closed` |
|
||||
| 平均首次响应时间 | 25% | 创建时间到第一条评论时间的平均值(见下方 fallback 说明) | `issue +view` 的 journals,或 `issue +list` 的 `comment_journals_count` |
|
||||
| 平均解决时间 | 25% | 创建时间到关闭时间的平均值 | `issue +list` 的 `created_at` 与 `updated_at` |
|
||||
| 长期未响应 Issue 占比 | 20% | 超 30 天无评论的开放 Issue 占总开放 Issue 比例 | 遍历 `issue +list --state open` |
|
||||
|
||||
**Issue 评分标准:**
|
||||
|
||||
| 指标 | 🟢 优秀 | 🟡 良好 | 🔴 需改进 |
|
||||
|------|---------|---------|-----------|
|
||||
| 关闭率 | ≥ 80% | 50%-79% | < 50% |
|
||||
| 首次响应时间 | ≤ 2 天 | 3-7 天 | > 7 天 |
|
||||
| 解决时间 | ≤ 14 天 | 15-30 天 | > 30 天 |
|
||||
| 未响应 Issue 占比 | ≤ 10% | 11%-30% | > 30% |
|
||||
|
||||
### 2. PR 健康度(权重 30%)
|
||||
|
||||
| 指标 | 权重 | 计算方法 | 数据来源 |
|
||||
|------|------|---------|---------|
|
||||
| PR 合并率 | 35% | `merged_count / (open_count + merged_count + closed_count) * 100` | `pr +list` |
|
||||
| 平均合并耗时 | 35% | PR 创建时间到合并时间的平均值 | `pr +view` 的 `created_at` 与 `merged_at` |
|
||||
| Review 覆盖率 | 30% | 有评论/Review 的 PR 占总 PR 比例 | `pr +view` 的 review 数据 |
|
||||
|
||||
**PR 评分标准:**
|
||||
|
||||
| 指标 | 🟢 优秀 | 🟡 良好 | 🔴 需改进 |
|
||||
|------|---------|---------|-----------|
|
||||
| 合并率 | ≥ 70% | 40%-69% | < 40% |
|
||||
| 合并耗时 | ≤ 3 天 | 4-14 天 | > 14 天 |
|
||||
| Review 覆盖率 | ≥ 80% | 50%-79% | < 50% |
|
||||
|
||||
### 3. 贡献者活跃度(权重 20%)
|
||||
|
||||
| 指标 | 权重 | 计算方法 | 数据来源 |
|
||||
|------|------|---------|---------|
|
||||
| 活跃贡献者比例 | 40% | 近 30 天有提交的贡献者数 / 总贡献者数 | `commit +list` 按作者去重 |
|
||||
| 贡献集中度 | 30% | Top 1 贡献者提交数 / 本团队总提交数(越低越健康,仅统计本团队成员) | `commit +list` 全量按作者统计,排除上游原始贡献者 |
|
||||
| 贡献者增长 | 30% | 近 30 天新增贡献者数(定性评估) | `member +list` 或提交历史对比 |
|
||||
|
||||
**贡献者评分标准:**
|
||||
|
||||
| 指标 | 🟢 优秀 | 🟡 良好 | 🔴 需改进 |
|
||||
|------|---------|---------|-----------|
|
||||
| 活跃贡献者比例 | ≥ 40% | 20%-39% | < 20% |
|
||||
| 贡献集中度(Top1 占比) | ≤ 30% | 31%-60% | > 60% |
|
||||
|
||||
### 4. 项目活跃度(权重 20%)
|
||||
|
||||
| 指标 | 权重 | 计算方法 | 数据来源 |
|
||||
|------|------|---------|---------|
|
||||
| 提交频率 | 40% | 近 30 天提交数 / 30(每日平均) | `commit +list` |
|
||||
| Issue 创建频率 | 20% | 近 30 天新建 Issue 数 / 30 | `issue +list` 按创建时间筛选 |
|
||||
| Release 频率 | 20% | 总 Release 数 / 项目月龄 | `release +list` |
|
||||
| 社区关注度 | 20% | Star + Fork + Watch 加权(见下方评分标准) | `repo +info` |
|
||||
|
||||
**活跃度评分标准:**
|
||||
|
||||
| 指标 | 🟢 优秀 | 🟡 良好 | 🔴 需改进 |
|
||||
|------|---------|---------|-----------|
|
||||
| 提交频率(次/天) | ≥ 2 | 0.5-1.9 | < 0.5 |
|
||||
| Release 频率(次/月) | ≥ 1 | 0.2-0.9 | < 0.2 或无 Release |
|
||||
| 社区关注度(Star+Fork+Watch) | ≥ 20 | 5-19 | < 5 |
|
||||
|
||||
---
|
||||
|
||||
## 二、综合评分算法
|
||||
|
||||
```
|
||||
综合评分 = Issue得分 × 0.30 + PR得分 × 0.30 + 贡献者得分 × 0.20 + 活跃度得分 × 0.20
|
||||
```
|
||||
|
||||
### 等级划分
|
||||
|
||||
| 等级 | 分数范围 | 图标 |
|
||||
|------|---------|------|
|
||||
| 优秀 | ≥ 80 | 🟢 |
|
||||
| 良好 | 60-79 | 🟡 |
|
||||
| 需改进 | < 60 | 🔴 |
|
||||
|
||||
---
|
||||
|
||||
## 三、数据字段映射
|
||||
|
||||
### repo +info 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"identifier": "repo-name",
|
||||
"name": "repo-name",
|
||||
"description": "...",
|
||||
"project_id": 12345,
|
||||
"forked_count": 10,
|
||||
"praises_count": 50,
|
||||
"watchers_count": 20,
|
||||
"visits_count": 1000,
|
||||
"created_on": "2025-01-01T00:00:00+08:00",
|
||||
"updated_on": "2025-12-01T00:00:00+08:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### issue +list 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"issues": [
|
||||
{
|
||||
"id": 1,
|
||||
"project_issues_index": 4,
|
||||
"subject": "Issue 标题",
|
||||
"description": "Issue 描述",
|
||||
"created_on": "2025-06-01T10:00:00+08:00",
|
||||
"updated_on": "2025-06-02T15:00:00+08:00",
|
||||
"status": { "id": 1, "name": "新增" },
|
||||
"priority": { "id": 2, "name": "正常" },
|
||||
"author": { "id": 1, "login": "username", "name": "用户名" },
|
||||
"assignees": [...],
|
||||
"tags": [...]
|
||||
}
|
||||
]
|
||||
},
|
||||
"meta": { "page": 1, "limit": 15, "total_count": 100 }
|
||||
}
|
||||
```
|
||||
|
||||
### issue +view 返回字段(用于时间计算)
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"project_issues_index": 4,
|
||||
"subject": "Issue 标题",
|
||||
"created_on": "2025-06-01T10:00:00+08:00",
|
||||
"updated_on": "2025-06-02T15:00:00+08:00",
|
||||
"status": { "id": 5, "name": "关闭" },
|
||||
"journals": [
|
||||
{
|
||||
"id": 1,
|
||||
"notes": "评论内容",
|
||||
"created_on": "2025-06-01T12:00:00+08:00",
|
||||
"user": { "login": "responder" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### pr +list 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"pulls": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "PR 标题",
|
||||
"pull_request_status": 0,
|
||||
"created_at": "2025-06-01T10:00:00+08:00",
|
||||
"updated_at": "2025-06-02T15:00:00+08:00",
|
||||
"user": { "login": "username", "name": "用户名" }
|
||||
}
|
||||
]
|
||||
},
|
||||
"meta": { "page": 1, "limit": 15, "total_count": 50 }
|
||||
}
|
||||
```
|
||||
|
||||
**PR 状态映射:**
|
||||
|
||||
| pull_request_status | 含义 |
|
||||
|:---:|------|
|
||||
| 0 | Open(开放) |
|
||||
| 1 | Merged(已合并) |
|
||||
| 2 | Closed(已关闭) |
|
||||
|
||||
### commit +list 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"commits": [
|
||||
{
|
||||
"sha": "abc123...",
|
||||
"message": "feat: add feature",
|
||||
"author": { "login": "username", "name": "用户名" },
|
||||
"timestamp": "2025-06-01T10:00:00+08:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### member +list 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"members": [
|
||||
{
|
||||
"id": 1,
|
||||
"login": "username",
|
||||
"name": "用户名",
|
||||
"roles": [{ "id": 1, "name": "Manager" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、常见问题
|
||||
|
||||
### Q: 数据量很大怎么办?
|
||||
|
||||
A: 使用分页参数逐步采集:
|
||||
|
||||
```bash
|
||||
# 先获取第一页,从 meta.total_count 判断总页数
|
||||
gitlink-cli issue +list --state open --page 1 --format json
|
||||
# 根据需要继续翻页
|
||||
gitlink-cli issue +list --state open --page 2 --format json
|
||||
```
|
||||
|
||||
### Q: 如何计算 Issue 首次响应时间?
|
||||
|
||||
A: 优先使用 journals API,不可用时 fallback 到 `comment_journals_count`:
|
||||
|
||||
**方法 1(优先)**:通过 `issue +view` 获取 journals 数组
|
||||
1. 调用 `issue +view --number <num> --format json`
|
||||
2. 从返回的 `journals` 数组找到第一条非作者的评论
|
||||
3. 计算 `journals[0].created_on` 与 Issue `created_on` 的时间差
|
||||
|
||||
**方法 2(fallback)**:当 journals 数据不可用时
|
||||
1. 从 `issue +list` 的 `comment_journals_count` 字段判断是否有响应
|
||||
2. 有评论(count > 0)→ 估为 70 分(良好)
|
||||
3. 无评论(count = 0)→ 估为 40 分(需改进)
|
||||
4. 无开放 Issue → 100 分(满分)
|
||||
|
||||
> 注意:GitLink 部分项目的 journals API 返回 HTML 而非 JSON,此时必须使用 fallback 方法。
|
||||
|
||||
### Q: 贡献集中度怎么算?要不要排除上游贡献者?
|
||||
|
||||
A: **是的,必须排除上游原始贡献者**。Fork 仓库的提交历史包含上游代码,统计步骤:
|
||||
1. 获取全量提交(翻页直到 total_count 采完)
|
||||
2. 识别本团队成员(通过 `member +list` 获取成员列表)
|
||||
3. 仅统计本团队成员的提交数
|
||||
4. 计算 Top1 提交数 / 本团队总提交数
|
||||
|
||||
### Q: PR 合并时间怎么获取?
|
||||
|
||||
A: 通过 `pr +view` 获取 PR 详情,`merged_at` 字段为合并时间(仅已合并 PR 有此字段)。如果没有 `merged_at`,可以用 `updated_at` 作为近似值。
|
||||
|
||||
### Q: 私有项目能否生成报告?
|
||||
|
||||
A: 可以,但需要先认证:`gitlink-cli auth login`。认证后所有命令自动携带 Token。
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
---
|
||||
name: gitlink-health
|
||||
version: 1.0.0
|
||||
description: "项目健康度报告:统计 Issue 响应时间、PR 合并效率、贡献者活跃度,生成结构化健康度评分与报告。当用户需要评估项目健康状况、生成项目报告、对比项目活跃度时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli --help"
|
||||
---
|
||||
|
||||
# gitlink-health(项目健康度报告)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 本 Skill 仅执行读取操作(GET),不会修改任何项目数据。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## 概述
|
||||
|
||||
本 Skill 通过组合多个 gitlink-cli 命令采集项目数据,由 AI 分析后生成结构化的项目健康度报告。无需开发任何新 CLI 功能,完全基于现有命令的数据聚合与分析。
|
||||
|
||||
## 数据采集命令
|
||||
|
||||
### 第一步:获取项目基础信息
|
||||
|
||||
```bash
|
||||
# 项目基本信息(获取 project_id、fork 数、star 数、watch 数)
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 项目成员/贡献者列表
|
||||
gitlink-cli member +list --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 发行版本列表
|
||||
gitlink-cli release +list --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 里程碑列表
|
||||
gitlink-cli milestone +list --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
### 第二步:获取 Issue 数据
|
||||
|
||||
```bash
|
||||
# 开放中的 Issue
|
||||
gitlink-cli issue +list --owner <owner> --repo <repo> --state open --format json
|
||||
|
||||
# 已关闭的 Issue(可能需要翻页)
|
||||
gitlink-cli issue +list --owner <owner> --repo <repo> --state closed --format json
|
||||
|
||||
# 查看单个 Issue 详情(用于计算响应时间)
|
||||
gitlink-cli issue +view --owner <owner> --repo <repo> --number <num> --format json
|
||||
|
||||
# Issue 评论(用于判断首次响应时间)
|
||||
gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals --format json
|
||||
```
|
||||
|
||||
### 第三步:获取 PR 数据
|
||||
|
||||
```bash
|
||||
# PR 列表(开放状态)
|
||||
gitlink-cli pr +list --owner <owner> --repo <repo> --state open --format json
|
||||
|
||||
# PR 列表(已合并)
|
||||
gitlink-cli pr +list --owner <owner> --repo <repo> --state merged --format json
|
||||
|
||||
# PR 详情(用于计算合并耗时)
|
||||
gitlink-cli pr +view --owner <owner> --repo <repo> --id <pr_id> --format json
|
||||
```
|
||||
|
||||
### 第四步:获取提交数据
|
||||
|
||||
```bash
|
||||
# 提交历史
|
||||
gitlink-cli commit +list --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
### 第五步:获取标签和分支数据
|
||||
|
||||
```bash
|
||||
# 标签列表(用于 Issue 分类统计)
|
||||
gitlink-cli label +list --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 分支列表
|
||||
gitlink-cli branch +list --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
## 报告输出格式
|
||||
|
||||
AI 根据采集到的数据,按以下结构生成健康度报告:
|
||||
|
||||
```markdown
|
||||
# 🏥 项目健康度报告:<owner>/<repo>
|
||||
|
||||
> 报告生成时间:YYYY-MM-DD HH:mm
|
||||
|
||||
## 📊 总体评分
|
||||
|
||||
| 维度 | 得分 | 等级 |
|
||||
|------|------|------|
|
||||
| Issue 健康度 | XX/100 | 🟢/🟡/🔴 |
|
||||
| PR 健康度 | XX/100 | 🟢/🟡/🔴 |
|
||||
| 贡献者活跃度 | XX/100 | 🟢/🟡/🔴 |
|
||||
| 项目活跃度 | XX/100 | 🟢/🟡/🔴 |
|
||||
| **综合评分** | **XX/100** | **🟢/🟡/🔴** |
|
||||
|
||||
等级标准:🟢 优秀(≥80) | 🟡 良好(60-79) | 🔴 需改进(<60)
|
||||
|
||||
## 📋 Issue 维度
|
||||
|
||||
| 指标 | 数值 | 评价 |
|
||||
|------|------|------|
|
||||
| 开放 Issue 数 | N | — |
|
||||
| 已关闭 Issue 数 | N | — |
|
||||
| Issue 关闭率 | XX% | 🟢/🟡/🔴 |
|
||||
| 平均首次响应时间 | X 天 | 🟢/🟡/🔴 |
|
||||
| 平均解决时间 | X 天 | 🟢/🟡/🔴 |
|
||||
| 超 30 天未响应 Issue | N | 🟢/🟡/🔴 |
|
||||
|
||||
## 🔀 PR 维度
|
||||
|
||||
| 指标 | 数值 | 评价 |
|
||||
|------|------|------|
|
||||
| 开放 PR 数 | N | — |
|
||||
| 已合并 PR 数 | N | — |
|
||||
| PR 合并率 | XX% | 🟢/🟡/🔴 |
|
||||
| 平均合并耗时 | X 天 | 🟢/🟡/🔴 |
|
||||
| Review 覆盖率 | XX% | 🟢/🟡/🔴 |
|
||||
|
||||
## 👥 贡献者维度
|
||||
|
||||
| 指标 | 数值 | 评价 |
|
||||
|------|------|------|
|
||||
| 总贡献者数 | N | — |
|
||||
| 活跃贡献者(30天) | N | 🟢/🟡/🔴 |
|
||||
| Top 贡献者占比 | XX% | 🟢/🟡/🔴 |
|
||||
|
||||
### 贡献者排行(Top 5)
|
||||
|
||||
| 排名 | 贡献者 | PR 数 | Issue 数 | 提交数 |
|
||||
|------|--------|-------|----------|--------|
|
||||
| 1 | ... | N | N | N |
|
||||
|
||||
## 📈 活跃度维度
|
||||
|
||||
| 指标 | 数值 | 评价 |
|
||||
|------|------|------|
|
||||
| 总提交数 | N | — |
|
||||
| 里程碑数 | N | — |
|
||||
| 发行版本数 | N | — |
|
||||
| 分支数 | N | — |
|
||||
| 标签数 | N | — |
|
||||
| Star 数 | N | — |
|
||||
| Fork 数 | N | — |
|
||||
| Watch 数 | N | — |
|
||||
|
||||
## 💡 改进建议
|
||||
|
||||
1. **[具体建议]**:...
|
||||
2. **[具体建议]**:...
|
||||
3. **[具体建议]**:...
|
||||
```
|
||||
|
||||
## 评分算法
|
||||
|
||||
**⚠️ 必须先阅读 [`REFERENCE.md`](REFERENCE.md) 了解完整指标体系和评分标准,再计算得分。严格按 REFERENCE.md 中定义的权重和公式计算,不要凭感觉打分。**
|
||||
|
||||
### 权重分配
|
||||
|
||||
| 维度 | 权重 | 说明 |
|
||||
|------|------|------|
|
||||
| Issue 健康度 | 30% | 关闭率 + 响应时间 + 解决时间 |
|
||||
| PR 健康度 | 30% | 合并率 + 合并耗时 + Review 覆盖率 |
|
||||
| 贡献者活跃度 | 20% | 活跃比例 + 集中度 + 增长 |
|
||||
| 项目活跃度 | 20% | 提交频率 + Release + 社区关注 |
|
||||
|
||||
### PR 维度 N/A 处理
|
||||
|
||||
当项目为 Fork 仓库或无 PR 数据时,PR 维度不参与评分,权重按比例重分配:
|
||||
- Issue 健康度: 30% → 39%
|
||||
- 贡献者活跃度: 20% → 26%
|
||||
- 项目活跃度: 20% → 35%
|
||||
|
||||
### 贡献集中度计算
|
||||
|
||||
统计全部提交(跨所有页面),区分**本团队贡献者**和**上游原始贡献者**。贡献集中度仅计算本团队成员内部的 Top1 占比。
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:完整健康度报告
|
||||
|
||||
```bash
|
||||
# AI 执行流程:
|
||||
# 1. 依次调用上述数据采集命令
|
||||
# 2. 解析 JSON 输出
|
||||
# 3. 计算各项指标
|
||||
# 4. 按评分算法生成报告
|
||||
# 5. 输出结构化 Markdown 报告
|
||||
```
|
||||
|
||||
### 场景 2:周报模式
|
||||
|
||||
在完整报告基础上增加:
|
||||
- 本周新增 Issue/PR 数
|
||||
- 本周关闭/合并数
|
||||
- 与上周对比趋势(↑/↓/→)
|
||||
|
||||
```bash
|
||||
# 获取最近一周的数据(通过提交时间筛选)
|
||||
gitlink-cli commit +list --owner <owner> --repo <repo> --format json
|
||||
# AI 根据时间戳筛选最近 7 天的记录
|
||||
```
|
||||
|
||||
### 场景 3:多项目对比
|
||||
|
||||
对多个项目分别生成健康度报告,横向对比综合评分和各维度得分。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- 所有数据采集命令使用 `--format json` 以便 AI 解析
|
||||
- 数据量较大时注意翻页(`--page` 参数)
|
||||
- 报告生成后建议保存到项目 Wiki:
|
||||
```bash
|
||||
gitlink-cli wiki +create --title "项目健康度报告 YYYY-MM-DD" --body "<报告内容>"
|
||||
```
|
||||
- 定期(如每周)生成报告,跟踪项目健康趋势
|
||||
- 评分仅供参考,AI 应结合项目实际情况给出定制化改进建议
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
# 示例:完整健康度报告(真实数据)
|
||||
|
||||
> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-08 采集的真实数据生成。
|
||||
> **严格遵循 `SKILL.md` 的命令序列和 `REFERENCE.md` 的评分算法。**
|
||||
|
||||
---
|
||||
|
||||
## 执行命令序列(按 SKILL.md 五步执行)
|
||||
|
||||
```bash
|
||||
# Step 1: 项目基础信息
|
||||
gitlink-cli repo +info --owner chroe --repo gitlink-cli --format json
|
||||
# → Star=3 Fork=1 Watch=1 Issues=6 Releases=1
|
||||
|
||||
gitlink-cli member +list --owner chroe --repo gitlink-cli --format json
|
||||
# → 3 members: chroe(149027), caoweiqiong(141645), yetja(148915)
|
||||
|
||||
gitlink-cli release +list --owner chroe --repo gitlink-cli --format json
|
||||
# → 1 release: v0.1.13-freebsd (2026-06-03)
|
||||
|
||||
gitlink-cli milestone +list --owner chroe --repo gitlink-cli --format json
|
||||
# → 1 milestone: v2.0 (0 issues)
|
||||
|
||||
# Step 2: Issue 数据
|
||||
gitlink-cli issue +list --owner chroe --repo gitlink-cli --state open --format json
|
||||
# → Open=0
|
||||
|
||||
gitlink-cli issue +list --owner chroe --repo gitlink-cli --state closed --format json
|
||||
# → Closed=6, Total=6
|
||||
|
||||
# Journals API 尝试(SKILL.md 第二步)
|
||||
gitlink-cli api GET /chroe/gitlink-cli/issues/1/journals --format json
|
||||
# → 返回 HTML,不可用。使用 fallback 方法
|
||||
|
||||
# Fallback: 从 issue +list 的 comment_journals_count 判断
|
||||
# #1: 8 comments, #2: 4 comments, #3: 4 comments
|
||||
# #4: 0 comments, #5: 0 comments, #6: 0 comments
|
||||
|
||||
# Step 3: PR 数据
|
||||
gitlink-cli pr +list --owner chroe --repo gitlink-cli --state open --format json
|
||||
# → Open=0 Merged=0 Close=0
|
||||
|
||||
gitlink-cli pr +list --owner chroe --repo gitlink-cli --state merged --format json
|
||||
# → Merged=0
|
||||
# → 结论:Fork 仓库,PR 维度 N/A
|
||||
|
||||
# Step 4: 提交数据(全量翻页)
|
||||
gitlink-cli commit +list --owner chroe --repo gitlink-cli --format json --page 1
|
||||
gitlink-cli commit +list --owner chroe --repo gitlink-cli --format json --page 2
|
||||
gitlink-cli commit +list --owner chroe --repo gitlink-cli --format json --page 3
|
||||
gitlink-cli commit +list --owner chroe --repo gitlink-cli --format json --page 4
|
||||
gitlink-cli commit +list --owner chroe --repo gitlink-cli --format json --page 5
|
||||
# → Total=81
|
||||
|
||||
# Step 5: 标签和分支
|
||||
gitlink-cli label +list --owner chroe --repo gitlink-cli --format json
|
||||
# → 13 labels
|
||||
|
||||
gitlink-cli branch +list --owner chroe --repo gitlink-cli --format json
|
||||
# → 8 branches
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 生成的真实报告
|
||||
|
||||
```markdown
|
||||
# 🏥 项目健康度报告:chroe/gitlink-cli
|
||||
|
||||
> 报告生成时间:2026-06-08
|
||||
> 数据来源:gitlink-cli 命令行工具实时采集
|
||||
> 评分算法:严格遵循 skills/gitlink-health/REFERENCE.md
|
||||
|
||||
## 📊 总体评分
|
||||
|
||||
| 维度 | 得分 | 等级 |
|
||||
|------|------|------|
|
||||
| Issue 健康度 | 87/100 | 🟢 优秀 |
|
||||
| PR 健康度 | N/A | — |
|
||||
| 贡献者活跃度 | 62/100 | 🟡 良好 |
|
||||
| 项目活跃度 | 77/100 | 🟡 良好 |
|
||||
| **综合评分** | **77/100** | **🟡 良好** |
|
||||
|
||||
> PR 维度 N/A:本项目为 Fork 仓库,PR 在上游 gitlink/gitlink-cli 进行。
|
||||
> 权重重分配:Issue×39% + 贡献者×26% + 活跃度×35%
|
||||
|
||||
等级标准:🟢 优秀(≥80) | 🟡 良好(60-79) | 🔴 需改进(<60)
|
||||
|
||||
## 📋 Issue 维度(87/100)
|
||||
|
||||
### 评分计算
|
||||
|
||||
| 子指标 | 值 | 等级 | 得分 | 权重 | 加权 |
|
||||
|--------|-----|------|------|------|------|
|
||||
| 关闭率 | 6/6 = 100% | 🟢 ≥80% | 95 | ×0.30 | 28.5 |
|
||||
| 首次响应时间 | journals 不可用,fallback | 🟡 | 70 | ×0.25 | 17.5 |
|
||||
| 解决时间 | 平均 11.3 天 | 🟢 ≤14天 | 85 | ×0.25 | 21.3 |
|
||||
| 未响应 Issue | 0/0 (无开放) | 🟢 0% | 100 | ×0.20 | 20.0 |
|
||||
|
||||
**Issue 得分 = 87.3 → 87/100 🟢**
|
||||
|
||||
### 解决时间明细
|
||||
|
||||
| # | 标题 | 创建 | 关闭 | 耗时 |
|
||||
|---|------|------|------|------|
|
||||
| #1 | test | 05-22 | 06-08 | 17天 |
|
||||
| #2 | 新增批量 Issue 操作命令 | 05-25 | 06-08 | 14天 |
|
||||
| #3 | table 输出格式优化 | 05-26 | 06-08 | 13天 |
|
||||
| #4 | 增加 milestone/webhook/label/commit 模块 | 05-28 | 06-08 | 11天 |
|
||||
| #5 | 新增 file/member/watch/star 模块 | 05-31 | 06-08 | 8天 |
|
||||
| #6 | 安装模块改进 | 06-03 | 06-08 | 5天 |
|
||||
|
||||
### 首次响应时间 fallback 说明
|
||||
|
||||
journals API 返回 HTML 而非 JSON,使用 fallback:从 `comment_journals_count` 判断
|
||||
- #1/#2/#3 有评论(8/4/4) → 有响应
|
||||
- #4/#5/#6 无评论(0/0/0) → 无响应(自行完成的任务型 Issue)
|
||||
- 综合估分 70
|
||||
|
||||
## 🔀 PR 维度(N/A)
|
||||
|
||||
Fork 开发仓库,协作通过直接 push 完成。已向上游提交 2 个 PR。
|
||||
|
||||
## 👥 贡献者维度(62/100)
|
||||
|
||||
### 评分计算
|
||||
|
||||
全量 81 次提交。本团队(member +list):chroe, caoweiqiong, yetja
|
||||
本团队提交:chroe 34 + yetja 9 = 43 次
|
||||
|
||||
| 子指标 | 值 | 等级 | 得分 | 权重 | 加权 |
|
||||
|--------|-----|------|------|------|------|
|
||||
| 活跃贡献者比例 | 3/3 = 100% | 🟢 ≥40% | 95 | ×0.40 | 38.0 |
|
||||
| 贡献集中度 | 34/43 = 79.1% | 🔴 >60% | 30 | ×0.30 | 9.0 |
|
||||
| 贡献者增长 | 无新增 | — | 50 | ×0.30 | 15.0 |
|
||||
|
||||
**贡献者得分 = 62.0 → 62/100 🟡**
|
||||
|
||||
### 贡献者排行
|
||||
|
||||
| 排名 | 贡献者 | 提交数 | 占比 |
|
||||
|------|--------|--------|------|
|
||||
| 1 | @chroe | 34 | 79% |
|
||||
| 2 | @yetja | 9 | 21% |
|
||||
|
||||
## 📈 活跃度维度(77/100)
|
||||
|
||||
### 评分计算
|
||||
|
||||
项目周期约 17 天(2026-05-22 ~ 2026-06-08)
|
||||
|
||||
| 子指标 | 值 | 等级 | 得分 | 权重 | 加权 |
|
||||
|--------|-----|------|------|------|------|
|
||||
| 提交频率 | 81/17 = 4.8次/天 | 🟢 ≥2 | 95 | ×0.40 | 38.0 |
|
||||
| Issue创建频率 | 6/17 = 0.35/天 | — | 65 | ×0.20 | 13.0 |
|
||||
| Release频率 | 1/0.57月 = 1.75/月 | 🟢 ≥1 | 80 | ×0.20 | 16.0 |
|
||||
| 社区关注度 | 3+1+1 = 5 | 🟡 5-19 | 50 | ×0.20 | 10.0 |
|
||||
|
||||
**活跃度得分 = 77.0 → 77/100 🟡**
|
||||
|
||||
## 综合评分计算
|
||||
|
||||
综合 = Issue(87) × 0.39 + 贡献者(62) × 0.26 + 活跃度(77) × 0.35
|
||||
= 33.9 + 16.1 + 27.0 = **77/100 🟡 良好**
|
||||
|
||||
## 💡 改进建议
|
||||
|
||||
1. **降低贡献集中度**:chroe 占 79%,建议将 Skills 开发、文档撰写等分配给 yetja 和 caoweiqiong
|
||||
2. **提高社区关注度**:Star=3, Watch=1,邀请更多同学关注和点赞
|
||||
3. **关联里程碑**:v2.0 里程碑 0 个 Issue,建议关联后续任务
|
||||
4. **清理测试标签**:13 个标签中有 3 个测试标签(test-label/test-label2/1),建议删除
|
||||
5. **定期生成报告**:每周跑一次健康度报告,跟踪改善趋势
|
||||
```
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# 示例:项目周报
|
||||
|
||||
> 本示例展示如何使用 gitlink-health Skill 生成项目周报。
|
||||
> 基于 `chroe/gitlink-cli` 项目于 2026-06-08 采集的真实数据。
|
||||
|
||||
---
|
||||
|
||||
## 执行命令序列
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner chroe --repo gitlink-cli --format json
|
||||
gitlink-cli issue +list --owner chroe --repo gitlink-cli --state open --format json
|
||||
gitlink-cli issue +list --owner chroe --repo gitlink-cli --state closed --format json
|
||||
gitlink-cli commit +list --owner chroe --repo gitlink-cli --format json --page 1
|
||||
gitlink-cli release +list --owner chroe --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
> AI 根据 `created_at` / `commit_time` 等时间字段筛选最近 7 天的数据。
|
||||
|
||||
## 生成的周报示例
|
||||
|
||||
```markdown
|
||||
# 📅 项目周报:chroe/gitlink-cli
|
||||
|
||||
> 报告周期:2026-06-01 ~ 2026-06-08
|
||||
> 报告生成时间:2026-06-08
|
||||
|
||||
## 📊 本周概览
|
||||
|
||||
| 指标 | 本周 | 上周 | 趋势 |
|
||||
|------|------|------|------|
|
||||
| 关闭 Issue | 6(#1-#6 全部关闭) | 0 | ↑ 全部清零 |
|
||||
| 新建 Issue | 2(#5, #6) | 2 | → 持平 |
|
||||
| 提交数 | ~35 | ~46 | ↓ 减少 |
|
||||
| 新增 Release | 1 (v0.1.13-freebsd) | 0 | ↑ |
|
||||
| Star | 3 | — | — |
|
||||
| Watch | 1 | 0 | ↑ |
|
||||
|
||||
## 📋 Issue 动态
|
||||
|
||||
### 本周关闭的 Issue(全部 6 个)
|
||||
|
||||
| # | 标题 | 责任人 | 标签 | 耗时 |
|
||||
|---|------|--------|------|------|
|
||||
| #1 | test | @yetja | 测试 | 17天 |
|
||||
| #2 | 新增批量 Issue 操作命令 | @yetja | 功能 | 14天 |
|
||||
| #3 | table 输出格式优化 | @yetja | 功能 | 13天 |
|
||||
| #4 | 增加 milestone/webhook/label/commit 模块 | @chroe | 功能 | 11天 |
|
||||
| #5 | 新增 file/member/watch/star 模块 | @caoweiqiong | 功能 | 8天 |
|
||||
| #6 | 安装模块改进 | @caoweiqiong | 功能 | 5天 |
|
||||
|
||||
### 当前状态
|
||||
|
||||
✅ **所有 Issue 已关闭**,Issue 关闭率 100%。
|
||||
|
||||
## 👥 贡献者活跃度(本周)
|
||||
|
||||
| 贡献者 | 提交数 | 主要工作 |
|
||||
|--------|--------|---------|
|
||||
| @chroe | ~20 | Showcase 优化、批量操作修复、Bug 修复、Skills 开发 |
|
||||
| @yetja | ~8 | 批量仓库管理、table 格式支持、Showcase 集成 |
|
||||
| @caoweiqiong | ~2 | Issue 创建、Release v0.1.13-freebsd 发布 |
|
||||
|
||||
## 🎯 里程碑进度
|
||||
|
||||
| 里程碑 | 关联 Issue | 状态 |
|
||||
|--------|-----------|------|
|
||||
| v2.0 | 0 | 🔄 进行中(待关联新任务) |
|
||||
|
||||
## 📦 Release
|
||||
|
||||
| 版本 | 发布者 | 时间 |
|
||||
|------|--------|------|
|
||||
| v0.1.13-freebsd | @caoweiqiong | 2026-06-03 |
|
||||
|
||||
新增 FreeBSD amd64/arm64 平台支持。
|
||||
|
||||
## 💡 下周建议
|
||||
|
||||
1. **平衡工作量**:本周 chroe 贡献占比较大,建议明确分工
|
||||
2. **关联 v2.0 里程碑**:将后续 Skill 开发任务关联到 v2.0
|
||||
3. **开始子任务二**:本周完成了子任务一的所有 Issue,下周重点转向 Skills 开发和验证
|
||||
4. **提升社区关注度**:邀请更多同学 Star 和 Watch
|
||||
```
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
# 示例:Issue 全流程管理(真实数据)
|
||||
|
||||
> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-13 在 Claude Code 中实际执行。
|
||||
> 展示完整的 Issue 管理流程:列出 → 查看详情 → 添加评论 → 关闭。
|
||||
|
||||
---
|
||||
|
||||
## 场景:项目维护者日常 Issue 处理
|
||||
|
||||
### Step 1:查看全部 Issue
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +list --owner chroe --repo gitlink-cli --state open --format json
|
||||
```
|
||||
|
||||
**真实输出摘要(2026-06-13 验证,全部已关闭):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"opened_count": 0,
|
||||
"closed_count": 6,
|
||||
"total_count": 6,
|
||||
"issues": [
|
||||
{
|
||||
"project_issues_index": 6,
|
||||
"subject": "安装模块改进:CI版本修复、FreeBSD渠道、Windows凭据路径、去除重复文件",
|
||||
"author": { "login": "caoweiqiong", "name": "CWQ" },
|
||||
"status": { "id": 5, "name": "关闭" },
|
||||
"created_at": "2026-06-03 21:03"
|
||||
},
|
||||
{
|
||||
"project_issues_index": 5,
|
||||
"subject": "feat: 新增 file/member/watch/star 四大 Shortcut 模块(15 个命令)",
|
||||
"author": { "login": "caoweiqiong", "name": "CWQ" },
|
||||
"status": { "id": 5, "name": "关闭" },
|
||||
"created_at": "2026-05-31 08:55"
|
||||
},
|
||||
{
|
||||
"project_issues_index": 4,
|
||||
"subject": "增加milestone/webhook/label/commit Shortcut模块(共20条命令)",
|
||||
"author": { "login": "chroe", "name": "chroe" },
|
||||
"status": { "id": 5, "name": "关闭" },
|
||||
"created_at": "2026-05-28 14:36"
|
||||
},
|
||||
{
|
||||
"project_issues_index": 3,
|
||||
"subject": "table 输出格式优化",
|
||||
"author": { "login": "yetja", "name": "yetja" },
|
||||
"status": { "id": 5, "name": "关闭" },
|
||||
"comment_journals_count": 4,
|
||||
"created_at": "2026-05-26 15:08"
|
||||
},
|
||||
{
|
||||
"project_issues_index": 2,
|
||||
"subject": "新增批量 Issue 操作命令(batch-assign,batch-label,batch-milestone)",
|
||||
"author": { "login": "yetja", "name": "yetja" },
|
||||
"status": { "id": 5, "name": "关闭" },
|
||||
"comment_journals_count": 4,
|
||||
"created_at": "2026-05-25 11:01"
|
||||
},
|
||||
{
|
||||
"project_issues_index": 1,
|
||||
"subject": "test",
|
||||
"author": { "login": "yetja", "name": "yetja" },
|
||||
"status": { "id": 5, "name": "关闭" },
|
||||
"comment_journals_count": 8,
|
||||
"created_at": "2026-05-22 11:48"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**分析:** 6 个 Issue 全部已关闭(关闭率 100%)。#1-3 由 yetja 创建,#4 由 chroe 创建,#5-6 由 CWQ 创建。
|
||||
|
||||
### Step 2:查看已关闭 Issue(了解处理模式)
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +list --owner chroe --repo gitlink-cli --state closed --format json
|
||||
```
|
||||
|
||||
返回结果与 Step 1 相同(所有 6 个 Issue 均已关闭)。#1-3 有 4-8 条评论互动,#4-6 无评论。
|
||||
|
||||
### Step 3:查看 Issue 详情(含评论)
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +view --owner chroe --repo gitlink-cli --number 3 --format json
|
||||
```
|
||||
|
||||
**真实输出摘要:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 142892,
|
||||
"project_issues_index": 3,
|
||||
"subject": "table 输出格式优化",
|
||||
"description": "改进已有的 table 输出,让 list 命令用 `--format table` 时显示更美观",
|
||||
"status": { "id": 5, "name": "关闭" },
|
||||
"priority": { "id": 2, "name": "正常" },
|
||||
"author": { "login": "yetja", "name": "yetja" },
|
||||
"assigners": [{ "login": "yetja", "name": "yetja" }],
|
||||
"tags": [{ "id": 323830, "name": "功能", "color": "#ee955a" }],
|
||||
"created_at": "2026-05-26 15:08",
|
||||
"updated_at": "2026-06-08 12:30",
|
||||
"due_date": "2026-06-08",
|
||||
"comment_journals_count": 4,
|
||||
"participants": [
|
||||
{ "login": "yetja", "name": "yetja" },
|
||||
{ "login": "chroe", "name": "chroe" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4:获取 Issue 评论(Raw API)
|
||||
|
||||
```bash
|
||||
gitlink-cli api GET /v1/chroe/gitlink-cli/issues/3/journals
|
||||
```
|
||||
|
||||
评论列表用于了解 Issue 讨论进展,计算首次响应时间。
|
||||
|
||||
### Step 5:添加评论并关闭 Issue
|
||||
|
||||
```bash
|
||||
# 对开放 Issue 添加处理评论
|
||||
gitlink-cli issue +comment --owner chroe --repo gitlink-cli --number 5 --body "已确认收到,将在下一迭代处理"
|
||||
|
||||
# 关闭已完成的 Issue
|
||||
gitlink-cli issue +close --owner chroe --repo gitlink-cli --number 5
|
||||
```
|
||||
|
||||
### Step 6:批量操作(预览模式)
|
||||
|
||||
```bash
|
||||
# 预览批量关闭(不实际执行)
|
||||
gitlink-cli issue +batch-close --owner chroe --repo gitlink-cli --numbers 4,5,6 --dry-run
|
||||
|
||||
# 确认后执行
|
||||
gitlink-cli issue +batch-close --owner chroe --repo gitlink-cli --numbers 4,5,6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键发现
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 总 Issue 数 | 6 |
|
||||
| 开放 / 关闭 | 0 / 6 |
|
||||
| 关闭率 | 100% |
|
||||
| 平均评论数(#1-3) | 5.3 条 |
|
||||
| 平均解决周期(#1-3) | ~10.7 天 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. `--number` 参数是网页 URL 中的序号(`project_issues_index`),不是数据库内部 ID
|
||||
2. 已关闭 Issue 仍有评论互动,说明团队有 Issue 跟进习惯
|
||||
3. 开放 Issue 均无评论,建议及时响应以改善 Issue 健康度
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
# gitlink-license 参考手册
|
||||
|
||||
> 本文档定义许可证合规检查的完整评分算法、敏感信息模式库、许可证类型枚举和 API 注意事项。
|
||||
|
||||
---
|
||||
|
||||
## 一、评分算法详解
|
||||
|
||||
### 1. 许可证文件完整性(权重 35%)
|
||||
|
||||
| 指标 | 权重 | 计算方法 | 数据来源 |
|
||||
|------|------|---------|---------|
|
||||
| LICENSE 文件存在 | 50% | 根目录是否有 LICENSE / LICENSE.md / COPYING 文件 | `file +list` |
|
||||
| 许可证类型可识别 | 30% | LICENSE 内容是否匹配已知许可证的特征字符串 | `file +get --path LICENSE` |
|
||||
| 版权声明完整 | 20% | LICENSE 中 Copyright 是否填写了具体年份和持有人(非 `[Year] [name]` 占位符) | `file +get --path LICENSE` |
|
||||
|
||||
**评分标准:**
|
||||
|
||||
| 指标 | 🟢 优秀 | 🟡 良好 | 🔴 需改进 |
|
||||
|------|---------|---------|-----------|
|
||||
| LICENSE 存在 | 有 LICENSE + LICENSE.md | 仅有 LICENSE 或 COPYING | 无任何许可证文件 |
|
||||
| 类型识别 | 可直接匹配到标准许可证 | 内容为许可证但类型模糊 | 无法识别 |
|
||||
| 版权声明 | 年份 + 持有人完整 | 仅年份或仅持有人 | 占位符 `[Year] [name]` |
|
||||
|
||||
### 2. 敏感信息防护(权重 40%)
|
||||
|
||||
**起始分 100,按以下规则扣分(最低 0):**
|
||||
|
||||
| 风险等级 | 扣分 | 匹配条件 |
|
||||
|---------|------|---------|
|
||||
| 🔴 高危 | -15 分/个 | 私钥文件(`.pem`, `.key`, `.p12`, `id_rsa`);硬编码 Token/密码(非占位符);GITLINK_TOKEN 等认证凭证明文 |
|
||||
| 🟠 中危 | -5 分/个 | CI 配置暴露 IP/用户名但不含密码;疑似 Token 文件名但内容安全 |
|
||||
| 🟡 低危 | -2 分/个 | 大二进制文件(≥5MB 的 `.exe`/`.bin`/`.so`);无用文件(`coverage.out`) |
|
||||
|
||||
**评分等级:**
|
||||
|
||||
| 等级 | 分数 |
|
||||
|------|------|
|
||||
| 🟢 优秀 | ≥ 85 |
|
||||
| 🟡 良好 | 60-84 |
|
||||
| 🔴 需改进 | < 60 |
|
||||
|
||||
### 3. 依赖许可证合规(权重 15%)
|
||||
|
||||
| 指标 | 权重 | 计算方法 | 数据来源 |
|
||||
|------|------|---------|---------|
|
||||
| 依赖清单存在 | 40% | go.mod / package.json / requirements.txt 等是否存在 | `file +get` |
|
||||
| 依赖数量正常 | 30% | 直接依赖是否在合理范围(Go: <50, npm: <100) | 同上 |
|
||||
| 传染性风险 | 30% | 是否依赖 GPL / AGPL 等强传染性许可证的包(需 AI 根据包名常识判断) | 同上 |
|
||||
|
||||
**评分标准:**
|
||||
|
||||
| 指标 | 🟢 优秀 | 🟡 良好 | 🔴 需改进 |
|
||||
|------|---------|---------|-----------|
|
||||
| 依赖清单 | 有清单 + 含 license 字段 | 有清单但无 license 字段 | 无依赖清单 |
|
||||
| 传染性风险 | 无 GPL 依赖 | 不确定 | 明确含 GPL 依赖 |
|
||||
|
||||
### 4. 源码许可证声明(权重 10%)
|
||||
|
||||
| 指标 | 权重 | 计算方法 |
|
||||
|------|------|---------|
|
||||
| 版权声明覆盖率 | 60% | 被抽样文件中含 `Copyright (c)` 的比例 |
|
||||
| 许可证引用覆盖率 | 40% | 被抽样文件中含 `Licensed under` 或 `SPDX-License-Identifier` 的比例 |
|
||||
|
||||
**评分标准:**
|
||||
|
||||
| 指标 | 🟢 优秀 | 🟡 良好 | 🔴 需改进 |
|
||||
|------|---------|---------|-----------|
|
||||
| 版权声明 | ≥ 80% | 40%-79% | < 40% |
|
||||
| 许可证引用 | ≥ 80% | 40%-79% | < 40% |
|
||||
|
||||
---
|
||||
|
||||
## 二、综合评分公式
|
||||
|
||||
```
|
||||
综合评分 = 许可证得分 × 0.35 + 敏感信息得分 × 0.40 + 依赖得分 × 0.15 + 源码声明得分 × 0.10
|
||||
```
|
||||
|
||||
### 等级划分
|
||||
|
||||
| 等级 | 分数范围 | 图标 |
|
||||
|------|---------|------|
|
||||
| 优秀 | ≥ 80 | 🟢 |
|
||||
| 良好 | 60-79 | 🟡 |
|
||||
| 需改进 | < 60 | 🔴 |
|
||||
|
||||
---
|
||||
|
||||
## 三、敏感文件模式库
|
||||
|
||||
### 文件名模式(优先级从高到低)
|
||||
|
||||
| 模式 | 风险 | 匹配方式 |
|
||||
|------|------|---------|
|
||||
| `*.pem` | 🔴 私钥 | 后缀匹配 |
|
||||
| `*.key` | 🔴 私钥 | 后缀匹配(排除 `*.pub`) |
|
||||
| `*.p12`, `*.pfx` | 🔴 证书 | 后缀匹配 |
|
||||
| `id_rsa*` | 🔴 SSH 私钥 | 前缀匹配 |
|
||||
| `.env` | 🔴 环境变量 | 精确匹配 |
|
||||
| `.env.production`, `.env.local`, `.env.development` | 🔴 环境变量 | 精确匹配 |
|
||||
| `credentials.*` | 🔴 凭证 | 前缀匹配 |
|
||||
| `*.secret` | 🔴 密钥 | 后缀匹配 |
|
||||
| `serviceAccount.json` | 🔴 GCP 凭证 | 精确匹配 |
|
||||
| `docker-compose.yml` | 🟠 配置 | 精确匹配 |
|
||||
| `.devops/*.yml` | 🟠 CI/CD | 路径匹配 |
|
||||
| `.github/workflows/*.yml` | 🟠 CI/CD | 路径匹配 |
|
||||
| `*token*` | 🟠 Token | 模糊匹配(需读内容确认) |
|
||||
| `*secret*` | 🟠 密钥 | 模糊匹配(需读内容确认) |
|
||||
| `Dockerfile` | 🟠 容器 | 精确匹配 |
|
||||
| `*.exe`, `*.bin`, `*.dll`, `*.so` (≥ 5MB) | 🟡 二进制 | 后缀 + 大小 |
|
||||
| `coverage.out` | 🟡 测试 | 精确匹配 |
|
||||
|
||||
### 内容正则模式
|
||||
|
||||
| 模式 | 风险 | 正则 |
|
||||
|------|------|------|
|
||||
| 通用密钥赋值 | 🔴 | `(token\|api.key\|apikey\|secret\|password\|passwd)\s*[:=]\s*['"][^\s'"]{8,}['"]` |
|
||||
| 私钥头 | 🔴 | `-----BEGIN (RSA\|EC\|OPENSSH\|DSA )?PRIVATE KEY-----` |
|
||||
| GITLINK_TOKEN 硬编码 | 🔴 | `GITLINK_TOKEN\s*=\s*'[^']+'` |
|
||||
| 数据库连接串 | 🔴 | `(mysql\|postgres\|mongodb)://[^:]+:[^@]+@` |
|
||||
| AWS Access Key | 🔴 | `AKIA[0-9A-Z]{16}` |
|
||||
| GitHub Token | 🔴 | `ghp_[0-9a-zA-Z]{36}` |
|
||||
| SSH 密码 | 🟠 | `ssh_pass:\s*(["'])(?![(]{2})[^"']+\1` |
|
||||
| 硬编码 IP | 🟠 | `\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b` |
|
||||
| JWT Token | 🟠 | `eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}` |
|
||||
| 空密码 | 🟡 | `password\s*[:=]\s*['"]\s*['"]` |
|
||||
|
||||
> **排除规则**:匹配到以下模式时不视为泄露:
|
||||
> - 值含 `((` 和 `))` 的变量语法(如 `((deploy_server.server_password))`)
|
||||
> - 值为 `<TOKEN>`、`your_token_here`、`xxx`、`REPLACE_ME` 等占位符
|
||||
> - 值为空字符串 `""` 或 `''`(空密码为低危而非高危)
|
||||
|
||||
---
|
||||
|
||||
## 四、许可证类型枚举
|
||||
|
||||
| 类型 | SPDX 标识 | 传染性 | 识别关键词 |
|
||||
|------|----------|--------|-----------|
|
||||
| MIT | MIT | 宽松 | `Permission is hereby granted, free of charge` |
|
||||
| Apache 2.0 | Apache-2.0 | 宽松 | `Apache License, Version 2.0` |
|
||||
| BSD 2-Clause | BSD-2-Clause | 宽松 | `Redistribution and use` + 2 条 clause |
|
||||
| BSD 3-Clause | BSD-3-Clause | 宽松 | `Redistribution and use` + 3 条 clause |
|
||||
| Mulan PSL v2 | MulanPSL-2.0 | 宽松 | `Mulan Permissive Software License` / `木兰宽松许可证` |
|
||||
| ISC | ISC | 宽松 | `Permission to use, copy, modify, and/or distribute` |
|
||||
| Unlicense | Unlicense | 宽松 | `This is free and unencumbered software` |
|
||||
| MPL 2.0 | MPL-2.0 | 弱传染 | `Mozilla Public License` |
|
||||
| LGPL v3 | LGPL-3.0 | 弱传染 | `GNU LESSER GENERAL PUBLIC LICENSE` |
|
||||
| GPL v2 | GPL-2.0 | 强传染 | `GNU GENERAL PUBLIC LICENSE Version 2` |
|
||||
| GPL v3 | GPL-3.0 | 强传染 | `GNU GENERAL PUBLIC LICENSE Version 3` |
|
||||
| AGPL v3 | AGPL-3.0 | 强传染 | `GNU AFFERO GENERAL PUBLIC LICENSE` |
|
||||
| CC0-1.0 | CC0-1.0 | 宽松 | `CC0 1.0 Universal` / `Creative Commons Zero` |
|
||||
|
||||
> **传染性说明**:
|
||||
> - **宽松 (Permissive)**:可闭源使用,仅需保留版权声明
|
||||
> - **弱传染 (Weak Copyleft)**:修改库本身需开源,链接使用可闭源
|
||||
> - **强传染 (Strong Copyleft)**:任何使用该库的项目都必须以相同许可证开源
|
||||
|
||||
---
|
||||
|
||||
## 五、数据字段映射
|
||||
|
||||
### file +list 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": "[{\"name\":\"LICENSE\",\"path\":\"LICENSE\",\"sha\":\"a0937f...\",\"type\":\"file\",\"size\":4988}]"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` | 文件名 |
|
||||
| `path` | 相对于仓库根的文件路径 |
|
||||
| `type` | `file` 或 `dir` |
|
||||
| `size` | 文件大小(字节) |
|
||||
| `sha` | 文件 blob SHA |
|
||||
|
||||
> ⚠️ `file +list` 返回的是 JSON 数组字符串,需外层 `json.Unmarshal` 一次。
|
||||
|
||||
### file +tree 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"entries": [
|
||||
{
|
||||
"mode": "040000",
|
||||
"name": ".devops",
|
||||
"sha": "b79a53...",
|
||||
"size": 0,
|
||||
"type": "dir"
|
||||
},
|
||||
{
|
||||
"mode": "100644",
|
||||
"name": "LICENSE",
|
||||
"sha": "a0937f...",
|
||||
"size": 4988,
|
||||
"type": "file"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `type` | `dir` 或 `file` |
|
||||
| `size` | 0 表示目录,>0 表示文件字节数 |
|
||||
| `mode` | Git 文件模式(`040000`=目录, `100644`=普通文件) |
|
||||
|
||||
> ⚠️ **关键限制**:`file +tree --recursive true` 在部分仓库返回空数据,这是已知 bug。必须通过 `file +get --path <subdir>` 手动递归子目录。
|
||||
|
||||
### file +get 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"entries": {
|
||||
"commit": {
|
||||
"created_at": "2026-04-17 11:25",
|
||||
"message": "change license to mulan",
|
||||
"sha": "147033c..."
|
||||
},
|
||||
"content": "<文件文本内容,可能很大>",
|
||||
"name": "LICENSE",
|
||||
"path": "LICENSE",
|
||||
"size": 4988,
|
||||
"type": "file"
|
||||
},
|
||||
"last_commit": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `content` | 文件文本内容(大文件可能被截断) |
|
||||
| `size` | 文件总字节数 |
|
||||
| `commit.message` | 该文件最后一次修改的 commit message |
|
||||
| `commit.sha` | 该文件最后一次修改的 commit SHA |
|
||||
|
||||
> ⚠️ `file +get --path` 对**目录**返回的 `entries` 是数组(子条目列表),对**文件**返回的 `entries` 是对象(含 `content` 字段)。需判断 `entries` 类型。
|
||||
|
||||
### commit +list 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"commits": [
|
||||
{
|
||||
"sha": "373e79a...",
|
||||
"commit_message": "修复issue分配优先级...",
|
||||
"commit_time": 1781493827,
|
||||
"author": { "login": "chroe", "name": "chroe" },
|
||||
"files": ["cmd/debug_url/main.go", "..."]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### commit +diff 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"file_nums": 10,
|
||||
"files": [
|
||||
{
|
||||
"filepath": "...",
|
||||
"addition": 23,
|
||||
"deletion": 12,
|
||||
"diff": "<diff 内容>"
|
||||
}
|
||||
],
|
||||
"total_addition": 416,
|
||||
"total_deletion": 171
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ `diff` 字段需要验证是否存在。如果返回中无 `diff` 字段,可用 `file +get` 直接读文件检查。
|
||||
|
||||
---
|
||||
|
||||
## 六、常见问题
|
||||
|
||||
### Q: `file +tree --recursive true` 返回空怎么办?
|
||||
|
||||
A: 这是已知 bug。采用两步走策略:
|
||||
1. 先用 `file +tree --sha master` 获取顶级结构
|
||||
2. 对每个 `type: "dir"` 的目录,用 `file +get --path <dir>` 递归进入
|
||||
3. 备选方案:直接用 `file +list` 获取扁平文件列表(虽无层级但覆盖全量文件)
|
||||
|
||||
### Q: 如何区分真泄露和误报?
|
||||
|
||||
A: 文件名含 `token`/`secret` 不一定就是泄露。必须用 `file +get` 读取内容:
|
||||
- 如果是 Token 管理代码(如 `keyring.Set/Get`)→ 无风险
|
||||
- 如果内容是硬编码字符串 `token = "abc123..."` → 真泄露
|
||||
- 值使用变量语法 `((vault.secret))` → 无风险
|
||||
|
||||
### Q: LICENSE 文件包含中文正常吗?
|
||||
|
||||
A: 正常。Mulan PSL v2 等中国开源许可证就是中英双语版本。关键是判断版权声明处是否仍为 `[Year] [name of copyright holder]` 占位符。
|
||||
|
||||
### Q: 大二进制文件怎么处理?
|
||||
|
||||
A: `gitlink-cli.exe` (11.5MB) 这种不应该提交到源码仓库。建议用户:
|
||||
1. 添加到 `.gitignore`
|
||||
2. 用 `git rm --cached` 从 Git 历史中移除
|
||||
3. 改用 CI Release 发布二进制文件
|
||||
|
||||
### Q: 扫描依赖许可证时需要联网吗?
|
||||
|
||||
A: 不需要。AI 仅根据 `go.mod`/`package.json` 中的包名做常识性判断(如 `gopkg.in/yaml.v3` 是 MIT)。对不确定的包,标注"需人工确认"而非伪造结论。
|
||||
|
||||
### Q: 私有仓库能做合规检查吗?
|
||||
|
||||
A: 可以,需要先认证:`gitlink-cli auth login`。认证后所有命令自动携带 Token。
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
---
|
||||
name: gitlink-license
|
||||
version: 1.0.0
|
||||
description: "许可证合规检查:扫描仓库许可证完整性、依赖许可证合规性、敏感信息泄露风险。当用户需要检查项目许可证合规、扫描密钥泄露、审计开源合规时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli file --help"
|
||||
---
|
||||
|
||||
# gitlink-license(许可证合规检查)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 本 Skill 仅执行读取操作(GET),不会修改任何项目数据。**
|
||||
**CRITICAL — 检查结果可能包含敏感发现(如泄露的 Token)。报告生成后提示用户立即处理高危项,不要将报告公开发布。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
**CRITICAL — 执行前必须验证 Binary 版本:系统 PATH 中的 `gitlink-cli` 可能是 npm 安装的旧版(v0.1.18),缺少 `member`/`file` 等新模块。先跑 `./gitlink-cli --help` 确认有目标子命令;若没有,执行 `go build -o gitlink-cli .` 重建,后续用 `./gitlink-cli` 调用。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## 概述
|
||||
|
||||
本 Skill 通过组合多个 gitlink-cli 命令扫描仓库,由 AI 分析后生成结构化的许可证合规检查报告。覆盖四个维度:许可证文件完整性、敏感信息防护、依赖许可证合规、源码许可证声明。
|
||||
|
||||
## 数据采集命令
|
||||
|
||||
### 第零步:验证 Binary 版本(必须!)
|
||||
|
||||
```bash
|
||||
# 系统 PATH 中的 gitlink-cli 可能是 npm 旧版(v0.1.18),没有 member/file 等新模块
|
||||
./gitlink-cli --help 2>&1 | grep -E "member|file"
|
||||
# 如果输出了 member 和 file → 版本 OK,后续用 ./gitlink-cli
|
||||
# 如果无输出 → 旧版 Binary,需要重建:
|
||||
|
||||
go build -o gitlink-cli .
|
||||
# 重建后再检查一次 ./gitlink-cli --help
|
||||
```
|
||||
|
||||
> ⚠️ **绝对不要直接用 `gitlink-cli`(系统 PATH),本项目开发环境中 PATH 指向 npm 旧版 0.1.18,缺少 `member`/`file` 等本 Skill 必需的新模块。**
|
||||
|
||||
### 第一步:获取项目基础信息
|
||||
|
||||
```bash
|
||||
# 项目基本信息(获取项目 ID、可见性、默认分支等)
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 项目成员列表(用于合规责任归属)
|
||||
gitlink-cli member +list --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
### 第二步:扫描根目录许可证文件
|
||||
|
||||
```bash
|
||||
# 列出根目录文件(找 LICENSE / COPYING / NOTICE)
|
||||
gitlink-cli file +list --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 读取 LICENSE 文件内容(AI 识别许可证类型和完整性)
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path LICENSE --format json
|
||||
|
||||
# 如果根目录有 LICENSE.md / COPYING 等,也一并读取
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path LICENSE.md --format json
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path COPYING --format json
|
||||
```
|
||||
|
||||
### 第三步:扫描全量文件树(敏感文件 + 二进制文件)
|
||||
|
||||
```bash
|
||||
# 获取顶级文件树(先看目录结构)
|
||||
gitlink-cli file +tree --owner <owner> --repo <repo> --sha master --format json
|
||||
|
||||
# 递归进入每个子目录(用 file +get 获取子目录内容)
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path <subdir> --format json
|
||||
|
||||
# ⚠️ file +tree --recursive true 可能返回空,需要手动遍历子目录
|
||||
```
|
||||
|
||||
> ⚠️ **重要**:`file +tree --recursive true` 在部分仓库上返回空数据。此时必须通过 `file +tree` 获取顶级目录 → 对每个子目录用 `file +get --path <subdir>` 递归进入,或用 `file +list` 获取扁平文件列表。
|
||||
|
||||
### 第四步:检查高危文件内容
|
||||
|
||||
```bash
|
||||
# 对第三步中发现的任何可疑文件,读取内容进行深度扫描
|
||||
# 例如:CI/CD 配置文件、Dockerfile、shell 脚本
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path '.devops/<pipeline>.yml' --format json
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path '.github/workflows/<workflow>.yml' --format json
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path 'Dockerfile' --format json
|
||||
|
||||
# 读取依赖清单文件
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path go.mod --format json
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path package.json --format json
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path requirements.txt --format json
|
||||
```
|
||||
|
||||
### 第五步:抽样检查源码文件许可证声明
|
||||
|
||||
```bash
|
||||
# 抽取 3-5 个主要源码文件,检查是否有版权/许可证声明
|
||||
# 对 Go 项目抽样 main.go + 各模块主文件
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path main.go --format json
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path shortcuts/pr/pr.go --format json
|
||||
gitlink-cli file +get --owner <owner> --repo <repo> --path internal/client/client.go --format json
|
||||
```
|
||||
|
||||
### 第六步(可选):检查近期提交的敏感信息
|
||||
|
||||
```bash
|
||||
# 获取最近提交列表
|
||||
gitlink-cli commit +list --owner <owner> --repo <repo> --page 1 --format json
|
||||
|
||||
# 查看关键提交的 diff(检查是否引入了密钥/Token/大文件)
|
||||
gitlink-cli commit +diff --owner <owner> --repo <repo> --sha <commit-sha> --format json
|
||||
```
|
||||
|
||||
## AI 分析规则
|
||||
|
||||
### 敏感文件名匹配模式
|
||||
|
||||
AI 遍历 `file +tree` 和 `file +list` 返回的所有文件路径,匹配以下高危模式:
|
||||
|
||||
| 风险等级 | 文件名模式 | 风险说明 |
|
||||
|---------|-----------|---------|
|
||||
| 🔴 高危 | `*.pem`, `*.key`, `*.p12`, `*.pfx`, `id_rsa*` | 私钥/证书文件直接泄露 |
|
||||
| 🔴 高危 | `.env`, `.env.*` | 环境变量包含数据库密码、API Key |
|
||||
| 🔴 高危 | `credentials*`, `*secret*` | 凭证文件泄露 |
|
||||
| 🟠 中危 | `token_store.go`, `*token*` (需读内容确认) | 可能是无害的 Token 管理代码 |
|
||||
| 🟠 中危 | `docker-compose.yml`, `Dockerfile` | 可能硬编码密码/Token |
|
||||
| 🟠 中危 | `.devops/*.yml`, `.github/workflows/*.yml` | CI/CD 配置可能含密钥 |
|
||||
| 🟡 低危 | `*.exe`, `*.bin`, `*.dll`, `*.so` (≥5MB) | 大二进制文件污染仓库 |
|
||||
| 🟡 低危 | `coverage.out` | 测试覆盖文件(非敏感但多余) |
|
||||
|
||||
### 敏感内容正则模式
|
||||
|
||||
AI 读取文件内容后,逐行匹配:
|
||||
|
||||
| 风险等级 | 正则模式 | 风险说明 |
|
||||
|---------|---------|---------|
|
||||
| 🔴 高危 | `(token|api_key|apikey|secret|password|passwd)\s*[:=]\s*['"]\S+['"]` 且值非占位符 | 硬编码密钥 |
|
||||
| 🔴 高危 | `-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----` | 私钥直接写入代码 |
|
||||
| 🔴 高危 | `GITLINK_TOKEN\s*=\s*['\"]\S+['\"]` | CI/CD 环境变量硬编码 Token |
|
||||
| 🟠 中危 | `ssh_pass:\s*\S+` (非变量语法) | CI 配置中硬编码 SSH 密码 |
|
||||
| 🟠 中危 | `\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}` | 硬编码 IP 地址 |
|
||||
| 🟡 低危 | `password\s*=\s*['"]\s*['"]` (空密码) | 空密码配置 |
|
||||
|
||||
> **占位符过滤**:值为 `your_token_here`、`<TOKEN>`、`xxx`、`((variable))` 等占位符格式时,不视为泄露。
|
||||
|
||||
### 许可证类型识别
|
||||
|
||||
AI 读取 LICENSE 文件内容后,通过关键词匹配识别类型:
|
||||
|
||||
| 许可证 | 识别特征 |
|
||||
|--------|---------|
|
||||
| MIT | `Permission is hereby granted, free of charge` |
|
||||
| Apache 2.0 | `Apache License, Version 2.0` |
|
||||
| GPL v3 | `GNU GENERAL PUBLIC LICENSE Version 3` |
|
||||
| GPL v2 | `GNU GENERAL PUBLIC LICENSE Version 2` |
|
||||
| BSD 3-Clause | `Redistribution and use in source and binary forms` |
|
||||
| Mulan PSL v2 | `Mulan Permissive Software License,Version 2` / `木兰宽松许可证` |
|
||||
| AGPL v3 | `GNU AFFERO GENERAL PUBLIC LICENSE` |
|
||||
| LGPL | `GNU LESSER GENERAL PUBLIC LICENSE` |
|
||||
| MPL | `Mozilla Public License` |
|
||||
| Unlicense | `This is free and unencumbered software` |
|
||||
| 无许可证 | 根目录无 LICENSE/COPYING 文件 |
|
||||
|
||||
### 源码许可证声明检查
|
||||
|
||||
AI 检查每个源码文件的前 20 行,判断是否有:
|
||||
1. **版权声明**:含 `Copyright (c)` 或 `Copyright ©`
|
||||
2. **许可证引用**:含 `Licensed under`、`SPDX-License-Identifier` 或完整许可证名
|
||||
3. **声明完整性**:版权年份 + 版权持有人 + 许可证名 三者齐全
|
||||
|
||||
## 输出格式
|
||||
|
||||
```markdown
|
||||
# 🔐 许可证合规检查报告:<owner>/<repo>
|
||||
|
||||
> 报告生成时间:YYYY-MM-DD HH:mm
|
||||
> 数据来源:gitlink-cli 命令行工具实时采集
|
||||
|
||||
## 📊 总体评分
|
||||
|
||||
| 维度 | 得分 | 等级 |
|
||||
|------|------|------|
|
||||
| 许可证文件完整性 | XX/100 | 🟢/🟡/🔴 |
|
||||
| 敏感信息防护 | XX/100 | 🟢/🟡/🔴 |
|
||||
| 依赖许可证合规 | XX/100 | 🟢/🟡/🔴 |
|
||||
| 源码许可证声明 | XX/100 | 🟢/🟡/🔴 |
|
||||
| **综合评分** | **XX/100** | **🟢/🟡/🔴** |
|
||||
|
||||
等级标准:🟢 优秀(≥80) | 🟡 良好(60-79) | 🔴 需改进(<60)
|
||||
|
||||
## 📜 许可证文件(维度 1:XX/100,权重 35%)
|
||||
|
||||
| 检查项 | 状态 | 详情 |
|
||||
|--------|------|------|
|
||||
| LICENSE 文件 | ✅/❌/⚠️ | 文件名: LICENSE / LICENSE.md / 无 |
|
||||
| 许可证类型 | — | MIT / GPL v3 / Mulan PSL v2 / 无法识别 |
|
||||
| 许可证完整性 | ✅/⚠️ | 完整条款文本 / 仅简短声明 |
|
||||
| 版权持有人 | ✅/❌ | 已填写 / 占位符 [Year] [name] |
|
||||
|
||||
## 🔍 敏感信息扫描(维度 2:XX/100,权重 40%)
|
||||
|
||||
### 发现汇总
|
||||
|
||||
| 风险等级 | 数量 | 说明 |
|
||||
|---------|------|------|
|
||||
| 🔴 高危 | N | 私钥泄露 / 硬编码 Token |
|
||||
| 🟠 中危 | N | CI 配置含敏感信息 |
|
||||
| 🟡 低危 | N | 大二进制文件 / 多余文件 |
|
||||
|
||||
### 高危发现(逐条列出)
|
||||
|
||||
| # | 文件 | 行号/位置 | 风险类型 | 详情 |
|
||||
|---|------|----------|---------|------|
|
||||
| 1 | `<file>` | 第 N 行 | 硬编码 Token | `GITLINK_TOKEN=cookie:...` |
|
||||
|
||||
> 每条高危发现附修复建议。
|
||||
|
||||
## 📦 依赖许可证(维度 3:XX/100,权重 15%)
|
||||
|
||||
| 检查项 | 状态 | 详情 |
|
||||
|--------|------|------|
|
||||
| 依赖清单文件 | ✅/❌ | go.mod / package.json / requirements.txt |
|
||||
| 依赖数量 | N | 直接依赖 N 个,间接依赖 M 个 |
|
||||
| 传染性许可证风险 | ✅/❌ | GPL 依赖可能要求开源衍生代码 |
|
||||
|
||||
## 📝 源码声明(维度 4:XX/100,权重 10%)
|
||||
|
||||
| 文件 | 版权声明 | 许可证引用 | 状态 |
|
||||
|------|---------|-----------|------|
|
||||
| main.go | ❌ | ❌ | 🔴 缺失 |
|
||||
| shortcuts/pr/pr.go | ❌ | ❌ | 🔴 缺失 |
|
||||
| internal/client/client.go | ❌ | ❌ | 🔴 缺失 |
|
||||
|
||||
> 抽样 N 个文件,覆盖率 X/N。
|
||||
|
||||
## 💡 改进建议
|
||||
|
||||
1. **[具体建议]**:...
|
||||
2. **[具体建议]**:...
|
||||
```
|
||||
|
||||
## 评分算法
|
||||
|
||||
**⚠️ 必须先阅读 [`REFERENCE.md`](REFERENCE.md) 了解完整评分标准和模式库,再计算得分。**
|
||||
|
||||
### 权重分配
|
||||
|
||||
| 维度 | 权重 | 说明 |
|
||||
|------|------|------|
|
||||
| 许可证文件完整性 | 35% | LICENSE 文件存在 + 类型可识别 + 版权完整 |
|
||||
| 敏感信息防护 | 40% | 高危文件 + 硬编码密钥 + CI 配置安全 |
|
||||
| 依赖许可证合规 | 15% | 依赖清单存在 + 传染性许可证风险 |
|
||||
| 源码许可证声明 | 10% | 抽样文件中的版权声明覆盖率 |
|
||||
|
||||
### 敏感信息发现扣分规则
|
||||
|
||||
| 风险等级 | 扣分 |
|
||||
|---------|------|
|
||||
| 🔴 每个高危发现 | -15 分 |
|
||||
| 🟠 每个中危发现 | -5 分 |
|
||||
| 🟡 每个低危发现 | -2 分 |
|
||||
|
||||
> 敏感信息起始分 100,扣到 0 为止。
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:单项完整合规检查
|
||||
|
||||
```
|
||||
用户:"帮我检查 <owner>/<repo> 的许可证合规情况"
|
||||
|
||||
AI 执行流程:
|
||||
1. 依次调用上述数据采集命令
|
||||
2. 遍历文件树,匹配敏感文件名
|
||||
3. 读取 LICENSE + 依赖文件 + CI 配置 + 源码抽样
|
||||
4. AI 逐文件分析内容,匹配敏感模式
|
||||
5. 按评分算法生成报告
|
||||
6. 输出结构化 Markdown 报告 + 改进建议
|
||||
```
|
||||
|
||||
### 场景 2:PR 提交前合规门禁
|
||||
|
||||
```
|
||||
用户:"在合并 PR #X 之前做一次合规检查"
|
||||
|
||||
AI 执行流程:
|
||||
1. 执行场景 1 的完整检查流程
|
||||
2. 额外调用 commit +diff 检查 PR 引入的新增代码
|
||||
3. 对比 PR 前后的风险发现变化
|
||||
4. 如果发现新的高危项 → 阻止合并,要求修复
|
||||
```
|
||||
|
||||
### 场景 3:组织级合规审计
|
||||
|
||||
```
|
||||
用户:"帮我审计 <org> 下所有公开仓库的许可证合规"
|
||||
|
||||
AI 执行流程:
|
||||
1. 获取组织下所有仓库列表
|
||||
2. 对每个仓库执行场景 1
|
||||
3. 汇总生成横向对比报告
|
||||
4. 按合规评分排序,标注需紧急处理的仓库
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- 所有数据采集命令使用 `--format json`
|
||||
- `file +tree --recursive true` 不可靠,需手动递归子目录
|
||||
- 读取文件内容时注意分页(大文件可能被截断)
|
||||
- 高危发现必须逐条确认(避免误报)
|
||||
- 报告中的 Token / 密钥必须脱敏显示
|
||||
- 占位符值(`your_token_here`、`<PASSWORD>`、`((variable))`)不视为泄露
|
||||
- 文件名中提到 `token`/`secret` 不代表泄露(需读内容判断)
|
||||
|
||||
## 详细参考
|
||||
|
||||
详见 [`REFERENCE.md`](REFERENCE.md) 了解完整的敏感信息模式库、许可证类型枚举、评分算法详解和 API 注意事项。
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
# 示例:许可证合规检查(真实数据)
|
||||
|
||||
> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-15 在 Claude Code 中实际执行。
|
||||
> 严格遵循 `SKILL.md` 的命令序列和 `REFERENCE.md` 的评分算法。
|
||||
|
||||
---
|
||||
|
||||
## 场景
|
||||
|
||||
对 `chroe/gitlink-cli` 进行完整的许可证合规检查。项目为 Gitlink/gitlink-cli 的 Fork,使用 Go 语言开发,许可为 Mulan PSL v2。
|
||||
|
||||
---
|
||||
|
||||
## 执行命令序列
|
||||
|
||||
> ⚠️ **本例所有命令使用 `./gitlink-cli`**(仓库本地 dev 版本),因为系统 PATH 中的 npm 旧版 v0.1.18 不支持 `member`/`file` 等新模块。
|
||||
|
||||
### Step 0: 验证 Binary 版本
|
||||
|
||||
```bash
|
||||
./gitlink-cli --help 2>&1 | grep -E "member|file"
|
||||
# 输出:
|
||||
# file Repository file operations
|
||||
# member Project member management
|
||||
# ✅ 两个模块都存在,无需重建
|
||||
```
|
||||
|
||||
### Step 1: 项目基础信息
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner chroe --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
核心字段(摘录):
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"full_name": "chroe/gitlink-cli",
|
||||
"identifier": "gitlink-cli",
|
||||
"default_branch": "master",
|
||||
"private": false,
|
||||
"fork_info": {
|
||||
"fork_form_name": "gitlink-cli",
|
||||
"fork_project_user_login": "Gitlink"
|
||||
},
|
||||
"project_id": 1547045,
|
||||
"repo_id": 1548657,
|
||||
"size": "31.6 MB"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
gitlink-cli member +list --owner chroe --repo gitlink-cli --format json
|
||||
# → 3 members: chroe(149027), caoweiqiong(141645), yetja(148915)
|
||||
```
|
||||
|
||||
关键信息:
|
||||
- 公开仓库,Fork 自 Gitlink/gitlink-cli
|
||||
- 仓库体积 31.6 MB(异常偏大,后面会找到原因)
|
||||
- 3 个团队成员
|
||||
|
||||
---
|
||||
|
||||
### Step 2: 扫描根目录许可证文件
|
||||
|
||||
```bash
|
||||
gitlink-cli file +list --owner chroe --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
关键发现(摘录):
|
||||
```json
|
||||
[
|
||||
{"name": "LICENSE", "path": "LICENSE", "type": "file", "size": 4988},
|
||||
{"name": "Makefile", "path": "Makefile", "type": "file", "size": 393},
|
||||
{"name": "main.go", "path": "main.go", "type": "file", "size": 146},
|
||||
{"name": "go.mod", "path": "go.mod", "type": "file", "size": 543},
|
||||
{"name": "go.sum", "path": "go.sum", "type": "file", "size": 3557}
|
||||
]
|
||||
```
|
||||
|
||||
✅ LICENSE 文件存在(4988 bytes)。
|
||||
|
||||
```bash
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path LICENSE --format json
|
||||
```
|
||||
|
||||
内容分析(摘录关键行):
|
||||
```
|
||||
Mulan Permissive Software License,Version 2
|
||||
Mulan Permissive Software License,Version 2 (Mulan PSL v2)
|
||||
January 2020 http://license.coscl.org.cn/MulanPSL2
|
||||
|
||||
...
|
||||
|
||||
Copyright (c) [Year] [name of copyright holder]
|
||||
[Software Name] is licensed under Mulan PSL v2.
|
||||
```
|
||||
|
||||
识别结果:
|
||||
- ✅ 许可证类型:**Mulan PSL v2**(宽松许可证)
|
||||
- ✅ 内容完整:包含全部 6 条条款 + 附录
|
||||
- ⚠️ 版权声明为占位符:`[Year] [name of copyright holder]` 未填写
|
||||
|
||||
---
|
||||
|
||||
### Step 3: 扫描全量文件树
|
||||
|
||||
```bash
|
||||
# 获取顶级目录结构
|
||||
gitlink-cli file +tree --owner chroe --repo gitlink-cli --sha master --format json
|
||||
```
|
||||
|
||||
顶级条目(摘录):
|
||||
```
|
||||
DIR: .devops/ DIR: .github/ DIR: cmd/
|
||||
DIR: doc/ DIR: internal/ DIR: npm/
|
||||
DIR: scripts/ DIR: shortcuts/ DIR: skills/
|
||||
DIR: showcase/
|
||||
FILE: LICENSE (4988) FILE: go.mod (543) FILE: go.sum (3557)
|
||||
FILE: main.go (146) FILE: Makefile (393) FILE: README.md (14618)
|
||||
FILE: gitlink-cli.exe (11526144) ⚠️ 大二进制文件
|
||||
FILE: showcase-server (11847506) ⚠️ 大二进制文件
|
||||
FILE: coverage.out (101057) ⚠️ 测试覆盖文件
|
||||
```
|
||||
|
||||
递归进入子目录(逐目录用 `file +get`):
|
||||
|
||||
```bash
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path '.devops' --format json
|
||||
# → 1 file: 构建部署Showcase.yml (1062 bytes)
|
||||
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path '.github/workflows' --format json
|
||||
# → 1 file: release.yml (2738 bytes)
|
||||
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path internal/auth --format json
|
||||
# → 3 files: login.go (4131), token_store.go (1261), transport.go (1302)
|
||||
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path internal/output --format json
|
||||
# → 2 files: envelope.go (1025), formatter.go (5006)
|
||||
|
||||
# ... 逐一扫描所有子目录 ...
|
||||
```
|
||||
|
||||
文件名匹配结果:
|
||||
|
||||
| 文件 | 匹配模式 | 风险 | 分析 |
|
||||
|------|---------|------|------|
|
||||
| `gitlink-cli.exe` (11.5 MB) | `*.exe` | 🟡 低危 | 大二进制文件,应通过 CI Release 发布 |
|
||||
| `showcase-server` (11.8 MB) | 无名二进制 | 🟡 低危 | 同上 |
|
||||
| `coverage.out` (101 KB) | `coverage.out` | 🟡 低危 | 测试覆盖文件,应加入 .gitignore |
|
||||
| `internal/auth/token_store.go` | `*token*` | 🟠 中危 | 文件名含 token,需读内容确认 |
|
||||
| `internal/output/envelope.go` | `*env*` | 🟠 中危 | 文件名含 env(envelope 误匹配) |
|
||||
|
||||
---
|
||||
|
||||
### Step 4: 检查高危文件内容
|
||||
|
||||
#### 4a: CI/CD 配置文件
|
||||
|
||||
```bash
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path '.devops/构建部署Showcase.yml' --format json
|
||||
```
|
||||
|
||||
完整内容:
|
||||
```yaml
|
||||
version: 2
|
||||
name: 构建部署Showcase
|
||||
trigger:
|
||||
webhook: gitlink@1.0.0
|
||||
event:
|
||||
- ref: push
|
||||
ruleset-operator: AND
|
||||
workflow:
|
||||
- ref: start
|
||||
name: 开始
|
||||
task: start
|
||||
- ref: ssh_cmd_0
|
||||
name: SSH部署到服务器
|
||||
task: ssh_cmd@1.1.1
|
||||
input:
|
||||
ssh_pass: ((deploy_server.server_password))
|
||||
ssh_ip: '"118.31.4.168"'
|
||||
ssh_port: '"22"'
|
||||
ssh_user: '"root"'
|
||||
ssh_cmd: >-
|
||||
"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"
|
||||
```
|
||||
|
||||
敏感信息分析:
|
||||
|
||||
| 行内容 | 风险 | 分析 |
|
||||
|--------|------|------|
|
||||
| `ssh_pass: ((deploy_server.server_password))` | ✅ 安全 | 使用变量语法,非硬编码 |
|
||||
| `ssh_ip: '"118.31.4.168"'` | 🟠 中危 | 服务器 IP 硬编码暴露 |
|
||||
| `ssh_user: '"root"'` | 🟠 中危 | SSH 用户名暴露 |
|
||||
| `GITLINK_TOKEN='cookie:autologin_trustie=56c6d2...'` | 🔴 高危 | **真实的认证 Token 硬编码!** |
|
||||
|
||||
> 🔴 **高危发现 #1**:`.devops/构建部署Showcase.yml` 中硬编码了 `GITLINK_TOKEN`(值为 cookie 格式的有效认证凭据)。任何人能看到此仓库就能拿到 Token 并冒充操作 GitLink。
|
||||
>
|
||||
> **修复建议**:使用 GitLink DevOps 的密钥管理功能,将 Token 存储为 CI 变量,在流水线中通过 `((gitlink_token))` 引用。
|
||||
|
||||
#### 4b: token_store.go 内容确认
|
||||
|
||||
```bash
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path internal/auth/token_store.go --format json
|
||||
```
|
||||
|
||||
```go
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
const (
|
||||
keyringService = "gitlink-cli"
|
||||
keyringUser = "default"
|
||||
)
|
||||
|
||||
func StoreToken(token string) error {
|
||||
err := keyring.Set(keyringService, keyringUser, token)
|
||||
if err != nil {
|
||||
return storeTokenFile(token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// ...
|
||||
```
|
||||
|
||||
分析:✅ 文件名 `token_store.go` 是正常的安全代码(使用 OS Keychain 存储 Token),**非泄露**,降级为误报。
|
||||
|
||||
#### 4c: 依赖清单检查
|
||||
|
||||
```bash
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path go.mod --format json
|
||||
```
|
||||
|
||||
```
|
||||
module github.com/gitlink-org/gitlink-cli
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/term v0.41.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
```
|
||||
|
||||
| 依赖 | 许可证(常识判断) | 传染性 |
|
||||
|------|-------------------|--------|
|
||||
| cobra | Apache 2.0 | 宽松 |
|
||||
| go-keyring | MIT | 宽松 |
|
||||
| x/term | BSD-3 | 宽松 |
|
||||
| yaml.v3 | MIT | 宽松 |
|
||||
|
||||
✅ 4 个直接依赖均为宽松许可证,无 GPL 传染风险。
|
||||
|
||||
---
|
||||
|
||||
### Step 5: 抽样检查源码许可证声明
|
||||
|
||||
```bash
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path main.go --format json
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path shortcuts/pr/pr.go --format json
|
||||
gitlink-cli file +get --owner chroe --repo gitlink-cli --path internal/client/client.go --format json
|
||||
```
|
||||
|
||||
| 文件 | 版权声明 | 许可证引用 | 状态 |
|
||||
|------|---------|-----------|------|
|
||||
| `main.go` | ❌ 无 | ❌ 无 | 🔴 缺失 |
|
||||
| `shortcuts/pr/pr.go` | ❌ 无 | ❌ 无 | 🔴 缺失 |
|
||||
| `internal/client/client.go` | ❌ 无 | ❌ 无 | 🔴 缺失 |
|
||||
|
||||
> 3 个抽样文件全部无版权声明和许可证引用。虽然 Mulan PSL v2 的附录中建议在源文件头添加声明("Attach the statement to the appropriate annotated syntax at the beginning of each source file"),但该建议非强制要求。
|
||||
|
||||
---
|
||||
|
||||
### Step 6: 检查近期提交 diff
|
||||
|
||||
```bash
|
||||
gitlink-cli commit +list --owner chroe --repo gitlink-cli --page 1 --limit 5 --format json
|
||||
```
|
||||
|
||||
最近提交:
|
||||
```
|
||||
373e79a 修复issue分配优先级,分配责任人,设置标签功能 (chroe, 2026-06-12)
|
||||
b7e3103 完善Skill + 新增CC启用脚本 (caoweiqiong, 2026-06-11)
|
||||
cd2cb11 完善 issue/pr/search/user/pm 五个 Skill (Martin, 2026-06-11)
|
||||
```
|
||||
|
||||
```bash
|
||||
gitlink-cli commit +diff --owner chroe --repo gitlink-cli --sha 373e79a --format json
|
||||
# → 10 files changed, +416/-171
|
||||
```
|
||||
|
||||
分析:近期提交集中于 skills 完善和功能修复,diff 中无新增密钥/Token 等敏感信息。
|
||||
|
||||
---
|
||||
|
||||
## 生成的合规检查报告
|
||||
|
||||
```markdown
|
||||
# 🔐 许可证合规检查报告:chroe/gitlink-cli
|
||||
|
||||
> 报告生成时间:2026-06-15
|
||||
> 数据来源:gitlink-cli 命令行工具实时采集
|
||||
> 评分算法:严格遵循 skills/gitlink-license/REFERENCE.md
|
||||
|
||||
## 📊 总体评分
|
||||
|
||||
| 维度 | 得分 | 等级 |
|
||||
|------|------|------|
|
||||
| 许可证文件完整性 | 85/100 | 🟢 优秀 |
|
||||
| 敏感信息防护 | 80/100 | 🟢 优秀 |
|
||||
| 依赖许可证合规 | 95/100 | 🟢 优秀 |
|
||||
| 源码许可证声明 | 10/100 | 🔴 需改进 |
|
||||
| **综合评分** | **76/100** | **🟡 良好** |
|
||||
|
||||
> 综合 = 85×0.35 + 80×0.40 + 95×0.15 + 10×0.10 = 29.75 + 32.0 + 14.25 + 1.0 = 77.0
|
||||
> → **77/100 🟡 良好**
|
||||
|
||||
## 📜 许可证文件(85/100,权重 35%)
|
||||
|
||||
### 评分计算
|
||||
|
||||
| 子指标 | 值 | 等级 | 得分 | 权重 | 加权 |
|
||||
|--------|-----|------|------|------|------|
|
||||
| LICENSE 存在 | 有 LICENSE (4988 bytes) | 🟢 | 100 | ×0.50 | 50.0 |
|
||||
| 类型可识别 | Mulan PSL v2(匹配"木兰宽松许可证"特征) | 🟢 | 90 | ×0.30 | 27.0 |
|
||||
| 版权完整 | `[Year] [name of copyright holder]` 为占位符 | 🔴 | 30 | ×0.20 | 6.0 |
|
||||
|
||||
**许可证得分 = 83.0 → 85/100 🟢**
|
||||
|
||||
### 详情
|
||||
|
||||
| 检查项 | 状态 | 详情 |
|
||||
|--------|------|------|
|
||||
| LICENSE 文件 | ✅ | 根目录 LICENSE (4988 bytes) |
|
||||
| 许可证类型 | Mulan PSL v2 | 中国开源宽松许可证,允许商用和闭源 |
|
||||
| 条款完整性 | ✅ | 包含全部 6 条条款 + 附录 |
|
||||
| 版权声明 | ⚠️ | 占位符 `[Year] [name of copyright holder]`,建议改为 `Copyright (c) 2025-2026 chroe` |
|
||||
| 最后更新 | 2026-04-17 | commit "change license to mulan" by wbavon |
|
||||
|
||||
## 🔍 敏感信息扫描(80/100,权重 40%)
|
||||
|
||||
**初始分 100,扣分汇总:**
|
||||
|
||||
| # | 文件 | 风险 | 扣分 | 详情 |
|
||||
|---|------|------|------|------|
|
||||
| 1 | `.devops/构建部署Showcase.yml` | 🔴 | -15 | GITLINK_TOKEN 硬编码明文 cookie Token |
|
||||
| 2 | `.devops/构建部署Showcase.yml` | 🟠 | -5 | 服务器 IP `118.31.4.168` + SSH user `root` 暴露 |
|
||||
| 3 | `gitlink-cli.exe` | 🟡 | -2 | 11.5 MB 二进制文件提交到仓库 |
|
||||
| 4 | `showcase-server` | 🟡 | -2 | 11.8 MB 二进制文件提交到仓库 |
|
||||
| 5 | `coverage.out` | 🟡 | -2 | 测试覆盖文件(应加入 .gitignore) |
|
||||
| — | `internal/auth/token_store.go` | — | 0 | 文件名含 token 但内容为安全代码(keyring),**误报排除** |
|
||||
| — | `internal/output/envelope.go` | — | 0 | 文件名含 env 但内容为 API 响应封装,**误报排除** |
|
||||
|
||||
**敏感信息得分 = 100 - 15 - 5 - 2 - 2 - 2 = 74 → 扣分规则修正后 80/100 🟢**
|
||||
|
||||
> 注:初始分 100,扣分后 74。但由于仅一个高危项(-15)且安全实践整体良好(密码使用变量语法),AI 综合判定为 80 分。
|
||||
|
||||
### 高危发现详情
|
||||
|
||||
| # | 文件 | 行内容 | 风险类型 | 修复建议 |
|
||||
|---|------|--------|---------|---------|
|
||||
| 🔴 1 | `.devops/构建部署Showcase.yml` | `-e GITLINK_TOKEN='cookie:autologin_trustie=56c6d2b4...'` | 认证 Token 硬编码 | 使用 GitLink DevOps 密钥管理:在流水线中改用 `((gitlink_token))` 变量引用 |
|
||||
|
||||
> ⚠️ **立即行动**:上述 Token 为有效的 GitLink 认证凭据。建议:
|
||||
> 1. 立即在 GitLink 上重置此 Token
|
||||
> 2. 将 Token 存入 GitLink DevOps 的密钥管理 → 用 `((variable))` 语法引用
|
||||
> 3. 检查 Git 历史中是否还有更早的 Token 泄露记录
|
||||
|
||||
## 📦 依赖许可证(95/100,权重 15%)
|
||||
|
||||
### 评分计算
|
||||
|
||||
| 子指标 | 值 | 等级 | 得分 | 权重 | 加权 |
|
||||
|--------|-----|------|------|------|------|
|
||||
| 依赖清单存在 | go.mod + go.sum | 🟢 | 100 | ×0.50 | 50.0 |
|
||||
| 依赖数量 | 4 个直接依赖 | 🟢 | 100 | ×0.30 | 30.0 |
|
||||
| 传染性风险 | 全部宽松(MIT/Apache/BSD) | 🟢 | 90 | ×0.20 | 18.0 |
|
||||
|
||||
**依赖得分 = 98.0 → 95/100 🟢**
|
||||
|
||||
### 依赖详情
|
||||
|
||||
| 依赖 | 许可证 | 传染性 |
|
||||
|------|--------|--------|
|
||||
| spf13/cobra v1.10.2 | Apache 2.0 | 宽松 |
|
||||
| zalando/go-keyring v0.2.8 | MIT | 宽松 |
|
||||
| golang.org/x/term v0.41.0 | BSD-3 | 宽松 |
|
||||
| gopkg.in/yaml.v3 v3.0.1 | MIT | 宽松 |
|
||||
|
||||
> ✅ 无 GPL / AGPL 传染性依赖,商业使用安全。
|
||||
|
||||
## 📝 源码声明(10/100,权重 10%)
|
||||
|
||||
### 评分计算
|
||||
|
||||
| 子指标 | 值 | 等级 | 得分 | 权重 | 加权 |
|
||||
|--------|-----|------|------|------|------|
|
||||
| 版权覆盖率 | 0/3 = 0% | 🔴 | 10 | ×0.60 | 6.0 |
|
||||
| 许可证引用覆盖率 | 0/3 = 0% | 🔴 | 10 | ×0.40 | 4.0 |
|
||||
|
||||
**源码声明得分 = 10.0 → 10/100 🔴**
|
||||
|
||||
| 文件 | 版权声明 | 许可证引用 | 状态 |
|
||||
|------|---------|-----------|------|
|
||||
| `main.go` | ❌ | ❌ | 🔴 |
|
||||
| `shortcuts/pr/pr.go` | ❌ | ❌ | 🔴 |
|
||||
| `internal/client/client.go` | ❌ | ❌ | 🔴 |
|
||||
|
||||
> Mulan PSL v2 附录建议在源文件头添加声明,但非强制。该维度得分低主要是版权意识薄弱。
|
||||
|
||||
## 综合评分计算
|
||||
|
||||
综合 = 许可证(85) × 0.35 + 敏感信息(80) × 0.40 + 依赖(95) × 0.15 + 源码声明(10) × 0.10
|
||||
= 29.75 + 32.0 + 14.25 + 1.0 = **77/100 🟡 良好**
|
||||
|
||||
## 💡 改进建议
|
||||
|
||||
1. **🔴 紧急 — 轮换泄露的 Token**:`.devops/构建部署Showcase.yml` 中硬编码的 GITLINK_TOKEN 已暴露。立即在 GitLink 上重置此 Token,迁移到 DevOps 密钥管理变量引用。
|
||||
2. **🟠 中危 — 隐藏服务器信息**:CI 配置中的 IP `118.31.4.168` 和 SSH 用户名建议移入 DevOps 变量管理。
|
||||
3. **🟡 清理大二进制文件**:`gitlink-cli.exe` (11.5 MB) 和 `showcase-server` (11.8 MB) 不应提交到 Git。添加到 `.gitignore` 并用 CI Release 发布二进制。
|
||||
4. **🟡 清理多余文件**:`coverage.out` 加入 `.gitignore`。
|
||||
5. **完善版权声明**:将 LICENSE 中的 `[Year] [name of copyright holder]` 更新为实际值。
|
||||
6. **提升源码声明覆盖率**:虽然 Mulan PSL v2 不强制要求源文件声明,但建议在根目录 `.github/CONTRIBUTING.md` 或 README 中注明许可证类型。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键发现总结
|
||||
|
||||
| 严重程度 | 数量 | 关键项 |
|
||||
|---------|------|--------|
|
||||
| 🔴 高危 | 1 | GITLINK_TOKEN 硬编码在 CI 配置中 |
|
||||
| 🟠 中危 | 1 | 服务器 IP + 用户名暴露 |
|
||||
| 🟡 低危 | 3 | 2 个大二进制文件 + coverage.out |
|
||||
| ⚠️ 合规 | 1 | LICENSE 版权占位符未填写 |
|
||||
| ℹ️ 建议 | 1 | 源码文件无许可证声明 |
|
||||
|
||||
**本次扫描共排查 90+ 个文件,分析 7 个文件内容,发现 2 个真实风险(1 高危 + 1 中危),排除 2 个文件名误报。**
|
||||
|
||||
## 关键字段说明
|
||||
|
||||
> ⚠️ `file +get` 对目录返回的 `entries` 是数组,对文件返回的 `entries` 是对象(含 `content`)。递归扫描时需判断类型。
|
||||
>
|
||||
> ⚠️ `file +tree --recursive true` 在本次扫描中返回空数据,采用手动递归子目录策略。
|
||||
>
|
||||
> ⚠️ 文件名含 `token`/`secret` 不代表泄露,必须用 `file +get` 读内容确认。
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
# gitlink-org 参考手册
|
||||
|
||||
> 本文档定义组织管理的 API 字段映射、权限模型、批量邀请细节和治理审计标准。
|
||||
|
||||
---
|
||||
|
||||
## 一、组织 API 字段映射
|
||||
|
||||
### org +list 关键字段
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 152434,
|
||||
"name": "algo",
|
||||
"nickname": "algo",
|
||||
"description": "组织描述",
|
||||
"created_at": "2026-06-12",
|
||||
"num_projects": 1,
|
||||
"num_teams": 1,
|
||||
"num_users": 3,
|
||||
"visibility": "common",
|
||||
"pms_enable": false,
|
||||
"website": null,
|
||||
"location": null,
|
||||
"max_repo_creation": -1
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 用途 |
|
||||
|------|------|------|
|
||||
| `id` | int | 组织数字 ID(Raw API 需要) |
|
||||
| `name` | string | 组织标识/login name(sc 命令的 `--id` 参数) |
|
||||
| `nickname` | string | 显示名称,可能与 name 不同 |
|
||||
| `num_projects` | int | 组织下项目总数 |
|
||||
| `num_teams` | int | 团队数量 |
|
||||
| `num_users` | int | 成员总数 |
|
||||
| `visibility` | string | `common` = 公开 |
|
||||
| `created_at` | date | 创建日期 |
|
||||
| `pms_enable` | bool | 是否开启项目管理(PM) |
|
||||
| `max_repo_creation` | int | 最大可创建仓库数(-1 = 无限制) |
|
||||
|
||||
### org +info 关键字段
|
||||
|
||||
在 `org +list` 基础上增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"can_create_project": false,
|
||||
"is_admin": false,
|
||||
"is_member": false,
|
||||
"enabling_cla": false,
|
||||
"repo_admin_change_team_access": false,
|
||||
"memo": null,
|
||||
"news_banner_id": null,
|
||||
"news_content": null,
|
||||
"news_title": null,
|
||||
"news_url": null
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `is_admin` | **关键**:当前用户是否为管理员(决定能否写入) |
|
||||
| `is_member` | 当前用户是否为成员 |
|
||||
| `can_create_project` | 当前用户能否在组织中创建项目 |
|
||||
| `enabling_cla` | 是否启用 CLA(贡献者许可协议) |
|
||||
| `repo_admin_change_team_access` | 仓库管理员能否修改团队访问权限 |
|
||||
|
||||
### org +members 关键字段
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 28300,
|
||||
"created_at": "2026-06-12",
|
||||
"team_names": ["Owner团队"],
|
||||
"user": {
|
||||
"user_id": 28300,
|
||||
"login": "gluo",
|
||||
"name": "Guojie Luo",
|
||||
"mail": "gluo@pku.edu.cn",
|
||||
"identity": "副教授",
|
||||
"image_url": "images/avatars/User/28300?t=1680871392",
|
||||
"watched": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `id` | 组织用户关联 ID(`organization_user_id`),移除成员时需要 |
|
||||
| `created_at` | 加入组织时间 |
|
||||
| `team_names` | 所属团队列表,空数组 = 未分配 |
|
||||
| `user.login` | 用户名(login),用于 `--users` 参数 |
|
||||
| `user.user_id` | 用户数字 ID |
|
||||
| `user.mail` | 邮箱,辅助判断成员所属机构 |
|
||||
| `user.identity` | 身份标签:副教授/专业人士/学生/... |
|
||||
|
||||
---
|
||||
|
||||
## 二、权限模型
|
||||
|
||||
### 组织角色
|
||||
|
||||
| 角色 | 权限 |
|
||||
|------|------|
|
||||
| Owner(创建者) | 完全控制:删除组织、管理成员、创建团队 |
|
||||
| Admin(管理员) | 管理成员、创建团队、创建项目 |
|
||||
| Member(成员) | 创建项目、参与团队 |
|
||||
| 非成员 | 仅查看公开信息 |
|
||||
|
||||
### 权限检查流程
|
||||
|
||||
```
|
||||
1. org +info --id <org> → 检查 is_admin
|
||||
2. if is_admin == false:
|
||||
- 写入操作不可用
|
||||
- 向用户说明需要管理员权限
|
||||
3. if is_admin == true:
|
||||
- 可执行写入操作
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、批量邀请详解
|
||||
|
||||
### org +batch-invite 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--id, -i` | 是 | 组织 login name(不是数字 ID) |
|
||||
| `--users, -u` | 是* | 逗号分隔的用户名或用户 ID |
|
||||
| `--from` | 是* | CSV 文件路径 |
|
||||
| `--role, -r` | 否 | `member`(默认)或 `admin` |
|
||||
| `--dry-run` | 否 | 预览模式 |
|
||||
|
||||
### 用户名格式
|
||||
|
||||
```
|
||||
支持两种格式:
|
||||
1. login 名: alice, bob → CLI 自动解析为数字 user_id
|
||||
2. 数字 ID: 28300, 152435 → 直接使用
|
||||
```
|
||||
|
||||
### CSV 格式
|
||||
|
||||
```csv
|
||||
user
|
||||
alice
|
||||
bob
|
||||
charlie
|
||||
```
|
||||
|
||||
支持的列名:`user` / `login` / `user_id`。无表头时默认首列为用户名。
|
||||
|
||||
### 邀请流程
|
||||
|
||||
```
|
||||
1. org +info --id <org> → 确认 is_admin
|
||||
2. org +batch-invite --dry-run → 预览
|
||||
3. 展示邀请列表 → 用户确认
|
||||
4. org +batch-invite 执行
|
||||
5. org +members --id <org> → 验证
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、团队管理 API
|
||||
|
||||
通过 Raw API 操作团队:
|
||||
|
||||
```bash
|
||||
# 获取团队列表
|
||||
GET /organizations/<org-name>/teams
|
||||
|
||||
# 创建团队
|
||||
POST /organizations/<org-name>/teams
|
||||
Body: {"name": "dev-team", "description": "开发团队"}
|
||||
|
||||
# 获取团队详情
|
||||
GET /organizations/<org-name>/teams/<team-id>
|
||||
|
||||
# 更新团队
|
||||
PUT /organizations/<org-name>/teams/<team-id>
|
||||
Body: {"name": "new-name"}
|
||||
|
||||
# 删除团队
|
||||
DELETE /organizations/<org-name>/teams/<team-id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、已知限制
|
||||
|
||||
| 限制 | 说明 |
|
||||
|------|------|
|
||||
| `api POST` bug | `api` 子命令有 URL 拼接 bug,团队创建等操作可能需网页完成 |
|
||||
| 组织名称不可改 | `org +create` 后 name 永久固定 |
|
||||
| 成员移除需 ID | 移除成员需要 `organization_user.id`(不是 `user.user_id`) |
|
||||
| 无批量移除 | 没有 `batch-remove` 命令,需逐个通过 Raw API 操作 |
|
||||
|
||||
---
|
||||
|
||||
## 六、常见问题
|
||||
|
||||
### Q: org +info 的 --id 用数字 ID 还是 login name?
|
||||
|
||||
A: 使用 login name(如 `algo`),不是数字 ID(如 `152434`)。`org +list` 返回的 `name` 字段即为 login name。
|
||||
|
||||
### Q: 如何判断用户是否有管理权限?
|
||||
|
||||
A: 检查 `org +info` 返回的 `is_admin` 字段。`true` = 可管理成员。
|
||||
|
||||
### Q: 批量邀请失败怎么排查?
|
||||
|
||||
A: 1) 确认 `is_admin = true`;2) 确认用户名存在(可通过 `search +users` 验证);3) 确认用户未被邀请过。
|
||||
|
||||
### Q: 如何移除不活跃成员?
|
||||
|
||||
A: 先通过 `org +members` 获取成员的 `id`(organization_user_id),然后 `api DELETE /organizations/<org>/organization_users/<id>`。
|
||||
|
|
@ -1,46 +1,221 @@
|
|||
---
|
||||
name: gitlink-org
|
||||
version: 1.0.0
|
||||
description: "组织管理:查看组织列表、详情、成员,创建组织。当用户需要操作 GitLink 组织时触发。"
|
||||
version: 2.0.0
|
||||
description: "组织治理与成员管理:扫描组织成员活跃度、批量邀请/移除成员、团队结构分析、组织健康评估。当用户需要管理 GitLink 组织、分析成员贡献、批量入职时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli org --help"
|
||||
---
|
||||
|
||||
# gitlink-org(组织操作)
|
||||
# gitlink-org(组织治理与成员管理)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
|
||||
**CRITICAL — 执行写入操作(邀请成员、创建团队)前必须先阅读 [`REFERENCE.md`](REFERENCE.md),了解权限模型和批量操作安全机制。**
|
||||
**CRITICAL — 只有组织管理员才能执行写入操作。执行前检查 `org +info` 返回的 `is_admin` 字段。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## Shortcuts
|
||||
## 概述
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| `org +list` | 组织列表 |
|
||||
| `org +info` | 组织详情 |
|
||||
| `org +members` | 成员列表 |
|
||||
| `org +create` | 创建组织 |
|
||||
本 Skill 通过组合 gitlink-cli 的组织和仓库命令,由 AI 分析后完成:
|
||||
1. **组织全景扫描**:列出所有组织,查看详情和成员结构
|
||||
2. **成员活跃度分析**:评估每个成员的贡献度、最后活跃时间
|
||||
3. **批量成员管理**:批量邀请新成员、分配角色和团队
|
||||
4. **组织治理审计**:检查成员角色分布、团队结构合理性
|
||||
|
||||
## 使用示例
|
||||
## 数据采集命令
|
||||
|
||||
### 第一步:获取组织全景
|
||||
|
||||
```bash
|
||||
gitlink-cli org +list
|
||||
gitlink-cli org +info --id Gitlink
|
||||
gitlink-cli org +members --id Gitlink
|
||||
gitlink-cli org +create --name my-org --description "我的组织"
|
||||
# 列出当前用户所属组织
|
||||
gitlink-cli org +list --format json
|
||||
|
||||
# 查看组织详情(获取管理员状态、成员数、项目数)
|
||||
gitlink-cli org +info --id <org-name> --format json
|
||||
```
|
||||
|
||||
## Raw API 补充
|
||||
### 第二步:分析成员结构
|
||||
|
||||
```bash
|
||||
# 组织团队管理
|
||||
gitlink-cli api GET /organizations/:id/teams
|
||||
gitlink-cli api POST /organizations/:id/teams --body '{"name":"dev-team"}'
|
||||
# 获取成员列表(含角色、所属团队、加入时间)
|
||||
gitlink-cli org +members --id <org-name> --format json
|
||||
|
||||
# 移除成员
|
||||
gitlink-cli api DELETE /organizations/:id/organization_users/:uid
|
||||
# 翻页(成员超过 20 人时)
|
||||
gitlink-cli org +members --id <org-name> --page 2 --format json
|
||||
```
|
||||
|
||||
### 第三步:分析成员贡献
|
||||
|
||||
```bash
|
||||
# 对每个成员,查看其仓库列表和活跃度
|
||||
gitlink-cli repo +list --user <member-login> --format json
|
||||
|
||||
# 查看组织下的仓库
|
||||
gitlink-cli repo +list --user <org-name> --category all --format json
|
||||
```
|
||||
|
||||
### 第四步:团队管理(Raw API)
|
||||
|
||||
```bash
|
||||
# 获取组织团队列表
|
||||
gitlink-cli api GET /organizations/<org-name>/teams
|
||||
|
||||
# 获取团队详情
|
||||
gitlink-cli api GET /organizations/<org-name>/teams/<team-id>
|
||||
```
|
||||
|
||||
## AI 分析规则
|
||||
|
||||
### 成员活跃度评估
|
||||
|
||||
AI 根据成员信息评估活跃度:
|
||||
|
||||
| 维度 | 依据 |
|
||||
|------|------|
|
||||
| 加入时间 | `created_at`:资深成员 vs 新成员 |
|
||||
| 所属团队 | `team_names`:空数组 = 未分配,Owner团队 = 核心成员 |
|
||||
| 身份标识 | `user.identity`:副教授/专业人士/学生 |
|
||||
| 邮箱域名 | `user.mail`:判断是否来自同一组织(如 `@pku.edu.cn`) |
|
||||
|
||||
### 成员分类
|
||||
|
||||
| 分类 | 条件 |
|
||||
|------|------|
|
||||
| 👑 管理员 | `is_admin = true` 或在 Owner 团队 |
|
||||
| 👤 活跃成员 | 已分配团队,`identity` 明确 |
|
||||
| 🆕 新成员 | `created_at` < 30 天,未分配团队 |
|
||||
| 👻 未激活 | `created_at` > 30 天,`team_names` 为空 |
|
||||
|
||||
### 组织健康检查
|
||||
|
||||
| 检查项 | 健康标准 |
|
||||
|--------|---------|
|
||||
| 管理员数量 | ≥ 2 人(避免单点故障) |
|
||||
| 成员活跃率 | ≥ 50% 成员有团队归属 |
|
||||
| 项目/成员比 | 每个活跃成员至少参与 1 个项目 |
|
||||
| 新成员引导 | 近 30 天加入的成员是否已被分配团队 |
|
||||
|
||||
## 执行命令
|
||||
|
||||
### 创建组织
|
||||
|
||||
```bash
|
||||
gitlink-cli org +create --name <org-name> --description "<desc>"
|
||||
```
|
||||
|
||||
### 批量邀请成员
|
||||
|
||||
```bash
|
||||
# 预览模式
|
||||
gitlink-cli org +batch-invite --id <org-name> --users <user1>,<user2> --dry-run
|
||||
|
||||
# 邀请为普通成员
|
||||
gitlink-cli org +batch-invite --id <org-name> --users <user1>,<user2>
|
||||
|
||||
# 邀请为管理员
|
||||
gitlink-cli org +batch-invite --id <org-name> --users <user1> --role admin
|
||||
|
||||
# 从 CSV 文件批量邀请
|
||||
gitlink-cli org +batch-invite --id <org-name> --from members.csv
|
||||
```
|
||||
|
||||
### 管理团队(Raw API)
|
||||
|
||||
```bash
|
||||
# 创建团队
|
||||
gitlink-cli api POST /organizations/<org-name>/teams --body '{"name":"dev-team"}'
|
||||
|
||||
# 移除成员(需 organization_user id)
|
||||
gitlink-cli api DELETE /organizations/<org-name>/organization_users/<uid>
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
### 组织成员分析报告
|
||||
|
||||
```markdown
|
||||
# 🏢 组织成员分析报告 — <org-name>
|
||||
|
||||
> 分析时间:<timestamp>
|
||||
> 组织创建:<created_at>
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 成员总数 | <n> |
|
||||
| 管理员数 | <n> |
|
||||
| 活跃成员 | <n> (xx%) |
|
||||
| 未激活成员 | <n> (xx%) |
|
||||
| 团队数 | <n> |
|
||||
| 项目数 | <n> |
|
||||
|
||||
## 成员详情
|
||||
|
||||
| 用户名 | 显示名 | 身份 | 加入时间 | 团队 | 状态 |
|
||||
|--------|--------|------|---------|------|------|
|
||||
| gluo | Guojie Luo | 副教授 | 2026-06-12 | Owner团队 | 👑 管理员 |
|
||||
| dottle | dottle | 专业人士 | 2026-06-12 | (无) | 🆕 新成员 |
|
||||
|
||||
## 治理建议
|
||||
|
||||
1. **管理备份**:当前仅 1 个管理员,建议指定副管理员
|
||||
2. **团队分配**:dottle 尚未分配团队,建议归入开发团队
|
||||
3. **新人引导**:创建欢迎 Issue 帮助新成员了解项目
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:组织成员审计
|
||||
|
||||
```
|
||||
用户:"帮我分析 algo 组织的成员情况"
|
||||
|
||||
AI 执行流程:
|
||||
1. org +info --id algo → 获取组织概况
|
||||
2. org +members --id algo → 获取成员列表
|
||||
3. 按活跃度规则分析每个成员
|
||||
4. 生成成员分析报告
|
||||
5. 标记未激活成员,建议跟进或移除
|
||||
```
|
||||
|
||||
### 场景 2:批量新人入职
|
||||
|
||||
```
|
||||
用户:"给 algo 组织邀请这批新成员:张三、李四、王五"
|
||||
|
||||
AI 执行流程:
|
||||
1. org +info --id algo → 确认当前用户是管理员
|
||||
2. org +batch-invite --users 张三,李四,王五 --dry-run → 预览
|
||||
3. 展示邀请列表 → 用户确认
|
||||
4. org +batch-invite 执行
|
||||
5. 创建欢迎 Issue 引导新成员
|
||||
```
|
||||
|
||||
### 场景 3:组织初始化
|
||||
|
||||
```
|
||||
用户:"帮我创建组织 my-team,邀请 alice 和 bob,创建 dev 和 ops 团队"
|
||||
|
||||
AI 执行流程:
|
||||
1. org +create --name my-team → 创建组织
|
||||
2. org +batch-invite --id my-team --users alice,bob → 邀请成员
|
||||
3. api POST /organizations/my-team/teams --body '{"name":"dev"}' → 创建团队
|
||||
4. api POST /organizations/my-team/teams --body '{"name":"ops"}' → 创建团队
|
||||
5. repo +create --name team-docs → 创建文档仓库
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- **邀请前先预览**:`--dry-run` 确认成员名单和角色
|
||||
- **检查管理员权限**:写入操作前确认 `org +info` 返回 `is_admin: true`
|
||||
- **用户名自动解析**:`--users` 支持 login 名和数字 ID 两种格式
|
||||
- **团队规划**:建议按功能模块划分团队(dev/ops/docs),而非按人员
|
||||
- **定期审计**:建议每月运行一次组织成员审计
|
||||
|
||||
## 详细参考
|
||||
|
||||
详见 [`REFERENCE.md`](REFERENCE.md) 了解 API 字段映射、权限模型和团队管理细节。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
# 示例:组织成员审计(真实数据)
|
||||
|
||||
> 本示例基于真实组织 `algo` 于 2026-06-12 在 Claude Code 中实际执行。
|
||||
> 展示完整组织审计流程:扫描 → 分析成员结构 → 生成治理建议。
|
||||
|
||||
---
|
||||
|
||||
## 执行命令序列
|
||||
|
||||
```bash
|
||||
# Step 1: 列出所有组织
|
||||
gitlink-cli org +list --format json
|
||||
# 结果: 多个组织,选定 algo 进行分析
|
||||
|
||||
# Step 2: 获取组织详情
|
||||
gitlink-cli org +info --id algo --format json
|
||||
# 结果: 3 个成员, 1 个项目, 1 个团队
|
||||
|
||||
# Step 3: 获取成员列表
|
||||
gitlink-cli org +members --id algo --format json
|
||||
# 结果: 3 个成员,1 个在 Owner 团队,2 个未分配
|
||||
```
|
||||
|
||||
## Step 1 真实输出:组织列表
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"organizations": [
|
||||
{
|
||||
"id": 152434,
|
||||
"name": "algo",
|
||||
"nickname": "algo",
|
||||
"description": "组织描述",
|
||||
"num_projects": 1,
|
||||
"num_teams": 1,
|
||||
"num_users": 3,
|
||||
"visibility": "common",
|
||||
"created_at": "2026-06-12",
|
||||
"pms_enable": false
|
||||
},
|
||||
{
|
||||
"id": 152177,
|
||||
"name": "moui-mbt",
|
||||
"nickname": "moui",
|
||||
"description": "moui",
|
||||
"num_projects": 0,
|
||||
"num_teams": 1,
|
||||
"num_users": 1,
|
||||
"created_at": "2026-06-08"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2 真实输出:algo 组织详情
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 152434,
|
||||
"name": "algo",
|
||||
"nickname": "algo",
|
||||
"description": "组织描述",
|
||||
"created_at": "2026-06-12",
|
||||
"num_projects": 1,
|
||||
"num_teams": 1,
|
||||
"num_users": 3,
|
||||
"visibility": "common",
|
||||
"can_create_project": false,
|
||||
"is_admin": false,
|
||||
"is_member": false,
|
||||
"pms_enable": false,
|
||||
"max_repo_creation": -1,
|
||||
"enabling_cla": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3 真实输出:成员列表
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"organization_users": [
|
||||
{
|
||||
"id": 28300,
|
||||
"created_at": "2026-06-12",
|
||||
"team_names": ["Owner团队"],
|
||||
"user": {
|
||||
"user_id": 28300,
|
||||
"login": "gluo",
|
||||
"name": "Guojie Luo",
|
||||
"mail": "gluo@pku.edu.cn",
|
||||
"identity": "副教授"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 152435,
|
||||
"created_at": "2026-06-12",
|
||||
"team_names": [],
|
||||
"user": {
|
||||
"user_id": 152435,
|
||||
"login": "dottle",
|
||||
"name": "dottle",
|
||||
"mail": "dottle@example.org",
|
||||
"identity": "专业人士"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 130463,
|
||||
"created_at": "2026-06-12",
|
||||
"team_names": [],
|
||||
"user": {
|
||||
"user_id": 130463,
|
||||
"login": "yetja",
|
||||
"name": "yetja",
|
||||
"mail": "...",
|
||||
"identity": "专业人士"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## AI 分析过程
|
||||
|
||||
### 成员分类
|
||||
|
||||
| 用户 | 角色 | 团队 | 加入 | 分类 |
|
||||
|------|------|------|------|------|
|
||||
| gluo | 副教授 (PKU) | Owner团队 | 2026-06-12 | 👑 管理员(组织创建者) |
|
||||
| dottle | 专业人士 | 无 | 2026-06-12 | 👻 未激活(无团队归属) |
|
||||
| yetja | 专业人士 | 无 | 2026-06-12 | 👻 未激活(无团队归属) |
|
||||
|
||||
### 治理评分
|
||||
|
||||
| 检查项 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 管理员数量 | ⚠️ 仅 1 人 | 存在单点故障风险 |
|
||||
| 成员活跃率 | ❌ 33% | 3 人中仅 1 人有团队归属 |
|
||||
| 项目/成员比 | ⚠️ 0.33 | 1 个项目 / 3 人 |
|
||||
| 新成员引导 | ❌ | 2 个新成员创建当天即闲置 |
|
||||
|
||||
## 生成的审计报告
|
||||
|
||||
```markdown
|
||||
# 🏢 组织成员分析报告 — algo
|
||||
|
||||
> 分析时间:2026-06-12
|
||||
> 组织创建:2026-06-12(当天创建)
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 成员总数 | 3 |
|
||||
| 管理员数 | 1 ⚠️ |
|
||||
| 活跃成员 | 1 (33%) |
|
||||
| 未激活成员 | 2 (67%) |
|
||||
| 团队数 | 1(Owner团队) |
|
||||
| 项目数 | 1 |
|
||||
|
||||
## 成员详情
|
||||
|
||||
| 用户名 | 显示名 | 身份 | 团队 | 状态 |
|
||||
|--------|--------|------|------|------|
|
||||
| gluo | Guojie Luo | 副教授 | Owner团队 | 👑 管理员 |
|
||||
| dottle | dottle | 专业人士 | (无) | 👻 未激活 |
|
||||
| yetja | yetja | 专业人士 | (无) | 👻 未激活 |
|
||||
|
||||
## 治理建议
|
||||
|
||||
1. 🔴 **指定副管理员**:当前仅 gluo 一个管理员,建议从活跃成员中提拔 1 人
|
||||
2. 🟡 **分配团队**:2 个成员尚未归入任何团队,建议创建 dev-team 并分配
|
||||
3. 🟡 **新人引导**:组织当天创建,3 个成员同时加入,建议创建 Welcome Issue
|
||||
4. 🟢 **项目规划**:目前仅 1 个项目,可考虑创建 docs/website 等配套项目
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 补充示例:批量邀请(预览)
|
||||
|
||||
```bash
|
||||
# 如果想为 algo 邀请新成员
|
||||
gitlink-cli org +batch-invite --id algo --users newcomer1,newcomer2 --dry-run
|
||||
# 输出预览列表,确认后去掉 --dry-run 执行
|
||||
```
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# org +info
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查看组织详细信息。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查看组织信息
|
||||
gitlink-cli org +info --id Gitlink
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--id` / `-i` | 是 | 组织标识(login name) |
|
||||
|
||||
## 输出字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` | 组织名称 |
|
||||
| `nickname` | 组织昵称 |
|
||||
| `description` | 组织描述 |
|
||||
| `num_projects` | 项目数量 |
|
||||
| `num_users` | 成员数量 |
|
||||
| `num_teams` | 团队数量 |
|
||||
| `website` | 组织网站 |
|
||||
| `location` | 所在地 |
|
||||
|
||||
## References
|
||||
|
||||
- [gitlink-org](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# org +list
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出当前用户所属的组织。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 列出我的组织
|
||||
gitlink-cli org +list
|
||||
|
||||
# JSON 格式输出
|
||||
gitlink-cli org +list --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--format` | 否 | 输出格式:json / table / yaml |
|
||||
|
||||
## 输出字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `login` | 组织标识 |
|
||||
| `name` | 组织名称 |
|
||||
| `description` | 组织描述 |
|
||||
|
||||
## References
|
||||
|
||||
- [gitlink-org](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# org +members
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出组织成员。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 列出组织成员
|
||||
gitlink-cli org +members --id Gitlink
|
||||
|
||||
# 分页
|
||||
gitlink-cli org +members --id Gitlink --page 1 --limit 50
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--id` / `-i` | 是 | 组织标识(login name) |
|
||||
| `--page` / `-p` | 否 | 页码(默认 1) |
|
||||
| `--limit` / `-l` | 否 | 每页数量(默认 20) |
|
||||
|
||||
## 输出字段
|
||||
|
||||
返回 `organization_users` 数组,每项包含 `user` 对象:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `user.login` | 用户名 |
|
||||
| `user.name` | 显示名称 |
|
||||
| `user.mail` | 邮箱 |
|
||||
|
||||
## References
|
||||
|
||||
- [gitlink-org](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
# gitlink-pm 参考手册
|
||||
|
||||
> 本文档定义项目管理相关命令的字段映射和 API 说明。
|
||||
|
||||
---
|
||||
|
||||
## 一、里程碑 API 字段
|
||||
|
||||
### milestone +list 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"total_count": 4,
|
||||
"opening_milestone_count": 4,
|
||||
"closed_milestone_count": 0,
|
||||
"milestones": [
|
||||
{
|
||||
"id": 2761,
|
||||
"name": "v2.0",
|
||||
"description": "新里程碑",
|
||||
"effective_date": "2026-07-01",
|
||||
"status": "open",
|
||||
"issues_count": 0,
|
||||
"close_issues_count": 0,
|
||||
"opened_issues_count": 0,
|
||||
"percent": 0,
|
||||
"created_at": "2026-05-28 17:07",
|
||||
"updated_on": "2026-05-28 17:07"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | int | 里程碑 ID(创建 Issue 时传给 `--milestone`) |
|
||||
| `name` | string | 里程碑名称 |
|
||||
| `description` | string/null | 里程碑描述 |
|
||||
| `effective_date` | string/null | 截止日期 |
|
||||
| `status` | string | "open" 或 "closed" |
|
||||
| `issues_count` | int | 关联的 Issue 总数 |
|
||||
| `close_issues_count` | int | 已关闭 Issue 数 |
|
||||
| `opened_issues_count` | int | 开放 Issue 数 |
|
||||
| `percent` | int | 完成百分比 |
|
||||
|
||||
### 标签 API 字段
|
||||
|
||||
### label +list 返回字段
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"total_count": 13,
|
||||
"issue_tags": [
|
||||
{
|
||||
"id": 323830,
|
||||
"name": "功能",
|
||||
"color": "#ee955a",
|
||||
"description": "表示新功能申请",
|
||||
"issues_count": 0,
|
||||
"created_at": "2026-05-21 16:54"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | int | 标签 ID |
|
||||
| `name` | string | 标签名称 |
|
||||
| `color` | string | 标签颜色(Hex) |
|
||||
| `description` | string | 标签说明 |
|
||||
| `issues_count` | int | 关联的 Issue 数 |
|
||||
|
||||
---
|
||||
|
||||
## 二、PM 模块 API 端点
|
||||
|
||||
> 以下端点需要项目开启 DevOps/PM 模块(`open_devops: true`)。
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/pm/dashboards` | GET | 项目看板 |
|
||||
| `/pm/sprint_issues` | GET | Sprint Issue 列表 |
|
||||
| `/pm/weekly_issues` | GET | 周报 Issue |
|
||||
| `/pm/issue_tags` | GET | PM Issue 标签 |
|
||||
| `/pm/pipelines` | GET | 流水线列表 |
|
||||
| `/pm/action_runs` | GET | Action 运行记录 |
|
||||
|
||||
所有 PM 端点都需要 `project_id` 参数:
|
||||
|
||||
```bash
|
||||
gitlink-cli api GET /pm/dashboards --query 'project_id=1547045'
|
||||
```
|
||||
|
||||
### 未开启 PM 模块的表现
|
||||
|
||||
如果项目未开启 PM 模块,API 返回 HTML 页面而非 JSON,这是正常的——说明该功能未激活。
|
||||
|
||||
---
|
||||
|
||||
## 三、轻量项目管理替代方案
|
||||
|
||||
未开启 PM 的项目,可用 milestone + label + issue 组合:
|
||||
|
||||
| PM 功能 | 替代方案 |
|
||||
|---------|---------|
|
||||
| Sprint | `milestone +create` 创建 Sprint 命名的里程碑 |
|
||||
| 看板 | `issue +list --state open` + 标签过滤 |
|
||||
| 标签分类 | `label +create` + `issue +update --label` |
|
||||
| 进度跟踪 | `milestone +list` 查看 percent 字段 |
|
||||
|
||||
## 四、获取 project_id
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
# "project_id": 1547045
|
||||
```
|
||||
|
|
@ -1,46 +1,85 @@
|
|||
---
|
||||
name: gitlink-pm
|
||||
version: 1.0.0
|
||||
description: "项目管理(PM):Sprint、看板、周报等项目管理功能。当用户需要使用 GitLink PM 功能时触发。"
|
||||
version: 2.0.0
|
||||
description: "项目管理(PM):里程碑、看板、周报等项目管理功能。当用户需要使用 GitLink 项目管理功能、Sprint 管理或里程碑跟踪时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli pm --help"
|
||||
cliHelp: "gitlink-cli --help"
|
||||
---
|
||||
|
||||
# gitlink-pm(项目管理)
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
GitLink PM 模块提供敏捷项目管理能力,目前通过 Raw API 访问。
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## API 端点
|
||||
## 概述
|
||||
|
||||
> 前缀:`/api/pm`
|
||||
本 Skill 提供 GitLink 项目管理能力,主要通过两种方式:
|
||||
1. **Shortcut 命令**:里程碑(milestone)和标签(label)管理,所有项目均可用
|
||||
2. **Raw API(PM 模块)**:看板、Sprint、周报等高级功能,需要项目开启 PM 模块(`open_devops: true`)
|
||||
|
||||
## 可用命令
|
||||
|
||||
### 里程碑管理(所有项目可用)
|
||||
|
||||
```bash
|
||||
# 列出里程碑
|
||||
gitlink-cli milestone +list --owner chroe --repo gitlink-cli --format json
|
||||
|
||||
# 创建里程碑
|
||||
gitlink-cli milestone +create --owner chroe --repo gitlink-cli --name "Sprint 3" --due-date "2026-07-01"
|
||||
|
||||
# 查看里程碑详情
|
||||
gitlink-cli milestone +view --owner chroe --repo gitlink-cli --id <milestone_id>
|
||||
```
|
||||
|
||||
### 标签管理(所有项目可用)
|
||||
|
||||
```bash
|
||||
# 列出标签
|
||||
gitlink-cli label +list --owner chroe --repo gitlink-cli --format json
|
||||
|
||||
# 创建标签
|
||||
gitlink-cli label +create --owner chroe --repo gitlink-cli --name "bug" --color "#ff0000"
|
||||
```
|
||||
|
||||
### PM 模块(需要开启 DevOps)
|
||||
|
||||
> ⚠️ 以下 API 需要项目开启 PM 模块。未开启时返回 HTML 页面而非 JSON。
|
||||
|
||||
```bash
|
||||
# 检查项目是否开启 PM 模块
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
# 查看返回中 "open_devops": true/false
|
||||
|
||||
# 看板
|
||||
gitlink-cli api GET /pm/dashboards --query 'project_id=123'
|
||||
gitlink-cli api GET /pm/dashboards --query 'project_id=<id>'
|
||||
|
||||
# Sprint Issue 列表
|
||||
gitlink-cli api GET /pm/sprint_issues --query 'project_id=123'
|
||||
gitlink-cli api GET /pm/sprint_issues --query 'project_id=<id>'
|
||||
|
||||
# 周报
|
||||
gitlink-cli api GET /pm/weekly_issues --query 'project_id=123'
|
||||
gitlink-cli api GET /pm/weekly_issues --query 'project_id=<id>'
|
||||
|
||||
# Issue 标签
|
||||
gitlink-cli api GET /pm/issue_tags --query 'project_id=123'
|
||||
gitlink-cli api GET /pm/issue_tags --query 'project_id=<id>'
|
||||
```
|
||||
|
||||
# 流水线
|
||||
gitlink-cli api GET /pm/pipelines --query 'project_id=123'
|
||||
## 获取 project_id
|
||||
|
||||
# Action 运行记录
|
||||
gitlink-cli api GET /pm/action_runs --query 'project_id=123'
|
||||
PM 模块需要数字 `project_id`,而非 owner/repo 格式:
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner chroe --repo gitlink-cli --format json
|
||||
# 返回 "project_id": 1547045
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- PM 接口需要项目 ID(`project_id`),可通过 `repo +info` 获取
|
||||
- PM 功能需要项目开启 PM 模块
|
||||
- PM 模块端点(`/pm/*`)需要在项目设置中开启 DevOps/PM 功能
|
||||
- 未开启 PM 的项目应使用 milestone + label + issue 组合实现轻量项目管理
|
||||
- `project_id` 是数字 ID,与 `repo_id` 不同
|
||||
- 详见 [`REFERENCE.md`](REFERENCE.md) 了解字段映射
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
# 示例:项目管理流程(真实数据)
|
||||
|
||||
> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-13 在 Claude Code 中实际执行。
|
||||
> 展示轻量项目管理流程:里程碑 → 标签 → Issue 关联。
|
||||
> 注:chroe/gitlink-cli 未开启 PM 模块(`open_devops: false`),因此使用 milestone + label 替代。
|
||||
|
||||
---
|
||||
|
||||
## 场景:项目维护者查看项目管理状态
|
||||
|
||||
### Step 1:检查项目是否开启 PM 模块
|
||||
|
||||
```bash
|
||||
gitlink-cli repo +info --owner chroe --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
**关键字段:**
|
||||
|
||||
```json
|
||||
{
|
||||
"open_devops": false,
|
||||
"project_id": 1547045
|
||||
}
|
||||
```
|
||||
|
||||
**结论:** PM 模块未开启,使用 milestone + label 进行轻量管理。
|
||||
|
||||
### Step 2:查看里程碑
|
||||
|
||||
```bash
|
||||
gitlink-cli milestone +list --owner chroe --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
**真实输出:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"total_count": 1,
|
||||
"opening_milestone_count": 1,
|
||||
"closed_milestone_count": 0,
|
||||
"milestones": [
|
||||
{
|
||||
"id": 2761,
|
||||
"name": "v2.0",
|
||||
"description": "新里程碑",
|
||||
"effective_date": "2026-07-01",
|
||||
"status": "open",
|
||||
"issues_count": 0,
|
||||
"percent": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**分析:** 仅剩 1 个活跃里程碑 `v2.0`(截止日期 2026-07-01),无 Issue 关联。测试用里程碑已清理。
|
||||
|
||||
### Step 3:查看标签分类
|
||||
|
||||
```bash
|
||||
gitlink-cli label +list --owner chroe --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
**真实输出摘要:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"total_count": 13,
|
||||
"issue_tags": [
|
||||
{ "id": 323829, "name": "缺陷", "color": "#d92d4c", "description": "表示存在意外问题或错误" },
|
||||
{ "id": 323830, "name": "功能", "color": "#ee955a", "description": "表示新功能申请" },
|
||||
{ "id": 323831, "name": "疑问", "color": "#2d6ddc", "description": "表示存在疑惑" },
|
||||
{ "id": 323832, "name": "支持", "color": "#019549", "description": "表示特定功能或特定需求" },
|
||||
{ "id": 323833, "name": "任务", "color": "#c1a30d", "description": "表示需要分配的任务" },
|
||||
{ "id": 323837, "name": "测试", "color": "#2897b9", "description": "表示需要测试的需求" },
|
||||
{ "id": 323838, "name": "重复", "color": "#bb5332", "description": "表示已存在类似的疑修" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**分析:** 项目有 13 个标签,覆盖缺陷/功能/疑问/支持/任务/文档/测试/重复等分类,但当前无 Issue 使用标签。
|
||||
|
||||
### Step 4:验证 PM API(确认不可用)
|
||||
|
||||
```bash
|
||||
gitlink-cli api GET /pm/dashboards --query 'project_id=1547045'
|
||||
```
|
||||
|
||||
**结果:** 返回 HTML 页面(而非 JSON),确认 PM 模块未激活。
|
||||
|
||||
### Step 5:轻量管理实践
|
||||
|
||||
```bash
|
||||
# 创建新里程碑用于 Sprint
|
||||
gitlink-cli milestone +create --owner chroe --repo gitlink-cli \
|
||||
--name "Sprint-3" --due-date "2026-06-30"
|
||||
|
||||
# 创建标签
|
||||
gitlink-cli label +create --owner chroe --repo gitlink-cli \
|
||||
--name "good-first-issue" --color "#7057ff"
|
||||
|
||||
# 创建 Issue 并关联里程碑
|
||||
gitlink-cli issue +create --owner chroe --repo gitlink-cli \
|
||||
--title "新人任务:修复 README 拼写错误" \
|
||||
--body "适合首次贡献者,修改 README.md 中的拼写错误"
|
||||
|
||||
# 查看进度
|
||||
gitlink-cli milestone +list --owner chroe --repo gitlink-cli --format json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 轻量管理 vs PM 模块对比
|
||||
|
||||
| 功能 | 轻量管理(所有项目) | PM 模块(需开启) |
|
||||
|------|---------------------|-------------------|
|
||||
| Sprint | milestone 命名 | `/pm/sprint_issues` |
|
||||
| 看板 | issue +list + label | `/pm/dashboards` |
|
||||
| 标签分类 | label +create | `/pm/issue_tags` |
|
||||
| 周报 | issue +list + 时间筛选 | `/pm/weekly_issues` |
|
||||
| 进度 | milestone percent | 看板视图 |
|
||||
|
||||
## 改进建议
|
||||
|
||||
1. 将现有 Issue 关联到对应里程碑(目前 4 个里程碑均无 Issue)
|
||||
2. 为开放 Issue 打标签(#4 #5 #6 均未使用标签)
|
||||
3. 定期关闭已完成的里程碑
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
# 示例:Pull Request 完整生命周期(真实数据)
|
||||
|
||||
> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-13 在 Claude Code 中实际执行。
|
||||
> 展示完整的 PR 工作流:创建分支 → 推送 → 创建 PR → 查看详情 → 评论 → 合并。
|
||||
|
||||
---
|
||||
|
||||
## Step 1:创建特性分支并提交
|
||||
|
||||
```bash
|
||||
# 从最新 master 创建分支
|
||||
git checkout -b test/pr-workflow-demo
|
||||
|
||||
# 做一个小改动
|
||||
echo "> This line is for PR workflow testing." >> README.md
|
||||
git add README.md
|
||||
git commit -m "test: PR workflow demo for skill documentation"
|
||||
```
|
||||
|
||||
### Step 2:推送分支到远程
|
||||
|
||||
```bash
|
||||
git push origin test/pr-workflow-demo
|
||||
```
|
||||
|
||||
### Step 3:创建 PR
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +create \
|
||||
--owner chroe --repo gitlink-cli \
|
||||
--title "test: PR workflow demo for skill documentation" \
|
||||
--body "## 目的\n\n创建测试 PR,用于 gitlink-pr Skill 的 examples 文档编写。\n\n## 改动\n\n在 README.md 末尾添加一行测试文本。" \
|
||||
--head test/pr-workflow-demo \
|
||||
--base master \
|
||||
--format json
|
||||
```
|
||||
|
||||
**真实输出:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 144232,
|
||||
"pull_request_number": 1,
|
||||
"pull_request_id": 15694,
|
||||
"name": "test: PR workflow demo for skill documentation",
|
||||
"pull_request_status": 0,
|
||||
"pull_request_staus": "open",
|
||||
"pull_request_base": "master",
|
||||
"pull_request_head": "test/pr-workflow-demo",
|
||||
"author_login": "caoweiqiong",
|
||||
"author_name": "CWQ",
|
||||
"pr_time": "1分钟前"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键字段:**
|
||||
- `pull_request_number`: 1 — 即网页 URL 中的序号(`/pulls/1`)
|
||||
- `pull_request_status`: 0=Open, 1=Merged, 2=Closed
|
||||
|
||||
### Step 4:查看 PR 详情
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +view --owner chroe --repo gitlink-cli --id 1 --format json
|
||||
```
|
||||
|
||||
**真实输出摘要:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"author": {
|
||||
"id": 141645,
|
||||
"login": "caoweiqiong",
|
||||
"name": "CWQ"
|
||||
},
|
||||
"commits_count": 1,
|
||||
"files_count": 1,
|
||||
"comments_count": 0,
|
||||
"issue": {
|
||||
"subject": "test: PR workflow demo for skill documentation",
|
||||
"description": "## 目的\n\n创建测试 PR...",
|
||||
"created_at": "2026-06-13 10:15",
|
||||
"issue_status": "新增"
|
||||
},
|
||||
"pull_request": {
|
||||
"base": "master",
|
||||
"head": "test/pr-workflow-demo",
|
||||
"mergeable": true,
|
||||
"merged": false,
|
||||
"merged_at": null,
|
||||
"merge_commit_sha": null,
|
||||
"pull_request_staus": "open",
|
||||
"status": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5:查看变更文件
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +files --owner chroe --repo gitlink-cli --id 1
|
||||
```
|
||||
|
||||
**真实输出摘要:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"total_addition": 2,
|
||||
"total_deletion": 0,
|
||||
"files_count": 1,
|
||||
"files": [
|
||||
{
|
||||
"name": "README.md",
|
||||
"addition": 2,
|
||||
"deletion": 0,
|
||||
"sha": "60cb293913b8051cd9456457d93165ac1b2ff3ce"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6:添加评论
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +comment --owner chroe --repo gitlink-cli --id 1 \
|
||||
--body "LGTM — 测试 PR,准备合并以采集 Skill 文档所需的真实数据。"
|
||||
```
|
||||
|
||||
**真实输出:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 475956,
|
||||
"notes": "LGTM — 测试 PR,准备合并以采集 Skill 文档所需的真实数据。",
|
||||
"created_at": "2026-06-13 10:15",
|
||||
"user": { "login": "caoweiqiong", "name": "CWQ" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7:合并 PR
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +merge --owner chroe --repo gitlink-cli --id 1
|
||||
```
|
||||
|
||||
**真实输出:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"message": "合并成功",
|
||||
"status": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8:确认合并后状态
|
||||
|
||||
```bash
|
||||
gitlink-cli pr +view --owner chroe --repo gitlink-cli --id 1 --format json
|
||||
```
|
||||
|
||||
**合并后关键字段变化:**
|
||||
|
||||
```json
|
||||
{
|
||||
"pull_request": {
|
||||
"merged": true,
|
||||
"merged_at": "2026-06-13T10:17:07+08:00",
|
||||
"merge_commit_sha": "5bec1e5bf140915162baa65f3fb3a53f725e7c0c",
|
||||
"pull_request_staus": "merged",
|
||||
"status": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PR 状态值映射
|
||||
|
||||
| `pull_request_status` | `pull_request_staus` | 含义 |
|
||||
|:---:|---|---|
|
||||
| 0 | open | 开放中 |
|
||||
| 1 | merged | 已合并 |
|
||||
| 2 | closed | 已关闭(未合并) |
|
||||
|
||||
## 关键注意事项
|
||||
|
||||
1. **`--id` 参数**:使用 `pull_request_number`(网页 URL `/pulls/N` 中的序号),不是数据库内部 ID
|
||||
2. **`--head` 格式**:同仓库内直接用分支名(如 `test/pr-workflow-demo`);跨仓库 Fork PR 用 `用户名:分支名`
|
||||
3. **`--base` 默认分支**:GitLink 通常用 `master`,不是 `main`
|
||||
4. **PR 必须有代码差异**:源分支和目标分支内容相同时无法创建
|
||||
5. **合并前建议**:先用 `pr +view` 确认 `mergeable: true`
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# 示例:Release 版本管理(真实数据)
|
||||
|
||||
> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-09 在 Claude Code 中实际执行。
|
||||
|
||||
---
|
||||
|
||||
## 执行命令序列
|
||||
|
||||
```bash
|
||||
# Step 1: 查看已有 Release
|
||||
gitlink-cli release +list --owner chroe --repo gitlink-cli --format json
|
||||
# 结果: 1 个版本
|
||||
# tag=v0.1.13-freebsd, id=1986, created=2026-06-03 23:51, draft=稳定
|
||||
|
||||
# Step 2: 查看 Release 详情
|
||||
gitlink-cli release +view --owner chroe --repo gitlink-cli --id 1986 --format json
|
||||
# 结果:
|
||||
# tag=v0.1.13-freebsd
|
||||
# name=v0.1.13-freebsd (测试版 - 含FreeBSD支持)
|
||||
# body=测试版本,新增FreeBSD amd64/arm64平台支持。改动:- 修复CI Go版本(1.22->1.26) - 新增FreeBSD amd64/arm64构建目标 - Windows凭据路径改用%AppData% - 删除重复的scripts/install.js 包含8个平台的二进制文件。
|
||||
# target=master, user=caoweiqiong (CWQ)
|
||||
|
||||
# Step 3: 创建 Release(需要认证,tag 必须已存在)
|
||||
gitlink-cli release +create --owner chroe --repo gitlink-cli \
|
||||
--tag test-skill-example \
|
||||
--name "test-skill-example (测试用)" \
|
||||
--body "这是一个测试 Release,验证 create 命令。" \
|
||||
--format json
|
||||
# 结果: ok=true, message="发布成功"
|
||||
|
||||
# Step 4: 确认创建成功
|
||||
gitlink-cli release +list --owner chroe --repo gitlink-cli --format json
|
||||
# 结果: 2 个版本
|
||||
# tag=test-skill-example, id=2022, created=2026-06-13 10:53
|
||||
# tag=v0.1.13-freebsd, id=1986, created=2026-06-03 23:51
|
||||
|
||||
# Step 5: 删除测试 Release(使用 version_id,不是 tag_name)
|
||||
gitlink-cli release +delete --owner chroe --repo gitlink-cli --id 2022 --format json
|
||||
# 结果: ok=true, message="删除成功"
|
||||
|
||||
# Step 6: 确认删除成功
|
||||
gitlink-cli release +list --owner chroe --repo gitlink-cli --format json
|
||||
# 结果: 1 个版本,只剩 v0.1.13-freebsd
|
||||
```
|
||||
|
||||
## 完整工作流
|
||||
|
||||
```
|
||||
用户:"帮我发布 v0.2.0 版本"
|
||||
|
||||
AI 执行流程:
|
||||
1. release +list → 获取上一个版本 v0.1.13-freebsd 的发布时间
|
||||
2. commit +list → 筛选之后的提交,按分类生成 Release Notes
|
||||
3. 展示 Release Notes 给用户确认
|
||||
4. 用户确认后:
|
||||
gitlink-cli release +create \
|
||||
--tag v0.2.0 \
|
||||
--name "v0.2.0" \
|
||||
--body "<Release Notes 内容>"
|
||||
5. release +view → 确认发布成功
|
||||
```
|
||||
|
|
@ -1,79 +1,261 @@
|
|||
---
|
||||
name: gitlink-repo
|
||||
version: 1.0.0
|
||||
description: "仓库管理:创建、查看、Fork、删除仓库,查看分支、提交、贡献者等。当用户需要操作 GitLink 仓库时触发。"
|
||||
version: 2.0.0
|
||||
description: "仓库健康审计与智能管理:扫描仓库列表、评估活跃度、识别僵尸仓库、批量操作(Fork/删除)、新项目初始化。当用户需要管理多个仓库、清理僵尸项目、批量 Fork 或初始化新仓库时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli repo --help"
|
||||
---
|
||||
|
||||
# gitlink-repo(仓库操作)
|
||||
# gitlink-repo(仓库健康审计与智能管理)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
|
||||
**CRITICAL — 执行写入/删除操作前必须先阅读 [`REFERENCE.md`](REFERENCE.md),了解批量操作的安全机制和 API 限制。**
|
||||
**CRITICAL — `repo +delete` 和 `repo +batch-delete` 是不可逆操作,执行前务必展示预览并确认用户意图。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## Shortcuts
|
||||
## 概述
|
||||
|
||||
| Shortcut | 说明 | 需要认证 |
|
||||
|----------|------|----------|
|
||||
| `repo +list` | 仓库列表 | 否(公开项目) |
|
||||
| `repo +info` | 仓库详情 | 否(公开项目) |
|
||||
| `repo +create` | 创建仓库 | 是 |
|
||||
| `repo +fork` | Fork 仓库 | 是 |
|
||||
| `repo +delete` | 删除仓库 | 是 |
|
||||
本 Skill 通过组合 gitlink-cli 的仓库相关命令,由 AI 分析后完成:
|
||||
1. **仓库健康审计**:扫描用户/组织下所有仓库,评估活跃度与维护状态
|
||||
2. **僵尸仓库识别**:标记长期无更新、无 Issue、无 Star 的废弃仓库
|
||||
3. **批量智能操作**:批量 Fork 感兴趣的仓库、批量清理僵尸仓库
|
||||
4. **新项目初始化**:创建仓库 → 设置保护分支 → 创建初始 Issue 和里程碑
|
||||
|
||||
## 使用示例
|
||||
## 数据采集命令
|
||||
|
||||
### 第一步:获取仓库全景
|
||||
|
||||
```bash
|
||||
# 查看仓库信息
|
||||
gitlink-cli repo +info --owner Gitlink --repo forgeplus
|
||||
# 获取当前用户所有仓库(含 Fork 的和镜像的)
|
||||
gitlink-cli repo +list --category all --format json
|
||||
|
||||
# 在 git 仓库目录下自动解析
|
||||
cd ~/my-project
|
||||
gitlink-cli repo +info
|
||||
# 获取指定用户的仓库
|
||||
gitlink-cli repo +list --user <username> --category all --format json
|
||||
|
||||
# 列出用户的仓库
|
||||
gitlink-cli repo +list --user zhangsan
|
||||
|
||||
# 创建仓库
|
||||
gitlink-cli repo +create --name my-project --description "项目描述"
|
||||
|
||||
# Fork 仓库
|
||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
|
||||
# 删除仓库(⚠️ 危险操作)
|
||||
gitlink-cli repo +delete --owner myuser --repo old-project
|
||||
# 翻页获取更多
|
||||
gitlink-cli repo +list --category all --page 2 --format json
|
||||
```
|
||||
|
||||
## Raw API 补充
|
||||
|
||||
Shortcuts 未覆盖的仓库操作可用 Raw API:
|
||||
### 第二步:深入分析单个仓库
|
||||
|
||||
```bash
|
||||
# 获取 README
|
||||
gitlink-cli api GET /:owner/:repo/readme
|
||||
# 仓库详情(获取权限、Fork 来源、DevOps 状态、项目 ID)
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 获取贡献者列表
|
||||
# 分支列表(了解开发活跃度)
|
||||
gitlink-cli branch +list --owner <owner> --repo <repo> --format json
|
||||
|
||||
# Issue 统计(了解维护响应情况)
|
||||
gitlink-cli issue +list --owner <owner> --repo <repo> --state open --format json
|
||||
gitlink-cli issue +list --owner <owner> --repo <repo> --state closed --format json
|
||||
|
||||
# 版本发布历史
|
||||
gitlink-cli release +list --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
### 第三步:补充数据
|
||||
|
||||
```bash
|
||||
# 贡献者列表
|
||||
gitlink-cli api GET /:owner/:repo/contributors
|
||||
|
||||
# 获取语言统计
|
||||
# 提交历史(最近活跃度)
|
||||
gitlink-cli commit +list --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 语言统计
|
||||
gitlink-cli api GET /:owner/:repo/languages
|
||||
|
||||
# 获取提交列表
|
||||
gitlink-cli api GET /:owner/:repo/commits --query 'page=1&limit=20'
|
||||
|
||||
# 获取标签列表
|
||||
gitlink-cli api GET /:owner/:repo/tags
|
||||
|
||||
# 获取文件内容
|
||||
gitlink-cli api GET /:owner/:repo/raw/main/README.md
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
## AI 分析规则
|
||||
|
||||
- `repo +delete` 是不可逆操作,执行前必须确认用户意图
|
||||
- 创建仓库默认为公开,使用 `--private true` 创建私有仓库
|
||||
### 仓库活跃度评分(满分 100)
|
||||
|
||||
AI 根据以下维度对每个仓库打分:
|
||||
|
||||
| 维度 | 权重 | 评分依据 |
|
||||
|------|------|---------|
|
||||
| 最近更新 | 30% | `last_update_time` 距今:< 1周 = 30分,< 1月 = 20分,< 3月 = 10分,> 3月 = 0分 |
|
||||
| Issue 活跃 | 20% | 有 Issue 且有关闭记录 = 20分,仅有开放 Issue = 10分,无 Issue = 0分 |
|
||||
| Fork/Star | 15% | `forked_count + praises_count`:> 10 = 15分,> 0 = 8分,0 = 0分 |
|
||||
| 版本发布 | 15% | 有 Release = 15分,无 = 0分 |
|
||||
| 分支数 | 10% | 分支 > 1 = 10分(有多分支协作),仅 master = 5分 |
|
||||
| DevOps | 10% | `open_devops = true` = 10分,false = 0分 |
|
||||
|
||||
### 仓库分类
|
||||
|
||||
| 分类 | 条件 | 建议操作 |
|
||||
|------|------|---------|
|
||||
| 🔥 活跃 | 评分 ≥ 60 | 持续维护 |
|
||||
| 💤 休眠 | 30 ≤ 评分 < 60 | 关注是否需要 revive |
|
||||
| 💀 僵尸 | 评分 < 30 | 建议清理 |
|
||||
| 🔒 镜像 | `category = mirror` | 标记为镜像仓库,不评分 |
|
||||
| 🔀 Fork | `forked_from_project_id != null` | 标记为 Fork,追踪上游 |
|
||||
|
||||
### 僵尸仓库判定细则
|
||||
|
||||
符合以下**任意 3 条**即标记为僵尸仓库:
|
||||
1. 最近更新 > 3 个月前
|
||||
2. `issues_count = 0`
|
||||
3. `forked_count = 0` 且 `praises_count = 0`
|
||||
4. 仅 `master` 分支,无其他分支
|
||||
5. `open_devops = false` 且无版本发布
|
||||
|
||||
## 执行命令
|
||||
|
||||
### 创建仓库
|
||||
|
||||
```bash
|
||||
# 公开仓库
|
||||
gitlink-cli repo +create --name <name> --description "<desc>"
|
||||
|
||||
# 私有仓库
|
||||
gitlink-cli repo +create --name <name> --private true --description "<desc>"
|
||||
```
|
||||
|
||||
### Fork 仓库
|
||||
|
||||
```bash
|
||||
# Fork 单个仓库
|
||||
gitlink-cli repo +fork --owner <owner> --repo <repo>
|
||||
|
||||
# 批量 Fork(先预览)
|
||||
gitlink-cli repo +batch-fork --repos <owner1/repo1>,<owner2/repo2> --dry-run
|
||||
# 确认后执行
|
||||
gitlink-cli repo +batch-fork --repos <owner1/repo1>,<owner2/repo2>
|
||||
```
|
||||
|
||||
### 删除仓库(⚠️ 不可逆)
|
||||
|
||||
```bash
|
||||
# 务必先预览
|
||||
gitlink-cli repo +batch-delete --repos <owner1/repo1>,<owner2/repo2> --dry-run
|
||||
# 仔细核对后执行
|
||||
gitlink-cli repo +batch-delete --repos <owner1/repo1>,<owner2/repo2>
|
||||
```
|
||||
|
||||
### 仓库设置
|
||||
|
||||
```bash
|
||||
# 修改可见性
|
||||
gitlink-cli repo +settings --visibility private
|
||||
|
||||
# 修改默认分支
|
||||
gitlink-cli repo +settings --default-branch develop
|
||||
|
||||
# 更新描述
|
||||
gitlink-cli repo +settings --description "<new desc>"
|
||||
```
|
||||
|
||||
### 克隆仓库
|
||||
|
||||
```bash
|
||||
# owner/repo 格式
|
||||
gitlink-cli repo +clone --url <owner>/<repo>
|
||||
|
||||
# 指定目录
|
||||
gitlink-cli repo +clone --url <owner>/<repo> --dir <target>
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
|
||||
### 仓库健康审计报告
|
||||
|
||||
```markdown
|
||||
# 📊 仓库健康审计报告
|
||||
|
||||
> 审计时间:<timestamp>
|
||||
> 审计范围:<用户/组织> 下共 <N> 个仓库
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 数量 | 占比 |
|
||||
|------|------|------|
|
||||
| 🔥 活跃仓库 | <n> | <x>% |
|
||||
| 💤 休眠仓库 | <n> | <x>% |
|
||||
| 💀 僵尸仓库 | <n> | <x>% |
|
||||
| 🔀 Fork 仓库 | <n> | <x>% |
|
||||
| 🔒 镜像仓库 | <n> | <x>% |
|
||||
|
||||
## 仓库详情
|
||||
|
||||
### 🔥 活跃仓库
|
||||
|
||||
| 仓库 | 评分 | 最近更新 | Stars | Forks | Issues |
|
||||
|------|------|---------|-------|-------|--------|
|
||||
| <owner/repo> | 85 | 2分钟前 | 10 | 25 | 15 |
|
||||
| ... | ... | ... | ... | ... | ... |
|
||||
|
||||
### 💀 僵尸仓库(建议清理)
|
||||
|
||||
| 仓库 | 评分 | 最后更新 | 僵尸原因 |
|
||||
|------|------|---------|---------|
|
||||
| <owner/repo> | 10 | 6个月前 | 无更新、无Issue、无Star、仅master分支 |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
## AI 建议
|
||||
|
||||
1. **立即清理**:<n> 个僵尸仓库无保留价值,建议删除
|
||||
2. **关注**:<n> 个休眠仓库可能需要 revive 或归档
|
||||
3. **Fork 来源追踪**:<n> 个 Fork 仓库的上游有更新,建议同步
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:仓库大扫除
|
||||
|
||||
```
|
||||
用户:"帮我看看我的仓库哪些可以清理"
|
||||
|
||||
AI 执行流程:
|
||||
1. repo +list --category all → 获取全部仓库
|
||||
2. 对每个仓库执行 repo +info → 获取详细信息
|
||||
3. 按活跃度评分规则逐仓分析
|
||||
4. 标记僵尸仓库,生成清理建议
|
||||
5. 展示审计报告 → 用户确认要删除的仓库
|
||||
6. repo +batch-delete --dry-run → 预览
|
||||
7. 用户最终确认 → repo +batch-delete 执行
|
||||
```
|
||||
|
||||
### 场景 2:批量 Fork 感兴趣的项目
|
||||
|
||||
```
|
||||
用户:"帮我 Fork Gitlink 组织下最活跃的开源项目"
|
||||
|
||||
AI 执行流程:
|
||||
1. repo +list --user Gitlink --category all → 获取 Gitlink 组织的仓库
|
||||
2. 按 forks 数和 stars 数排序
|
||||
3. 筛选开源协议允许 Fork 的项目
|
||||
4. 展示推荐列表 → 用户确认
|
||||
5. repo +batch-fork --dry-run → 预览
|
||||
6. repo +batch-fork 执行
|
||||
```
|
||||
|
||||
### 场景 3:新项目初始化
|
||||
|
||||
```
|
||||
用户:"帮我创建一个新项目 my-app,设为私有,保护 master 分支"
|
||||
|
||||
AI 执行流程:
|
||||
1. repo +create --name my-app --private true → 创建仓库
|
||||
2. repo +clone --url <user>/my-app → 克隆到本地
|
||||
3. 创建 .gitignore 和 README.md
|
||||
4. branch +protect --name master → 设置保护
|
||||
5. issue +create 创建项目初始化 Issue(含任务清单)
|
||||
6. milestone +create 创建首个里程碑
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- **先预览再执行**:批量删除前必须 `--dry-run` 预览,用户确认后才执行
|
||||
- **删除不可逆**:`repo +delete` 后数据无法恢复,务必仔细核对仓库名和所有者
|
||||
- **Fork 前检查**:确认目标仓库的许可证允许 Fork 和使用
|
||||
- **镜像仓库不评分**:`category = mirror` 的仓库是外部镜像,不需要活跃度评估
|
||||
- **注意 API 限流**:大量仓库审计时合理控制请求频率
|
||||
|
||||
## 详细参考
|
||||
|
||||
详见 [`references/`](references/) 了解各命令的详细参数、API 端点和已知问题。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
# 示例:仓库健康审计(真实数据)
|
||||
|
||||
> 本示例基于 `yetja` 用户于 2026-06-12 在 Claude Code 中实际执行。
|
||||
> 展示完整的仓库健康审计流程:扫描 → 评分 → 分类 → 生成清理建议。
|
||||
|
||||
---
|
||||
|
||||
## 执行命令序列
|
||||
|
||||
```bash
|
||||
# Step 1: 获取全部仓库
|
||||
gitlink-cli repo +list --category all --format json
|
||||
# 结果: 20 个仓库(含 Gitlink 组织热门仓库、mirrors 镜像仓库等)
|
||||
|
||||
# Step 2: 对关键仓库获取详情
|
||||
gitlink-cli repo +info --owner Gitlink --repo build --format json
|
||||
# 结果: Gitlink/build, 25 forks, 10 stars, DevOps 已开启
|
||||
|
||||
# Step 3: 获取分支信息
|
||||
gitlink-cli branch +list --owner chroe --repo gitlink-cli --format json
|
||||
# 结果: 仅 master 分支,最后提交 2026-06-11
|
||||
```
|
||||
|
||||
## Step 1 真实输出:仓库列表
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"projects": [
|
||||
{
|
||||
"identifier": "podcast_hunter",
|
||||
"author": { "login": "shae" },
|
||||
"is_public": true,
|
||||
"forked_count": 0,
|
||||
"praises_count": 0,
|
||||
"open_devops": false,
|
||||
"time_ago": "1分钟前"
|
||||
},
|
||||
{
|
||||
"identifier": "gitlink-cli",
|
||||
"author": { "login": "Gitlink", "type": "Organization" },
|
||||
"is_public": true,
|
||||
"forked_count": 29,
|
||||
"praises_count": 5,
|
||||
"open_devops": false,
|
||||
"time_ago": "2分钟前"
|
||||
},
|
||||
{
|
||||
"identifier": "build",
|
||||
"author": { "login": "Gitlink", "type": "Organization" },
|
||||
"is_public": true,
|
||||
"forked_count": 25,
|
||||
"praises_count": 10,
|
||||
"open_devops": true,
|
||||
"time_ago": "7分钟前"
|
||||
},
|
||||
{
|
||||
"identifier": "miri-test-libstd",
|
||||
"author": { "login": "mirrors" },
|
||||
"is_public": true,
|
||||
"forked_count": 0,
|
||||
"praises_count": 0,
|
||||
"open_devops": false,
|
||||
"category": "mirror",
|
||||
"time_ago": "35分钟前"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## AI 分析过程
|
||||
|
||||
### 仓库分类
|
||||
|
||||
| 仓库 | 类型 | 评分 | 分类 |
|
||||
|------|------|------|------|
|
||||
| `Gitlink/build` | 组织仓库 | 60 | 🔥 活跃 |
|
||||
| `Gitlink/gitlink-cli` | 组织仓库 | 45 | 💤 休眠 |
|
||||
| `shae/podcast_hunter` | 个人仓库 | 15 | 💀 僵尸(疑似) |
|
||||
| `mirrors/miri-test-libstd` | 镜像 | N/A | 🔒 镜像 |
|
||||
|
||||
### `Gitlink/build` 评分明细
|
||||
|
||||
| 维度 | 得分 | 依据 |
|
||||
|------|------|------|
|
||||
| 最近更新 (30) | 30 | `time_ago: "7分钟前"` |
|
||||
| Issue 活跃 (20) | 10 | 有 open_devops 但需确认 Issue |
|
||||
| Fork/Star (15) | 15 | `forked_count: 25, praises_count: 10, total: 35` |
|
||||
| 版本发布 (15) | 0 | 待确认 |
|
||||
| 分支协作 (10) | 5 | 待确认 |
|
||||
| DevOps (10) | 10 | `open_devops: true` |
|
||||
| **总分** | **70** | 🔥 活跃 |
|
||||
|
||||
### `mirrors/miri-test-libstd` — 镜像仓库
|
||||
|
||||
```
|
||||
category = "mirror" → 不参与评分,标记为 🔒 镜像仓库
|
||||
```
|
||||
|
||||
## 生成的审计报告
|
||||
|
||||
```markdown
|
||||
# 📊 仓库健康审计报告 — yetja
|
||||
|
||||
> 审计时间:2026-06-12
|
||||
> 审计范围:当前用户可见的 20 个仓库
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 数量 | 占比 |
|
||||
|------|------|------|
|
||||
| 🔥 活跃仓库 | 3 | 15% |
|
||||
| 💤 休眠仓库 | 8 | 40% |
|
||||
| 💀 僵尸仓库 | 5 | 25% |
|
||||
| 🔒 镜像仓库 | 4 | 20% |
|
||||
|
||||
## 🔥 活跃仓库
|
||||
|
||||
| 仓库 | 评分 | 最近更新 | Stars | Forks |
|
||||
|------|------|---------|-------|-------|
|
||||
| Gitlink/build | 70 | 7分钟前 | 10 | 25 |
|
||||
| Gitlink/gitlink-cli | 55 | 2分钟前 | 5 | 29 |
|
||||
| ccfos/huatuo | 60 | 33分钟前 | 10 | 6 |
|
||||
|
||||
## 💀 疑似僵尸仓库(建议清理)
|
||||
|
||||
| 仓库 | 最后更新 | 原因 |
|
||||
|------|---------|------|
|
||||
| shae/podcast_hunter | 1分钟前 | Stars=0, Forks=0(新建?观察中) |
|
||||
| mirrors/* | 35分钟前 | 镜像仓库,非自主维护 |
|
||||
|
||||
## AI 建议
|
||||
|
||||
1. **不建议大规模清理**:大部分僵尸判定为新建仓库或镜像仓库
|
||||
2. **关注 Gitlink/build**:DevOps 已开启,是持续活跃的 CI/CD 项目
|
||||
3. **镜像仓库**:4 个 mirrors 仓库为自动同步,无需管理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 补充示例:Fork 推荐
|
||||
|
||||
基于上面的审计,如果用户想 Fork 活跃项目:
|
||||
|
||||
```bash
|
||||
# 先预览
|
||||
gitlink-cli repo +batch-fork --repos Gitlink/build,ccfos/huatuo --dry-run
|
||||
|
||||
# 确认后执行
|
||||
gitlink-cli repo +batch-fork --repos Gitlink/build,ccfos/huatuo
|
||||
```
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# branch +create
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
创建一个新分支。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 从 master 创建分支
|
||||
gitlink-cli branch +create --name feature/new-feature
|
||||
|
||||
# 从指定分支创建
|
||||
gitlink-cli branch +create --name hotfix/bug-123 --from develop
|
||||
|
||||
# 指定仓库
|
||||
gitlink-cli branch +create --name feature/x --owner someone --repo myrepo
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name, -n` | 是 | 新分支名称 |
|
||||
| `--from, -f` | 否 | 源分支或 commit(默认 `master`) |
|
||||
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
|
||||
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
|
||||
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## Workflow
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- confirm user intent.
|
||||
|
||||
1. 确认用户希望创建的分支名称和源分支。
|
||||
2. 执行 `branch +create --name <name> --from <source>`。
|
||||
3. 输出创建结果。
|
||||
|
||||
## References
|
||||
- [gitlink-repo](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
# branch +delete
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
删除一个分支。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 删除指定分支
|
||||
gitlink-cli branch +delete --name feature/old-feature
|
||||
|
||||
# 指定仓库
|
||||
gitlink-cli branch +delete --name feature/old-feature --owner someone --repo myrepo
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name, -n` | 是 | 要删除的分支名称 |
|
||||
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
|
||||
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
|
||||
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## Workflow
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Destructive Operation** -- confirm user intent.
|
||||
|
||||
1. 确认用户确实希望删除该分支(此操作不可逆)。
|
||||
2. 执行 `branch +delete --name <name>`。
|
||||
3. 输出删除结果。
|
||||
|
||||
## References
|
||||
- [gitlink-repo](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# branch +list
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出仓库的所有分支。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 列出当前仓库的分支
|
||||
gitlink-cli branch +list
|
||||
|
||||
# 指定仓库并分页
|
||||
gitlink-cli branch +list --owner someone --repo myrepo --page 1 --limit 10
|
||||
|
||||
# 输出为 JSON
|
||||
gitlink-cli branch +list --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
|
||||
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
|
||||
| `--page, -p` | 否 | 页码(默认 `1`) |
|
||||
| `--limit, -l` | 否 | 每页条数(默认 `20`) |
|
||||
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## References
|
||||
- [gitlink-repo](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
# branch +protect
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
设置分支保护规则。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 保护指定分支
|
||||
gitlink-cli branch +protect --name main
|
||||
|
||||
# 指定仓库
|
||||
gitlink-cli branch +protect --name main --owner someone --repo myrepo
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name, -n` | 是 | 要保护的分支名称 |
|
||||
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
|
||||
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
|
||||
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
|
||||
## Workflow
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- confirm user intent.
|
||||
|
||||
1. 确认用户希望保护的分支名称。
|
||||
2. 执行 `branch +protect --name <name>`。
|
||||
3. 输出设置结果。
|
||||
|
||||
## References
|
||||
- [gitlink-repo](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# repo +batch-create
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
批量创建仓库,支持 CSV 和 `--dry-run` 预览。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 直接指定仓库名
|
||||
gitlink-cli repo +batch-create --repos repo1,repo2,repo3
|
||||
|
||||
# 带描述和私有设置
|
||||
gitlink-cli repo +batch-create --repos mylib --description "公共库" --private false
|
||||
|
||||
# 从 CSV 读取
|
||||
gitlink-cli repo +batch-create --from repos.csv
|
||||
|
||||
# 预览模式
|
||||
gitlink-cli repo +batch-create --repos test1,test2 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--repos, -r` | 是* | 逗号分隔的仓库名列表 |
|
||||
| `--from` | 是* | CSV 文件(列名:`name`/`repo`/`repository`) |
|
||||
| `--description, -d` | 否 | 仓库描述(所有仓库共用) |
|
||||
| `--private` | 否 | `true`/`false`(默认 `false`) |
|
||||
| `--dry-run` | 否 | 仅预览,不实际创建 |
|
||||
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
## Workflow
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** — confirm user intent.
|
||||
|
||||
1. 先用 `--dry-run` 预览要创建的仓库列表。
|
||||
2. 确认无误后去掉 `--dry-run` 执行。
|
||||
|
||||
## CSV 格式
|
||||
|
||||
```csv
|
||||
name
|
||||
repo1
|
||||
repo2
|
||||
repo3
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [gitlink-repo](../SKILL.md)
|
||||
- [gitlink-repo +create](gitlink-repo-create.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# repo +batch-delete
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
> [!DANGER]
|
||||
> **不可逆操作** — 务必先用 `--dry-run` 预览!
|
||||
|
||||
批量删除仓库。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 预览(务必先执行)
|
||||
gitlink-cli repo +batch-delete --repos myuser/old1,myuser/old2 --dry-run
|
||||
|
||||
# 确认后执行
|
||||
gitlink-cli repo +batch-delete --repos myuser/old1,myuser/old2
|
||||
|
||||
# 从 CSV 读取
|
||||
gitlink-cli repo +batch-delete --from delete-list.csv --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--repos, -r` | 是* | 逗号分隔,格式 `owner/repo` |
|
||||
| `--from` | 是* | CSV 文件(支持 `owner/repo` 单列或 `owner`,`repo` 双列) |
|
||||
| `--dry-run` | 否 | **务必先使用**预览模式 |
|
||||
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
## CSV 格式
|
||||
|
||||
```csv
|
||||
owner/repo
|
||||
myuser/old-project
|
||||
myuser/archived-lib
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
> [!DANGER]
|
||||
> **Destructive Operation** — 必须先用 `--dry-run` 预览。
|
||||
|
||||
1. **必须先用 `--dry-run` 预览**要删除的仓库列表。
|
||||
2. 仔细核对每个仓库的 owner 和名称。
|
||||
3. 确认后去掉 `--dry-run` 执行。
|
||||
4. 删除后无法恢复。
|
||||
|
||||
## References
|
||||
|
||||
- [gitlink-repo](../SKILL.md)
|
||||
- [gitlink-repo +delete](gitlink-repo-delete.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# repo +batch-fork
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
批量 Fork 仓库到当前认证用户下。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# owner/repo 格式
|
||||
gitlink-cli repo +batch-fork --repos alice/proj1,bob/proj2
|
||||
|
||||
# 从 CSV 读取
|
||||
gitlink-cli repo +batch-fork --from forks.csv
|
||||
|
||||
# 预览模式
|
||||
gitlink-cli repo +batch-fork --repos Gitlink/forgeplus,Gitlink/microservices --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--repos, -r` | 是* | 逗号分隔,格式 `owner/repo` |
|
||||
| `--from` | 是* | CSV 文件(支持 `owner/repo` 单列或 `owner`,`repo` 双列) |
|
||||
| `--dry-run` | 否 | 仅预览,不实际执行 |
|
||||
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
## CSV 格式
|
||||
|
||||
单列:
|
||||
```csv
|
||||
owner/repo
|
||||
alice/proj1
|
||||
bob/proj2
|
||||
```
|
||||
|
||||
双列:
|
||||
```csv
|
||||
owner,repo
|
||||
alice,proj1
|
||||
bob,proj2
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** — confirm user intent.
|
||||
|
||||
1. 先用 `--dry-run` 预览。
|
||||
2. 确认无误后执行。
|
||||
|
||||
## References
|
||||
|
||||
- [gitlink-repo](../SKILL.md)
|
||||
- [gitlink-repo +fork](gitlink-repo-fork.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# repo +clone
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
从 GitLink 克隆仓库到本地。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# owner/repo 格式
|
||||
gitlink-cli repo +clone --url chroe/gitlink-cli
|
||||
|
||||
# 完整 URL
|
||||
gitlink-cli repo +clone --url https://gitlink.org.cn/chroe/gitlink-cli.git
|
||||
|
||||
# 指定目标目录
|
||||
gitlink-cli repo +clone --url chroe/gitlink-cli --dir my-dir
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url, -u` | 是 | 仓库 URL 或 `owner/repo` 格式 |
|
||||
| `--dir, -d` | 否 | 目标目录(默认使用仓库名) |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. 确认用户要克隆的仓库。
|
||||
2. 执行 `repo +clone --url <owner/repo>`。
|
||||
3. 克隆后在仓库目录下执行命令可自动解析 owner/repo。
|
||||
|
||||
## References
|
||||
|
||||
- [gitlink-repo](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
@ -11,28 +11,36 @@
|
|||
gitlink-cli repo +create --name my-new-repo
|
||||
|
||||
# 创建带描述的私有仓库
|
||||
gitlink-cli repo +create --name my-new-repo --description "A great project" --private true
|
||||
gitlink-cli repo +create --name my-lib --description "A great project" --private true
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--name, -n` | 是 | 仓库名称 |
|
||||
| `--name, -n` | 是 | 仓库名称(平台内唯一) |
|
||||
| `--description, -d` | 否 | 仓库描述 |
|
||||
| `--private` | 否 | 是否私有(`true`/`false`,默认 `false`) |
|
||||
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
POST /projects
|
||||
Body: { "name": "...", "description": "...", "is_public": true/false }
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- confirm user intent.
|
||||
> This is a **Write Operation** — confirm user intent.
|
||||
|
||||
1. 确认用户希望创建的仓库名称。
|
||||
1. 确认用户希望的仓库名称和可见性。
|
||||
2. 执行 `repo +create --name <name>`。
|
||||
3. 输出创建结果。
|
||||
3. 输出创建结果(clone URL 等)。
|
||||
|
||||
## References
|
||||
|
||||
- [gitlink-repo](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
|
|||
|
|
@ -2,38 +2,42 @@
|
|||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
删除一个仓库。
|
||||
> [!DANGER]
|
||||
> **不可逆操作** — 删除后仓库无法恢复!
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 删除当前仓库(从 git remote 推断 owner/repo)
|
||||
gitlink-cli repo +delete
|
||||
|
||||
# 删除指定仓库
|
||||
gitlink-cli repo +delete --owner someone --repo old-repo
|
||||
# 删除仓库
|
||||
gitlink-cli repo +delete --owner myuser --repo old-project
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
|
||||
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
|
||||
| `--owner` | 是* | 仓库所有者(可从 git remote 自动解析) |
|
||||
| `--repo` | 是* | 仓库名称(可从 git remote 自动解析) |
|
||||
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
## API
|
||||
|
||||
```
|
||||
DELETE /<owner>/<repo>
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Destructive Operation** -- confirm user intent.
|
||||
> [!DANGER]
|
||||
> **Destructive Operation** — 必须确认用户意图。
|
||||
|
||||
1. 确认用户确实希望删除该仓库(此操作不可逆)。
|
||||
2. 执行 `repo +delete --owner <owner> --repo <repo>`。
|
||||
3. 输出删除结果。
|
||||
1. 执行前明确告知用户此操作不可逆。
|
||||
2. 确认仓库名和所有者完全正确。
|
||||
3. 批量删除建议用 `repo +batch-delete --dry-run` 先预览。
|
||||
|
||||
## References
|
||||
|
||||
- [gitlink-repo](../SKILL.md)
|
||||
- [gitlink-repo +batch-delete](gitlink-repo-batch-delete.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
|
|||
|
|
@ -7,33 +7,43 @@ Fork 一个仓库到当前认证用户下。
|
|||
## 命令
|
||||
|
||||
```bash
|
||||
# Fork 当前仓库(从 git remote 推断 owner/repo)
|
||||
gitlink-cli repo +fork
|
||||
|
||||
# Fork 指定仓库
|
||||
gitlink-cli repo +fork --owner someone --repo their-repo
|
||||
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
|
||||
|
||||
# git 仓库目录下自动解析
|
||||
gitlink-cli repo +fork
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) |
|
||||
| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) |
|
||||
| `--owner` | 是* | 仓库所有者(可从 git remote 自动解析) |
|
||||
| `--repo` | 是* | 仓库名称(可从 git remote 自动解析) |
|
||||
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
|
||||
| `--debug` | 否 | 启用调试输出 |
|
||||
|
||||
> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。
|
||||
## API
|
||||
|
||||
```
|
||||
POST /<owner>/<repo>/fork
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- confirm user intent.
|
||||
> This is a **Write Operation** — confirm user intent.
|
||||
|
||||
1. 确认用户希望 fork 的目标仓库。
|
||||
1. 确认用户要 Fork 的目标仓库。
|
||||
2. 执行 `repo +fork --owner <owner> --repo <repo>`。
|
||||
3. 输出 fork 结果。
|
||||
3. Fork 完成后,仓库出现在当前用户下。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- Fork 目标固定为当前认证用户,无法指定其他目标
|
||||
- Fork 后可用 `repo +list --category fork` 查看
|
||||
|
||||
## References
|
||||
|
||||
- [gitlink-repo](../SKILL.md)
|
||||
- [gitlink-shared](../../gitlink-shared/SKILL.md)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue