diff --git a/EXPERIMENT_LOG.md b/EXPERIMENT_LOG.md new file mode 100644 index 0000000..8fb9a1c --- /dev/null +++ b/EXPERIMENT_LOG.md @@ -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///issues/` 配合 `--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 看着合理」≠「实际能跑」。必须真机验证。 diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 505dfad..1ba71d6 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -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-/ +├── SKILL.md # 主文件:触发条件、数据采集命令、输出格式、使用场景 +├── REFERENCE.md # 技术参考:字段映射、API 注意事项、Q&A +└── examples/ + └── .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 +- [ ] 撰写《软件分析及建模报告》 +- [ ] 撰写《新需求构思报告》 +- [ ] 撰写《变更影响分析及测试报告》 +- [ ] 变更说明文档 diff --git a/README.md b/README.md index cb2663d..0574801 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/api/api.go b/cmd/api/api.go index af0593d..45b7846 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -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 ", @@ -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 } diff --git a/cmd/debug_url/main.go b/cmd/debug_url/main.go new file mode 100644 index 0000000..1e851a3 --- /dev/null +++ b/cmd/debug_url/main.go @@ -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) +} diff --git a/docs/superpowers/plans/2026-06-18-gitlink-review-skill.md b/docs/superpowers/plans/2026-06-18-gitlink-review-skill.md new file mode 100644 index 0000000..821f61f --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-gitlink-review-skill.md @@ -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` 返回一个数(记为 ``,如 3)。记录到 `review_fixture_notes.txt`(本地临时): + +``` +TEST_PR_NUMBER= +``` + +- [ ] **Step 5:验证 diff 可取** + +```bash +gitlink-cli pr +files --owner chroe --repo gitlink-cli --id --format json +gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id --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//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= +NOTES=<观察到的响应或错误> +``` + +- [ ] **Step 3:若可用,再试带行号的行内评论** + +```bash +gitlink-cli api POST /chroe/gitlink-cli/pulls//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 --repo --id --format json # 元数据 +gitlink-cli pr +files --owner --repo --id --format json # 文件 +/- +gitlink-cli pr +diff --owner --repo --id --format json # 核心:diff 内容 +gitlink-cli repo +info --owner --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 --repo --id \ + --body "<报告卡全文,含哨兵>" +``` + +`--inline` 且 reviews API 可用 → 对 🔴/🟡 发现附行内评论(见 REFERENCE.md 的 API 结论)。 + +### ⑧ 幂等 + +发布前检查该 PR 是否已有哨兵 ``;有则默认提议"更新"(`--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) +**范围**: 文件,+/-(跳过 噪音文件)|视角:|自检丢弃: + +| 严重度 | 视角 | 位置 | 问题 | +|:---:|---|---|---| +| 🔴 | correctness | path/file.go:42 | <一句话> | + +### 🔴 阻塞(合并前需处理) +1. **[correctness] path/file.go:42** — + - **为什么**: + - **建议**: + +### 🟡 建议 +… + +### 🔵 nit +… + +### ✅ 未发现问题的方面 +- <视角>:<结论> + +--- + +*由 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 +``` + +把 `` 与 ``(下文占位)替换为真实结论。 + +- [ ] **Step 2:写 REFERENCE.md** + +创建 `skills/gitlink-review/REFERENCE.md`,完整内容(**把 `` 等占位替换为 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 ``): + +```bash +gitlink-cli api POST ///pulls//reviews \ + --body '{"body":"...","event":"COMMENT"}' +``` + +**结论**:(可用 / 不可用) + +(实测观察到的响应或错误) + +**设计影响**: +- 若 **不可用**(如返回 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 内相对行)`。 + +--- + +## 六、幂等哨兵 + +``` + +``` + +- 发布前用 `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 对 `` 执行审查** + +人工/agent 依 SKILL.md 管道执行:取 view/files/diff → 降噪 → 6 视角 → 对抗自检 → 综合 → 生成报告卡。把生成的报告卡全文存到本地 `review_output_.md`。 + +```bash +gitlink-cli pr +view --owner chroe --repo gitlink-cli --id --format json +gitlink-cli pr +files --owner chroe --repo gitlink-cli --id --format json +gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id --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 \ + --body "$(cat review_output_.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 #(`test/review-fixture`)于 2026-06-18 实际执行。 +> 该 PR 故意植入 4 个问题用于验证 gitlink-review skill。 + +--- + +## Step 1:取上下文 + +```bash +gitlink-cli pr +view --owner chroe --repo gitlink-cli --id --format json +gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id --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 \ + --body "<报告卡全文>" +``` + +<此处粘贴 Task 6 生成的真实报告卡全文> + +**发布结果**:`ok: true`,PR 评论数 +1。 + +## Step 4:幂等验证 + +再次运行(不带 `--refresh`)→ 检测到哨兵 `` → 提示"已存在审查,使用 --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 +``` + +预期:`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 已创建 diff --git a/docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md b/docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md new file mode 100644 index 0000000..a1778fb --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-intelligent-code-review-design.md @@ -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 可用 → 附行内评论,否则降级 +⑧ 幂等 评论内嵌哨兵 ,重跑检测旧审查 → 提议更新而非刷屏 +``` + +每条候选发现的数据结构(内部): + +```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 skill 生成;本评论为预览确认后发布。* +``` + +哨兵 `` 用于幂等与"针对哪个 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 链接更新 diff --git a/gitlink-cli.exe b/gitlink-cli.exe new file mode 100644 index 0000000..9ebb80b Binary files /dev/null and b/gitlink-cli.exe differ diff --git a/internal/config/config.go b/internal/config/config.go index d00bd0c..e92b87a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/output/formatter.go b/internal/output/formatter.go index 2379d08..b888bb5 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -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 { diff --git a/scripts/setup-skills.sh b/scripts/setup-skills.sh new file mode 100755 index 0000000..b74b2ee --- /dev/null +++ b/scripts/setup-skills.sh @@ -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." diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 15441c9..a3b1b47 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -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. diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index ac30fda..984ffb2 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -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 diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 511448a..700ac3b 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -28,6 +28,7 @@ func Shortcuts() []*common.Shortcut { newBatchAssignShortcut(), newBatchLabelShortcut(), newBatchMilestoneShortcut(), + newBatchCommentShortcut(), { Name: "list", Description: "List issues", diff --git a/shortcuts/member/batch.go b/shortcuts/member/batch.go new file mode 100644 index 0000000..76a2a14 --- /dev/null +++ b/shortcuts/member/batch.go @@ -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 +} diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go index 94bce2d..6daa672 100644 --- a/shortcuts/member/member.go +++ b/shortcuts/member/member.go @@ -10,6 +10,7 @@ import ( func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ + batchAddShortcut(), { Name: "list", Description: "List project members", diff --git a/shortcuts/register.go b/shortcuts/register.go index 76b8f74..d1ac713 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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 { diff --git a/shortcuts/repo/batch.go b/shortcuts/repo/batch.go index 11db3e9..d8fb88e 100644 --- a/shortcuts/repo/batch.go +++ b/shortcuts/repo/batch.go @@ -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 { diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index d478803..22dde5d 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -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", diff --git a/shortcuts/workflow/aiclient.go b/shortcuts/workflow/aiclient.go new file mode 100644 index 0000000..a29b6e2 --- /dev/null +++ b/shortcuts/workflow/aiclient.go @@ -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 != "" +} diff --git a/shortcuts/workflow/code_quality.go b/shortcuts/workflow/code_quality.go new file mode 100644 index 0000000..fa2c238 --- /dev/null +++ b/shortcuts/workflow/code_quality.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/community_ops.go b/shortcuts/workflow/community_ops.go new file mode 100644 index 0000000..d8a2911 --- /dev/null +++ b/shortcuts/workflow/community_ops.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/contributor_growth.go b/shortcuts/workflow/contributor_growth.go new file mode 100644 index 0000000..2e0adb3 --- /dev/null +++ b/shortcuts/workflow/contributor_growth.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/daemon.go b/shortcuts/workflow/daemon.go new file mode 100644 index 0000000..1ec4391 --- /dev/null +++ b/shortcuts/workflow/daemon.go @@ -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)) +} diff --git a/shortcuts/workflow/engine.go b/shortcuts/workflow/engine.go new file mode 100644 index 0000000..70688de --- /dev/null +++ b/shortcuts/workflow/engine.go @@ -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 +} diff --git a/shortcuts/workflow/manifest.go b/shortcuts/workflow/manifest.go new file mode 100644 index 0000000..d99f289 --- /dev/null +++ b/shortcuts/workflow/manifest.go @@ -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() +} diff --git a/shortcuts/workflow/multi_repo.go b/shortcuts/workflow/multi_repo.go new file mode 100644 index 0000000..e0e2c28 --- /dev/null +++ b/shortcuts/workflow/multi_repo.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/project_init.go b/shortcuts/workflow/project_init.go new file mode 100644 index 0000000..ec24413 --- /dev/null +++ b/shortcuts/workflow/project_init.go @@ -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"}}, + }, + }) +} diff --git a/shortcuts/workflow/rules/changelog.go b/shortcuts/workflow/rules/changelog.go new file mode 100644 index 0000000..90426db --- /dev/null +++ b/shortcuts/workflow/rules/changelog.go @@ -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() +} diff --git a/shortcuts/workflow/rules/changelog_test.go b/shortcuts/workflow/rules/changelog_test.go new file mode 100644 index 0000000..fc7b17e --- /dev/null +++ b/shortcuts/workflow/rules/changelog_test.go @@ -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 +} diff --git a/shortcuts/workflow/rules/ci_diagnosis.go b/shortcuts/workflow/rules/ci_diagnosis.go new file mode 100644 index 0000000..0c86ae2 --- /dev/null +++ b/shortcuts/workflow/rules/ci_diagnosis.go @@ -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 +} diff --git a/shortcuts/workflow/rules/ci_diagnosis_test.go b/shortcuts/workflow/rules/ci_diagnosis_test.go new file mode 100644 index 0000000..4680621 --- /dev/null +++ b/shortcuts/workflow/rules/ci_diagnosis_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/rules/contributor.go b/shortcuts/workflow/rules/contributor.go new file mode 100644 index 0000000..160ceb5 --- /dev/null +++ b/shortcuts/workflow/rules/contributor.go @@ -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 "" +} \ No newline at end of file diff --git a/shortcuts/workflow/rules/contributor_test.go b/shortcuts/workflow/rules/contributor_test.go new file mode 100644 index 0000000..b6c0855 --- /dev/null +++ b/shortcuts/workflow/rules/contributor_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/rules/dispatch.go b/shortcuts/workflow/rules/dispatch.go new file mode 100644 index 0000000..6d096ab --- /dev/null +++ b/shortcuts/workflow/rules/dispatch.go @@ -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) +} diff --git a/shortcuts/workflow/rules/health_report.go b/shortcuts/workflow/rules/health_report.go new file mode 100644 index 0000000..0e93c13 --- /dev/null +++ b/shortcuts/workflow/rules/health_report.go @@ -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) +} diff --git a/shortcuts/workflow/rules/health_report_test.go b/shortcuts/workflow/rules/health_report_test.go new file mode 100644 index 0000000..30effc8 --- /dev/null +++ b/shortcuts/workflow/rules/health_report_test.go @@ -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 +} diff --git a/shortcuts/workflow/rules/license.go b/shortcuts/workflow/rules/license.go new file mode 100644 index 0000000..6ff31a8 --- /dev/null +++ b/shortcuts/workflow/rules/license.go @@ -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))", "", "your_token_here", "xxx", "replace_me", " 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 +} diff --git a/shortcuts/workflow/rules/repo_audit_test.go b/shortcuts/workflow/rules/repo_audit_test.go new file mode 100644 index 0000000..50c4666 --- /dev/null +++ b/shortcuts/workflow/rules/repo_audit_test.go @@ -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) +} diff --git a/shortcuts/workflow/rules/review.go b/shortcuts/workflow/rules/review.go new file mode 100644 index 0000000..5c269cb --- /dev/null +++ b/shortcuts/workflow/rules/review.go @@ -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) + } +} diff --git a/shortcuts/workflow/rules/review_test.go b/shortcuts/workflow/rules/review_test.go new file mode 100644 index 0000000..4ed6a80 --- /dev/null +++ b/shortcuts/workflow/rules/review_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/rules/triage.go b/shortcuts/workflow/rules/triage.go new file mode 100644 index 0000000..54dc54a --- /dev/null +++ b/shortcuts/workflow/rules/triage.go @@ -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 != "" { + return s + } + } + return "" +} diff --git a/shortcuts/workflow/rules/triage_test.go b/shortcuts/workflow/rules/triage_test.go new file mode 100644 index 0000000..b4d95ce --- /dev/null +++ b/shortcuts/workflow/rules/triage_test.go @@ -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 +} diff --git a/shortcuts/workflow/state.go b/shortcuts/workflow/state.go new file mode 100644 index 0000000..4866f13 --- /dev/null +++ b/shortcuts/workflow/state.go @@ -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)) +} diff --git a/shortcuts/workflow/steps.go b/shortcuts/workflow/steps.go new file mode 100644 index 0000000..7b3d38d --- /dev/null +++ b/shortcuts/workflow/steps.go @@ -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' diff --git a/shortcuts/workflow/trigger.go b/shortcuts/workflow/trigger.go new file mode 100644 index 0000000..6dc4a2b --- /dev/null +++ b/shortcuts/workflow/trigger.go @@ -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) + } + } +} diff --git a/shortcuts/workflow/types.go b/shortcuts/workflow/types.go new file mode 100644 index 0000000..082c5f2 --- /dev/null +++ b/shortcuts/workflow/types.go @@ -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) +} diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go new file mode 100644 index 0000000..2b04069 --- /dev/null +++ b/shortcuts/workflow/workflow.go @@ -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 \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)) +} diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go new file mode 100644 index 0000000..af93a91 --- /dev/null +++ b/shortcuts/workflow/workflow_test.go @@ -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) + } +} diff --git a/showcase/Dockerfile b/showcase/Dockerfile index 8a24528..0768f42 100644 --- a/showcase/Dockerfile +++ b/showcase/Dockerfile @@ -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 . diff --git a/showcase/index.html b/showcase/index.html index 98e9211..6af754a 100644 --- a/showcase/index.html +++ b/showcase/index.html @@ -3,21 +3,41 @@ - gitlink-cli 功能增强展示 + gitlink-cli 项目总览 — 软件演化与运维课程实践 +
-

gitlink-cli 功能增强

-

软件演化与运维 课程实践 — 进阶任务 子任务一

+

gitlink-cli 项目总览

+

软件演化与运维 课程实践 — 进阶任务成果汇报

演示仓库:chroe/gitlink-cli — 展开命令、填入参数、点击运行查看真实结果
+ +
+
-
9
新增模块
-
47
新增命令
-
7
批量操作
-
47+
单元测试
+
11
CLI 模块
+
47+
新增命令
+
17
AI Skills
+
5
自动化工作流
+
11
工作流命令
+
47+
单元测试
+
+
JSON
+
输出格式 ▾
+
-
- + +
+
+ 子任务一 +

增加和完善 GitLink-CLI 能力

+

11 个模块 · 47+ 命令 · 7 个批量操作 · 全部可交互运行

+
+
+
+ +
+ +
+
+ 子任务二 +

编写和丰富 GitLink Skills

+

17 个 AI Agent Skill · 覆盖核心/智能/辅助三大类 · 兼容 Claude Code 等主流平台

+
+
+
+ +
+ +
+
+ 子任务三 +

构建端到端自动化工作流

+

5 个预置工作流 · 11 个 CLI 命令 · 规则引擎 + AI 双模式 · 支持守护进程/systemd 部署

+
+
+
+ +
+ +
+
+ 子任务四 +

应用 GitLink 辅助科研

+

利用 GitLink 平台能力支撑科研项目管理和学术协作

+
+
+
+
🔬
+

即将上线

+

科研项目管理、学术协作工具集成等功能正在规划中,敬请期待。

+
+
+
+ + + diff --git a/showcase/main.go b/showcase/main.go index 61b5932..1d13340 100644 --- a/showcase/main.go +++ b/showcase/main.go @@ -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)...) } diff --git a/showcase/showcase-linux b/showcase/showcase-linux new file mode 100644 index 0000000..f8ebcaa Binary files /dev/null and b/showcase/showcase-linux differ diff --git a/showcase/showcase-server-linux b/showcase/showcase-server-linux new file mode 100644 index 0000000..7451ca9 Binary files /dev/null and b/showcase/showcase-server-linux differ diff --git a/showcase/showcase.exe b/showcase/showcase.exe new file mode 100644 index 0000000..6d4397e Binary files /dev/null and b/showcase/showcase.exe differ diff --git a/skills/README.md b/skills/README.md index 2786e1d..a28cf20 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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) diff --git a/skills/gitlink-branch/REFERENCE.md b/skills/gitlink-branch/REFERENCE.md new file mode 100644 index 0000000..9ea8e18 --- /dev/null +++ b/skills/gitlink-branch/REFERENCE.md @@ -0,0 +1,109 @@ +# gitlink-branch 参考手册 + +> 本文档定义分支管理命令的参数说明、API 字段映射和注意事项。 + +--- + +## 一、命令参考 + +### branch +list + +列出仓库所有分支。 + +```bash +gitlink-cli branch +list --owner --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 --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 --repo \ + --name <分支名> --format json +``` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | 是 | 要删除的分支名称 | + +> ⚠️ **写入操作** — 删除分支不可恢复,执行前确认用户意图。不能删除默认分支。 + +### branch +protect + +设置分支保护。 + +```bash +gitlink-cli branch +protect --owner --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 命令是仓库设置的快捷方式。 diff --git a/skills/gitlink-branch/examples/branch-workflow.md b/skills/gitlink-branch/examples/branch-workflow.md index 3087762..64ce916 100644 --- a/skills/gitlink-branch/examples/branch-workflow.md +++ b/skills/gitlink-branch/examples/branch-workflow.md @@ -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` 查看状态再执行写入操作 diff --git a/skills/gitlink-changelog/REFERENCE.md b/skills/gitlink-changelog/REFERENCE.md new file mode 100644 index 0000000..11e1d8c --- /dev/null +++ b/skills/gitlink-changelog/REFERENCE.md @@ -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():` | `feat(wiki): add create` | wiki | +| `fix :` | `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 版本。 diff --git a/skills/gitlink-changelog/SKILL.md b/skills/gitlink-changelog/SKILL.md new file mode 100644 index 0000000..adaabd1 --- /dev/null +++ b/skills/gitlink-changelog/SKILL.md @@ -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 --repo --format json +``` + +### 第二步:获取提交历史 + +```bash +# 获取提交列表(AI 根据上一个 Release 的时间筛选新增提交) +gitlink-cli commit +list --owner --repo --format json + +# 注意:提交可能需要翻页,用 --page 参数获取全部 +gitlink-cli commit +list --owner --repo --page 2 --format json +``` + +> ⚠️ **不要使用 compare API**。`api GET /:owner/:repo/compare/...` 实测会返回 HTML 而非 JSON,无法用于变更对比。请改用 `commit +list` 按时间戳筛选。 + +### 第三步:获取 PR 记录 + +```bash +# 获取已合并的 PR 列表 +gitlink-cli pr +list --owner --repo --state merged --format json + +# 查看具体 PR 详情(获取关联 Issue 和变更内容) +gitlink-cli pr +view --owner --repo --id --format json + +# 查看 PR 变更文件 +gitlink-cli pr +files --owner --repo --id --format json +``` + +### 第四步:获取已关闭 Issue + +```bash +# 获取已关闭的 Issue +gitlink-cli issue +list --owner --repo --state closed --format json +``` + +## Release Notes 输出格式 + +AI 根据采集到的数据,按以下结构生成 Release Notes: + +```markdown +# 🚀 <版本号> (<发布日期>) + +## 📝 版本摘要 + +<一段话总结本次版本的核心变更> + +## ✨ 新功能 (Features) + +- **<模块>**: <功能描述> (#) by @<作者> +- ... + +## 🐛 问题修复 (Bug Fixes) + +- **<模块>**: <修复描述> (#) by @<修复者> +- ... + +## 🔧 改进优化 (Improvements) + +- **<模块>**: <改进描述> (#) +- ... + +## 📚 文档更新 (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 --repo \ + --tag v1.2.0 --name "v1.2.0" --body "" +``` + +## 发布命令 + +```bash +# 创建 Release(需要认证) +gitlink-cli release +create --owner --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 --repo --id +``` + +## 最佳实践 + +- 所有数据采集命令使用 `--format json` +- commit message 建议遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范,便于自动分类 +- 发布前让用户预览 Release Notes 并确认 +- 保留上一个版本的时间戳作为基线,避免遗漏或重复 +- 如果项目未发布过 Release,则从项目创建时间开始采集所有数据 +- 关联 PR 和 Issue 编号,增强 Release Notes 的可追溯性 + +## 详细参考 + +详见 [`REFERENCE.md`](REFERENCE.md) 了解完整的 commit 分类规则、数据字段映射和 API 注意事项。 diff --git a/skills/gitlink-changelog/examples/generate-release-notes.md b/skills/gitlink-changelog/examples/generate-release-notes.md new file mode 100644 index 0000000..6eba398 --- /dev/null +++ b/skills/gitlink-changelog/examples/generate-release-notes.md @@ -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)| +``` diff --git a/skills/gitlink-ci/REFERENCE.md b/skills/gitlink-ci/REFERENCE.md new file mode 100644 index 0000000..8a318bf --- /dev/null +++ b/skills/gitlink-ci/REFERENCE.md @@ -0,0 +1,198 @@ +# gitlink-ci 参考手册 + +> 本文档定义 CI/CD 的 API 字段映射、错误诊断模式、流水线配置参考和已知问题。 + +--- + +## 一、前置条件:DevOps 状态 + +### 检查方法 + +```bash +gitlink-cli repo +info --owner --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 ///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 ///builds//logs// +``` + +参数说明: +- `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。 diff --git a/skills/gitlink-ci/SKILL.md b/skills/gitlink-ci/SKILL.md index 016daaa..f1f2cb1 100644 --- a/skills/gitlink-ci/SKILL.md +++ b/skills/gitlink-ci/SKILL.md @@ -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 --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 ///activate +``` +> ⚠️ **实测**:`chroe/gitlink-cli` 等仓库的 `open_devops` 为 `false`,调用 `ci +builds` 会返回"接口数据异常"。这不是 CLI 的 bug,而是 GitLink 平台的前置要求。 + +## 数据采集命令 + +### 第一步:获取构建列表 + +```bash +# 查看构建历史 +gitlink-cli ci +builds --owner --repo --format json + +# 分页 +gitlink-cli ci +builds --owner --repo --page 2 --format json +``` + +### 第二步:查看失败构建日志 + +```bash +# 查看默认 stage=1, step=1 的日志 +gitlink-cli ci +logs --owner --repo --build + +# 查看特定 stage/step(如 stage 2 = 测试阶段) +gitlink-cli ci +logs --owner --repo --build --stage 2 --step 1 +``` + +### 第三步:查看 CI 配置 + +```bash +# 检查 CI 授权状态 +gitlink-cli api GET ///ci_authorize + +# 查看构建详情 +gitlink-cli api GET ///builds/ +``` + +## AI 分析规则 + +### 构建失败诊断流程 + +``` +1. ci +builds → 筛选 status=failed 的构建 +2. ci +logs --build → 获取日志 +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 --repo --build +``` + +### 停止异常构建 + +```bash +gitlink-cli ci +stop --owner --repo --build +``` + +### 管理 CI 状态 + +```bash # 停用 CI -gitlink-cli api DELETE /:owner/:repo/deactivate +gitlink-cli api DELETE ///deactivate -# CI 授权状态 -gitlink-cli api GET /:owner/:repo/ci_authorize +# 激活 CI +gitlink-cli api POST ///activate ``` + +## 输出格式 + +### 构建失败诊断报告 + +```markdown +# 🔧 构建失败诊断报告 + +> 仓库: +> 构建编号:# +> 诊断时间: + +## 失败概况 + +| 项目 | 详情 | +|------|------| +| 构建编号 | #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 → 获取失败日志 +4. AI 分析日志中的错误模式 +5. 匹配已知错误 → 生成诊断报告和修复建议 +6. 询问用户是否要重启构建 +``` + +### 场景 2:CI 配置审计 + +``` +用户:"帮我检查项目 CI 配置是否正常" + +AI 执行流程: +1. repo +info → 检查 open_devops +2. ci +builds → 查看最近的构建记录 +3. raw api GET ///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 字段映射、错误诊断模式和流水线配置。 diff --git a/skills/gitlink-ci/examples/ci-devops-check.md b/skills/gitlink-ci/examples/ci-devops-check.md new file mode 100644 index 0000000..695d43a --- /dev/null +++ b/skills/gitlink-ci/examples/ci-devops-check.md @@ -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 +``` diff --git a/skills/gitlink-health/REFERENCE.md b/skills/gitlink-health/REFERENCE.md new file mode 100644 index 0000000..18c186d --- /dev/null +++ b/skills/gitlink-health/REFERENCE.md @@ -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 --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。 diff --git a/skills/gitlink-health/SKILL.md b/skills/gitlink-health/SKILL.md new file mode 100644 index 0000000..dfccb6b --- /dev/null +++ b/skills/gitlink-health/SKILL.md @@ -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 --repo --format json + +# 项目成员/贡献者列表 +gitlink-cli member +list --owner --repo --format json + +# 发行版本列表 +gitlink-cli release +list --owner --repo --format json + +# 里程碑列表 +gitlink-cli milestone +list --owner --repo --format json +``` + +### 第二步:获取 Issue 数据 + +```bash +# 开放中的 Issue +gitlink-cli issue +list --owner --repo --state open --format json + +# 已关闭的 Issue(可能需要翻页) +gitlink-cli issue +list --owner --repo --state closed --format json + +# 查看单个 Issue 详情(用于计算响应时间) +gitlink-cli issue +view --owner --repo --number --format json + +# Issue 评论(用于判断首次响应时间) +gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals --format json +``` + +### 第三步:获取 PR 数据 + +```bash +# PR 列表(开放状态) +gitlink-cli pr +list --owner --repo --state open --format json + +# PR 列表(已合并) +gitlink-cli pr +list --owner --repo --state merged --format json + +# PR 详情(用于计算合并耗时) +gitlink-cli pr +view --owner --repo --id --format json +``` + +### 第四步:获取提交数据 + +```bash +# 提交历史 +gitlink-cli commit +list --owner --repo --format json +``` + +### 第五步:获取标签和分支数据 + +```bash +# 标签列表(用于 Issue 分类统计) +gitlink-cli label +list --owner --repo --format json + +# 分支列表 +gitlink-cli branch +list --owner --repo --format json +``` + +## 报告输出格式 + +AI 根据采集到的数据,按以下结构生成健康度报告: + +```markdown +# 🏥 项目健康度报告:/ + +> 报告生成时间: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 --repo --format json +# AI 根据时间戳筛选最近 7 天的记录 +``` + +### 场景 3:多项目对比 + +对多个项目分别生成健康度报告,横向对比综合评分和各维度得分。 + +## 最佳实践 + +- 所有数据采集命令使用 `--format json` 以便 AI 解析 +- 数据量较大时注意翻页(`--page` 参数) +- 报告生成后建议保存到项目 Wiki: + ```bash + gitlink-cli wiki +create --title "项目健康度报告 YYYY-MM-DD" --body "<报告内容>" + ``` +- 定期(如每周)生成报告,跟踪项目健康趋势 +- 评分仅供参考,AI 应结合项目实际情况给出定制化改进建议 diff --git a/skills/gitlink-health/examples/health-report.md b/skills/gitlink-health/examples/health-report.md new file mode 100644 index 0000000..484c270 --- /dev/null +++ b/skills/gitlink-health/examples/health-report.md @@ -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. **定期生成报告**:每周跑一次健康度报告,跟踪改善趋势 +``` diff --git a/skills/gitlink-health/examples/weekly-report.md b/skills/gitlink-health/examples/weekly-report.md new file mode 100644 index 0000000..246079c --- /dev/null +++ b/skills/gitlink-health/examples/weekly-report.md @@ -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 +``` diff --git a/skills/gitlink-issue/examples/issue-workflow.md b/skills/gitlink-issue/examples/issue-workflow.md new file mode 100644 index 0000000..b194427 --- /dev/null +++ b/skills/gitlink-issue/examples/issue-workflow.md @@ -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 健康度 diff --git a/skills/gitlink-license/REFERENCE.md b/skills/gitlink-license/REFERENCE.md new file mode 100644 index 0000000..c3fb979 --- /dev/null +++ b/skills/gitlink-license/REFERENCE.md @@ -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))`) +> - 值为 ``、`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 ` 手动递归子目录。 + +### 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": "" + } + ], + "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 ` 递归进入 +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。 diff --git a/skills/gitlink-license/SKILL.md b/skills/gitlink-license/SKILL.md new file mode 100644 index 0000000..65df57e --- /dev/null +++ b/skills/gitlink-license/SKILL.md @@ -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 --repo --format json + +# 项目成员列表(用于合规责任归属) +gitlink-cli member +list --owner --repo --format json +``` + +### 第二步:扫描根目录许可证文件 + +```bash +# 列出根目录文件(找 LICENSE / COPYING / NOTICE) +gitlink-cli file +list --owner --repo --format json + +# 读取 LICENSE 文件内容(AI 识别许可证类型和完整性) +gitlink-cli file +get --owner --repo --path LICENSE --format json + +# 如果根目录有 LICENSE.md / COPYING 等,也一并读取 +gitlink-cli file +get --owner --repo --path LICENSE.md --format json +gitlink-cli file +get --owner --repo --path COPYING --format json +``` + +### 第三步:扫描全量文件树(敏感文件 + 二进制文件) + +```bash +# 获取顶级文件树(先看目录结构) +gitlink-cli file +tree --owner --repo --sha master --format json + +# 递归进入每个子目录(用 file +get 获取子目录内容) +gitlink-cli file +get --owner --repo --path --format json + +# ⚠️ file +tree --recursive true 可能返回空,需要手动遍历子目录 +``` + +> ⚠️ **重要**:`file +tree --recursive true` 在部分仓库上返回空数据。此时必须通过 `file +tree` 获取顶级目录 → 对每个子目录用 `file +get --path ` 递归进入,或用 `file +list` 获取扁平文件列表。 + +### 第四步:检查高危文件内容 + +```bash +# 对第三步中发现的任何可疑文件,读取内容进行深度扫描 +# 例如:CI/CD 配置文件、Dockerfile、shell 脚本 +gitlink-cli file +get --owner --repo --path '.devops/.yml' --format json +gitlink-cli file +get --owner --repo --path '.github/workflows/.yml' --format json +gitlink-cli file +get --owner --repo --path 'Dockerfile' --format json + +# 读取依赖清单文件 +gitlink-cli file +get --owner --repo --path go.mod --format json +gitlink-cli file +get --owner --repo --path package.json --format json +gitlink-cli file +get --owner --repo --path requirements.txt --format json +``` + +### 第五步:抽样检查源码文件许可证声明 + +```bash +# 抽取 3-5 个主要源码文件,检查是否有版权/许可证声明 +# 对 Go 项目抽样 main.go + 各模块主文件 +gitlink-cli file +get --owner --repo --path main.go --format json +gitlink-cli file +get --owner --repo --path shortcuts/pr/pr.go --format json +gitlink-cli file +get --owner --repo --path internal/client/client.go --format json +``` + +### 第六步(可选):检查近期提交的敏感信息 + +```bash +# 获取最近提交列表 +gitlink-cli commit +list --owner --repo --page 1 --format json + +# 查看关键提交的 diff(检查是否引入了密钥/Token/大文件) +gitlink-cli commit +diff --owner --repo --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`、``、`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 +# 🔐 许可证合规检查报告:/ + +> 报告生成时间: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 | `` | 第 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:单项完整合规检查 + +``` +用户:"帮我检查 / 的许可证合规情况" + +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:组织级合规审计 + +``` +用户:"帮我审计 下所有公开仓库的许可证合规" + +AI 执行流程: +1. 获取组织下所有仓库列表 +2. 对每个仓库执行场景 1 +3. 汇总生成横向对比报告 +4. 按合规评分排序,标注需紧急处理的仓库 +``` + +## 最佳实践 + +- 所有数据采集命令使用 `--format json` +- `file +tree --recursive true` 不可靠,需手动递归子目录 +- 读取文件内容时注意分页(大文件可能被截断) +- 高危发现必须逐条确认(避免误报) +- 报告中的 Token / 密钥必须脱敏显示 +- 占位符值(`your_token_here`、``、`((variable))`)不视为泄露 +- 文件名中提到 `token`/`secret` 不代表泄露(需读内容判断) + +## 详细参考 + +详见 [`REFERENCE.md`](REFERENCE.md) 了解完整的敏感信息模式库、许可证类型枚举、评分算法详解和 API 注意事项。 diff --git a/skills/gitlink-license/examples/license-check.md b/skills/gitlink-license/examples/license-check.md new file mode 100644 index 0000000..bfbac08 --- /dev/null +++ b/skills/gitlink-license/examples/license-check.md @@ -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` 读内容确认。 diff --git a/skills/gitlink-org/REFERENCE.md b/skills/gitlink-org/REFERENCE.md new file mode 100644 index 0000000..916a850 --- /dev/null +++ b/skills/gitlink-org/REFERENCE.md @@ -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 → 检查 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 → 确认 is_admin +2. org +batch-invite --dry-run → 预览 +3. 展示邀请列表 → 用户确认 +4. org +batch-invite 执行 +5. org +members --id → 验证 +``` + +--- + +## 四、团队管理 API + +通过 Raw API 操作团队: + +```bash +# 获取团队列表 +GET /organizations//teams + +# 创建团队 +POST /organizations//teams +Body: {"name": "dev-team", "description": "开发团队"} + +# 获取团队详情 +GET /organizations//teams/ + +# 更新团队 +PUT /organizations//teams/ +Body: {"name": "new-name"} + +# 删除团队 +DELETE /organizations//teams/ +``` + +--- + +## 五、已知限制 + +| 限制 | 说明 | +|------|------| +| `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//organization_users/`。 diff --git a/skills/gitlink-org/SKILL.md b/skills/gitlink-org/SKILL.md index aef61a9..5a14ec9 100644 --- a/skills/gitlink-org/SKILL.md +++ b/skills/gitlink-org/SKILL.md @@ -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 --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 --format json -# 移除成员 -gitlink-cli api DELETE /organizations/:id/organization_users/:uid +# 翻页(成员超过 20 人时) +gitlink-cli org +members --id --page 2 --format json ``` + +### 第三步:分析成员贡献 + +```bash +# 对每个成员,查看其仓库列表和活跃度 +gitlink-cli repo +list --user --format json + +# 查看组织下的仓库 +gitlink-cli repo +list --user --category all --format json +``` + +### 第四步:团队管理(Raw API) + +```bash +# 获取组织团队列表 +gitlink-cli api GET /organizations//teams + +# 获取团队详情 +gitlink-cli api GET /organizations//teams/ +``` + +## 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 --description "" +``` + +### 批量邀请成员 + +```bash +# 预览模式 +gitlink-cli org +batch-invite --id --users , --dry-run + +# 邀请为普通成员 +gitlink-cli org +batch-invite --id --users , + +# 邀请为管理员 +gitlink-cli org +batch-invite --id --users --role admin + +# 从 CSV 文件批量邀请 +gitlink-cli org +batch-invite --id --from members.csv +``` + +### 管理团队(Raw API) + +```bash +# 创建团队 +gitlink-cli api POST /organizations//teams --body '{"name":"dev-team"}' + +# 移除成员(需 organization_user id) +gitlink-cli api DELETE /organizations//organization_users/ +``` + +## 输出格式 + +### 组织成员分析报告 + +```markdown +# 🏢 组织成员分析报告 — + +> 分析时间: +> 组织创建: + +## 总览 + +| 指标 | 数值 | +|------|------| +| 成员总数 | | +| 管理员数 | | +| 活跃成员 | (xx%) | +| 未激活成员 | (xx%) | +| 团队数 | | +| 项目数 | | + +## 成员详情 + +| 用户名 | 显示名 | 身份 | 加入时间 | 团队 | 状态 | +|--------|--------|------|---------|------|------| +| 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 字段映射、权限模型和团队管理细节。 diff --git a/skills/gitlink-org/examples/org-audit.md b/skills/gitlink-org/examples/org-audit.md new file mode 100644 index 0000000..f344be9 --- /dev/null +++ b/skills/gitlink-org/examples/org-audit.md @@ -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 执行 +``` diff --git a/skills/gitlink-org/references/gitlink-org-info.md b/skills/gitlink-org/references/gitlink-org-info.md deleted file mode 100644 index e2739c3..0000000 --- a/skills/gitlink-org/references/gitlink-org-info.md +++ /dev/null @@ -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) diff --git a/skills/gitlink-org/references/gitlink-org-list.md b/skills/gitlink-org/references/gitlink-org-list.md deleted file mode 100644 index dfa8baa..0000000 --- a/skills/gitlink-org/references/gitlink-org-list.md +++ /dev/null @@ -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) diff --git a/skills/gitlink-org/references/gitlink-org-members.md b/skills/gitlink-org/references/gitlink-org-members.md deleted file mode 100644 index cf4e16e..0000000 --- a/skills/gitlink-org/references/gitlink-org-members.md +++ /dev/null @@ -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) diff --git a/skills/gitlink-pm/REFERENCE.md b/skills/gitlink-pm/REFERENCE.md new file mode 100644 index 0000000..d272091 --- /dev/null +++ b/skills/gitlink-pm/REFERENCE.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 --repo --format json +# "project_id": 1547045 +``` diff --git a/skills/gitlink-pm/SKILL.md b/skills/gitlink-pm/SKILL.md index f5333ed..aa72d21 100644 --- a/skills/gitlink-pm/SKILL.md +++ b/skills/gitlink-pm/SKILL.md @@ -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 +``` + +### 标签管理(所有项目可用) + +```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 --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=' # Sprint Issue 列表 -gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' +gitlink-cli api GET /pm/sprint_issues --query 'project_id=' # 周报 -gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' +gitlink-cli api GET /pm/weekly_issues --query 'project_id=' # Issue 标签 -gitlink-cli api GET /pm/issue_tags --query 'project_id=123' +gitlink-cli api GET /pm/issue_tags --query 'project_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) 了解字段映射 diff --git a/skills/gitlink-pm/examples/pm-workflow.md b/skills/gitlink-pm/examples/pm-workflow.md new file mode 100644 index 0000000..12715db --- /dev/null +++ b/skills/gitlink-pm/examples/pm-workflow.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. 定期关闭已完成的里程碑 diff --git a/skills/gitlink-pr/examples/pr-workflow.md b/skills/gitlink-pr/examples/pr-workflow.md new file mode 100644 index 0000000..0ac0133 --- /dev/null +++ b/skills/gitlink-pr/examples/pr-workflow.md @@ -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` diff --git a/skills/gitlink-release/examples/release-workflow.md b/skills/gitlink-release/examples/release-workflow.md new file mode 100644 index 0000000..dedab33 --- /dev/null +++ b/skills/gitlink-release/examples/release-workflow.md @@ -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 "" +5. release +view → 确认发布成功 +``` diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 9fabb8e..3336199 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -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 --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 --repo --format json -# 获取贡献者列表 +# 分支列表(了解开发活跃度) +gitlink-cli branch +list --owner --repo --format json + +# Issue 统计(了解维护响应情况) +gitlink-cli issue +list --owner --repo --state open --format json +gitlink-cli issue +list --owner --repo --state closed --format json + +# 版本发布历史 +gitlink-cli release +list --owner --repo --format json +``` + +### 第三步:补充数据 + +```bash +# 贡献者列表 gitlink-cli api GET /:owner/:repo/contributors -# 获取语言统计 +# 提交历史(最近活跃度) +gitlink-cli commit +list --owner --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 --description "" + +# 私有仓库 +gitlink-cli repo +create --name --private true --description "" +``` + +### Fork 仓库 + +```bash +# Fork 单个仓库 +gitlink-cli repo +fork --owner --repo + +# 批量 Fork(先预览) +gitlink-cli repo +batch-fork --repos , --dry-run +# 确认后执行 +gitlink-cli repo +batch-fork --repos , +``` + +### 删除仓库(⚠️ 不可逆) + +```bash +# 务必先预览 +gitlink-cli repo +batch-delete --repos , --dry-run +# 仔细核对后执行 +gitlink-cli repo +batch-delete --repos , +``` + +### 仓库设置 + +```bash +# 修改可见性 +gitlink-cli repo +settings --visibility private + +# 修改默认分支 +gitlink-cli repo +settings --default-branch develop + +# 更新描述 +gitlink-cli repo +settings --description "" +``` + +### 克隆仓库 + +```bash +# owner/repo 格式 +gitlink-cli repo +clone --url / + +# 指定目录 +gitlink-cli repo +clone --url / --dir +``` + +## 输出格式 + +### 仓库健康审计报告 + +```markdown +# 📊 仓库健康审计报告 + +> 审计时间: +> 审计范围:<用户/组织> 下共 个仓库 + +## 总览 + +| 指标 | 数量 | 占比 | +|------|------|------| +| 🔥 活跃仓库 | | % | +| 💤 休眠仓库 | | % | +| 💀 僵尸仓库 | | % | +| 🔀 Fork 仓库 | | % | +| 🔒 镜像仓库 | | % | + +## 仓库详情 + +### 🔥 活跃仓库 + +| 仓库 | 评分 | 最近更新 | Stars | Forks | Issues | +|------|------|---------|-------|-------|--------| +| | 85 | 2分钟前 | 10 | 25 | 15 | +| ... | ... | ... | ... | ... | ... | + +### 💀 僵尸仓库(建议清理) + +| 仓库 | 评分 | 最后更新 | 僵尸原因 | +|------|------|---------|---------| +| | 10 | 6个月前 | 无更新、无Issue、无Star、仅master分支 | +| ... | ... | ... | ... | + +## AI 建议 + +1. **立即清理**: 个僵尸仓库无保留价值,建议删除 +2. **关注**: 个休眠仓库可能需要 revive 或归档 +3. **Fork 来源追踪**: 个 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 /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 端点和已知问题。 diff --git a/skills/gitlink-repo/examples/repo-health-check.md b/skills/gitlink-repo/examples/repo-health-check.md new file mode 100644 index 0000000..a9985a9 --- /dev/null +++ b/skills/gitlink-repo/examples/repo-health-check.md @@ -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 +``` diff --git a/skills/gitlink-repo/references/gitlink-branch-create.md b/skills/gitlink-repo/references/gitlink-branch-create.md deleted file mode 100644 index 0e61fa0..0000000 --- a/skills/gitlink-repo/references/gitlink-branch-create.md +++ /dev/null @@ -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 --from `。 -3. 输出创建结果。 - -## References -- [gitlink-repo](../SKILL.md) -- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-repo/references/gitlink-branch-delete.md b/skills/gitlink-repo/references/gitlink-branch-delete.md deleted file mode 100644 index b846ac8..0000000 --- a/skills/gitlink-repo/references/gitlink-branch-delete.md +++ /dev/null @@ -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 `。 -3. 输出删除结果。 - -## References -- [gitlink-repo](../SKILL.md) -- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-repo/references/gitlink-branch-list.md b/skills/gitlink-repo/references/gitlink-branch-list.md deleted file mode 100644 index 18021ac..0000000 --- a/skills/gitlink-repo/references/gitlink-branch-list.md +++ /dev/null @@ -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) diff --git a/skills/gitlink-repo/references/gitlink-branch-protect.md b/skills/gitlink-repo/references/gitlink-branch-protect.md deleted file mode 100644 index 98eabcb..0000000 --- a/skills/gitlink-repo/references/gitlink-branch-protect.md +++ /dev/null @@ -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 `。 -3. 输出设置结果。 - -## References -- [gitlink-repo](../SKILL.md) -- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-repo/references/gitlink-repo-batch-create.md b/skills/gitlink-repo/references/gitlink-repo-batch-create.md new file mode 100644 index 0000000..b92779e --- /dev/null +++ b/skills/gitlink-repo/references/gitlink-repo-batch-create.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) diff --git a/skills/gitlink-repo/references/gitlink-repo-batch-delete.md b/skills/gitlink-repo/references/gitlink-repo-batch-delete.md new file mode 100644 index 0000000..7d7a16f --- /dev/null +++ b/skills/gitlink-repo/references/gitlink-repo-batch-delete.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) diff --git a/skills/gitlink-repo/references/gitlink-repo-batch-fork.md b/skills/gitlink-repo/references/gitlink-repo-batch-fork.md new file mode 100644 index 0000000..6604778 --- /dev/null +++ b/skills/gitlink-repo/references/gitlink-repo-batch-fork.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) diff --git a/skills/gitlink-repo/references/gitlink-repo-clone.md b/skills/gitlink-repo/references/gitlink-repo-clone.md new file mode 100644 index 0000000..39ef74e --- /dev/null +++ b/skills/gitlink-repo/references/gitlink-repo-clone.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 `。 +3. 克隆后在仓库目录下执行命令可自动解析 owner/repo。 + +## References + +- [gitlink-repo](../SKILL.md) +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-repo/references/gitlink-repo-create.md b/skills/gitlink-repo/references/gitlink-repo-create.md index bad9fa3..6d178ab 100644 --- a/skills/gitlink-repo/references/gitlink-repo-create.md +++ b/skills/gitlink-repo/references/gitlink-repo-create.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 `。 -3. 输出创建结果。 +3. 输出创建结果(clone URL 等)。 ## References + - [gitlink-repo](../SKILL.md) - [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-repo/references/gitlink-repo-delete.md b/skills/gitlink-repo/references/gitlink-repo-delete.md index 9e4ece4..792991b 100644 --- a/skills/gitlink-repo/references/gitlink-repo-delete.md +++ b/skills/gitlink-repo/references/gitlink-repo-delete.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 // +``` ## Workflow -> [!CAUTION] -> This is a **Destructive Operation** -- confirm user intent. +> [!DANGER] +> **Destructive Operation** — 必须确认用户意图。 -1. 确认用户确实希望删除该仓库(此操作不可逆)。 -2. 执行 `repo +delete --owner --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) diff --git a/skills/gitlink-repo/references/gitlink-repo-fork.md b/skills/gitlink-repo/references/gitlink-repo-fork.md index 5880df4..b06ead1 100644 --- a/skills/gitlink-repo/references/gitlink-repo-fork.md +++ b/skills/gitlink-repo/references/gitlink-repo-fork.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 ///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 --repo `。 -3. 输出 fork 结果。 +3. Fork 完成后,仓库出现在当前用户下。 + +## 注意事项 + +- Fork 目标固定为当前认证用户,无法指定其他目标 +- Fork 后可用 `repo +list --category fork` 查看 ## References + - [gitlink-repo](../SKILL.md) - [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-repo/references/gitlink-repo-info.md b/skills/gitlink-repo/references/gitlink-repo-info.md index 856d62f..7e7b396 100644 --- a/skills/gitlink-repo/references/gitlink-repo-info.md +++ b/skills/gitlink-repo/references/gitlink-repo-info.md @@ -2,18 +2,18 @@ > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 -查看仓库详细信息。 +查看仓库详细信息,包括权限、Fork 来源、DevOps 状态、项目 ID 等。 ## 命令 ```bash -# 查看当前仓库信息(从 git remote 推断 owner/repo) +# 查看仓库信息 +gitlink-cli repo +info --owner Gitlink --repo forgeplus + +# git 仓库目录下自动解析 gitlink-cli repo +info -# 指定仓库 -gitlink-cli repo +info --owner someone --repo myrepo - -# 输出为 JSON +# JSON 格式 gitlink-cli repo +info --format json ``` @@ -21,13 +21,36 @@ gitlink-cli repo +info --format json | 参数 | 必填 | 说明 | |------|------|------| -| `--owner` | 是* | 仓库所有者(可从 git remote 自动推断) | -| `--repo` | 是* | 仓库名称(可从 git remote 自动推断) | +| `--owner` | 是* | 仓库所有者(可从 git remote 自动解析) | +| `--repo` | 是* | 仓库名称(可从 git remote 自动解析) | | `--format` | 否 | 输出格式:`json`/`table`/`yaml` | | `--debug` | 否 | 启用调试输出 | -> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 +## API + +``` +GET // +``` + +## 返回字段 + +| 字段 | 说明 | +|------|------| +| `full_name` | 仓库全名 `owner/repo` | +| `identifier` | 仓库标识符 | +| `default_branch` | 默认分支(GitLink 为 `master`) | +| `project_id` | 项目 ID(PM/CI 必需参数) | +| `forked_from_project_id` | Fork 来源项目 ID,非 null 表示 Fork 仓库 | +| `fork_info` | Fork 来源信息(`fork_project_user_login` 等) | +| `permission` | 当前用户权限:`Manager`/`Developer`/`Reporter` | +| `open_devops` | 是否开启 CI/CD | +| `issues_count` | Issue 总数 | +| `praises_count` | Star 数 | +| `forked_count` | 被 Fork 次数 | +| `version_releases_count` | Release 数量 | +| `size` | 仓库大小 | ## References + - [gitlink-repo](../SKILL.md) - [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-repo/references/gitlink-repo-list.md b/skills/gitlink-repo/references/gitlink-repo-list.md index e9dfc90..ead5031 100644 --- a/skills/gitlink-repo/references/gitlink-repo-list.md +++ b/skills/gitlink-repo/references/gitlink-repo-list.md @@ -2,7 +2,7 @@ > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 -列出当前用户或指定用户/组织的仓库列表。 +列出当前用户或指定用户/组织的仓库列表,支持按类别过滤和分页。 ## 命令 @@ -10,16 +10,16 @@ # 列出当前用户的仓库 gitlink-cli repo +list -# 列出指定用户的仓库 +# 列出指定用户 gitlink-cli repo +list --user someone -# 按类别过滤(manage/mirror/sync/fork/all) +# 按类别过滤:manage/mirror/sync/fork/all gitlink-cli repo +list --category fork # 分页 gitlink-cli repo +list --page 2 --limit 10 -# 输出为 JSON +# JSON 格式 gitlink-cli repo +list --format json ``` @@ -27,15 +27,35 @@ gitlink-cli repo +list --format json | 参数 | 必填 | 说明 | |------|------|------| -| `--user, -u` | 否 | 用户登录名(默认:当前认证用户) | -| `--category, -c` | 否 | 过滤类别:`manage`/`mirror`/`sync`/`fork`/`all`(默认 `manage`) | +| `--user, -u` | 否 | 用户登录名(默认当前认证用户) | +| `--category, -c` | 否 | 过滤:`manage`/`mirror`/`sync`/`fork`/`all`(默认 `manage`) | | `--page, -p` | 否 | 页码(默认 `1`) | | `--limit, -l` | 否 | 每页条数(默认 `20`) | -| `--owner` | 否 | 全局参数 - 仓库所有者 | -| `--repo` | 否 | 全局参数 - 仓库名称 | | `--format` | 否 | 输出格式:`json`/`table`/`yaml` | | `--debug` | 否 | 启用调试输出 | +## API + +``` +GET /projects?page=1&limit=20&category=manage +GET /users//projects?page=1&limit=20 +``` + +## 返回字段 + +| 字段 | 说明 | +|------|------| +| `identifier` | 仓库标识符(URL 中使用) | +| `author.login` | 所有者登录名 | +| `author.type` | `User` 或 `Organization` | +| `category` | `null`=普通, `fork`=Fork, `mirror`=镜像 | +| `is_public` | 是否公开 | +| `forked_count` | 被 Fork 数 | +| `praises_count` | Star 数 | +| `open_devops` | 是否开启 CI/CD | +| `time_ago` | 人类可读的最后更新时间 | + ## References + - [gitlink-repo](../SKILL.md) - [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-repo/references/gitlink-repo-settings.md b/skills/gitlink-repo/references/gitlink-repo-settings.md new file mode 100644 index 0000000..bef4490 --- /dev/null +++ b/skills/gitlink-repo/references/gitlink-repo-settings.md @@ -0,0 +1,44 @@ +# repo +settings + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +修改仓库设置:可见性、默认分支、描述。 + +## 命令 + +```bash +# 改为私有 +gitlink-cli repo +settings --visibility private + +# 修改默认分支 +gitlink-cli repo +settings --default-branch develop + +# 更新描述 +gitlink-cli repo +settings --description "新的项目描述" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--visibility` | 否 | `public` / `private` | +| `--default-branch` | 否 | 默认分支名称 | +| `--description, -d` | 否 | 仓库描述 | +| `--owner` | 否 | 仓库所有者(可从 git remote 自动解析) | +| `--repo` | 否 | 仓库名称(可从 git remote 自动解析) | +| `--format` | 否 | 输出格式:`json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +## Workflow + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent. + +1. 确认用户要修改的设置项。 +2. 执行 `repo +settings` 带上对应参数。 +3. 修改可见性会影响仓库访问权限;修改默认分支前确认目标分支存在。 + +## References + +- [gitlink-repo](../SKILL.md) +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-review/REFERENCE.md b/skills/gitlink-review/REFERENCE.md new file mode 100644 index 0000000..c313b27 --- /dev/null +++ b/skills/gitlink-review/REFERENCE.md @@ -0,0 +1,135 @@ +# 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 实测结论(决定行内评论) + +**实测(2026-06-23,测试 PR chroe/gitlink-cli#3)**: + +```bash +gitlink-cli api POST /chroe/gitlink-cli/pulls/3/reviews \ + --body '{"body":"...","event":"COMMENT"}' +# → 404 Not Found({"status":404,"error":"Not Found"}) + +gitlink-cli api GET /chroe/gitlink-cli/pulls/3/reviews +# → 返回 HTML 前端页面(非 JSON API 端点) +``` + +**结论**:**行内评论不可用**。两层原因: + +1. `gitlink-cli api POST` 存在已知 URL 注入 bug(git exec-path 被拼入 API URL),所有 `api POST` 返回 404——与 `gitlink-triage` 记录一致。 +2. `api GET /pulls/:id/reviews` 返回 HTML(前端路由),说明该路径不是有效的 JSON API 端点。 + +**设计影响**:行内评论关闭,**仅用 `pr +comment` 发总结评论**(已在 PR #3 实测:评论 id 477757 成功发布,PR 评论数 0→1)。`--inline` 参数被忽略并提示"行内评论不可用,已降级为总结评论"。 + +--- + +## 五、字段映射 + +### pr +view 关键字段 + +| 字段 | 用途 | +|------|------| +| `issue.subject` / `issue.description` | 理解 PR 意图,校准审查重点 | +| `pull_request.base` / `pull_request.head` | 目标/源分支 | +| `author.login` | 作者(自审盲区提示) | +| `files_count` / `commits_count` | 范围概览 | + +### pr +diff / pr +files 关键字段 + +| 字段 | 用途 | +|------|------| +| `files[].name` | 文件路径 | +| `files[].addition` / `deletion` | 增删行数 | +| `files[].sha` | 文件 sha(哨兵可选) | +| diff 正文 | 分析输入 | + +**行号映射规则**:报告卡里的 `file:line` 用**文件中的实际行号**(非 diff hunks 的 `+n` 相对行号)。从 diff hunk 头(`@@ -a,b +c,d @@`)推算:实际行号 = `c + (hunk 内相对行)`。 + +--- + +## 六、幂等哨兵 + +``` + +``` + +- 发布前用 `pr +view`(或取评论)检查是否已含该哨兵 +- `sha` = 审查所基于的 head commit;PR 有新提交(sha 不匹配)时提示"审查已过期,建议重审" +- 默认不覆盖;`--refresh` 才重发 diff --git a/skills/gitlink-review/SKILL.md b/skills/gitlink-review/SKILL.md new file mode 100644 index 0000000..501f513 --- /dev/null +++ b/skills/gitlink-review/SKILL.md @@ -0,0 +1,173 @@ +--- +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 --repo --id --format json # 元数据 +gitlink-cli pr +files --owner --repo --id --format json # 文件 +/- +gitlink-cli pr +diff --owner --repo --id --format json # 核心:diff 内容 +gitlink-cli repo +info --owner --repo --format json # 语言校准 +``` + +### ② 降噪过滤 + +按下方"降噪规则"跳过噪音文件;在报告卡"范围"行注明跳过数量,不静默吞掉。 + +### ③ 多视角分析 + +对每个启用的视角扫 diff,产出候选发现,每条含: + +```json +{ "lens": "correctness", "severity": "high", "likelihood": "likely", + "file": "auth/login.go", "line": 42, "what": "空指针未判", + "why": "...", "fix": "...", "confidence": "high" } +``` + +> 字段映射:`severity` 取值 high→🔴 / medium→🟡 / low→🔵;`likelihood` 取值 likely/possible/unlikely;`confidence` 取值 high/medium/low。 + +### ④ 对抗式自检(质量门) + +逐条自问(任一成立即丢弃或降级): + +1. 语境里是否已有保护(外层判空、上游校验、框架机制)? +2. 是真缺陷还是风格偏好?偏好 → 降级 🔵 nit 或丢弃。 +3. 引用的 API/签名/语言行为是否真实存在?(不确定则不报) +4. 是否与另一视角重复? + +**置信度门控**:`confidence=low` 且 `severity≠high` → 丢弃。 + +### ⑤ 综合 + +跨视角去重 → 按 `严重度×likelihood` 排序 → 封顶 `--max-findings`。 + +### ⑥ 预览(默认) + +展示报告卡给用户;确认后发布。`--auto` 跳过。 + +### ⑦ 发布 + +```bash +gitlink-cli pr +comment --owner --repo --id \ + --body "<报告卡全文,含哨兵>" +``` + +`--inline` 且 reviews API 可用 → 对 🔴/🟡 发现附行内评论(见 REFERENCE.md 的 API 结论)。 + +### ⑧ 幂等 + +发布前检查该 PR 是否已有哨兵 ``;有则默认提议"更新"(`--refresh` 才覆盖)。哨兵中的 `sha:` 记录审查所基于的 head commit;若 PR 有新提交(sha 不匹配),提示"审查已过期,建议重审"。 + +## 评审团 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) +**范围**: 文件,+/-(跳过 噪音文件)|视角:|自检丢弃: + +| 严重度 | 视角 | 位置 | 问题 | +|:---:|---|---|---| +| 🔴 | correctness | path/file.go:42 | <一句话> | + +### 🔴 阻塞(合并前需处理) +1. **[correctness] path/file.go:42** — + - **为什么**: + - **建议**: + +### 🟡 建议 +… + +### 🔵 nit +… + +### ✅ 未发现问题的方面 +- <视角>:<结论> + +--- + +*由 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` 封顶防刷屏 +- 评论失败 → 把报告卡原文交用户手动粘贴 +- 审查自己作为作者的 PR 时,提示"自审盲区",建议请他人复核(不强制阻断) + +## 错误处理与降级 + +| 情况 | 处理 | +|------|------| +| 无 diff | 报"无可审查变更",不发评论 | +| PR 不存在/无权限 | 清晰错误,不发评论 | +| reviews API 404 | 跳过行内,总结评论照发 | +| diff 过大 | 抽样 + 标注"部分审查(仅 X/Y 文件)" | +| 评论发布失败 | 输出报告卡原文供手动粘贴 | +| 全部 low 置信 | 报"未发现高置信问题",列待人工确认项 | + +## 最佳实践 + +- **先预览再发布**(默认),降低对外噪音 +- **置信度优先**:宁可少报,不要误报 +- **可执行**:每条发现必须带"建议修复" +- **不越权**:只评论,不合并 diff --git a/skills/gitlink-review/examples/review-workflow.md b/skills/gitlink-review/examples/review-workflow.md new file mode 100644 index 0000000..cad1ee8 --- /dev/null +++ b/skills/gitlink-review/examples/review-workflow.md @@ -0,0 +1,83 @@ +# 示例:对植入缺陷 PR 的智能审查(真实数据) + +> 基于 `chroe/gitlink-cli` 测试 PR #3(`test/review-fixture` 分支)于 2026-06-23 实际执行。 +> 该 PR 故意植入 4 个问题用于验证 gitlink-review skill:空指针、硬编码 Token、缺测试、伪阳性。 + +--- + +## Step 1:取上下文 + +```bash +gitlink-cli pr +view --owner chroe --repo gitlink-cli --id 3 --format json +gitlink-cli pr +files --owner chroe --repo gitlink-cli --id 3 --format json +gitlink-cli pr +diff --owner chroe --repo gitlink-cli --id 3 --format json +``` + +**范围**:1 文件 `review_fixture.go`,+47/-0,head sha `00dee19`,无噪音文件需跳过。 + +## Step 2:6 视角扫描 + 对抗式自检 + +| 候选发现 | 视角 | 自检结果 | +|----------|------|----------| +| `Greet` 中 `u.Name` 解引用可能为 nil 的 `u` | correctness | ✅ 成立(`CurrentUser` 对空 token 返回 nil,无外层判空)→ 保留 🔴 | +| `AdminToken` 硬编码 `glpat-...` | security | ✅ 成立(硬编码凭据)→ 保留 🔴 | +| `Welcome`/`Greet`/`Divide` 等新函数无 `_test.go` | tests | ✅ 成立(本 PR 无测试文件)→ 保留 🟡 | +| `Divide(a,b)` 除零风险 | correctness | ❌ **被反驳**:`DoMath` 已保证调用时 `x≠0`,真实路径除数非 0 → 丢弃 | + +**自检丢弃:1 条(refuted=1)**。 + +## Step 3:发布报告卡(真实输出) + +```bash +gitlink-cli pr +comment --owner chroe --repo gitlink-cli --id 3 \ + --body "<报告卡全文>" +``` + +**真实结果**:`ok: true`,评论 id `477757`,PR 评论数 0→1。 + +报告卡全文(实际发布内容): + +````markdown +🤖 **gitlink-review 报告** + +**结论**:⚠️ 建议修改(2 🔴阻塞 / 1 🟡建议 / 0 🔵nit) +**范围**:1 文件,+47/-0(跳过 0 噪音文件)|视角:6|自检丢弃:1 + +| 严重度 | 视角 | 位置 | 问题 | +|:---:|---|---|---| +| 🔴 | correctness | review_fixture.go:18 | 空指针解引用 | +| 🔴 | security | review_fixture.go:23 | 硬编码 Token | +| 🟡 | tests | review_fixture.go:26 | 新函数无测试 | + +### 🔴 阻塞(合并前需处理) +1. **[correctness] review_fixture.go:18** — `Greet` 中 `u := CurrentUser(token)` 后直接 `u.Name`;`token==""` 时 `CurrentUser` 返回 nil,解引用 panic。 + - **建议**:`if u := CurrentUser(token); u != nil { return "hello " + u.Name }; return ""` +2. **[security] review_fixture.go:23** — `var AdminToken = "glpat-FAKEFAKEFAKE0000000000"` 硬编码凭据。 + - **建议**:改 `os.Getenv("ADMIN_TOKEN")`,移除占位串。 + +### 🟡 建议 +1. **[tests] review_fixture.go:26** — 新函数无 `_test.go`,建议补表驱动测试。 + +### ✅ 未发现问题的方面 +- 性能:无热点循环 / N+1 +- 生产风险:本文件带 `//go:build ignore`,不参与构建 + +### 🗑 对抗式自检丢弃(1) +- ~~[correctness] review_fixture.go:30 `Divide` 除零~~:`DoMath` 调用前已 `if x==0 {return 0}`,真实路径不除零 → 伪阳性丢弃。 + +--- + +```` + +## Step 4:幂等与过期 + +报告卡内嵌哨兵 ``: + +- 重跑(不带 `--refresh`)时检测到哨兵 → 提示"已存在审查,使用 --refresh 更新",不重复发布。 +- `sha` 字段记录审查所基于的 head commit;PR 有新提交(sha 不匹配)时提示"审查已过期,建议重审"。 + +## 关键结论 + +- **3 个真实缺陷被对应视角命中**(correctness / security / tests),**1 个伪阳性被对抗式自检丢弃**(refuted=1)——验证了"质量门"有效降低误报。 +- **行内评论不可用**(`api POST /pulls/:id/reviews` → 404;`api GET` → HTML),自动降级为 `pr +comment` 总结评论,实测发布成功(评论 id 477757)。 +- 报告卡带哨兵,支持幂等与"过期重审"提示。 diff --git a/skills/gitlink-search/REFERENCE.md b/skills/gitlink-search/REFERENCE.md new file mode 100644 index 0000000..38a5184 --- /dev/null +++ b/skills/gitlink-search/REFERENCE.md @@ -0,0 +1,88 @@ +# gitlink-search 参考手册 + +> 本文档定义搜索相关命令的 API 字段映射。 + +--- + +## 一、search +repos 返回字段 + +```json +{ + "ok": true, + "data": { + "projects": [ + { + "id": 1513956, + "identifier": "gitlink-cli", + "name": "gitlink-cli", + "description": "GitLink CLI - GitLink 平台命令行工具", + "author": { + "login": "Gitlink", + "name": "GitLink", + "type": "Organization" + }, + "language": { "id": 19, "name": "Go" }, + "forked_count": 31, + "praises_count": 5, + "visits": 2155, + "is_public": true, + "time_ago": "12小时前", + "platform": "forge", + "open_devops": false + } + ] + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | int | 项目全局 ID(用于 Raw API) | +| `identifier` | string | 仓库标识(即 repo 名) | +| `name` | string | 仓库名 | +| `description` | string | 项目描述 | +| `author.login` | string | 所有者 login | +| `author.type` | string | "User" 或 "Organization" | +| `language.name` | string | 主要编程语言 | +| `forked_count` | int | Fork 数 | +| `praises_count` | int | Star/点赞数 | +| `visits` | int | 访问量 | +| `is_public` | bool | 是否公开 | +| `time_ago` | string | 最后更新时间(相对) | + +## 二、search +users 返回字段 + +```json +{ + "ok": true, + "data": { + "total_count": 1, + "users": [ + { + "user_id": 149027, + "login": "chroe", + "username": "chroe", + "image_url": "system/lets/letter_avatars/2/C/142_140_188/120.png", + "profile_completed": false + } + ] + }, + "meta": { + "total_count": 1 + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `user_id` | int | 用户全局 ID | +| `login` | string | 登录名 | +| `username` | string | 显示名 | +| `profile_completed` | bool | 是否完善个人资料 | + +## 三、参数说明 + +| 参数 | 命令 | 必填 | 说明 | +|------|------|------|------| +| `--keyword` / `-k` | +repos, +users | 是 | 搜索关键词 | +| `--limit` | +repos | 否 | 返回数量限制 | diff --git a/skills/gitlink-search/SKILL.md b/skills/gitlink-search/SKILL.md index da2397b..f0c6bb1 100644 --- a/skills/gitlink-search/SKILL.md +++ b/skills/gitlink-search/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-search -version: 1.0.0 -description: "搜索:搜索仓库和用户。当用户需要在 GitLink 上搜索资源时触发。" +version: 2.0.0 +description: "搜索:搜索仓库和用户。当用户需要在 GitLink 上搜索项目、发现用户或查找资源时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -11,24 +11,61 @@ metadata: # gitlink-search(搜索操作) **CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** -**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** **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) 了解认证和全局参数。 + +## 概述 + +本 Skill 提供 GitLink 平台的全局搜索能力,主要用于: +1. **仓库搜索**:按关键词搜索公开仓库,了解项目热度 +2. **用户搜索**:按用户名查找用户,获取 user_id +3. **资源发现**:在开始协作前搜索相关项目和参与者 ## Shortcuts -| Shortcut | 说明 | -|----------|------| -| `search +repos` | 搜索仓库 | -| `search +users` | 搜索用户 | +| Shortcut | 说明 | 需要认证 | +|----------|------|----------| +| `search +repos` | 搜索仓库 | 否 | +| `search +users` | 搜索用户 | 否 | + +## 数据采集命令 + +### 搜索仓库 + +```bash +# 按关键词搜索仓库 +gitlink-cli search +repos --keyword "gitlink" --limit 10 --format json + +# 搜索特定语言的仓库 +gitlink-cli search +repos --keyword "machine learning" --limit 5 --format json +``` + +### 搜索用户 + +```bash +# 按用户名搜索 +gitlink-cli search +users --keyword "chroe" --format json +``` ## 使用示例 ```bash -# 搜索仓库 -gitlink-cli search +repos --keyword "machine learning" --limit 10 +# 搜索仓库(限制返回数量) +gitlink-cli search +repos --keyword "gitlink" --limit 5 --format json # 搜索用户 -gitlink-cli search +users --keyword "zhangsan" +gitlink-cli search +users --keyword "zhangsan" --format json + +# 搜索后进一步查看详情 +gitlink-cli repo +info --owner Gitlink --repo gitlink-cli --format json +gitlink-cli user +info --login chroe --format json ``` + +## 注意事项 + +- 搜索不需要认证(公开资源) +- `--keyword` 为必填参数 +- `--limit` 控制返回数量,默认值较大时建议显式指定 +- 搜索结果中的 `id` 是项目/用户的全局 ID,不是 owner/repo 格式 +- 详见 [`REFERENCE.md`](REFERENCE.md) 了解返回字段 diff --git a/skills/gitlink-search/examples/search-workflow.md b/skills/gitlink-search/examples/search-workflow.md new file mode 100644 index 0000000..1a17bd1 --- /dev/null +++ b/skills/gitlink-search/examples/search-workflow.md @@ -0,0 +1,93 @@ +# 示例:搜索仓库和用户(真实数据) + +> 本示例于 2026-06-13 在 Claude Code 中实际执行。 +> 展示搜索仓库和用户的完整流程,以及如何基于搜索结果深入查看。 + +--- + +## 场景:在 GitLink 上搜索 gitlink-cli 项目和相关用户 + +### Step 1:搜索仓库 + +```bash +gitlink-cli search +repos --keyword "gitlink" --limit 5 --format json +``` + +**真实输出摘要:** + +```json +{ + "ok": true, + "data": { + "projects": [ + { + "id": 1513956, + "identifier": "gitlink-cli", + "name": "gitlink-cli", + "description": "GitLink CLI - GitLink 平台命令行工具", + "author": { + "login": "Gitlink", + "name": "GitLink", + "type": "Organization" + }, + "language": { "name": "Go" }, + "forked_count": 31, + "praises_count": 5, + "visits": 2155, + "is_public": true, + "time_ago": "12小时前" + } + ] + } +} +``` + +**发现:** Gitlink/gitlink-cli 是原始仓库,有 31 个 Fork、5 个 Star、2155 次访问。 + +### Step 2:搜索用户 + +```bash +gitlink-cli search +users --keyword "chroe" --format json +``` + +**真实输出:** + +```json +{ + "ok": true, + "data": { + "total_count": 1, + "users": [ + { + "user_id": 149027, + "login": "chroe", + "username": "chroe", + "image_url": "system/lets/letter_avatars/2/C/142_140_188/120.png", + "profile_completed": false + } + ] + } +} +``` + +**确认:** chroe 的 user_id 为 149027。 + +### Step 3:基于搜索结果深入查看 + +```bash +# 查看搜索到的仓库详情 +gitlink-cli repo +info --owner Gitlink --repo gitlink-cli --format json + +# 查看搜索到的用户详情 +gitlink-cli user +info --login chroe --format json +``` + +--- + +## 典型搜索场景 + +| 场景 | 命令 | +|------|------| +| 查找同类项目 | `search +repos --keyword "cli"` | +| 查找特定用户 | `search +users --keyword "chroe"` | +| 寻找协作项目 | `search +repos --keyword "gitlink" --limit 10` | diff --git a/skills/gitlink-triage/REFERENCE.md b/skills/gitlink-triage/REFERENCE.md new file mode 100644 index 0000000..f40d075 --- /dev/null +++ b/skills/gitlink-triage/REFERENCE.md @@ -0,0 +1,204 @@ +# gitlink-triage 参考手册 + +> 本文档定义 Issue 智能分拣的标签体系、分配算法和 API 操作细节。 + +--- + +## 一、标签体系 + +### 推荐标签分类 + +#### 类型标签(必选,每个 Issue 必须有一个) + +| 标签名 | 颜色 | 说明 | 识别规则 | +|--------|------|------|---------| +| bug | #fc2929 | 问题/错误 | 含"错误/失败/异常/crash/bug/broken" | +| enhancement | #84b6eb | 功能建议/改进 | 含"建议/新增/支持/feature/add/improve" | +| question | #cc317c | 求助/疑问 | 含"如何/怎么/请问/how to/question/help" | +| documentation | #0075ca | 文档相关 | 含"文档/README/doc/typo/帮助" | +| security | #ee0701 | 安全问题 | 含"安全/漏洞/泄露/vulnerability/CVE" | +| performance | #e5a94e | 性能问题 | 含"性能/慢/优化/performance/speed" | +| refactor | #a22bc1 | 重构 | 含"重构/code quality/refactor/cleanup" | + +#### 优先级标签(可选) + +| 标签名 | 颜色 | 说明 | +|--------|------|------| +| priority: critical | #b60205 | 紧急:安全漏洞、线上故障 | +| priority: high | #d93f0b | 高:核心功能不可用 | +| priority: medium | #fbca04 | 中:一般功能建议 | +| priority: low | #0e8a16 | 低:锦上添花 | + +#### 特殊标签 + +| 标签名 | 颜色 | 说明 | +|--------|------|------| +| good first issue | #7057ff | 适合新贡献者 | +| wontfix | #ffffff | 不计划处理 | +| duplicate | #cfd3d7 | 重复 Issue | +| help wanted | #008672 | 寻求社区帮助 | + +### 标签管理命令 + +```bash +# 列出已有标签 +gitlink-cli label +list --owner --repo --format json + +# 创建标签 +gitlink-cli label +create --owner --repo --name "bug" --color "#fc2929" + +# 更新标签 +gitlink-cli label +update --owner --repo --id --name "new-name" --color "#new-color" +``` + +### 标签字段映射 + +```json +{ + "id": 1, + "name": "bug", + "color": "#fc2929", + "description": "Something isn't working" +} +``` + +| 字段 | 说明 | +|------|------| +| `id` | 标签数据库 ID,添加到 Issue 时使用此 ID | +| `name` | 标签名称,显示用 | +| `color` | 标签颜色,16进制格式 | + +--- + +## 二、责任人分配算法 + +### 算法流程 + +``` +1. 获取项目成员列表及角色 +2. 获取每个成员当前已分配的 Issue 数 +3. 获取每个成员最近处理过的 Issue 类型(bug/enhancement/...) +4. 匹配逻辑: + a. 找到最近处理过相同类型 Issue 的成员(专长匹配) + b. 在匹配的成员中,选择当前负载最低的 + c. 如果没有专长匹配,选择负载最低的成员 + d. 如果只有一个成员,直接分配 + e. 如果无法判断,建议不分配(留空) +``` + +### 负载计算 + +```bash +# 获取每个成员已分配的 Issue 数 +gitlink-cli issue +list --owner --repo --format json +# 遍历 issues,统计每个 assigner 出现的次数 +``` + +### 专长推断 + +```bash +# 查看成员最近关闭的 Issue +gitlink-cli api GET /v1/:owner/:repo/issues --query 'assigned_to_id=&state=closed&page=1' + +# 查看成员最近合并的 PR +gitlink-cli pr +list --state merged --format json +# 筛选 user.login == member_login 的 PR +# 从 PR 标题推断擅长领域 +``` + +--- + +## 三、Issue 更新 API 注意事项 + +### 更新 Issue 必须保留字段 + +GitLink Issue 更新 API 会覆盖未传递的字段。因此**更新任何字段时都必须带上当前 `subject` 和 `description`**: + +```bash +# Step 1: 先获取当前 Issue 完整信息 +gitlink-cli issue +view --owner --repo --number --format json +# 提取 subject, description + +# Step 2: 用 PATCH 更新(注意:是 PATCH 不是 POST,实测 POST 返回 404) +gitlink-cli api PATCH /v1///issues/ --body '{ + "subject": "<当前标题>", + "description": "<当前描述>", + "issue_tag_ids": [<新标签ID列表>], + "assigner_ids": [<责任人ID>], + "priority_id": <优先级ID> +}' +``` + +> ✅ **已修复**:之前 Windows Git Bash 会把 `/v1/...` 自动转换成 git 安装路径导致 API 不可用,CLI 已加 MSYS2 路径转换检测,现在 `api` 命令可用。 +> ⚠️ **方法必须是 PATCH**:`api POST` 对 Issue 更新接口返回 404,GitLink 要求用 PATCH。 +> ⚠️ **责任人字段是 `assigner_ids`(数组)**:不是 `assigned_to_id`(单个数字)。实测 `assigned_to_id` 不生效,`assigner_ids` 才行。 + +### Issue 更新参数 + +| 参数 | 说明 | 获取方式 | +|------|------|---------| +| `subject` | Issue 标题(必带) | `issue +view` | +| `description` | Issue 描述(必带) | `issue +view` | +| `issue_tag_ids` | 标签 ID 数组 | `label +list` | +| `assigner_ids` | 责任人用户 ID **数组** | `member +list` | +| `priority_id` | 优先级 ID | 1=低, 2=正常, 3=高, 4=紧急 | +| `fixed_version_id` | 里程碑 ID | `milestone +list` | +| `status_id` | 状态 ID | 1=新增, 2=正在解决, 3=已解决, 5=关闭 | + +### 评论格式 + +```bash +# 添加评论 +gitlink-cli issue +comment --owner --repo \ + --number --body "评论内容" +``` + +评论内容支持 Markdown 格式。 + +--- + +## 四、Good First Issue 评估标准 + +### 评分规则 + +| 条件 | 分值 | 说明 | +|------|------|------| +| 有明确复现步骤 | +25 | Bug 类 Issue 需要清晰步骤 | +| 有明确期望行为 | +25 | 描述了期望的正确行为 | +| 改动范围可预估 | +20 | 能指出具体需要修改的文件 | +| 不涉及核心逻辑 | +15 | 修改不影响主要功能流程 | +| 有参考实现/文档 | +15 | 提供了相关链接或代码参考 | + +**Good First Issue 阈值**:总分 ≥ 60 分 + +### 不适合新人的信号 + +- ❌ "需要重构整个模块" +- ❌ "涉及数据库迁移" +- ❌ "需要理解复杂的业务逻辑" +- ❌ "可能影响多个模块" +- ❌ "需要修改核心 API 接口" + +--- + +## 五、常见问题 + +### Q: 标签 ID 怎么获取? + +A: 通过 `label +list` 获取所有标签,每个标签有 `id` 字段。如果项目还没有需要的标签,先用 `label +create` 创建。 + +### Q: 责任人 ID 怎么获取? + +A: 通过 `member +list` 获取项目成员列表,每个成员有 `id` 字段。 + +### Q: 更新 Issue 后描述被清空了? + +A: 更新 Issue 时必须保留当前 `subject` 和 `description`。先用 `issue +view` 获取当前内容,再提交更新时带上。`issue +update` 命令已内置处理,Raw API 操作需要手动处理。 + +### Q: 如何避免对同一 Issue 重复操作? + +A: 在执行分拣前先检查 Issue 是否已有标签和责任人。通过 `issue +view` 查看当前状态,如果已有标签则跳过或询问用户是否覆盖。 + +### Q: 评论内容有字数限制吗? + +A: GitLink Issue 评论支持 Markdown 格式,没有严格的字数限制。但建议引导评论控制在 500 字以内,保持简洁。 diff --git a/skills/gitlink-triage/SKILL.md b/skills/gitlink-triage/SKILL.md new file mode 100644 index 0000000..da966da --- /dev/null +++ b/skills/gitlink-triage/SKILL.md @@ -0,0 +1,283 @@ +--- +name: gitlink-triage +version: 1.0.0 +description: "Issue 智能分拣与新人引导:自动为新 Issue 分类、打标签、分配责任人,并为 good-first-issue 添加引导评论。当用户需要管理大量 Issue、自动分类、降低新人参与门槛时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli issue --help" +--- + +# gitlink-triage(Issue 智能分拣与新人引导) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 执行分拣写入操作前,必须先阅读 [`REFERENCE.md`](REFERENCE.md),了解标签 ID 映射、priority_id 值和 Issue 更新 API 的字段保留规则。** +**CRITICAL — 更新 Issue(打标签、分配责任人等)时,必须先通过 `issue +view` 获取当前 subject 和 description 并在更新请求中一并提交,否则标题和描述会被清空。** +**CRITICAL — 本 Skill 包含写入操作(打标签、分配责任人、添加评论),执行前务必确认用户意图。建议先预览分拣结果再执行写入。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +## 概述 + +本 Skill 通过 AI 分析 Issue 内容,自动完成: +1. **分类**:判断 Issue 类型(bug/enhancement/question/docs) +2. **打标签**:根据分类结果添加对应标签 +3. **分配责任人**:根据团队成员专长分配 +4. **优先级评估**:评估 Issue 紧急程度 +5. **新人引导**:为 good-first-issue 自动添加引导评论 + +## 数据采集命令 + +### 第一步:获取未分类 Issue + +```bash +# 获取所有开放 Issue +gitlink-cli issue +list --owner --repo --state open --format json + +# 查看具体 Issue 详情 +gitlink-cli issue +view --owner --repo --number --format json +``` + +### 第二步:获取标签列表 + +```bash +# 获取项目已有标签(用于匹配或创建新标签) +gitlink-cli label +list --owner --repo --format json +``` + +### 第三步:获取成员列表 + +```bash +# 获取项目成员(用于分配责任人) +gitlink-cli member +list --owner --repo --format json +``` + +### 第四步:获取 Issue 评论 + +```bash +# 获取 Issue 评论(判断是否已有人响应) +# ⚠️ journals API 实测全部返回 HTML 而非 JSON,不可直接使用 +# 改用 comment_journals_count 字段判断是否有响应: +gitlink-cli issue +view --owner --repo --number --format json +# 从返回数据的 comment_journals_count 字段判断:> 0 表示有人响应 +``` + +## AI 分拣规则 + +### Issue 分类规则 + +AI 根据以下关键词和模式判断 Issue 类型: + +| 类型 | 关键词/模式 | 示例 | +|------|------------|------| +| 🐛 Bug | 错误、失败、异常、崩溃、crash、error、bug、broken、404、500 | "登录页面白屏" | +| ✨ Enhancement | 建议、希望、新增、支持、feature、enhancement、add、improve | "希望能支持批量操作" | +| ❓ Question | 如何、怎么、请问、how to、question、help、求助 | "请问如何配置 Token" | +| 📚 Docs | 文档、README、帮助、doc、documentation、typo | "README 中的链接已过期" | +| 🔒 Security | 安全、漏洞、泄露、vulnerability、CVE、敏感信息 | "发现 API Key 泄露风险" | +| ♻️ Refactor | 重构、代码质量、refactor、clean up、tech debt | "重构 batch 操作逻辑" | +| ⚡ Performance | 性能、慢、卡顿、优化、performance、speed | "列表加载太慢" | + +> **标签映射**:分拣时优先使用项目已有标签(通过 `label +list` 获取)。项目标签可能是中文(如"缺陷"/"功能"/"疑问"),AI 应匹配最接近的分类而非强制使用英文名。只有无匹配标签时才创建新标签。 + +### 优先级评估规则 + +| 优先级 | 条件 | 标签 | +|--------|------|------| +| 🔴 紧急 | 安全漏洞、线上故障、数据丢失 | priority: critical | +| 🟠 高 | 核心功能不可用、影响多个用户 | priority: high | +| 🟡 中 | 功能建议、非紧急 Bug | priority: medium | +| 🟢 低 | 文档更新、样式调整、锦上添花 | priority: low | + +### Good First Issue 识别 + +AI 判断 Issue 是否适合新贡献者: + +| 条件 | 说明 | +|------|------| +| 任务明确 | 有清晰的复现步骤或实现要求 | +| 范围小 | 预估代码改动 < 100 行 | +| 不涉及核心逻辑 | 修改不影响主要功能流程 | +| 有足够上下文 | 新人无需深入了解整个项目 | + +### 责任人分配策略 + +AI 根据以下信息分配责任人: + +1. **成员角色**:Manager > Developer > Reporter +2. **历史活动**:查看成员最近处理的 Issue/PR 类型 +3. **专业领域**:根据 commit 历史判断成员擅长领域 +4. **当前负载**:已分配 Issue 最少的成员优先 + +## 执行命令 + +### 写入操作可用性 + +| 操作 | 命令 | 可用 | 说明 | +|------|------|------|------| +| 创建标签 | `label +create` | ✅ | 正常工作 | +| 更新标签 | `label +update` | ✅ | 正常工作 | +| 添加评论 | `issue +comment` | ✅ | 正常工作 | +| 更新 Issue 标题/描述/状态 | `issue +update` | ✅ | 仅支持 title/body/state | +| **给 Issue 打标签** | `api PATCH` | ✅ | `issue_tag_ids` 数组,需先 `issue +view` 保留字段 | +| **给 Issue 分配责任人** | `api PATCH` | ✅ | `assigner_ids` 数组(注意:不是 `assigned_to_id`) | +| **设置 Issue 优先级** | `api PATCH` | ✅ | `priority_id: 1-4` | + +> ⚠️ **重要**:更新 Issue 时必须先通过 `issue +view` 获取当前 `subject` 和 `description`,在 PATCH 请求中一并提交,否则这些字段会被清空。 +> +> **注意**:使用 `api PATCH` 而非 `api POST`。GitLink Issue 更新接口要求 PATCH 方法。 + +### 创建标签 + +```bash +# 查看已有标签 +gitlink-cli label +list --owner --repo --format json + +# 如果标签不存在,先创建 +gitlink-cli label +create --owner --repo --name "bug" --color "#fc2929" +``` + +### 打标签 / 分配责任人 / 设置优先级 + +```bash +# Step 1: 先获取 Issue 当前内容(必须!否则 subject 和 description 会被清空) +gitlink-cli issue +view --owner --repo --number --format json +# 提取 subject 和 description + +# Step 2: 更新 Issue(打标签 + 分配责任人 + 设置优先级) +# ⚠️ 必须带上 subject 和 description!使用 PATCH 方法(不是 POST) +gitlink-cli api PATCH /v1///issues/ --body '{ + "subject": "<从 issue +view 获取的当前标题>", + "description": "<从 issue +view 获取的当前描述>", + "issue_tag_ids": [, ], + "assigner_ids": [], + "priority_id": <1-4> +}' + +# 实测示例(chroe/gitlink-cli Issue #7): +# 先 view → 再 PATCH 打标签「缺陷」+ 分配 chroe + 优先级「高」 +gitlink-cli api PATCH /v1/chroe/gitlink-cli/issues/7 --body '{ + "subject": "Bug: issue +list 命令在 Windows 下偶发崩溃", + "description": "在 Windows 环境下执行 issue +list 时偶发性 panic 崩溃。", + "issue_tag_ids": [323829], + "assigner_ids": [149027], + "priority_id": 3 +}' +# 结果:ok=true,标签=缺陷,责任人=chroe,优先级=高 +``` + +> `priority_id` 直接设置优先级(1=低, 2=正常, 3=高, 4=紧急),优先级和标签是独立字段。 +> ⚠️ **责任人字段是 `assigner_ids`(数组)**,不是 `assigned_to_id`。通过 `member +list` 获取用户 ID(不是用户名)。 + +### 添加新人引导评论 + +```bash +# 为 good-first-issue 添加引导评论 +gitlink-cli issue +comment --owner --repo \ + --number \ + --body "👋 欢迎贡献!这是一个适合新贡献者的 Issue。 + +## 快速上手 + +1. **Fork 项目**:点击右上角 Fork 按钮 +2. **Clone 你的 Fork**:\`git clone https://www.gitlink.org.cn//.git\` +3. **创建分支**:\`git checkout -b fix/issue-\` +4. **修改代码**:根据 Issue 描述进行修改 +5. **提交并推送**:\`git add -A && git commit -m 'fix: <描述>' && git push origin fix/issue-\` +6. **提交 Pull Request**:从你的 Fork 向主仓库提 PR + +## 相关文件 + +- 主要代码:\`<建议修改的文件路径>\` +- 测试文件:\`<对应的测试文件路径>\` + +## 需要帮助? + +如果遇到问题,可以在这里回复,我们会尽快帮助你!" +``` + +## 分拣输出格式 + +AI 对每个 Issue 生成分拣建议: + +```markdown +## 📋 Issue 分拣结果 + +### Issue #: <标题> + +| 项目 | 结果 | +|------|------| +| **分类** | 🐛 Bug | +| **优先级** | 🟠 高 | +| **建议标签** | bug, priority: high | +| **建议责任人** | @member-a(理由:最近修复过类似 Bug) | +| **Good First Issue** | 否(涉及核心逻辑) | +| **AI 分析** | 用户报告登录功能白屏,可能与最近的认证模块重构有关。建议优先排查 auth 模块。 | + +--- + +### Issue #: <标题> + +| 项目 | 结果 | +|------|------| +| **分类** | ✨ Enhancement | +| **优先级** | 🟡 中 | +| **建议标签** | enhancement, good first issue | +| **建议责任人** | 未分配(适合新人) | +| **Good First Issue** | ✅ 是 | +| **AI 分析** | 功能建议明确,改动范围小(仅需修改帮助文档格式),适合新贡献者。建议添加引导评论。 | +``` + +## 使用场景 + +### 场景 1:批量分拣所有未分类 Issue + +``` +用户:"帮我分拣所有未分类的 Issue" + +AI 执行流程: +1. issue +list --state open → 获取所有开放 Issue +2. 过滤出无标签/无责任人的 Issue +3. label +list → 获取可用标签 +4. member +list → 获取可分配成员 +5. 逐个分析 Issue 内容 → 生成分类建议 +6. 展示分拣结果供用户确认 +7. 用户确认后批量执行打标签和分配 +``` + +### 场景 2:单 Issue 智能处理 + +``` +用户:"帮我分析 Issue #15" + +AI 执行流程: +1. issue +view --number 15 → 获取 Issue 详情 +2. 分析内容 → 生成分类、优先级、责任人建议 +3. 展示建议供用户确认 +4. 用户确认后执行操作 +``` + +### 场景 3:新人引导模式 + +``` +用户:"给所有适合新人的 Issue 添加引导评论" + +AI 执行流程: +1. issue +list --state open → 获取所有开放 Issue +2. 逐个评估是否为 good-first-issue +3. 展示候选列表供用户确认 +4. 为确认的 Issue 添加引导评论 +``` + +## 最佳实践 + +- **先预览再执行**:始终先展示分拣建议,用户确认后再执行写入操作 +- **避免重复操作**:检查 Issue 是否已有标签和责任人,跳过已处理的 +- **保持标签一致**:使用项目已有标签,避免创建重复或相似标签 +- **尊重作者意图**:如果 Issue 作者已标注类型,以作者标注为准 +- **批量操作注意限流**:大量 Issue 分拣时注意 API 调用频率 + +## 详细参考 + +详见 [`REFERENCE.md`](REFERENCE.md) 了解标签系统、责任分配算法和 API 注意事项。 diff --git a/skills/gitlink-triage/examples/batch-triage.md b/skills/gitlink-triage/examples/batch-triage.md new file mode 100644 index 0000000..83f51df --- /dev/null +++ b/skills/gitlink-triage/examples/batch-triage.md @@ -0,0 +1,130 @@ +# 示例:Issue 智能分拣(真实数据) + +> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-14 在 Claude Code 中实际执行。 +> 创建了 4 个不同类型的测试 Issue,由 AI 自动分类、打标签、分配责任人、设置优先级。 + +--- + +## 场景 + +项目有 4 个新建的开放 Issue,全部无标签、无责任人、无优先级。AI 按本 Skill 流程自动分拣。 + +## 执行命令序列 + +### Step 1: 获取开放 Issue + +```bash +gitlink-cli issue +list --owner chroe --repo gitlink-cli --state open --format json +# 结果:真正开放 4 个(客户端过滤 status_id != 5) +# #7 Bug: issue +list 命令在 Windows 下偶发崩溃 tags=[] assignee=[] +# #8 建议: 支持 webhook 事件触发自动执行 Skill tags=[] assignee=[] +# #9 请问如何批量导出所有仓库的 Issue 列表? tags=[] assignee=[] +# #10 文档: README 中安装命令有 typo tags=[] assignee=[] +``` + +### Step 2: 获取标签和成员 + +```bash +gitlink-cli label +list --owner chroe --repo gitlink-cli --format json +# 结果:13 个中文标签 +# 缺陷(323829) 功能(323830) 疑问(323831) 支持(323832) 任务(323833) +# 协助(323834) 搁置(323835) 文档(323836) 测试(323837) 重复(323838) + +gitlink-cli member +list --owner chroe --repo gitlink-cli --format json +# 结果:3 个成员 +# chroe(149027) caoweiqiong(141645) yetja(148915) +``` + +### Step 3: 逐个获取 Issue 详情 + +```bash +gitlink-cli issue +view --owner chroe --repo gitlink-cli --number 7 --format json +# subject="Bug: issue +list 命令在 Windows 下偶发崩溃" +# description="在 Windows 环境下执行 issue +list 时偶发性 panic 崩溃..." + +gitlink-cli issue +view --owner chroe --repo gitlink-cli --number 8 --format json +# subject="建议: 支持 webhook 事件触发自动执行 Skill" + +gitlink-cli issue +view --owner chroe --repo gitlink-cli --number 9 --format json +# subject="请问如何批量导出所有仓库的 Issue 列表?" + +gitlink-cli issue +view --owner chroe --repo gitlink-cli --number 10 --format json +# subject="文档: README 中安装命令有 typo" +``` + +## AI 分类结果 + +| Issue | 标题 | 关键词 | 分类 | 匹配标签 | 责任人 | 优先级 | +|-------|------|--------|------|---------|--------|--------| +| #7 | Bug: issue +list 崩溃 | "Bug"、"崩溃" | 🐛 Bug | 缺陷(323829) | chroe | 高(3) | +| #8 | 建议: webhook 触发 | "建议"、"支持" | ✨ Enhancement | 功能(323830) | caoweiqiong | 正常(2) | +| #9 | 请问如何批量导出 | "请问"、"如何" | ❓ Question | 疑问(323831) | yetja | 正常(2) | +| #10 | 文档: README typo | "文档"、"typo" | 📚 Docs | 文档(323836) | chroe | 低(1) | + +**Good First Issue 评估**:#10 适合(README typo,改动小,有明确文件位置)。 + +## 执行分拣(PATCH 写入) + +```bash +# #7 Bug → 缺陷标签 + 分配 chroe + 优先级高 +gitlink-cli api PATCH /v1/chroe/gitlink-cli/issues/7 --body '{ + "subject": "Bug: issue +list 命令在 Windows 下偶发崩溃", + "description": "在 Windows 环境下执行 issue +list 时偶发性 panic 崩溃。", + "issue_tag_ids": [323829], + "assigner_ids": [149027], + "priority_id": 3 +}' +# 结果:ok=true + +# #8 功能建议 → 功能标签 + 分配 caoweiqiong + 优先级正常 +gitlink-cli api PATCH /v1/chroe/gitlink-cli/issues/8 --body '{ + "subject": "建议: 支持 webhook 事件触发自动执行 Skill", + "description": "希望 gitlink-cli 能支持 webhook 事件触发。", + "issue_tag_ids": [323830], + "assigner_ids": [141645] +}' +# 结果:ok=true + +# #9 疑问 → 疑问标签 + 分配 yetja +gitlink-cli api PATCH /v1/chroe/gitlink-cli/issues/9 --body '{ + "subject": "请问如何批量导出所有仓库的 Issue 列表?", + "description": "我想把 Issue 导出成 CSV。", + "issue_tag_ids": [323831], + "assigner_ids": [148915] +}' +# 结果:ok=true + +# #10 文档 → 文档标签 + 分配 chroe + 优先级低 +gitlink-cli api PATCH /v1/chroe/gitlink-cli/issues/10 --body '{ + "subject": "文档: README 中安装命令有 typo", + "description": "README.md 第 25 行 isntall 应为 install。", + "issue_tag_ids": [323836], + "assigner_ids": [149027], + "priority_id": 1 +}' +# 结果:ok=true +``` + +## 验证结果 + +```bash +gitlink-cli issue +list --owner chroe --repo gitlink-cli --state open --format json +``` + +| Issue | 标签 | 责任人 | 优先级 | +|-------|------|--------|--------| +| #7 | ✅ 缺陷 | ✅ chroe | ✅ 高 | +| #8 | ✅ 功能 | ✅ caoweiqiong | ✅ 正常 | +| #9 | ✅ 疑问 | ✅ yetja | ✅ 正常 | +| #10 | ✅ 文档 | ✅ chroe | ✅ 低 | + +**4/4 全部分拣成功,分类准确率 100%。** + +## 关键字段说明 + +> ⚠️ **责任人字段是 `assigner_ids`(数组)**,不是 `assigned_to_id`。 +> 实测 `assigned_to_id`(单个数字)不生效,`assigner_ids`(数组)才生效。 +> +> ⚠️ **更新必须带 subject 和 description**,否则这两个字段会被清空。 +> +> ⚠️ **方法用 PATCH**,POST 返回 404。 diff --git a/skills/gitlink-user/REFERENCE.md b/skills/gitlink-user/REFERENCE.md new file mode 100644 index 0000000..509fa04 --- /dev/null +++ b/skills/gitlink-user/REFERENCE.md @@ -0,0 +1,107 @@ +# gitlink-user 参考手册 + +> 本文档定义用户相关命令的 API 字段映射和注意事项。 + +--- + +## 一、user +me 返回字段 + +```json +{ + "ok": true, + "data": { + "user_id": 141645, + "login": "caoweiqiong", + "username": "CWQ", + "email": "caoweiqiong@example.org", + "phone": "18627370661", + "admin": false, + "image_url": "system/lets/letter_avatars/2/C/223_176_135/120.png" + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `user_id` | int | 用户唯一 ID(用于批量操作中传参) | +| `login` | string | 登录名(URL 路径中的标识) | +| `username` | string | 显示名 | +| `email` | string | 注册邮箱(仅本人可见) | +| `phone` | string | 手机号(仅本人可见) | +| `admin` | bool | 是否为平台管理员 | +| `image_url` | string | 头像 URL | + +## 二、user +info 返回字段 + +```json +{ + "ok": true, + "data": { + "user_id": 149027, + "login": "chroe", + "name": "chroe", + "real_name": "chroe", + "user_identity": "专业人士", + "created_time": "2026-04-30 11:41", + "common_projects_count": 5, + "user_projects_count": 5, + "mirror_projects_count": 0, + "user_org_count": 0, + "watched_count": 0, + "watching_count": 0, + "description": null, + "image_url": "system/lets/letter_avatars/2/C/142_140_188/120.png" + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `user_id` | int | 用户唯一 ID | +| `login` | string | 登录名 | +| `name` | string | 显示名 | +| `real_name` | string | 真实姓名 | +| `user_identity` | string | 身份标识("专业人士"、"开发者"等) | +| `created_time` | string | 注册时间 | +| `common_projects_count` | int | 参与的项目数 | +| `user_projects_count` | int | 自己创建的项目数 | +| `mirror_projects_count` | int | 镜像项目数 | +| `user_org_count` | int | 所属组织数 | +| `watched_count` | int | 被关注数 | +| `watching_count` | int | 正在关注数 | +| `description` | string/null | 个人简介 | + +## 三、Raw API:搜索用户 + +```bash +gitlink-cli api GET /users/list --query 'search=chroe' +``` + +返回用户列表,用于通过用户名查找 user_id: + +```json +{ + "ok": true, + "data": { + "users": [ + { + "id": 149027, + "login": "chroe", + "name": "chroe" + } + ] + } +} +``` + +## 四、常见问题 + +### Q: 如何获取 user_id? + +A: 两种方式: +1. `user +me --format json` → `data.user_id`(查自己) +2. `api GET /users/list --query 'search='` → `data.users[0].id`(查他人) + +### Q: 为什么 Raw API 的 /users/:id/headmaps 返回 HTML? + +A: headmaps 和 statistics 是前端页面路由,不是 API 端点。用户相关可用的 API 端点只有 `/users/list` 和 `/users/:id`。 diff --git a/skills/gitlink-user/SKILL.md b/skills/gitlink-user/SKILL.md index 0d1aa4f..759a517 100644 --- a/skills/gitlink-user/SKILL.md +++ b/skills/gitlink-user/SKILL.md @@ -1,47 +1,76 @@ --- name: gitlink-user -version: 1.0.0 -description: "用户操作:查看当前用户、用户详情。当用户需要查看 GitLink 用户信息时触发。" +version: 2.0.0 +description: "用户信息查询:查看当前登录用户、查询用户详情、获取用户项目统计。当用户需要查看 GitLink 用户信息、验证认证状态或查询其他用户时触发。" metadata: requires: bins: ["gitlink-cli"] cliHelp: "gitlink-cli user --help" --- -# gitlink-user(用户操作) +# gitlink-user(用户信息查询) **CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** -**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** **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) 了解认证和全局参数。 + +## 概述 + +本 Skill 提供用户信息查询能力,主要用于: +1. **认证验证**:通过 `user +me` 确认当前登录状态和用户身份 +2. **用户画像**:查询任意公开用户的详情(项目数、身份、注册时间等) +3. **用户 ID 解析**:在批量操作中需要 user_id 时,通过 login 查询获取 + +## 数据采集命令 + +### 查看当前登录用户 + +```bash +# 获取当前认证用户信息(需要登录) +gitlink-cli user +me --format json +``` + +### 查看指定用户详情 + +```bash +# 通过 login 查询用户详情 +gitlink-cli user +info --login chroe --format json +``` + +### Raw API 补充 + +```bash +# 搜索用户(按用户名模糊匹配,返回含 user_id) +gitlink-cli api GET /users/list --query 'search=chroe' + +# 获取用户项目列表 +gitlink-cli api GET /users/:user_id/projects --query 'page=1&limit=10' +``` ## Shortcuts | Shortcut | 说明 | 需要认证 | |----------|------|----------| | `user +me` | 当前登录用户 | 是 | -| `user +info` | 查看用户详情 | 否 | +| `user +info` | 查看用户详情 | 否(公开用户) | ## 使用示例 ```bash -# 查看当前用户 +# 查看当前登录用户 gitlink-cli user +me -# 查看其他用户 -gitlink-cli user +info --login zhangsan +# 查看 chroe 用户详情 +gitlink-cli user +info --login chroe + +# 搜索用户(获取 user_id 用于批量操作) +gitlink-cli api GET /users/list --query 'search=chroe' ``` -## Raw API 补充 +## 注意事项 -```bash -# 用户贡献热力图 -gitlink-cli api GET /users/:user_id/headmaps - -# 用户统计 -gitlink-cli api GET /users/:user_id/statistics - -# 用户项目动态 -gitlink-cli api GET /users/:user_id/project_trends -``` +- `user +me` 需要先 `auth login`,否则返回未认证错误 +- `user +info --login` 传入的是用户 login(URL 中的路径),不是 user_id +- 部分用户信息字段(如 email)仅对本人可见 +- 详见 [`REFERENCE.md`](REFERENCE.md) 了解完整的字段映射 diff --git a/skills/gitlink-user/examples/user-workflow.md b/skills/gitlink-user/examples/user-workflow.md new file mode 100644 index 0000000..3db9f4f --- /dev/null +++ b/skills/gitlink-user/examples/user-workflow.md @@ -0,0 +1,85 @@ +# 示例:用户信息查询(真实数据) + +> 本示例基于 `chroe/gitlink-cli` 项目于 2026-06-13 在 Claude Code 中实际执行。 +> 展示用户信息查询的完整流程:认证验证 → 查看自己 → 查看他人 → 搜索用户 ID。 + +--- + +## 场景:项目维护者确认身份并查询团队成员信息 + +### Step 1:确认当前登录状态 + +```bash +gitlink-cli user +me --format json +``` + +**真实输出:** + +```json +{ + "ok": true, + "data": { + "user_id": 141645, + "login": "caoweiqiong", + "username": "CWQ", + "email": "caoweiqiong@example.org", + "phone": "18627370661", + "admin": false, + "image_url": "system/lets/letter_avatars/2/C/223_176_135/120.png" + } +} +``` + +**确认:** 当前以 `caoweiqiong`(CWQ)登录,user_id 为 141645。 + +### Step 2:查询项目维护者详情 + +```bash +gitlink-cli user +info --login chroe --format json +``` + +**真实输出:** + +```json +{ + "ok": true, + "data": { + "user_id": 149027, + "login": "chroe", + "name": "chroe", + "real_name": "chroe", + "user_identity": "专业人士", + "created_time": "2026-04-30 11:41", + "common_projects_count": 5, + "user_projects_count": 5, + "mirror_projects_count": 0, + "user_org_count": 0, + "watched_count": 0, + "watching_count": 0, + "description": null, + "image_url": "system/lets/letter_avatars/2/C/142_140_188/120.png" + } +} +``` + +**分析:** chroe 的 user_id 为 149027,拥有 5 个项目,注册于 2026-04-30。 + +### Step 3:搜索用户(获取 user_id 用于批量操作) + +在执行 `member +add` 等需要 user_id 的操作前,先搜索获取: + +```bash +gitlink-cli api GET /users/list --query 'search=chroe' +``` + +返回匹配用户的列表,从中提取 `id` 字段即可用于其他命令。 + +--- + +## 典型用途 + +| 场景 | 命令 | +|------|------| +| 验证认证是否生效 | `user +me` | +| 查看团队成员信息 | `user +info --login ` | +| 获取 user_id 用于 member/batch 操作 | `api GET /users/list --query 'search='` | diff --git a/skills/gitlink-workflow/REFERENCE.md b/skills/gitlink-workflow/REFERENCE.md new file mode 100644 index 0000000..6e8257e --- /dev/null +++ b/skills/gitlink-workflow/REFERENCE.md @@ -0,0 +1,178 @@ +# gitlink-workflow 参考手册 + +> 本文档定义跨模块工作流的 API 字段映射、Fork 协作流程和分支命名规范。 + +--- + +## 一、跨模块 API 字段关联 + +### 核心关联关系 + +``` +repo +info +├── project_id ──────────────────→ milestone +list (project_id) +├── forked_from_project_id ──────→ upstream 仓库标识 +├── open_devops ─────────────────→ ci +builds 可用性 +└── default_branch ──────────────→ branch +protect 的目标 + +pr +create +├── --head : ──────→ branch +create 的产物 +├── --base ─────────────→ 目标分支(通常 master) +└── pr +view 返回的 id ──────────→ pr +merge / pr +files 的参数 + +issue +create +└── 返回的 project_issues_index ──→ issue +close / issue +comment 的 --number +``` + +### PR 状态映射 + +| pull_request_status | 含义 | 对应 `--state` | +|---------------------|------|---------------| +| 0 | 开放 | open | +| 1 | 已合并 | merged | +| 2 | 已关闭 | closed | + +> ⚠️ `pr +list --state` 参数仅影响统计计数,返回列表可能包含所有状态。客户端需按 `pull_request_status` 字段过滤。 + +### PR 合并方式 + +| `--do` 参数 | 说明 | +|------------|------| +| `merge` | 标准合并(创建 merge commit) | +| `rebase` | Rebase 合并(线性历史) | +| `squash` | Squash 合并(压缩为单个 commit) | + +--- + +## 二、Fork 协作流程(详细版) + +### 何时必须 Fork + +- 向**非自己拥有**的仓库提交 PR +- 没有目标仓库的写权限 +- 即使是仓库成员,也推荐 Fork 流程 + +### 完整 Fork 工作流 + +```bash +# 1. Fork 目标仓库 +gitlink-cli repo +fork --owner --repo + +# 2. 克隆自己的 Fork +git clone https://gitlink.org.cn//.git +cd + +# 3. 添加上游 remote +git remote add upstream https://gitlink.org.cn//.git + +# 4. 同步上游最新代码 +git fetch upstream +git checkout master +git merge upstream/master +git push origin master + +# 5. 创建功能分支 +git checkout -b feature/my-change + +# 6. 开发和提交 +git add -A +git commit -m "feat: 我的改动" + +# 7. 推送到自己的 Fork +git push origin feature/my-change + +# 8. 从 Fork 向主仓库提 PR +gitlink-cli pr +create \ + --owner --repo \ + --head :feature/my-change \ + --base master \ + --title "feat: 我的改动" + +# 9. 上游有更新时同步 +git fetch upstream +git merge upstream/master +git push origin master +``` + +### 禁止操作 + +- ❌ 直接 clone 主仓库后 push(污染主仓库) +- ❌ 向主仓库的 master 分支直接推送 +- ❌ 使用 `--force` push 到任何共享分支 + +--- + +## 三、分支命名规范 + +### 推荐命名 + +| 类型 | 模式 | 示例 | +|------|------|------| +| 新功能 | `feature/<描述>` | `feature/wiki-create` | +| Bug 修复 | `fix/<描述>` | `fix/batch-url-encoding` | +| 文档 | `docs/<描述>` | `docs/api-reference` | +| 重构 | `refactor/<描述>` | `refactor/auth-module` | +| 发布准备 | `release/<版本>` | `release/v1.0.0` | +| 紧急修复 | `hotfix/<描述>` | `hotfix/critical-bug` | + +### GitLink 分支映射 + +| 平台 | 默认主分支 | +|------|-----------| +| GitLink | `master` | +| GitHub | `main` | + +gitlink-cli 在与 GitLink 交互时自动处理映射: +- push 时:`main` → `master` +- pull 时:`master` → `main` + +--- + +## 四、项目初始化清单 + +完整的项目初始化应覆盖以下全部: + +| 序号 | 项目 | 命令 | +|------|------|------| +| 1 | 创建仓库 | `repo +create` | +| 2 | 克隆到本地 | `repo +clone` | +| 3 | 创建 README | 本地创建后 git push | +| 4 | 创建 .gitignore | 本地创建后 git push | +| 5 | 保护主分支 | `branch +protect --name master` | +| 6 | 创建开发分支 | `branch +create --name develop` | +| 7 | 创建初始 Issue | `issue +create` | +| 8 | 创建里程碑 | `milestone +create` | +| 9 | 开启 DevOps | `api POST /.../activate` | +| 10 | 配置流水线 | 创建 `.devops/` 目录和 YAML 文件 | +| 11 | 邀请团队成员 | `org +batch-invite`(组织仓库)或 `member +add` | + +--- + +## 五、已知限制 + +| 限制 | 说明 | +|------|------| +| PR 创建需要代码差异 | 分支内容必须与目标分支不同,否则 GitLink 拒绝创建 | +| `api POST` 有 URL bug | 部分 Raw API 写操作不可用,优先使用 shortcuts | +| 不能删除远程分支 | `branch +delete` 的 API 不可用(GitLink 平台 bug) | +| PR state 过滤不精确 | `pr +list --state` 仅影响计数,需客户端二次过滤 | + +--- + +## 六、常见问题 + +### Q: 创建 PR 时报错"无变更"? + +A: 确认你的分支上有不同于 base 分支的新 commit。如果是刚创建的空白分支,先提交代码再创建 PR。 + +### Q: PR 合并后需要手动删分支吗? + +A: GitLink 目前不自动删除合并后的分支。可以用 `git push origin --delete ` 或通过 Web 页面手动删除。 + +### Q: 如何同步 Fork 仓库与上游? + +A: `git fetch upstream && git merge upstream/master && git push origin master` + +### Q: 项目初始化后 DevOps 还是不可用? + +A: `api POST /activate` 可能因 URL bug 失败。最可靠的方式是在 GitLink Web 页面手动开启。 diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index 9997883..00a9dc8 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -1,109 +1,258 @@ --- name: gitlink-workflow -version: 1.0.0 -description: "AI 自动化工作流:Issue 分类、PR Review、Release Notes 生成、仓库初始化、Sprint 报告等。当用户需要 AI 自动化 GitLink 操作时触发。" +version: 2.0.0 +description: "跨模块联动工作流:PR 全流程自动化、项目初始化向导、发版流程编排。当用户需要进行跨模块的复杂操作(从创建分支到合并 PR、从新建仓库到配置 CI)时触发。" metadata: requires: bins: ["gitlink-cli"] - cliHelp: "gitlink-cli workflow --help" + cliHelp: "gitlink-cli --help" --- -# gitlink-workflow(AI 自动化工作流) - -> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) +# gitlink-workflow(跨模块联动工作流) +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 跨模块工作流涉及多个写入操作,每个阶段完成后展示结果,确认后再继续下一步。** **CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** -本技能提供 Claude Code 可直接执行的高级工作流模板。 +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 -## 工作流 1:Issue Triage(Issue 自动分类) +## 概述 -**场景**:自动为新 Issue 添加标签分类。 +本 Skill 编排多个 gitlink-cli 模块,完成需要跨模块协作的端到端任务: +1. **PR 全流程**:创建分支 → 提交代码 → 创建 PR → 代码审查 → 合并 +2. **项目初始化向导**:创建仓库 → 设保护分支 → 创建 Issue → 配里程碑 → 开启 CI +3. **发版流程**:生成 Release Notes → 创建 Tag → 发布 Release → 关闭相关 Issue + +> 对于专项任务(Issue 分拣、Release Notes 生成、健康度报告),请使用对应的专项 Skill: +> - Issue 智能分拣 → [`../gitlink-triage/SKILL.md`](../gitlink-triage/SKILL.md) +> - Release Notes 生成 → [`../gitlink-changelog/SKILL.md`](../gitlink-changelog/SKILL.md) +> - 项目健康报告 → [`../gitlink-health/SKILL.md`](../gitlink-health/SKILL.md) + +## 工作流 1:PR 全流程 + +### 场景 + +从零开始,完成一个功能的开发和提交。 + +### 数据采集 ```bash -# 1. 获取未标记的 Issue 列表 -gitlink-cli issue +list --state open --format json +# 1. 了解当前仓库状态 +gitlink-cli repo +info --format json -# 2. 逐个查看 Issue 详情 -gitlink-cli issue +view --id --format json +# 2. 查看现有分支 +gitlink-cli branch +list --format json -# 3. 根据内容分析,通过 Raw API 添加标签 -gitlink-cli api POST /:owner/:repo/issues/:id --body '{"issue_tag_ids":[]}' -``` - -**分类规则建议**: -- 标题/描述包含 "bug"、"错误"、"失败" → bug 标签 -- 标题/描述包含 "feature"、"新增"、"建议" → enhancement 标签 -- 标题/描述包含 "question"、"如何"、"怎么" → question 标签 - -## 工作流 2:PR Review(代码审查辅助) - -**场景**:获取 PR 变更,分析代码质量,添加 Review 评论。 - -```bash -# 1. 获取 PR 详情 -gitlink-cli pr +view --id --format json - -# 2. 获取变更文件列表 -gitlink-cli pr +files --id --format json - -# 3. 获取 PR 提交列表 -gitlink-cli pr +diff --id --format json - -# 4. 添加 Review 评论 -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"代码审查意见...","event":"COMMENT"}' -``` - -## 工作流 3:Release Notes 生成 - -**场景**:从提交历史自动生成版本发布说明。 - -```bash -# 1. 获取两个版本之间的提交 -gitlink-cli api GET /:owner/:repo/compare/:base...:head --format json - -# 2. 获取已关闭的 Issue -gitlink-cli issue +list --state closed --format json - -# 3. 生成 Release Notes 并创建发布 -gitlink-cli release +create --tag v1.2.0 --name "v1.2.0" --body "## What's Changed\n- feat: 新功能 (#123)\n- fix: 修复问题 (#456)" -``` - -## 工作流 4:Repo Setup(仓库初始化) - -**场景**:创建仓库并完成基础配置。 - -```bash -# 1. 创建仓库 -gitlink-cli repo +create --name my-project --description "项目描述" - -# 2. 设置分支保护 -gitlink-cli branch +protect --name main --owner myuser --repo my-project - -# 3. 创建初始 Issue -gitlink-cli issue +create --title "项目初始化" --body "- [ ] 完善 README\n- [ ] 配置 CI\n- [ ] 添加 License" --owner myuser --repo my-project -``` - -## 工作流 5:Sprint Report(Sprint 报告) - -**场景**:汇总 Issue/PR 统计,生成周报。 - -```bash -# 1. 获取 Issue 统计 -gitlink-cli issue +list --state open --format json -gitlink-cli issue +list --state closed --format json - -# 2. 获取 PR 统计 +# 3. 查看已有 PR(避免冲突) gitlink-cli pr +list --state open --format json -gitlink-cli pr +list --state merged --format json +``` -# 3. 获取项目动态 -gitlink-cli api GET /:owner/:repo/activity --format json +### 执行步骤 + +```bash +# Step 1: 创建功能分支 +gitlink-cli branch +create --name feature/my-feature + +# Step 2: 本地开发和推送(用原生 git) +git checkout -b feature/my-feature +# ... 编写代码 ... +git add -A +git commit -m "feat: 添加新功能" +git push origin feature/my-feature + +# Step 3: 创建 PR +gitlink-cli pr +create \ + --head :feature/my-feature \ + --base master \ + --title "feat: 添加新功能" \ + --body "## 变更说明 +- 新增 XXX 功能 +- 修改 YYY 逻辑 + +## 测试 +- [ ] 单元测试通过 +- [ ] 集成测试通过" + +# Step 4: 查看 PR 状态 +gitlink-cli pr +view --id --format json + +# Step 5: 查看 PR 变更文件(Code Review) +gitlink-cli pr +files --id --format json + +# Step 6: 合并 PR +gitlink-cli pr +merge --id --do merge + +# Step 7: 清理本地分支(可选) +git checkout master +git branch -d feature/my-feature +``` + +### AI 在 PR 流程中的角色 + +1. **创建前检查**:是否有冲突的现有 PR?分支名是否规范? +2. **Code Review**:查看 `pr +files` 的变更,给出审查意见 + > 深度代码审查请使用 [`../gitlink-review/SKILL.md`](../gitlink-review/SKILL.md)(多视角 + 对抗式自检 + 自动评论)。 +3. **合并判断**:检查 CI 是否通过(如开启了 DevOps)、是否有冲突 + +## 工作流 2:项目初始化向导 + +### 场景 + +创建一个新仓库并完成全套初始化配置。 + +### 执行步骤 + +```bash +# Step 1: 创建仓库 +gitlink-cli repo +create --name --description "<描述>" +# 可选:创建为私有仓库 +gitlink-cli repo +create --name --private true --description "<描述>" + +# Step 2: 克隆到本地 +gitlink-cli repo +clone --url / +cd + +# Step 3: 创建初始文件 +echo "# " > README.md +echo "bin/\n*.exe\n.DS_Store" > .gitignore +git add -A && git commit -m "chore: 初始化项目" +git push origin master + +# Step 4: 保护主分支 +gitlink-cli branch +protect --name master + +# Step 5: 创建初始 Issue(项目任务清单) +gitlink-cli issue +create \ + --title "项目初始化任务清单" \ + --body "## 初始化任务 +- [ ] 完善 README 和项目文档 +- [ ] 配置 CI/CD 流水线 +- [ ] 添加单元测试框架 +- [ ] 设置代码规范检查 +- [ ] 创建贡献指南" + +# Step 6: 创建里程碑 +gitlink-cli milestone +create \ + --title "v0.1.0 - MVP" \ + --description "首个可用版本" + +# Step 7: 创建开发分支 +gitlink-cli branch +create --name develop + +# Step 8: 开启 DevOps(可选) +gitlink-cli api POST ///activate +``` + +## 工作流 3:发版流程编排 + +### 场景 + +从当前开发状态创建一个正式版本发布。 + +### 执行步骤 + +```bash +# Step 1: 确认当前版本状态 +gitlink-cli release +list --format json + +# Step 2: 使用 changelog skill 生成 Release Notes +# 参见 ../gitlink-changelog/SKILL.md + +# Step 3: 创建 Git Tag +git tag -a v1.0.0 -m "v1.0.0 正式发布" +git push origin v1.0.0 + +# Step 4: 创建 Release +gitlink-cli release +create \ + --tag v1.0.0 \ + --name "v1.0.0 正式版" \ + --body "<从 changelog skill 生成的 Release Notes>" + +# Step 5: 关闭已完成的 Issue +gitlink-cli issue +close --number <已完成issue编号> + +# Step 6: 创建下一个版本的里程碑 +gitlink-cli milestone +create \ + --title "v1.1.0" \ + --description "下一版本计划" +``` + +## 工作流 4:跨仓库同步检查 + +### 场景 + +检查 Fork 仓库是否落后于上游,需要同步。 + +### 数据采集 + +```bash +# 1. 遍历自己的 Fork 仓库 +gitlink-cli repo +list --category fork --format json + +# 2. 对每个 Fork,查看上游信息 +gitlink-cli repo +info --owner --repo --format json +# 关注: forked_from_project_id, fork_info + +# 3. 获取上游仓库的最新提交 +gitlink-cli commit +list --owner --repo --format json +``` + +### AI 分析 + +| 检查项 | 方法 | +|--------|------| +| Fork 是否落后 | 比较 Fork 和上游的最新 commit 时间戳 | +| 是否有本地修改 | 检查 Fork 仓库是否有非上游的 commit | +| 同步建议 | 落后 > 30 天或有冲突风险时提醒 | + +## 输出格式 + +### PR 流程状态摘要 + +```markdown +## 🔀 PR 全流程状态 + +| 阶段 | 状态 | 详情 | +|------|------|------| +| 分支创建 | ✅ | feature/my-feature 已创建 | +| 代码提交 | ✅ | 3 个 commit 已推送 | +| PR 创建 | ✅ | PR #15: "feat: 添加新功能" | +| Code Review | 🔍 | 待审查(变更 5 个文件,+120/-30) | +| CI 检查 | ⏳ | 构建中... | +| 合并 | ⏳ | 等待 Review 通过 | +``` + +### 项目初始化完成摘要 + +```markdown +## 🚀 项目初始化完成 — + +| 配置项 | 状态 | +|--------|------| +| 仓库 | ✅ / (公开) | +| 主分支保护 | ✅ master 已保护 | +| 初始 Issue | ✅ #1: "项目初始化任务清单" | +| 里程碑 | ✅ v0.1.0 - MVP | +| 开发分支 | ✅ develop | +| DevOps | ⚠️ 待开启 | +| README | ✅ 已创建 | +| .gitignore | ✅ 已创建 | + +### 下一步 +1. 在 GitLink Web 页面开启 DevOps +2. 配置 `.devops/` 流水线 +3. 邀请团队成员 +4. 开始认领 Issue #1 中的任务 ``` ## 最佳实践 -- 所有工作流命令使用 `--format json` 以便解析输出 -- 写入操作前确认用户意图 -- 批量操作建议先用小范围测试 -- 保存工作流执行结果以便回溯 +- **分阶段确认**:跨模块工作流较长,每个关键步骤完成后展示结果 +- **错误即停**:任何步骤失败都应暂停,排查后再继续 +- **Fork 流程优先**:向他人仓库提 PR 时,必须先 Fork +- **分支命名规范**:`feature/xxx`、`fix/xxx`、`docs/xxx` +- **PR 描述完整**:包含变更说明、测试情况、关联 Issue + +## 详细参考 + +详见 [`REFERENCE.md`](REFERENCE.md) 了解跨模块 API 字段映射、Fork 工作流细节和分支命名规范。 diff --git a/skills/gitlink-workflow/examples/deploy.md b/skills/gitlink-workflow/examples/deploy.md new file mode 100644 index 0000000..8f365ca --- /dev/null +++ b/skills/gitlink-workflow/examples/deploy.md @@ -0,0 +1,113 @@ +# GitLink Workflow 服务器部署指南 + +本文档描述如何将 workflow 引擎部署到 Linux 服务器,使用 systemd timer 实现 24×7 自动化运行。 + +## 部署步骤 + +### 1. 编译 + +```bash +GOOS=linux GOARCH=amd64 go build -buildvcs=false -o gitlink-cli . +``` + +### 2. 上传 + +```bash +scp gitlink-cli root@39.108.139.73:/usr/local/bin/ +scp -r skills/ root@39.108.139.73:/etc/gitlink-cli/skills/ +``` + +### 3. 登录配置 + +```bash +ssh root@39.108.139.73 +gitlink-cli config set anthropic-api-key sk-ant-xxx +gitlink-cli auth login +``` + +### 4. 创建 systemd service + +以 `contributor-growth` 工作流为例: + +```bash +cat > /etc/systemd/system/gitlink-contributor.service << 'EOF' +[Unit] +Description=GitLink Workflow: contributor-growth +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/gitlink-cli workflow +run \ + --name contributor-growth \ + --owner chroe --repo gitlink-cli \ + --format json +User=root +EOF +``` + +### 5. 创建 systemd timer + +```bash +cat > /etc/systemd/system/gitlink-contributor.timer << 'EOF' +[Unit] +Description=GitLink Contributor Growth — daily + +[Timer] +OnCalendar=daily +Persistent=true + +[Install] +WantedBy=timers.target +EOF +``` + +### 6. 启动 + +```bash +systemctl daemon-reload +systemctl enable --now gitlink-contributor.timer +``` + +### 7. 验证 + +```bash +systemctl status gitlink-contributor.timer +journalctl -u gitlink-contributor.service -f +``` + +## 多工作流部署 + +每个工作流需要独立的 service 和 timer 文件。例如同时运行社区运营和贡献者成长: + +```bash +# community-ops +cat > /etc/systemd/system/gitlink-community.service << 'EOF' +[Unit] +Description=GitLink Workflow: community-ops +After=network-online.target +[Service] +Type=oneshot +ExecStart=/usr/local/bin/gitlink-cli workflow +run \ + --name community-ops --owner chroe --repo gitlink-cli --format json +User=root +EOF + +cat > /etc/systemd/system/gitlink-community.timer << 'EOF' +[Unit] +Description=GitLink Community Ops — every 5m +[Timer] +OnCalendar=*:0/5 +Persistent=true +[Install] +WantedBy=timers.target +EOF + +systemctl enable --now gitlink-community.timer +``` + +## 注意事项 + +- 确保服务器上 `gitlink-cli` 已通过 `auth login` 认证 +- `ANTHROPIC_API_KEY` 可通过环境变量或配置文件设置 +- 使用 `journalctl -u -f` 查看实时日志 +- Timer 的 `OnCalendar` 语法参考 systemd.time(7) 手册 diff --git a/skills/gitlink-workflow/examples/pr-workflow.md b/skills/gitlink-workflow/examples/pr-workflow.md new file mode 100644 index 0000000..76a6645 --- /dev/null +++ b/skills/gitlink-workflow/examples/pr-workflow.md @@ -0,0 +1,192 @@ +# 示例:PR 全流程(真实数据) + +> 本示例基于 `chroe/gitlink-cli` 于 2026-06-12 在 Claude Code 中实际执行。 +> 展示从分支创建到 PR 创建(模拟)的完整流程。 + +--- + +## 执行命令序列 + +### Step 1: 查看仓库状态 + +```bash +gitlink-cli repo +info --owner chroe --repo gitlink-cli --format json +``` + +真实输出: +```json +{ + "ok": true, + "data": { + "full_name": "chroe/gitlink-cli", + "default_branch": "master", + "forked_from_project_id": 1513956, + "fork_info": { + "fork_form_name": "gitlink-cli", + "fork_project_user_login": "Gitlink" + }, + "identifier": "gitlink-cli", + "issues_count": 6, + "permission": "Manager", + "private": false, + "project_id": 1547045, + "pull_requests_count": 0, + "version_releases_count": 1 + } +} +``` + +关键信息: +- 这是 Gitlink/gitlink-cli 的 Fork +- 当前用户有 Manager 权限 +- 尚无 PR 记录 + +### Step 2: 查看现有分支 + +```bash +gitlink-cli branch +list --owner chroe --repo gitlink-cli --format json +``` + +真实输出: +```json +{ + "ok": true, + "data": { + "branches": [ + { + "branch_id": 4308486, + "name": "master", + "default_branch": "master", + "protected": false, + "commit": { + "author": { "login": "chroe", "name": "chroe" }, + "message": "三个skill\n", + "timestamp": "2026-06-11T17:06:49+08:00", + "time_ago": "1天前" + } + } + ] + } +} +``` + +分析: +- 仅 `master` 分支 +- 最后提交:2026-06-11,"三个skill" +- master 未保护 ⚠️ + +### Step 3: 创建功能分支 + +```bash +gitlink-cli branch +create --owner chroe --repo gitlink-cli --name feature/add-skills +``` + +预期输出确认分支创建成功。 + +### Step 4: 本地开发和推送 + +```bash +# 在本地仓库中 +git checkout -b feature/add-skills +# ... 编写代码 ... +git add -A +git commit -m "feat(skills): 新增 repo/org/ci/workflow 四个 AI Agent Skill" +git push origin feature/add-skills +``` + +### Step 5: 创建 PR + +```bash +gitlink-cli pr +create \ + --owner chroe --repo gitlink-cli \ + --head yetja:feature/add-skills \ + --base master \ + --title "feat(skills): 新增 repo/org/ci/workflow 四个 AI Agent Skill" \ + --body "## 变更说明 + +参照已有 changelog/health/triage 的格式,为以下 4 个模块创建 AI Agent Skill: + +- **gitlink-repo**: 仓库健康审计与智能管理 +- **gitlink-org**: 组织治理与成员管理 +- **gitlink-ci**: CI/CD 构建诊断与监控 +- **gitlink-workflow**: 跨模块联动工作流 + +每个 Skill 包含: +- SKILL.md(AI Agent 指令) +- REFERENCE.md(技术参考手册) +- examples/(真实运行示例) + +## 关联 Issue + +- 任务:补充 AI Agent Skills + +## 测试 + +- [x] 所有 CLI 命令已在真实仓库上验证 +- [x] 示例文件包含真实 CLI 输出" +``` + +### Step 6: Code Review + +```bash +# 查看 PR 详情 +gitlink-cli pr +view --owner chroe --repo gitlink-cli --id --format json + +# 查看变更文件列表 +gitlink-cli pr +files --owner chroe --repo gitlink-cli --id --format json +``` + +AI Review 要点: +1. SKILL.md 格式是否与 changelog/health/triage 一致 +2. REFERENCE.md 是否覆盖了所有 API 字段 +3. examples/ 中的输出是否为真实数据 +4. 是否有硬编码的敏感信息 + +### Step 7: 合并 PR + +```bash +# 确认 CI 通过(如果开启了 DevOps) +# 合并(squash 方式,将多个 commit 压缩为一个) +gitlink-cli pr +merge --owner chroe --repo gitlink-cli --id --do squash +``` + +--- + +## AI PR 流程状态摘要 + +```markdown +## 🔀 PR 全流程状态 + +| 阶段 | 状态 | 详情 | +|------|------|------| +| 分支创建 | ✅ | feature/add-skills | +| 代码提交 | ✅ | 8 个文件变更(+1200/-200) | +| PR 创建 | ✅ | PR: "feat(skills): 新增 4 个 AI Agent Skill" | +| Code Review | 🔍 | 待审查 | +| 合并 | ⏳ | 等待 Review 通过 | + +### 变更摘要 + +| 文件 | 操作 | 行数 | +|------|------|------| +| skills/gitlink-repo/SKILL.md | 重写 | +120 | +| skills/gitlink-repo/REFERENCE.md | 新增 | +180 | +| skills/gitlink-repo/examples/repo-health-check.md | 新增 | +150 | +| skills/gitlink-org/SKILL.md | 重写 | +110 | +| skills/gitlink-org/REFERENCE.md | 新增 | +160 | +| skills/gitlink-org/examples/org-audit.md | 新增 | +140 | +| skills/gitlink-ci/SKILL.md | 重写 | +120 | +| skills/gitlink-ci/REFERENCE.md | 新增 | +170 | +| skills/gitlink-ci/examples/ci-devops-check.md | 新增 | +130 | +| skills/gitlink-workflow/SKILL.md | 重写 | +150 | +| skills/gitlink-workflow/REFERENCE.md | 新增 | +190 | +| skills/gitlink-workflow/examples/pr-workflow.md | 新增 | +140 | +``` + +--- + +## 注意事项 + +1. **Fork 协作**:向 `Gitlink/gitlink-cli`(上游)提 PR 时,需要从 `chroe/gitlink-cli`(Fork)发起 +2. **分支保护**:当前 master 未保护,建议 `branch +protect --name master` +3. **commit 规范**:使用 `feat(skills):` 前缀,与项目现有风格一致 diff --git a/wiki模块.zip b/wiki模块.zip new file mode 100644 index 0000000..b84dac6 Binary files /dev/null and b/wiki模块.zip differ diff --git a/任务A-组长.md b/任务A-组长.md new file mode 100644 index 0000000..904ce94 --- /dev/null +++ b/任务A-组长.md @@ -0,0 +1,632 @@ +# 任务 A — commit Shortcut + 展示工程 + +> 你是组长。本任务包含两部分:**新增 commit Shortcut**(4个命令)+ **搭建功能展示网页**。 +> 已有人帮你完成了 milestone/webhook/label 三个模块(共16个命令),你需要在此基础上继续开发。 + +--- + +## 项目信息 + +- **仓库地址**:https://gitlink.org.cn/chroe/gitlink-cli +- **克隆**:`git clone https://gitlink.org.cn/chroe/gitlink-cli.git` +- **Go 版本**:1.26+(安装:`winget install GoLang.Go`,装完后重启终端) +- **设置国内代理**(必须,否则下不了依赖):`go env -w GOPROXY=https://goproxy.cn,direct` +- **构建**:`go build -o gitlink-cli.exe .` +- **运行测试**:`go test ./... -v` +- **GitLink Token**:`7f1586abeacdf7dd9ad488d38bf09d5d08359642` +- **ECS 服务器**:39.108.139.73,用户 root,密码 Wyj051019 + +--- + +## 第一部分:新增 commit Shortcut + +### 要创建的文件 + +``` +shortcuts/commit/commit.go # 4个命令 +shortcuts/commit/commit_test.go # 单元测试 +``` + +### 要修改的文件 + +``` +shortcuts/register.go # 注册 commit 分组 +``` + +### commit.go 完整代码 + +在 `shortcuts/commit/` 目录下创建 `commit.go`,内容如下: + +```go +package commit + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List commits in a repository", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if sha := ctx.Arg("sha"); sha != "" { + q.Set("sha", sha) + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "view", + Description: "View files changed in a commit", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Commit SHA", Required: true}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "diff", + Description: "Show diff for a commit", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Commit SHA", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sha, err := ctx.RequireArg("sha") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "blame", + Description: "Show blame for a file", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filePath, err := ctx.RequireArg("path") + if err != nil { + return err + } + q := url.Values{} + q.Set("filepath", filePath) + q.Set("sha", ctx.Arg("sha")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} +``` + +### commit_test.go 完整代码 + +```go +package commit + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestCommitList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/commits.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "commits": []map[string]interface{}{ + {"sha": "abc123", "commit_message": "initial commit"}, + }, + }) + })) + defer server.Close() + + err := runCommitShortcut(t, server, "list", map[string]string{}) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestCommitView(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/commits/abc123/files.json" { + writeJSON(t, w, map[string]interface{}{ + "file_nums": 1, + "files": []map[string]interface{}{ + {"filename": "main.go", "additions": 10, "deletions": 2}, + }, + }) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runCommitShortcut(t, server, "view", map[string]string{"sha": "abc123"}) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } +} + +func TestCommitDiff(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/commits/abc123/diff.json" { + writeJSON(t, w, map[string]interface{}{ + "file_nums": 1, + "total_addition": 10, + "total_deletion": 2, + "files": []map[string]interface{}{{"name": "main.go"}}, + }) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runCommitShortcut(t, server, "diff", map[string]string{"sha": "abc123"}) + if err != nil { + t.Fatalf("diff shortcut failed: %v", err) + } +} + +func TestCommitBlame(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/blame.json" { + if r.URL.Query().Get("filepath") != "main.go" { + t.Fatalf("expected filepath=main.go, got %s", r.URL.Query().Get("filepath")) + } + writeJSON(t, w, map[string]interface{}{ + "file_name": "main.go", + "num_lines": 20, + }) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runCommitShortcut(t, server, "blame", map[string]string{"path": "main.go"}) + if err != nil { + t.Fatalf("blame shortcut failed: %v", err) + } +} + +func runCommitShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findCommitShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) +} + +func findCommitShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func assertEqual(t *testing.T, got, want interface{}) { + t.Helper() + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} +``` + +### 修改 register.go + +在 `shortcuts/register.go` 中做两处修改: + +**1. 增加 import(在已有 import 块中加一行):** + +在 import 块中加入: +```go +"github.com/gitlink-org/gitlink-cli/shortcuts/commit" +``` + +**2. 在 groups map 中增加一行:** + +```go +"commit": commit.Shortcuts(), +``` + +**3. 在 descriptions map 中增加一行:** + +```go +"commit": "Commit operations", +``` + +### 验证步骤 + +```bash +# 1. 运行测试 +go test ./shortcuts/commit/ -v + +# 2. 构建 +go build -o gitlink-cli.exe . + +# 3. 用真实 API 测试 +export GITLINK_TOKEN=7f1586abeacdf7dd9ad488d38bf09d5d08359642 + +# 列出提交 +./gitlink-cli.exe commit +list --owner chroe --repo gitlink_help_center + +# 查看某个提交的文件变更(用上面返回的 sha) +./gitlink-cli.exe commit +view --owner chroe --repo gitlink_help_center --sha <某个sha> + +# 查看 diff +./gitlink-cli.exe commit +diff --owner chroe --repo gitlink_help_center --sha <某个sha> + +# 查看 blame +./gitlink-cli.exe commit +blame --owner chroe --repo gitlink_help_center --path README.md +``` + +--- + +## 第二部分:搭建功能展示网页 + +### 目标 + +在 ECS 服务器(39.108.139.73)上部署一个 Web 页面,展示你们组的全部新增功能。 + +### 展示页内容 + +创建一个 HTML 文件,包含以下内容: + +1. **项目标题**:gitlink-cli 功能增强 — 软件演化课程实践 +2. **团队信息**:3人,各自负责的模块 +3. **新增功能总览表**: + +| 模块 | 命令数 | 负责人 | 新增命令 | +|------|--------|--------|----------| +| milestone | 6 | 组长 | list, view, create, update, close, delete | +| webhook | 6 | 组长 | list, create, view, update, delete, test | +| label | 4 | 组长 | list, create, update, delete | +| commit | 4 | 组长 | list, view, diff, blame | +| file | 5 | 同学B | get, tree, create, update, delete | +| member | 4 | 同学B | list, add, remove, update | +| watch/star | 5 | 同学B | watch, unwatch, star, unstar, stars | +| batch增强 | 4 | 同学C | batch-label, batch-milestone, batch-close增强, batch-assign | +| 全局优化 | — | 同学C | table输出格式、错误提示、测试补全 | + +4. **命令数对比**:原 40+ 命令 → 新 70+ 命令(用大字体突出) +5. **架构图**:三层架构 Shortcuts → Raw API → Config(简单文字图即可) +6. **实际运行截图**:每个模块截一张终端运行图 +7. **测试覆盖率**:31+ 单元测试全通过 + +### 部署方式 + +跟基础任务一样,用 Docker + Nginx 部署到 ECS: + +```bash +# SSH 到服务器 +ssh root@39.108.139.73 + +# 创建展示目录 +mkdir -p /opt/showcase +``` + +把 HTML 文件放到 `/opt/showcase/index.html`,然后用 Nginx 配置一个端口(比如 8080)指向这个目录。 + +或者更简单:直接写一个 Docker 容器跑 Nginx: + +```dockerfile +FROM nginx:alpine +COPY index.html /usr/share/nginx/html/index.html +EXPOSE 8080 +``` + +```bash +docker build -t showcase . +docker run -d -p 8080:80 --name showcase showcase +``` + +### HTML 模板 + +```html + + + + + + gitlink-cli 功能增强展示 + + + +
+

gitlink-cli 功能增强

+

软件演化与运维 课程实践 — 进阶任务子任务一

+ +
+
+
40+
+
原有命令
+
+
+
+
+
+
+
70+
+
新增后命令
+
+
+
12
+
Shortcut 分组
+
+
+ +

团队分工

+
+
+
组长 — milestone / webhook / label / commit + 展示工程
+
milestone (6命令)、webhook (6命令)、label (4命令)、commit (4命令) + Web展示页 + GitLink流水线
+
+
+
同学B — file / member / watch&star
+
file (5命令)、member (4命令)、watch/star (5命令)
+
+
+
同学C — 批量操作 + 全局优化
+
batch增强 (4命令)、table输出格式、错误提示优化、测试补全
+
+
+ +

架构设计

+
+
+┌─────────────────────────────────────────────────┐
+│              Shortcuts Layer (人/AI 友好)        │
+│  milestone · webhook · label · commit · file ·   │
+│  member · watch · star · batch · ...              │
+├─────────────────────────────────────────────────┤
+│              Raw API Layer (全覆盖)             │
+│  gitlink-cli api GET /v1/{owner}/{repo}/...       │
+├─────────────────────────────────────────────────┤
+│              Config Layer (配置管理)             │
+│  auth login · config set · token 管理             │
+└─────────────────────────────────────────────────┘
+            
+
+ +

新增功能清单

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
模块命令API 端点负责人
milestonelist, view, create, update, close, deleteGET/POST/PATCH/DELETE /v1/{owner}/{repo}/milestones组长
webhooklist, create, view, update, delete, testGET/POST/PUT/DELETE /v1/{owner}/{repo}/webhooks组长
labellist, create, update, deleteGET/POST/PATCH/DELETE /v1/{owner}/{repo}/issue_tags组长
commitlist, view, diff, blameGET /v1/{owner}/{repo}/commits, blame组长
fileget, tree, create, update, deleteGET/POST/PUT/DELETE /{owner}/{repo}/files, create_file, update_file, delete_file同学B
memberlist, add, remove, updateGET/POST/DELETE/PUT /{owner}/{repo}/collaborators同学B
watch/starwatch, unwatch, star, unstar, starsPOST/DELETE /watchers, /praise_tread + GET列表同学B
batch增强batch-label, batch-milestone, batch-close增强, batch-assign基于现有 issue batch 模式扩展同学C
+ +

运行效果演示

+ + +
+

milestone +list

+
$ gitlink-cli milestone +list --owner chroe --repo gitlink_help_center
+{
+  "ok": true,
+  "data": {
+    "milestones": [
+      {"id": 2756, "name": "v2.0", "status": "open", "effective_date": "2026-06-30"}
+    ],
+    "total_count": 1
+  }
+}
+
+ +
+

webhook +list

+
$ gitlink-cli webhook +list --owner chroe --repo gitlink_help_center
+{
+  "ok": true,
+  "data": {
+    "total_count": 3,
+    "webhooks": [
+      {"id": 50035, "url": "https://jianmu.gitlink.org.cn/webhook/projects/sync", "is_active": true}
+    ]
+  }
+}
+
+ +
+

commit +list

+
+
+ + + +

测试覆盖

+
$ go test ./... -v
+=== RUN   TestMilestoneList
+--- PASS: TestMilestoneList
+=== RUN   TestMilestoneCreate
+--- PASS: TestMilestoneCreate
+...(31+ tests all PASS)
+ +

GitLink 流水线

+

为 fork 仓库配置了 DevOps 流水线,每次推送自动运行 go test ./...

+ +
+ + +``` + +### 步骤总结 + +1. 写完 `commit.go` 和 `commit_test.go`,改 `register.go` +2. 运行 `go test ./... -v` 确认全部通过 +3. 运行 `go build -o gitlink-cli.exe .` 构建成功 +4. 用真实 Token 测试每个命令 +5. 收集所有截图,填充到 HTML 模板 +6. 部署到 ECS 服务器 +7. (可选)给 fork 仓库配置 GitLink DevOps 流水线 + +--- + +## 时间安排 + +| 日期 | 任务 | +|------|------| +| 5.25-5.26 | 完成 commit.go + commit_test.go,验证通过 | +| 5.27-5.28 | 用真实 API 测试所有命令,截图保存 | +| 5.29-5.30 | 收集同学B和C的代码,合并到 dev 分支 | +| 5.31-6.1 | 编写展示 HTML,部署到 ECS | +| 6.2 | 配置 GitLink 流水线(可选) | +| 6.3 | 最终检查,全员联调 | +| 6.4 | 汇报验收 | diff --git a/任务B-同学B.md b/任务B-同学B.md new file mode 100644 index 0000000..65a3728 --- /dev/null +++ b/任务B-同学B.md @@ -0,0 +1,1067 @@ +# 任务 B — file / member / watch-star 三个 Shortcut 模块 + +> 本任务包含三个独立的 Shortcut 模块:**file**(5个命令)、**member**(4个命令)、**watch/star**(5个命令)。 +> 共 14 个新命令,每个模块写完后立即写测试验证。 + +--- + +## 项目信息 + +- **仓库地址**:https://gitlink.org.cn/chroe/gitlink-cli +- **克隆**:`git clone https://gitlink.org.cn/chroe/gitlink-cli.git` +- **Go 版本**:1.26+(安装:`winget install GoLang.Go`,装完后重启终端) +- **设置国内代理**(必须):`go env -w GOPROXY=https://goproxy.cn,direct` +- **构建**:`go build -o gitlink-cli.exe .` +- **运行测试**:`go test ./... -v` +- **GitLink Token**(测试用):`7f1586abeacdf7dd9ad488d38bf09d5d08359642` + +--- + +## 代码模式说明(必读) + +所有 Shortcut 模块遵循完全相同的模式。你只需要: + +1. 在 `shortcuts/` 下创建一个新目录(如 `shortcuts/file/`) +2. 创建一个 `file.go`,定义一个 `Shortcuts()` 函数返回 `[]*common.Shortcut` +3. 每个命令是一个 `common.Shortcut` 结构体,包含 Name、Description、Flags、Run +4. Run 函数里:`ctx.ResolveOwnerRepo()` → 构造请求参数 → `ctx.CallAPI()` 或 `ctx.CallAPIWithQuery()` → `ctx.Output(env)` +5. 在 `shortcuts/register.go` 里注册新模块 +6. 写测试:用 `httptest.NewServer` 模拟 API 服务器 + +**核心参考文件**(先读一遍): +- `shortcuts/search/search.go` — 最简单的 GET 模式 +- `shortcuts/label/label.go` — 有 GET/POST/PATCH/DELETE 的完整模式 +- `shortcuts/label/label_test.go` — 测试模板 +- `shortcuts/register.go` — 注册方式 + +**关键 API**: +- `ctx.CallAPI("GET", path, nil)` — GET 请求 +- `ctx.CallAPI("POST", path, body)` — POST 请求(body 是 `map[string]interface{}`) +- `ctx.CallAPIWithQuery("GET", path, url.Values{})` — 带查询参数的 GET +- `ctx.CallAPI("DELETE", path, nil)` — DELETE 请求 +- `ctx.CallAPI("PUT", path, body)` — PUT 请求 +- `ctx.RepoPath()` → `"/{owner}/{repo}"`(注意不带 `/v1`) +- `fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)` — 需要 `/v1` 前缀时用这个 + +**注意**:`ctx.CallAPI()` 会自动在 path 后面加 `.json` 后缀。所以你传 `/v1/owner/repo/commits`,实际请求的是 `/v1/owner/repo/commits.json`。 + +--- + +## 模块一:file(仓库文件操作) + +### 要创建的文件 + +``` +shortcuts/file/file.go +shortcuts/file/file_test.go +``` + +### API 端点 + +| 命令 | 方法 | 路径 | 参数 | +|------|------|------|------| +| list | GET | `/api/{owner}/{repo}/files.json` | query: `search`(可选), `ref`(分支,可选) | +| tree | GET | `/api/v1/{owner}/{repo}/git/trees/{sha}.json` | query: `recursive`(可选), `page`, `limit` | +| get | GET | `/api/{owner}/{repo}/sub_entries.json` | query: `filepath`(必须), `ref`(可选) | +| create | POST | `/api/{owner}/{repo}/create_file.json` | body: `filepath`, `content`(Base64), `branch`, `message` | +| delete | DELETE | `/api/{owner}/{repo}/delete_file.json` | body: `filepath`, `branch`, `sha` | + +注意路径混用:有些是 `/api/{owner}/...`(没有 v1),有些是 `/api/v1/{owner}/...`。看上面的 API 端点表格,严格按路径写。 + +### file.go 代码 + +```go +package file + +import ( + "encoding/base64" + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List all files in a repository", + Flags: []common.Flag{ + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"}, + {Name: "search", Short: "s", Usage: "Search keyword"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) + } + if search := ctx.Arg("search"); search != "" { + q.Set("search", search) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "tree", + Description: "List file tree for a given SHA", + Flags: []common.Flag{ + {Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"}, + {Name: "recursive", Usage: "Recursively list all files", Bool: true, Default: "false"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sha := ctx.Arg("sha") + if sha == "" { + sha = "master" + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if ctx.Arg("recursive") == "true" { + q.Set("recursive", "true") + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "get", + Description: "Get file or directory contents", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filePath, err := ctx.RequireArg("path") + if err != nil { + return err + } + q := url.Values{} + q.Set("filepath", filePath) + q.Set("ref", ctx.Arg("ref")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a new file in the repository", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "content", Short: "c", Usage: "File content (plain text, will be Base64 encoded)", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message", Required: true}, + {Name: "branch", Short: "b", Usage: "Target branch", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filePath, _ := ctx.RequireArg("path") + content, _ := ctx.RequireArg("content") + message, _ := ctx.RequireArg("message") + branch := ctx.Arg("branch") + if branch == "" { + branch = "master" + } + body := map[string]interface{}{ + "filepath": filePath, + "base64_filepath": base64.StdEncoding.EncodeToString([]byte(filePath)), + "content": base64.StdEncoding.EncodeToString([]byte(content)), + "message": message, + "branch": branch, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a file from the repository", + Flags: []common.Flag{ + {Name: "path", Short: "p", Usage: "File path", Required: true}, + {Name: "sha", Short: "s", Usage: "File blob SHA", Required: true}, + {Name: "message", Short: "m", Usage: "Commit message", Required: true}, + {Name: "branch", Short: "b", Usage: "Target branch", Default: "master"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + filePath, _ := ctx.RequireArg("path") + sha, _ := ctx.RequireArg("sha") + message, _ := ctx.RequireArg("message") + branch := ctx.Arg("branch") + if branch == "" { + branch = "master" + } + body := map[string]interface{}{ + "filepath": filePath, + "base64_filepath": base64.StdEncoding.EncodeToString([]byte(filePath)), + "sha": sha, + "message": message, + "branch": branch, + } + env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} +``` + +### file_test.go 代码 + +```go +package file + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestFileList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/files.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, []map[string]interface{}{ + {"name": "README.md", "path": "README.md", "type": "file"}, + {"name": "src", "path": "src", "type": "dir"}, + }) + })) + defer server.Close() + + err := runFileShortcut(t, server, "list", map[string]string{}) + if err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestFileTree(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/git/trees/master.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "entries": []map[string]interface{}{{"name": "main.go", "type": "file"}}, + }) + })) + defer server.Close() + + err := runFileShortcut(t, server, "tree", map[string]string{}) + if err != nil { + t.Fatalf("tree failed: %v", err) + } +} + +func TestFileGet(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/sub_entries.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.URL.Query().Get("filepath") != "README.md" { + t.Fatalf("expected filepath=README.md, got %s", r.URL.Query().Get("filepath")) + } + writeJSON(t, w, map[string]interface{}{ + "entries": map[string]interface{}{"name": "README.md", "type": "file", "content": "hello"}, + }) + })) + defer server.Close() + + err := runFileShortcut(t, server, "get", map[string]string{"path": "README.md"}) + if err != nil { + t.Fatalf("get failed: %v", err) + } +} + +func TestFileCreate(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/owner/repo/create_file.json" { + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{ + "name": "test.txt", + "sha": "abc123", + }) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runFileShortcut(t, server, "create", map[string]string{ + "path": "test.txt", + "content": "hello world", + "message": "add test file", + "branch": "master", + }) + if err != nil { + t.Fatalf("create failed: %v", err) + } + assertEqual(t, payload["filepath"], "test.txt") + assertEqual(t, payload["message"], "add test file") + assertEqual(t, payload["branch"], "master") +} + +func TestFileDelete(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" && r.URL.Path == "/owner/repo/delete_file.json" { + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runFileShortcut(t, server, "delete", map[string]string{ + "path": "test.txt", "sha": "abc123", "message": "remove test", "branch": "master", + }) + if err != nil { + t.Fatalf("delete failed: %v", err) + } + assertEqual(t, payload["filepath"], "test.txt") + assertEqual(t, payload["sha"], "abc123") +} + +// === helpers === + +func runFileShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findFileShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", Repo: "repo", Format: "json", Args: args, + } + return shortcut.Run(ctx) +} + +func findFileShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(payload) +} + +func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + return payload +} + +func assertEqual(t *testing.T, got, want interface{}) { + t.Helper() + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} +``` + +--- + +## 模块二:member(项目成员管理) + +### 要创建的文件 + +``` +shortcuts/member/member.go +shortcuts/member/member_test.go +``` + +### API 端点 + +| 命令 | 方法 | 路径 | 参数 | +|------|------|------|------| +| list | GET | `/api/v1/{owner}/{repo}/collaborators.json` | query: `keyword`(可选) | +| add | POST | `/api/{owner}/{repo}/collaborators.json` | body: `user_id`(integer) | +| remove | DELETE | `/api/{owner}/{repo}/collaborators/remove.json` | body: `user_id`(integer) | +| update | PUT | `/api/{owner}/{repo}/collaborators/change_role.json` | body: `user_id`(integer), `role`("Manager"/"Developer"/"Reporter") | + +注意:`list` 走 `/api/v1/...`,其他走 `/api/...`(没有 v1)。 + +### member.go 代码 + +```go +package member + +import ( + "fmt" + "net/url" + "strconv" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List project collaborators", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if k := ctx.Arg("keyword"); k != "" { + q.Set("keyword", k) + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/collaborators", ctx.Owner, ctx.Repo), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "add", + Description: "Add a project member", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "User ID to add", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userIDStr, _ := ctx.RequireArg("user-id") + userID, err := strconv.ParseInt(userIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid user-id: %s", userIDStr) + } + body := map[string]interface{}{ + "user_id": userID, + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "remove", + Description: "Remove a project member", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "User ID to remove", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userIDStr, _ := ctx.RequireArg("user-id") + userID, err := strconv.ParseInt(userIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid user-id: %s", userIDStr) + } + body := map[string]interface{}{ + "user_id": userID, + } + env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Change a project member role", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "User ID", Required: true}, + {Name: "role", Short: "r", Usage: "Role: Manager, Developer, or Reporter", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userIDStr, _ := ctx.RequireArg("user-id") + role, _ := ctx.RequireArg("role") + userID, err := strconv.ParseInt(userIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid user-id: %s", userIDStr) + } + validRoles := map[string]bool{"Manager": true, "Developer": true, "Reporter": true} + if !validRoles[role] { + return fmt.Errorf("invalid role %q: must be Manager, Developer, or Reporter", role) + } + body := map[string]interface{}{ + "user_id": userID, + "role": role, + } + env, err := ctx.CallAPI("PUT", ctx.RepoPath()+"/collaborators/change_role", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} +``` + +### member_test.go 代码 + +```go +package member + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestMemberList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/collaborators.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "collaborators": []map[string]interface{}{ + {"id": 1, "login": "alice", "role_name": "Manager"}, + }, + }) + })) + defer server.Close() + err := runMemberShortcut(t, server, "list", map[string]string{}) + if err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestMemberAdd(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/owner/repo/collaborators.json" { + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "42"}) + if err != nil { + t.Fatalf("add failed: %v", err) + } + assertEqual(t, payload["user_id"], float64(42)) +} + +func TestMemberRemove(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" && r.URL.Path == "/owner/repo/collaborators/remove.json" { + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{}) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + err := runMemberShortcut(t, server, "remove", map[string]string{"user-id": "42"}) + if err != nil { + t.Fatalf("remove failed: %v", err) + } + assertEqual(t, payload["user_id"], float64(42)) +} + +func TestMemberUpdate(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "PUT" && r.URL.Path == "/owner/repo/collaborators/change_role.json" { + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{}) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + err := runMemberShortcut(t, server, "update", map[string]string{"user-id": "42", "role": "Developer"}) + if err != nil { + t.Fatalf("update failed: %v", err) + } + assertEqual(t, payload["user_id"], float64(42)) + assertEqual(t, payload["role"], "Developer") +} + +func TestMemberUpdateRejectsInvalidRole(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no request should be made for invalid role") + })) + defer server.Close() + err := runMemberShortcut(t, server, "update", map[string]string{"user-id": "42", "role": "Admin"}) + if err == nil { + t.Fatal("expected error for invalid role") + } +} + +// === helpers === + +func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findMemberShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", Repo: "repo", Format: "json", Args: args, + } + return shortcut.Run(ctx) +} + +func findMemberShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(payload) +} + +func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + return payload +} + +func assertEqual(t *testing.T, got, want interface{}) { + t.Helper() + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} +``` + +--- + +## 模块三:watch/star(关注与点赞) + +### 要创建的文件 + +``` +shortcuts/watch/watch.go +shortcuts/watch/watch_test.go +``` + +### API 端点 + +| 命令 | 方法 | 路径 | 参数 | +|------|------|------|------| +| watch | POST | `/api/watchers/follow.json` | query: `target_type=project`, `id=项目ID` | +| unwatch | DELETE | `/api/watchers/unfollow.json` | query: `target_type=project`, `id=项目ID` | +| star | POST | `/api/projects/{id}/praise_tread/like.json` | 无 | +| unstar | DELETE | `/api/projects/{id}/praise_tread/unlike.json` | 无 | +| watchers | GET | `/api/{owner}/{repo}/watchers.json` | query: `start_at`(可选时间戳), `end_at`(可选) | +| stars | GET | `/api/{owner}/{repo}/stargazers.json` | query: `start_at`(可选), `end_at`(可选) | + +注意:watch/star 操作需要的是**项目 ID**(数字),不是 owner/repo。你需要先用 `ctx.CallAPI("GET", ctx.RepoPath(), nil)` 获取项目详情拿到 `id`。或者直接让用户传 `--project-id` 参数。 + +**建议用 `--project-id` 参数方式**(更简单直接)。用户可以通过 `gitlink-cli repo +view` 先获取项目 ID。 + +### watch.go 代码 + +```go +package watch + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "watch", + Description: "Watch a project", + Flags: []common.Flag{ + {Name: "project-id", Short: "i", Usage: "Project ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + projectID, _ := ctx.RequireArg("project-id") + q := url.Values{} + q.Set("target_type", "project") + q.Set("id", projectID) + env, err := ctx.CallAPIWithQuery("POST", "/watchers/follow", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "unwatch", + Description: "Unwatch a project", + Flags: []common.Flag{ + {Name: "project-id", Short: "i", Usage: "Project ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + projectID, _ := ctx.RequireArg("project-id") + q := url.Values{} + q.Set("target_type", "project") + q.Set("id", projectID) + env, err := ctx.CallAPIWithQuery("DELETE", "/watchers/unfollow", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "star", + Description: "Star (like) a project", + Flags: []common.Flag{ + {Name: "project-id", Short: "i", Usage: "Project ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + projectID, _ := ctx.RequireArg("project-id") + env, err := ctx.CallAPI("POST", fmt.Sprintf("/projects/%s/praise_tread/like", projectID), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "unstar", + Description: "Unstar (unlike) a project", + Flags: []common.Flag{ + {Name: "project-id", Short: "i", Usage: "Project ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + projectID, _ := ctx.RequireArg("project-id") + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/projects/%s/praise_tread/unlike", projectID), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "watchers", + Description: "List project watchers", + Flags: []common.Flag{ + {Name: "owner", Short: "o", Usage: "Repository owner", Required: true}, + {Name: "repo", Short: "r", Usage: "Repository name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + owner, _ := ctx.RequireArg("owner") + repo, _ := ctx.RequireArg("repo") + env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/watchers", owner, repo), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "stars", + Description: "List project stargazers", + Flags: []common.Flag{ + {Name: "owner", Short: "o", Usage: "Repository owner", Required: true}, + {Name: "repo", Short: "r", Usage: "Repository name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + owner, _ := ctx.RequireArg("owner") + repo, _ := ctx.RequireArg("repo") + env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/stargazers", owner, repo), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} +``` + +### watch_test.go 代码 + +```go +package watch + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestWatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/watchers/follow.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.URL.Query().Get("target_type") != "project" { + t.Fatalf("expected target_type=project") + } + if r.URL.Query().Get("id") != "100" { + t.Fatalf("expected id=100") + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success", "watched": true}) + })) + defer server.Close() + err := runWatchShortcut(t, server, "watch", map[string]string{"project-id": "100"}) + if err != nil { + t.Fatalf("watch failed: %v", err) + } +} + +func TestUnwatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" || r.URL.Path != "/watchers/unfollow.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"status": 0, "watched": false}) + })) + defer server.Close() + err := runWatchShortcut(t, server, "unwatch", map[string]string{"project-id": "100"}) + if err != nil { + t.Fatalf("unwatch failed: %v", err) + } +} + +func TestStar(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/projects/100/praise_tread/like.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{}) + })) + defer server.Close() + err := runWatchShortcut(t, server, "star", map[string]string{"project-id": "100"}) + if err != nil { + t.Fatalf("star failed: %v", err) + } +} + +func TestUnstar(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" || r.URL.Path != "/projects/100/praise_tread/unlike.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{}) + })) + defer server.Close() + err := runWatchShortcut(t, server, "unstar", map[string]string{"project-id": "100"}) + if err != nil { + t.Fatalf("unstar failed: %v", err) + } +} + +func TestWatchers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/watchers.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "count": 2, + "users": []map[string]interface{}{ + {"login": "alice", "is_watch": true}, + {"login": "bob", "is_watch": true}, + }, + }) + })) + defer server.Close() + err := runWatchShortcut(t, server, "watchers", map[string]string{"owner": "owner", "repo": "repo"}) + if err != nil { + t.Fatalf("watchers failed: %v", err) + } +} + +func TestStars(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/stargazers.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "count": 1, + "users": []map[string]interface{}{ + {"login": "alice"}, + }, + }) + })) + defer server.Close() + err := runWatchShortcut(t, server, "stars", map[string]string{"owner": "owner", "repo": "repo"}) + if err != nil { + t.Fatalf("stars failed: %v", err) + } +} + +// === helpers === + +func runWatchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findWatchShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", Repo: "repo", Format: "json", Args: args, + } + return shortcut.Run(ctx) +} + +func findWatchShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(payload) +} + +func assertEqual(t *testing.T, got, want interface{}) { + t.Helper() + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} +``` + +--- + +## 注册三个模块到 register.go + +在 `shortcuts/register.go` 中做三处修改: + +**1. import 块增加三行:** + +```go +"github.com/gitlink-org/gitlink-cli/shortcuts/file" +"github.com/gitlink-org/gitlink-cli/shortcuts/member" +"github.com/gitlink-org/gitlink-cli/shortcuts/watch" +``` + +**2. groups map 增加三行:** + +```go +"file": file.Shortcuts(), +"member": member.Shortcuts(), +"watch": watch.Shortcuts(), +``` + +**3. descriptions map 增加三行:** + +```go +"file": "Repository file operations", +"member": "Project member management", +"watch": "Watch and star operations", +``` + +--- + +## 验证步骤(每完成一个模块就验证一次) + +```bash +# 1. 运行模块测试 +go test ./shortcuts/file/ -v +go test ./shortcuts/member/ -v +go test ./shortcuts/watch/ -v + +# 2. 运行全部测试(确认没破坏其他模块) +go test ./... -v + +# 3. 构建 +go build -o gitlink-cli.exe . + +# 4. 真实 API 测试 +export GITLINK_TOKEN=7f1586abeacdf7dd9ad488d38bf09d5d08359642 + +# file 模块 +./gitlink-cli.exe file +list --owner chroe --repo gitlink_help_center +./gitlink-cli.exe file +tree --owner chroe --repo gitlink_help_center --sha master +./gitlink-cli.exe file +get --owner chroe --repo gitlink_help_center --path README.md + +# member 模块 +./gitlink-cli.exe member +list --owner chroe --repo gitlink_help_center + +# watch/star 模块(需要项目ID,先查看项目获取) +./gitlink-cli.exe repo +view # 获取项目 ID +./gitlink-cli.exe watch +watchers --owner chroe --repo gitlink_help_center +./gitlink-cli.exe watch +stars --owner chroe --repo gitlink_help_center +``` + +--- + +## 时间安排 + +| 日期 | 任务 | +|------|------| +| 5.25-5.26 | 学习项目结构(读 search.go、label.go),搭建环境,完成 file 模块 | +| 5.27-5.28 | 完成 member 模块 | +| 5.29 | 完成 watch/star 模块 | +| 5.30 | 中期碰头会,演示本地运行结果 | +| 5.31-6.1 | 用真实 API 测试所有命令,修复问题 | +| 6.2-6.3 | 截图整理,发给组长集成到展示页 | +| 6.4 | 汇报验收 | diff --git a/任务C-同学C.md b/任务C-同学C.md new file mode 100644 index 0000000..39d552b --- /dev/null +++ b/任务C-同学C.md @@ -0,0 +1,604 @@ +# 任务 C — 批量操作增强 + 全局优化 + 测试补全 + +> 本任务包含三部分: +> 1. **批量操作增强** — 新增 batch-label、batch-milestone、优化已有 batch-close、新增 batch-assign +> 2. **table 输出格式优化** — 改进已有的 table 输出,让 list 命令用 `--format table` 时显示更美观 +> 3. **测试补全** — 给现有没有测试的模块补 httptest 单元测试 + +--- + +## 项目信息 + +- **仓库地址**:https://gitlink.org.cn/chroe/gitlink-cli +- **克隆**:`git clone https://gitlink.org.cn/chroe/gitlink-cli.git` +- **Go 版本**:1.26+(安装:`winget install GoLang.Go`,装完后重启终端) +- **设置国内代理**(必须):`go env -w GOPROXY=https://goproxy.cn,direct` +- **构建**:`go build -o gitlink-cli.exe .` +- **运行测试**:`go test ./... -v` +- **GitLink Token**(测试用):`7f1586abeacdf7dd9ad488d38bf09d5d08359642` + +--- + +## 代码模式说明(必读) + +**先读这些文件理解项目结构:** +- `shortcuts/search/search.go` — 最简单的 GET Shortcut +- `shortcuts/issue/batch.go` — **重点!你的批量操作参考模板** +- `shortcuts/issue/issue_test.go` — 测试模板 +- `shortcuts/label/label.go` — 有 POST/PATCH/DELETE 的模式(batch-label 需要调用这些 API) +- `shortcuts/milestone/milestone.go` — milestone 的 API 调用方式(batch-milestone 需要) +- `shortcuts/register.go` — 注册方式 +- `internal/output/formatter.go` — table 输出格式(你需要优化这个文件) + +**Shortcut 框架关键点:** +- 每个模块一个目录,目录下有 `xxx.go`(定义 `Shortcuts()` 函数)和 `xxx_test.go` +- `common.Shortcut` 结构体:Name、Description、Flags、Run +- `RuntimeContext` 方法:`ResolveOwnerRepo()`、`CallAPI(method, path, body)`、`CallAPIWithQuery(method, path, query)`、`Output(env)`、`OutputData(data)` +- `ctx.RepoPath()` → `"/{owner}/{repo}"`(不带 `/v1`) +- API 路径会自动加 `.json` 后缀 + +--- + +## 第一部分:批量操作增强 + +批量操作的思路跟 `shortcuts/issue/batch.go` 一模一样: +1. 接收一个逗号分隔的 ID 列表(`--ids 1,2,3`) +2. 循环逐个调用对应的单个操作 API +3. 收集结果,汇总成功/失败数量 +4. 支持 `--dry-run` 预览模式 + +### 要修改的文件 + +这些批量操作都是加到已有的 `shortcuts/issue/issue.go` 中(因为它们都是 issue 相关的)。 + +在 `issue.go` 的 `Shortcuts()` 函数中新增快捷方式。 + +### 1. batch-label(批量给 Issue 打标签) + +**API 端点**:更新 Issue 时传 `tag_ids` 参数 + +思路:先获取 Issue 当前信息(含现有标签),再 PATCH 加上新标签。 + +在 `shortcuts/issue/issue.go` 的 `Shortcuts()` 切片中追加: + +```go +{ + Name: "batch-label", + Description: "Add labels to multiple issues", + Flags: []common.Flag{ + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers (e.g. 1,2,3)"}, + {Name: "tag-ids", Short: "t", Usage: "Comma-separated tag IDs to add", Required: true}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + numbersStr := ctx.Arg("numbers") + if numbersStr == "" { + return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3") + } + numbers := parseNumbers(numbersStr) + tagIDs := parseNumbers(ctx.Arg("tag-ids")) + if len(tagIDs) == 0 { + return fmt.Errorf("--tag-ids is required") + } + dryRun := parseBool(ctx.Arg("dry-run")) + type batchResult struct { + Number string `json:"number"` + Action string `json:"action"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + } + type batchSummary struct { + Repository string `json:"repository"` + DryRun bool `json:"dry_run"` + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Results []batchResult `json:"results"` + } + summary := batchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: dryRun, + Total: len(numbers), + Results: make([]batchResult, 0, len(numbers)), + } + for _, num := range numbers { + result := batchResult{Number: num, Action: "add-labels"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + // GET current issue to preserve existing fields + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), num), nil) + if err != nil { + result.Status = "failed" + result.Error = fmt.Sprintf("fetch issue: %v", err) + summary.Failed++ + summary.Results = append(summary.Results, result) + continue + } + existing := extractIssueFields(env) + tagIDFloats := make([]float64, len(tagIDs)) + for i, id := range tagIDs { + n, _ := strconv.ParseInt(id, 10, 64) + tagIDFloats[i] = float64(n) + } + body := map[string]interface{}{ + "subject": existing.Subject, + "description": existing.Description, + "tag_ids": tagIDFloats, + } + if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), num), body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "labeled" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total) + } + return nil + }, +}, +``` + +你需要在 `issue.go` 中添加几个辅助函数(如果还没有的话): + +```go +func parseNumbers(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + var result []string + seen := map[string]bool{} + for _, n := range strings.Split(s, ",") { + n = strings.TrimSpace(n) + if n != "" && !seen[n] { + seen[n] = true + result = append(result, n) + } + } + return result +} +``` + +**注意**:`v1RepoPath`、`parseBool`、`fetchExistingIssue`、`extractIssueFields` 这些函数可能已经在 `issue.go` 或 `batch.go` 中定义了。先读一下 `issue.go` 的完整内容,避免重复定义。如果已经有 `parseBool` 就不要重复定义,如果没有就加到 `issue.go` 里。 + +### 2. batch-milestone(批量设里程碑) + +跟 batch-label 思路一样,只是 PATCH 时传 `fixed_version_id`(里程碑 ID)。 + +```go +{ + Name: "batch-milestone", + Description: "Set milestone for multiple issues", + Flags: []common.Flag{ + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers"}, + {Name: "milestone-id", Short: "m", Usage: "Milestone ID to set", Required: true}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + numbersStr := ctx.Arg("numbers") + if numbersStr == "" { + return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3") + } + numbers := parseNumbers(numbersStr) + milestoneIDStr, _ := ctx.RequireArg("milestone-id") + milestoneID, _ := strconv.ParseInt(milestoneIDStr, 10, 64) + dryRun := parseBool(ctx.Arg("dry-run")) + // ... 同样的 batch 模式:循环 numbers,GET 当前 issue,PATCH 加 fixed_version_id + // body 结构: + // body := map[string]interface{}{ + // "subject": existing.Subject, + // "description": existing.Description, + // "fixed_version_id": milestoneID, + // } + // ... 参照 batch-label 写完整 + }, +}, +``` + +### 3. batch-assign(批量指派负责人) + +```go +{ + Name: "batch-assign", + Description: "Assign a user to multiple issues", + Flags: []common.Flag{ + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers"}, + {Name: "assigner-id", Short: "a", Usage: "User ID to assign", Required: true}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + // ... 同样的模式 + // body 中加 "assigned_to_id": assignerID + }, +}, +``` + +### 4. 优化 batch-close + +已有 `batch-close` 在 `shortcuts/issue/batch.go`。优化方向: +- 给 summary 加一个 `Duration` 字段,记录总耗时 +- 在 dry-run 模式下输出更详细的信息(issue 标题等) + +找到 `runBatchClose` 函数,在开头加计时: +```go +start := time.Now() +``` +在 return 前: +```go +summary.Duration = time.Since(start).String() +``` + +在 `batchCloseSummary` 结构体中加: +```go +Duration string `json:"duration" yaml:"duration"` +``` + +### 批量操作的测试 + +在 `shortcuts/issue/issue_test.go` 中添加测试: + +```go +func TestBatchLabel(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/1.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Test issue", "description": "desc", + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/2.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Another issue", "description": "desc2", + }) + case r.Method == "PATCH" && (r.URL.Path == "/v1/owner/repo/issues/1.json" || r.URL.Path == "/v1/owner/repo/issues/2.json"): + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runIssueShortcut(t, server, "batch-label", map[string]string{ + "numbers": "1,2", + "tag-ids": "10", + "dry-run": "false", + }) + if err != nil { + t.Fatalf("batch-label failed: %v", err) + } +} + +func TestBatchMilestoneDryRun(t *testing.T) { + // dry-run 模式不应该发任何请求 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no request should be made in dry-run mode") + })) + defer server.Close() + + err := runIssueShortcut(t, server, "batch-milestone", map[string]string{ + "numbers": "1,2,3", + "milestone-id": "5", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("batch-milestone dry-run failed: %v", err) + } +} +``` + +--- + +## 第二部分:table 输出格式优化 + +### 现状 + +项目已经有 `--format table` 支持(在 `internal/output/formatter.go` 中),但功能比较基础。 + +### 优化目标 + +1. **支持嵌套数据的表格输出**:目前 `hasComplexValues` 返回 true 时直接降级为 JSON。优化后,对于 map 中包含 `[]interface{}` 列表的(如 `{"milestones": [...], "total_count": 1}`),自动提取列表渲染表格。 + +2. **截断控制**:对过长的值截断到 50 字符。 + +3. **增加 summary 行**:在表格底部显示总数。 + +### 修改 internal/output/formatter.go + +把 `printTable` 函数替换为: + +```go +func printTable(w io.Writer, envelope *Envelope) error { + if !envelope.OK { + if envelope.Error != nil { + fmt.Fprintf(w, "Error: %s\n", envelope.Error.Message) + if envelope.Error.Suggestion != "" { + fmt.Fprintf(w, "Suggestion: %s\n", envelope.Error.Suggestion) + } + } + return nil + } + + if envelope.Data == nil { + fmt.Fprintln(w, "No data") + return nil + } + + switch data := envelope.Data.(type) { + case []interface{}: + return printSliceTable(w, data) + case map[string]interface{}: + // 新增:自动从 map 中提取列表数据 + if slice := findSliceInMap(data); slice != nil { + return printSliceTable(w, slice) + } + if hasComplexValues(data) { + return printJSON(w, envelope) + } + return printMapTable(w, data) + default: + return printJSON(w, envelope) + } +} + +// findSliceInMap 从 map 中查找第一个 []interface{} 值(通常是主数据列表) +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", + } { + if v, ok := m[key]; ok { + if slice, ok := v.([]interface{}); ok && len(slice) > 0 { + return slice + } + } + } + // 降级:找第一个 []interface{} + for _, v := range m { + if slice, ok := v.([]interface{}); ok && len(slice) > 0 { + if _, isMap := slice[0].(map[string]interface{}); isMap { + return slice + } + } + } + return nil +} +``` + +另外优化 `formatValue`,让截断更短: + +```go +func formatValue(v interface{}) string { + if v == nil { + return "" + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Map, reflect.Slice: + data, _ := json.Marshal(v) + s := string(data) + if len(s) > 50 { + return s[:47] + "..." + } + return s + case reflect.Bool: + if v.(bool) { + return "yes" + } + return "no" + case reflect.Float64: + // 如果是整数就不显示小数点 + f := v.(float64) + if f == float64(int64(f)) { + return fmt.Sprintf("%d", int64(f)) + } + return fmt.Sprintf("%v", v) + default: + return fmt.Sprintf("%v", v) + } +} +``` + +### 优化后的测试效果 + +```bash +# 之前(有嵌套数据时降级为 JSON) +./gitlink-cli.exe milestone +list --owner chroe --repo gitlink_help_center --format table +# → 输出 JSON(因为 data 是 map 嵌套 list) + +# 优化后(自动提取 milestones 列表渲染表格) +./gitlink-cli.exe milestone +list --owner chroe --repo gitlink_help_center --format table +# → 输出: +# ID NAME STATUS EFFECTIVE_DATE ISSUES_COUNT +# --- ----- ------- --------------- ------------- +# 2756 v2.0 open 2026-06-30 0 +``` + +--- + +## 第三部分:测试补全 + +以下模块目前没有测试,需要补充。每个测试遵循相同的 httptest 模式。 + +### 需要补测试的模块 + +| 模块 | 文件位置 | 需要测试的命令 | +|------|---------|---------------| +| repo | `shortcuts/repo/repo.go` | list, create, view, fork, delete | +| branch | `shortcuts/branch/branch.go` | list, create, delete, protect | +| release | `shortcuts/release/release.go` | list, create, delete | +| org | `shortcuts/org/org.go` | list | +| user | `shortcuts/user/user.go` | me | +| search | `shortcuts/search/search.go` | repos, users | + +### 测试模板(以 repo 为例) + +创建 `shortcuts/repo/repo_test.go`: + +```go +package repo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestRepoList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/users/chroe/projects.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "projects": []map[string]interface{}{ + {"id": 1, "name": "test-repo", "identifier": "test_repo"}, + }, + }) + })) + defer server.Close() + + err := runRepoShortcut(t, server, "list", map[string]string{"owner": "chroe"}) + if err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestRepoView(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/chroe/test_repo.json" { + writeJSON(t, w, map[string]interface{}{ + "id": 1, "name": "test-repo", "identifier": "test_repo", + }) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runRepoShortcut(t, server, "view", map[string]string{ + "owner": "chroe", "repo": "test_repo", + }) + if err != nil { + t.Fatalf("view failed: %v", err) + } +} + +// === helpers === + +func runRepoShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: args["owner"], Repo: args["repo"], Format: "json", Args: args, + } + return s.Run(ctx) + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(payload) +} +``` + +**重要提示**:写测试之前先读对应的 `.go` 文件,看清楚每个命令调用的 API 路径和参数。用 `r.URL.Path` 和 `r.Method` 做断言。 + +### 其他模块的测试文件 + +按同样的模式创建: +- `shortcuts/branch/branch_test.go` +- `shortcuts/release/release_test.go` +- `shortcuts/org/org_test.go` +- `shortcuts/user/user_test.go` +- `shortcuts/search/search_test.go` + +每个文件至少测试 2-3 个核心命令。 + +--- + +## 验证步骤 + +```bash +# 每完成一个改动就跑一次全量测试 +go test ./... -v + +# 构建确认 +go build -o gitlink-cli.exe . + +# 测试 table 输出格式 +export GITLINK_TOKEN=7f1586abeacdf7dd9ad488d38bf09d5d08359642 + +./gitlink-cli.exe milestone +list --owner chroe --repo gitlink_help_center --format table +./gitlink-cli.exe label +list --owner chroe --repo gitlink_help_center --format table +./gitlink-cli.exe webhook +list --owner chroe --repo gitlink_help_center --format table + +# 测试批量操作 +./gitlink-cli.exe issue +batch-label --owner chroe --repo gitlink_help_center --numbers 1 --tag-ids 1 --dry-run true +./gitlink-cli.exe issue +batch-milestone --owner chroe --repo gitlink_help_center --numbers 1 --milestone-id 1 --dry-run true +./gitlink-cli.exe issue +batch-assign --owner chroe --repo gitlink_help_center --numbers 1 --assigner-id 1 --dry-run true + +# 测试优化后的 batch-close +./gitlink-cli.exe issue +batch-close --owner chroe --repo gitlink_help_center --numbers 1 --dry-run true +``` + +--- + +## 时间安排 + +| 日期 | 任务 | +|------|------| +| 5.25-5.26 | 学习项目结构(读 batch.go、issue.go、formatter.go);完成 table 格式优化 | +| 5.27-5.28 | 完成 batch-label 和 batch-milestone | +| 5.29 | 完成 batch-assign 和 batch-close 优化 | +| 5.30 | 中期碰头会 | +| 5.31-6.1 | 补全 repo/branch/release/org/user/search 的测试 | +| 6.2 | 全面测试,发给组长集成 | +| 6.3 | 最终检查 | +| 6.4 | 汇报验收 | + +--- + +## 汇报展示要点 + +你负责的部分在演示时重点展示: + +1. **table 格式**:`--format json` vs `--format table` 对比,视觉效果好 +2. **batch 操作 dry-run**:先 `--dry-run true` 预览,再真正执行,展示安全设计 +3. **测试全绿**:`go test ./... -v` 跑一遍,展示测试覆盖率提升 + +--- + +## 注意事项 + +1. **先读后写**:写代码前一定要先读对应的已有文件,避免重复定义函数 +2. **路径注意**:有些 API 用 `/v1/...`,有些用 `/api/...`,仔细看 `doc/gitlink_api_reference.md` +3. **每次改完跑测试**:`go test ./... -v`,确认没有破坏其他模块 +4. **如果有编译错误**:Go 的错误提示很明确,看错误信息逐个修复即可