From 740a47f865df49e7c6405f426858e93339160331 Mon Sep 17 00:00:00 2001 From: zzx-coder Date: Thu, 4 Jun 2026 15:02:59 +0800 Subject: [PATCH 1/5] refactor: .devops/gitlink-cli-autodeploy.yml --- .devops/gitlink-cli-autodeploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devops/gitlink-cli-autodeploy.yml b/.devops/gitlink-cli-autodeploy.yml index c1a3d815..692e2c44 100644 --- a/.devops/gitlink-cli-autodeploy.yml +++ b/.devops/gitlink-cli-autodeploy.yml @@ -67,7 +67,7 @@ workflow: ssh_port: '"22"' ssh_user: '"root"' ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key)) - ssh_cmd: '"gitlink-cli version && ls -lh /opt/gitlink-cli/gitlink-cli"' + ssh_cmd: '"gitlink-cli version"' needs: - deploy_on_server_0 From 6583d09f044c665357226aa59b7dc20be589a48f Mon Sep 17 00:00:00 2001 From: camelliamc <16583354+camelliamc@user.noreply.gitee.com> Date: Sun, 14 Jun 2026 19:41:52 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(skills):=20=E6=96=B0=E5=A2=9E=20gitlin?= =?UTF-8?q?k-changelog=20=E5=92=8C=20gitlink-health=20=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E7=8B=AC=E7=AB=8Bskill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/README.md | 20 +- skills/gitlink-changelog/SKILL.md | 140 ++++++++++ .../examples/full-workflow.md | 152 +++++++++++ .../references/classify-rules.md | 110 ++++++++ .../references/collect-data.md | 84 ++++++ .../references/generate-and-publish.md | 133 +++++++++ skills/gitlink-health/SKILL.md | 256 ++++++++++++++++++ .../gitlink-health/examples/full-workflow.md | 185 +++++++++++++ .../gitlink-health/references/collect-data.md | 122 +++++++++ .../references/generate-report.md | 158 +++++++++++ .../references/health-metrics.md | 246 +++++++++++++++++ skills/gitlink-workflow/SKILL.md | 11 +- 12 files changed, 1606 insertions(+), 11 deletions(-) create mode 100644 skills/gitlink-changelog/SKILL.md create mode 100644 skills/gitlink-changelog/examples/full-workflow.md create mode 100644 skills/gitlink-changelog/references/classify-rules.md create mode 100644 skills/gitlink-changelog/references/collect-data.md create mode 100644 skills/gitlink-changelog/references/generate-and-publish.md create mode 100644 skills/gitlink-health/SKILL.md create mode 100644 skills/gitlink-health/examples/full-workflow.md create mode 100644 skills/gitlink-health/references/collect-data.md create mode 100644 skills/gitlink-health/references/generate-report.md create mode 100644 skills/gitlink-health/references/health-metrics.md diff --git a/skills/README.md b/skills/README.md index e97e4762..de6a8fdc 100644 --- a/skills/README.md +++ b/skills/README.md @@ -92,6 +92,22 @@ skills/ │ ├── REFERENCE.md # Release API 参考 │ └── examples/ │ └── release-workflow.md # Release 工作流 +├── gitlink-changelog/ # Release Notes / Changelog 生成 +│ ├── SKILL.md # Changelog 操作指南 +│ ├── references/ +│ │ ├── collect-data.md # 收集变更数据 +│ │ ├── classify-rules.md # 变更分类规则 +│ │ └── generate-and-publish.md # 生成并发布 +│ └── examples/ +│ └── full-workflow.md # 完整生成示例 +├── gitlink-health/ # 项目健康度报告 +│ ├── SKILL.md # 健康度报告操作指南 +│ ├── references/ +│ │ ├── collect-data.md # 收集项目数据 +│ │ ├── health-metrics.md # 指标计算和评分规则 +│ │ └── generate-report.md # 报告生成和输出 +│ └── examples/ +│ └── full-workflow.md # 完整生成示例 ├── gitlink-search/ # 搜索功能 │ ├── SKILL.md # 搜索操作指南 │ └── examples/ @@ -128,6 +144,7 @@ skills/ | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +review` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | | **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` | +| **gitlink-health** | 项目健康度报告 | Issue 响应时间、PR 合并效率、贡献者活跃度统计 | ### 辅助 Skills @@ -139,7 +156,8 @@ skills/ | **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | | **gitlink-wiki** | Wiki 管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` | | **gitlink-pm** | 项目管理 | 通过 Raw API 访问 | -| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes | +| **gitlink-changelog** | Release Notes / Changelog 生成 | 自动收集 commits/PR/Issue,生成结构化版本说明 | +| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、仓库初始化、Sprint 报告 | --- diff --git a/skills/gitlink-changelog/SKILL.md b/skills/gitlink-changelog/SKILL.md new file mode 100644 index 00000000..f9763769 --- /dev/null +++ b/skills/gitlink-changelog/SKILL.md @@ -0,0 +1,140 @@ +--- +name: gitlink-changelog +version: 1.0.0 +description: "Release Notes 生成:根据 commit 和 PR 记录自动生成结构化版本发布说明。当用户需要生成 Release Notes、发版说明时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli release --help" +--- + +# gitlink-changelog(Release Notes 生成) + +**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) 了解认证和全局参数。 + +## 工作流 + +Release Notes 生成分三步:收集 → 分类 → 发布。 + +| 步骤 | 说明 | 所用命令 | +|------|------|----------| +| 1. 收集数据 | 获取版本间的 commits、已合并 PR、已关闭 Issue | `release +list`, `api GET compare`, `pr +list`, `issue +list` | +| 2. 分类整理 | 按类型归类变更(新功能/Bug修复/改进/破坏性变更) | AI 分析 | +| 3. 生成发布 | 套用模板生成 Notes,创建或更新 Release | `release +create`, `release +update` | + +## 命令参考 + +### 收集数据 + +```bash +# 确定版本范围:获取已有 release 列表,找上一个 tag +gitlink-cli release +list --format json + +# 获取两个版本间的 commit 差异(平台 compare API) +gitlink-cli api GET /:owner/:repo/compare/v1.0.0...v1.1.0 --format json + +# 获取已合并的 PR +gitlink-cli pr +list --state merged --format json + +# 获取已关闭的 Issue +gitlink-cli issue +list --state closed --format json +``` + +### 发布 Release Notes + +```bash +# 创建 Release 并附带 Notes +gitlink-cli release +create --tag v1.1.0 --name "v1.1.0" --body "" + +# 更新已有 Release 的 Notes +gitlink-cli release +update --id --body "<更新后的 Notes>" +``` + +## 分类规则 + +| 类型 | 图标 | Issue 标签 | Commit 关键词 | +|------|------|------------|---------------| +| 新功能 | ✨ | `feature`, `enhancement` | `feat:`, `add`, `新增` | +| Bug 修复 | 🐛 | `bug`, `fix` | `fix:`, `bugfix`, `修复` | +| 功能改进 | 🔧 | `improvement`, `optimize` | `improve:`, `optimize:`, `refactor:` | +| 破坏性变更 | ⚠️ | `breaking`, `major` | `BREAKING`, `breaking:`, `!` | +| 文档 | 📝 | `docs`, `documentation` | `docs:`, `doc` | +| 安全修复 | 🔒 | `security`, `vulnerability` | `security:`, `安全` | + +## Release Notes 模板 + +### 标准模板 + +```markdown +# 🎉 Release {VERSION} + +## 📊 变更统计 +- **新功能**: {FEATURE_COUNT} 个 +- **Bug 修复**: {BUG_FIX_COUNT} 个 +- **功能改进**: {ENHANCEMENT_COUNT} 个 +- **破坏性变更**: {BREAKING_COUNT} 个 + +## ✨ 新功能 +{FEATURES} + +## 🐛 Bug 修复 +{BUG_FIXES} + +## 🔧 功能改进 +{ENHANCEMENTS} + +## ⚠️ 破坏性变更 +{BREAKING_CHANGES} + +## 🙏 贡献者 +{CONTRIBUTORS} + +--- +**完整变更日志**: https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION} +``` + +### 简化模板 + +```markdown +# {VERSION} + +## 新增 +{FEATURES} + +## 修复 +{BUG_FIXES} + +## 改进 +{ENHANCEMENTS} +``` + +## 版本号规范 + +遵循语义化版本(Semantic Versioning):`MAJOR.MINOR.PATCH` + +| 变更类型 | 版本变化 | 示例 | +|----------|----------|------| +| 破坏性变更 | MAJOR +1 | `1.2.0` → `2.0.0` | +| 向后兼容的新功能 | MINOR +1 | `1.1.0` → `1.2.0` | +| 向后兼容的 Bug 修复 | PATCH +1 | `1.1.0` → `1.1.1` | + +## API 注意事项 + +- `compare` API 无专用 shortcut,通过 `gitlink-cli api GET /:owner/:repo/compare/{head}...{base}` 调用 +- `compare` API 另支持查询参数格式:`GET /v1/:owner/:repo/compare.json?from=&to=` +- `release +create` 的 `--body` 接受完整 Markdown,支持多行文本 +- `release +view` / `release +delete` 必须使用 `version_id`(从 `release +list` 获取),不可用 `tag_name` +- 创建 Release 前务必让用户审核生成的 Notes 内容 + +## References + +- [collect-data](references/collect-data.md) — 收集 commits、PR、Issue 数据 +- [classify-rules](references/classify-rules.md) — 变更分类规则详解 +- [generate-and-publish](references/generate-and-publish.md) — 生成 Notes 并发布 +- [full-workflow](examples/full-workflow.md) — 完整端到端示例 +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证和全局参数 +- [gitlink-release](../gitlink-release/SKILL.md) — Release 操作 diff --git a/skills/gitlink-changelog/examples/full-workflow.md b/skills/gitlink-changelog/examples/full-workflow.md new file mode 100644 index 00000000..e2dabd83 --- /dev/null +++ b/skills/gitlink-changelog/examples/full-workflow.md @@ -0,0 +1,152 @@ +# Release Notes 完整生成示例 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **适用场景:** AI Agent 端到端生成 Release Notes,从数据收集到发布。 + +以 `zzx-coder/gitlink-cli` 项目从 `v1.0.0` 到 `v1.1.0` 为例。 + +## 完整流程 + +### 第一步:确定版本范围 + +```bash +# 获取已有 release +gitlink-cli release +list --format json + +# 返回示例(截取关键字段): +# { +# "ok": true, +# "data": { +# "releases": [ +# { "tag_name": "v1.0.0", "created_at": "2026-05-01", ... }, +# ... +# ] +# } +# } + +# AI 据此确定:PREV_VERSION = "v1.0.0",NEW_VERSION = "v1.1.0" +``` + +### 第二步:收集 commits + +```bash +gitlink-cli api GET /:owner/:repo/compare/v1.0.0...v1.1.0 --format json + +# 从返回中提取 commits 列表,每个 commit 含: +# - commit.message (提交信息) +# - commit.author.name (作者) +# - sha (提交 SHA) +``` + +### 第三步:收集已合并 PR + +```bash +gitlink-cli pr +list --state merged --format json + +# AI 筛选 merged_at >= "2026-05-01"(v1.0.0 发布时间)的 PR +# 提取每个 PR 的 title、number、author.login +``` + +### 第四步:收集已关闭 Issue + +```bash +gitlink-cli issue +list --state closed --format json + +# AI 筛选 closed_at >= "2026-05-01" 的 Issue +# 提取每个 Issue 的 subject、project_issues_index、issue_tags +``` + +### 第五步:AI 分类 + +AI 根据 [分类规则](../references/classify-rules.md) 对收集到的数据分类: + +``` +新功能: + - 支持批量 Issue 操作 (#12) + - 新增 uninstall 命令 (#11) + +Bug 修复: + - 修复 URL 解析异常 (#10) + +功能改进: + - 重构自动部署配置 + +文档: + - 更新分支映射说明 +``` + +### 第六步:生成 Notes 并确认 + +AI 套用标准模板生成草稿并展示给用户: + +```markdown +# 🎉 Release v1.1.0 + +## 📊 变更统计 +- **新功能**: 2 个 +- **Bug 修复**: 1 个 +- **功能改进**: 1 个 +- **破坏性变更**: 0 个 + +## ✨ 新功能 +- 支持批量 Issue 操作 (#12) +- 新增 uninstall 命令 (#11) + +## 🐛 Bug 修复 +- 修复 URL 解析异常 (#10) + +## 🔧 功能改进 +- 重构自动部署配置 + +## 🙏 贡献者 +zzx-coder, camelliamc + +--- +**完整变更日志**: https://www.gitlink.org.cn/zzx-coder/gitlink-cli/compare/v1.0.0...v1.1.0 +``` + +### 第七步:用户确认后发布 + +```bash +gitlink-cli release +create \ + --tag v1.1.0 \ + --name "v1.1.0" \ + --body "# 🎉 Release v1.1.0 + +## 📊 变更统计 +- **新功能**: 2 个 +- **Bug 修复**: 1 个 +- **功能改进**: 1 个 +- **破坏性变更**: 0 个 + +## ✨ 新功能 +- 支持批量 Issue 操作 (#12) +- 新增 uninstall 命令 (#11) + +## 🐛 Bug 修复 +- 修复 URL 解析异常 (#10) + +## 🔧 功能改进 +- 重构自动部署配置 + +## 🙏 贡献者 +zzx-coder, camelliamc + +--- +**完整变更日志**: https://www.gitlink.org.cn/zzx-coder/gitlink-cli/compare/v1.0.0...v1.1.0" +``` + +## AI Agent 执行要点 + +1. **自动解析 `--owner` / `--repo`**:在仓库目录下执行,CLI 自动从 git remote 解析 +2. **始终使用 `--format json`**:所有命令加此参数,便于 AI 解析返回值 +3. **时间筛选**:用上一个 Release 的 `created_at` 作为 PR/Issue 的时间筛选基线 +4. **去重**:PR 和 Issue 描述同一变更时合并为一条 +5. **确认优先**:生成 Notes 后必须展示给用户,收到确认才执行 `release +create` + +## References + +- [SKILL.md](../SKILL.md) — 工作流和模板总览 +- [collect-data](../references/collect-data.md) — 数据收集详细说明 +- [classify-rules](../references/classify-rules.md) — 分类规则 +- [generate-and-publish](../references/generate-and-publish.md) — 生成和发布 diff --git a/skills/gitlink-changelog/references/classify-rules.md b/skills/gitlink-changelog/references/classify-rules.md new file mode 100644 index 00000000..6e2627dc --- /dev/null +++ b/skills/gitlink-changelog/references/classify-rules.md @@ -0,0 +1,110 @@ +# 变更分类规则 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +将收集到的 commits、PRs 和 Issues 按类型归类,为生成结构化 Release Notes 做准备。 + +## 分类维度 + +变更按以下维度分类: + +| 类型 | 图标 | 标题 | +|------|------|------| +| 新功能 | ✨ | 新功能 | +| Bug 修复 | 🐛 | Bug 修复 | +| 功能改进 | 🔧 | 功能改进 | +| 破坏性变更 | ⚠️ | 破坏性变更 | +| 文档 | 📝 | 文档 | +| 安全修复 | 🔒 | 安全修复 | + +## 分类依据 + +### 按 Issue 标签分类(最可靠) + +从 `issue +list` 返回的 `issue_tags` 字段匹配: + +| Issue 标签 | 对应类型 | +|------------|----------| +| `feature`, `enhancement` | 新功能 | +| `bug`, `fix` | Bug 修复 | +| `improvement`, `optimize` | 功能改进 | +| `breaking`, `major` | 破坏性变更 | +| `docs`, `documentation` | 文档 | +| `security`, `vulnerability` | 安全修复 | + +### 按 Commit 关键词分类(辅助) + +从 `compare` API 返回的 `commit.message` 第一行匹配: + +| 关键词 | 对应类型 | +|--------|----------| +| `feat:`, `add`, `新增` | 新功能 | +| `fix:`, `bugfix`, `修复` | Bug 修复 | +| `improve:`, `optimize:`, `refactor:`, `优化`, `重构` | 功能改进 | +| `BREAKING`, `breaking:`, `!` | 破坏性变更 | +| `docs:`, `doc` | 文档 | +| `security:`, `安全` | 安全修复 | + +### 按 PR 标题分类(辅助) + +PR 标题通常遵循 Conventional Commits 格式,按前缀匹配: + +| PR 标题前缀 | 对应类型 | +|-------------|----------| +| `feat:` / `feature:` | 新功能 | +| `fix:` | Bug 修复 | +| `refactor:` / `perf:` | 功能改进 | +| `docs:` | 文档 | + +## 分类优先级 + +1. **Issue 标签**(最准确,优先采用) +2. **PR 标题前缀**(次之) +3. **Commit 关键词**(兜底) + +对于同一个变更,如果 Issue 标签和 commit 关键词都在,以 Issue 标签为准。 + +## 去重 + +以下情况会产生重复条目,需去重: + +- PR 和 Issue 关联同一个变更 → 合并为一条:`变更描述 (#PR编号, #Issue编号)` +- 同一变更的多个 commit → 只保留摘要最清晰的一条 +- PR 标题与关联 Issue 主题高度相似 → 合并,优先使用 Issue 的 subject + +## 输出格式 + +分类完成后,整理为结构化数据供模板填充: + +``` +新功能: + - 支持批量关闭 Issue (#123) + - 新增 Wiki 管理命令 (#130) + +Bug 修复: + - 修复 Windows 登录 token 存储失败 (#118) + +功能改进: + - 优化 API 请求性能 (#125) + +破坏性变更: + - 重构认证模块接口(不向下兼容)(#140) + +文档: + - 补充分支映射说明 (#115) +``` + +## 贡献者收集 + +从 commit 和 PR 数据中提取贡献者列表: + +- Commit: `author.name` 或 `author.login` +- PR: `author.login` + +去重后生成贡献者名单,写入 Release Notes 末尾。 + +## References + +- [collect-data](collect-data.md) — 数据收集步骤 +- [generate-and-publish](generate-and-publish.md) — 生成 Notes 并发布 +- [SKILL.md](../SKILL.md) — 分类规则速查表 diff --git a/skills/gitlink-changelog/references/collect-data.md b/skills/gitlink-changelog/references/collect-data.md new file mode 100644 index 00000000..30195b16 --- /dev/null +++ b/skills/gitlink-changelog/references/collect-data.md @@ -0,0 +1,84 @@ +# 收集变更数据 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +收集 Release Notes 所需的三类数据:版本范围、commits、PRs 和 Issues。 + +## 命令 + +### 步骤 1:确定版本范围 + +```bash +# 获取已有 release 列表,找到上一个版本 tag +gitlink-cli release +list --format json +# 从返回的 releases 中提取最后一个 tag_name 作为 PREV_VERSION +# 用户指定或 AI 推断新版本号 NEW_VERSION +``` + +### 步骤 2:获取 commits(平台 compare API) + +```bash +# 获取两个 tag 之间的 commit 比较 +gitlink-cli api GET /:owner/:repo/compare/{PREV_VERSION}...{NEW_VERSION} --format json + +# 也可用查询参数格式 +gitlink-cli api GET /v1/:owner/:repo/compare.json --query "from={PREV_VERSION}&to={NEW_VERSION}" --format json +``` + +返回数据包含:`commits`(提交列表含 message/author/date/sha)、`total_commits`(提交总数)、`files`(变更文件)等。 + +### 步骤 3:获取已合并的 PR + +```bash +# 获取已合并的 PR 列表 +gitlink-cli pr +list --state merged --format json + +# 从返回的 PRs 中按 merged_at 时间筛选: +# 只保留 merged_at >= 上一个版本发布时间的 PR +``` + +返回数据包含:每个 PR 的 `title`、`number`、`author`、`merged_at`、`pull_request_number` 等。 + +### 步骤 4:获取已关闭的 Issue + +```bash +# 获取已关闭的 Issue 列表 +gitlink-cli issue +list --state closed --format json + +# 从返回的 Issues 中按 closed_at 时间筛选: +# 只保留 closed_at >= 上一个版本发布时间的 Issue +``` + +返回数据包含:每个 Issue 的 `subject`、`project_issues_index`、`issue_tags`(标签)、`author`、`closed_at` 等。 + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--format` | 否 | 始终建议 `json`,便于 AI 解析 | +| `--state` | 否 | PR: `merged`;Issue: `closed` | +| `--page` | 否 | 大量数据时分页获取 | +| `--limit` | 否 | 每页条数 | + +## 数据整合 + +收集完成后,AI 整合三类数据: + +1. **Commits** → 提取 commit message 第一行作为变更摘要 +2. **PRs** → 用 `title` 和 `number` 生成条目:`- 功能描述 (#PR编号)` +3. **Issues** → 用 `subject` 和 `project_issues_index` 生成条目:`- Issue 描述 (#编号)` + +时间筛选逻辑:从 `release +list` 获取上一个版本的发布时间,只取该时间之后的 PR/Issue。 + +## 注意事项 + +- 如果是**第一个版本**(无上一版本),只收集当前版本时间范围内的 PR/Issue,commits 用全量最近提交 +- `compare` API 的 tag 需要真实存在,否则返回 404 +- PR 和 Issue 的返回可能超过单页,注意分页获取全部数据 +- `--owner` / `--repo` 在仓库目录下可自动解析 + +## References + +- [classify-rules](classify-rules.md) — 收集完成后对变更进行分类 +- [generate-and-publish](generate-and-publish.md) — 生成 Notes 并发布 +- [gitlink-release](../gitlink-release/SKILL.md) — Release 操作 diff --git a/skills/gitlink-changelog/references/generate-and-publish.md b/skills/gitlink-changelog/references/generate-and-publish.md new file mode 100644 index 00000000..cec2013d --- /dev/null +++ b/skills/gitlink-changelog/references/generate-and-publish.md @@ -0,0 +1,133 @@ +# 生成并发布 Release Notes + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **CRITICAL — 此为写入操作,执行前务必确认用户已审核 Release Notes 内容。** + +将分类整理后的变更数据套用模板,生成 Markdown 格式的 Release Notes,并发布到 GitLink。 + +## 模板 + +### 标准模板 + +```markdown +# 🎉 Release {VERSION} + +## 📊 变更统计 +- **新功能**: {FEATURE_COUNT} 个 +- **Bug 修复**: {BUG_FIX_COUNT} 个 +- **功能改进**: {ENHANCEMENT_COUNT} 个 +- **破坏性变更**: {BREAKING_COUNT} 个 + +## ✨ 新功能 +{FEATURES} + +## 🐛 Bug 修复 +{BUG_FIXES} + +## 🔧 功能改进 +{ENHANCEMENTS} + +## ⚠️ 破坏性变更 +{BREAKING_CHANGES} + +## 🙏 贡献者 +{CONTRIBUTORS} + +--- +**完整变更日志**: https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION} +``` + +### 简化模板(适用于 patch 版本或小型发布) + +```markdown +# {VERSION} + +## 新增 +{FEATURES} + +## 修复 +{BUG_FIXES} + +## 改进 +{ENHANCEMENTS} + +## 贡献者 +{CONTRIBUTORS} + +[完整变更](https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION}) +``` + +## 模板占位符说明 + +| 占位符 | 来源 | +|--------|------| +| `{VERSION}` | 用户指定或 AI 推断的新版本号(如 `v1.2.0`) | +| `{PREV}` | `release +list` 获取的上一个版本 tag | +| `{OWNER}` | 仓库所有者,从 git remote 解析 | +| `{REPO}` | 仓库名称,从 git remote 解析 | +| `{FEATURE_COUNT}` 等 | 分类后的各类变更数量 | +| `{FEATURES}` 等 | 分类后的各类变更条目,每条一行 `- 描述 (#编号)` | +| `{CONTRIBUTORS}` | 从 commits/PRs 去重后的贡献者列表 | + +## 命令 + +### 新建 Release + +```bash +gitlink-cli release +create \ + --tag v1.2.0 \ + --name "v1.2.0" \ + --body "" +``` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--tag` | **是** | 版本 tag(如 `v1.2.0`) | +| `--name` | **是** | Release 名称,通常与 tag 一致 | +| `--body` | 否 | Release Notes 正文(Markdown,支持多行) | +| `--target` | 否 | 目标分支,默认 `master` | +| `--prerelease` | 否 | 标记为预发布版本 | + +### 更新已有 Release + +```bash +gitlink-cli release +update \ + --id \ + --body "<更新后的 Release Notes>" +``` + +> ⚠️ `release +update` 使用 `version_id`(数字 ID,从 `release +list` 获取),不是 `tag_name`。 + +## Workflow + +> [!CAUTION] +> Release Notes 发布是 **Write Operation**,执行前必须让用户审核内容。 + +1. **生成** Release Notes 草稿(套用模板填充数据) +2. **展示**草稿给用户审核 +3. **确认**用户同意后才执行 `release +create` 或 `release +update` +4. **报告**创建的 Release URL 给用户 + +## 发布前检查清单 + +- [ ] 版本号遵循语义化版本规范 +- [ ] 变更统计与实际一致 +- [ ] 破坏性变更已明确标注 +- [ ] 贡献者列表完整 +- [ ] 无敏感信息泄露 +- [ ] 对比链接可访问 + +## 注意事项 + +- `release +create --body` 接受完整 Markdown,换行和格式由模板控制 +- `release +view` / `release +delete` 使用 `version_id`(数字),不是 `tag_name` +- 可用 `release +update --body` 修正已发布的 Notes +- 首个版本(无上一版本)省略对比链接 + +## References + +- [collect-data](collect-data.md) — 收集变更数据 +- [classify-rules](classify-rules.md) — 变更分类规则 +- [full-workflow](../examples/full-workflow.md) — 完整端到端示例 +- [gitlink-release](../../gitlink-release/SKILL.md) — Release 操作 +- [release +create](../../gitlink-release/references/gitlink-release-create.md) — 创建 Release 详细参数 diff --git a/skills/gitlink-health/SKILL.md b/skills/gitlink-health/SKILL.md new file mode 100644 index 00000000..f18317c3 --- /dev/null +++ b/skills/gitlink-health/SKILL.md @@ -0,0 +1,256 @@ +--- +name: gitlink-health +version: 1.0.0 +description: "项目健康度报告:统计 Issue 响应时间、PR 合并效率、贡献者活跃度。当用户需要项目健康分析、开发效率报告、团队活跃度统计时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli issue --help" +--- + +# gitlink-health(项目健康度报告) + +**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) 了解认证和全局参数。 + +## 工作流 + +健康度报告生成分四步:收集 → 计算 → 评分 → 输出。 + +| 步骤 | 说明 | 所用命令 | +|------|------|----------| +| 1. 收集数据 | 获取 Issue(open/closed)、PR(open/merged)、contributor 统计 | `issue +list`, `pr +list`, `api GET contributors` | +| 2. 计算指标 | Issue 响应时间、PR 合并效率、贡献者活跃度 | AI 解析 JSON 计算 | +| 3. 健康评分 | 100 分制综合评分,扣分项 6 条 | AI 套用评分规则 | +| 4. 输出报告 | 生成 Markdown 报告,展示给用户 | 终端预览 / Issue 发布 / 文件导出 | + +## 命令参考 + +### 收集数据 + +```bash +# Issue 数据(两个状态都需要) +gitlink-cli issue +list --state open --format json +gitlink-cli issue +list --state closed --format json + +# PR 数据(两个状态都需要) +gitlink-cli pr +list --state open --format json +gitlink-cli pr +list --state merged --format json + +# 贡献者统计(Raw API) +gitlink-cli api GET /:owner/:repo/contributors --format json + +# 项目活动(Raw API,可选) +gitlink-cli api GET /:owner/:repo/activity --format json +``` + +## 三大指标 + +### 1. Issue 响应时间 + +支持两种口径,**推荐优先使用响应时间**(新增→已解决): + +| 口径 | 计算方式 | 说明 | +|------|----------|------| +| **响应时间(推荐)** | `avg(resolved_at - created_at)` | 从创建到解决,反映实际处理效率 | +| 全周期时间 | `avg(closed_at - created_at)` | 从创建到正式关闭,反映完整生命周期 | + +| 指标 | 计算方式 | 说明 | +|------|----------|------| +| 平均响应时间 | `avg(resolved_at - created_at)` 或 `avg(closed_at - created_at)` | 无 `resolved_at` 时用历史 `updated_at` 推断 | +| 中位数响应时间 | `median(...)` | 排除极端值影响 | +| Issue 积压数 | `count(open Issues)` | 当前待处理的 Issue 数量 | +| 按优先级分布 | 按 `priority_id` 分组:低(1)/正常(2)/高(3)/紧急(4) | 高优先级积压更值得关注 | + +> **注意**:GitLink API 不返回 `closed_at`/`resolved_at` 字段。若项目有"先解决后批量关闭"的工作流,`updated_at` 可能被关闭操作覆盖而虚高,应优先从会话历史推断解决时间。 + +### 2. PR 合并效率 + +PR 合并效率由**三类 PR** 共同决定,需计算**三段时间**: + +| 时间 | 名称 | 公式 | 适用对象 | +|------|------|------|----------| +| ① | 已合并 PR 平均合并时长 | `avg(merged_at - created_at)` | status=1(已合并) | +| ② | 开放 PR 平均等待时长 | `avg(now - created_at)` | status=0(开放中) | +| ③ | 加权总平均处理时长 | `(merged/total) * ① + (open/total) * ②` | 已合并 ∪ 开放 | + +**总样本数 = 已合并数 + 开放数**(已关闭未合并的 PR 不参与时间计算,仅参与合并率分母)。 + +| 指标 | 计算方式 | 说明 | +|------|----------|------| +| ① 已合并平均合并时长 | `avg(merged_at - created_at)` | **仅对已合并 PR**(`status=1`)计算,从创建到合并成功的时长 | +| ② 开放平均等待时长 | `avg(now - created_at)` | 开放 PR 从创建到当前的等待时长,反映积压压力 | +| ③ 加权总平均 | `(merged/total) * ① + (open/total) * ②` | 综合 PR 处理节奏的总体指标 | +| PR 积压数 | `count(open PRs)` | 当前待合并的 PR 数量 | +| 合并率 | `merged / (merged + closed)` | 已合并占所有已关闭 PR(合并+关闭)的比例 | + +**统计对象规则**: + +| PR 状态 | 计入时间计算 | 计入合并率分母 | 计入积压 | +|---------|------------|---------------|---------| +| 已合并(status=1) | ① | ✅ | ❌ | +| 已关闭未合并(status=2) | ❌ | ✅ | ❌ | +| 开放中(status=0) | ② | ❌ | ✅ | + +> **数据获取**:`merged_at` 仅在 `pr +view --id ` 单个 PR 详情中返回(路径 `data.pull_request.merged_at`),列表接口不暴露。 +> **绝对禁止**:用「当前时间 - 创建时间」估算已合并 PR 的合并时间。开放 PR 用此公式是允许的(且必要),因为它们没有合并时间点,等待时长反映积压。 + +### 3. 贡献者活跃度 + +| 指标 | 数据源 | 说明 | +|------|--------|------| +| 贡献者总数 | `contributors.author_count` | 项目总贡献者数 | +| 每人 commits | `contributors.authors[].commits` | 按 commit 数排名 | +| 每人增删行数 | `contributors.authors[].additions / deletions` | 代码贡献量 | +| 活跃度分级 | 综合 commits + PRs + Issues | 高频 / 正常 / 低频 | + +活跃度分级标准: + +| 级别 | 条件 | +|------|------| +| 🔥 高频 | 近 30 天 commits ≥ 5 或 PRs ≥ 2 | +| 🟢 正常 | 近 30 天 commits ≥ 1 或 PRs ≥ 1 | +| 🟡 低频 | 近 30 天无 commit 和 PR,但有近期 Issue 活动 | +| ⚪ 不活跃 | 近 60 天无任何活动记录 | + +## 健康度综合评分(100 分制) + +### 核心指标(75 分) + +| 扣分项 | 扣分 | 条件 | +|--------|------|------| +| Issue 响应慢 | -25 | Issue 平均响应时间(新增→已解决)> 3 天 | +| PR 合并慢 | -25 | PR 加权总平均处理时长 > 2 天(③) | +| 贡献者活跃度低 | -25 | 近 30 天有活跃行为的贡献者 < 2 人,或单一贡献者占总 commit 数 > 70% | + +### 辅助指标(25 分) + +| 扣分项 | 扣分 | 条件 | +|--------|------|------| +| Issue 积压严重 | -10 | open 状态 Issue 数量 > 20 | +| PR 积压严重 | -10 | open 状态 PR 数量 > 10 | +| 近期无发布 | -5 | 最近 30 天无新 Release | + +评分等级: + +| 分数 | 等级 | 图标 | +|------|------|------| +| 90-100 | 优秀 | 🟢 | +| 70-89 | 良好 | 🔵 | +| 50-69 | 一般 | 🟡 | +| 30-49 | 需关注 | 🟠 | +| 0-29 | 严重 | 🔴 | + +## 报告模板 + +### 标准模板 + +```markdown +# 🏥 项目健康度报告 + +**项目**: {OWNER}/{REPO} +**报告时间**: {DATE} +**统计周期**: {PERIOD_DAYS} 天 + +--- + +## 📊 综合评分 + +| 评分 | 等级 | +|------|------| +| {SCORE}/100 | {GRADE_ICON} {GRADE} | + +| 扣分明细 | 扣分 | 结果 | +|----------|------|------| +| Issue 积压(当前 {OPEN_ISSUES} 个) | {DEDUCTION} | {ISSUE_BACKLOG_STATUS} | +| PR 积压(当前 {OPEN_PRS} 个) | {DEDUCTION} | {PR_BACKLOG_STATUS} | +| Issue 响应时间(平均 {RESPONSE_TIME}) | {DEDUCTION} | {RESPONSE_STATUS} | +| PR 合并时间(平均 {MERGE_TIME}) | {DEDUCTION} | {MERGE_STATUS} | +| 贡献者集中度(最高占比 {TOP_SHARE}) | {DEDUCTION} | {CONCENTRATION_STATUS} | +| 近期发布(最近 {LAST_RELEASE}) | {DEDUCTION} | {RELEASE_STATUS} | + +--- + +## 🐛 Issue 分析 + +| 指标 | 数值 | +|------|------| +| 全部 Issue | {TOTAL_ISSUES} | +| 已关闭 | {CLOSED_ISSUES} | +| 未关闭 | {OPEN_ISSUES} | +| 平均关闭时长 | {AVG_RESPONSE_TIME} | +| 中位数关闭时长 | {MEDIAN_RESPONSE_TIME} | + +### 按优先级分布 + +| 优先级 | 数量 | 占比 | +|--------|------|------| +| 🔴 紧急 | {URGENT_COUNT} | {URGENT_PCT}% | +| 🟠 高 | {HIGH_COUNT} | {HIGH_PCT}% | +| 🟡 正常 | {NORMAL_COUNT} | {NORMAL_PCT}% | +| 🟢 低 | {LOW_COUNT} | {LOW_PCT}% | + +--- + +## 🔀 PR 分析 + +| 指标 | 数值 | +|------|------| +| 全部 PR | {TOTAL_PRS} | +| 已合并 | {MERGED_PRS} | +| 未合并 | {OPEN_PRS} | +| 合并率 | {MERGE_RATE}% | +| 平均合并时长 | {AVG_MERGE_TIME} | +| 中位数合并时长 | {MEDIAN_MERGE_TIME} | + +--- + +## 👥 贡献者活跃度 + +| 贡献者 | Commits | PRs | Issues | 增删行数 | 活跃度 | +|---------|---------|-----|--------|----------|--------| +| {NAME} | {COMMITS} | {PRS} | {ISSUES} | +{ADD}/-{DEL} | {ACTIVITY_ICON} {LEVEL} | +| ... | ... | ... | ... | ... | ... | + +**总贡献者**: {TOTAL_CONTRIBUTORS} | **总 commits**: {TOTAL_COMMITS} | **总代码变更**: +{TOTAL_ADD}/-{TOTAL_DEL} + +--- + +## 💡 改进建议 + +{SUGGESTIONS} +``` + +### 简化模板 + +```markdown +# 🏥 {OWNER}/{REPO} 健康度: {SCORE}/100 {GRADE} + +| 指标 | 数值 | 状态 | +|------|------|------| +| Issue 响应 | avg {RESPONSE_TIME} | {RESPONSE_STATUS} | +| PR 合并 | avg {MERGE_TIME} | {MERGE_STATUS} | +| 贡献者 | {ACTIVE}/{TOTAL} 活跃 | {CONTRIBUTOR_STATUS} | +| 积压 | {OPEN_ISSUES} issues + {OPEN_PRS} PRs | {BACKLOG_STATUS} | +| 发布 | 最新 {LAST_RELEASE} | {RELEASE_STATUS} | +``` + +## API 注意事项 + +- `GET /:owner/:repo/contributors` 无 Shortcut,通过 `gitlink-cli api GET` 调用 +- PR list 的 `--state` 仅影响统计计数,返回列表需客户端按 `pull_request_status` 过滤 +- Issue 字段名:网页编号为 `project_issues_index`,数据库 ID 为 `id` +- Issue 时间字段:API **不返回**独立的 `closed_at` 或 `resolved_at`,仅有 `created_at` 和 `updated_at`。`updated_at` 是最后更新时间(可能反映解决时间或关闭时间,需根据上下文判断) +- 贡献者统计基于默认分支,不含其他分支的 commit +- `contributors` 返回 `author_count`(总数)和 `authors[]`(每人明细) + +## References + +- [collect-data](references/collect-data.md) — 数据收集详细说明 +- [health-metrics](references/health-metrics.md) — 指标计算和评分规则 +- [generate-report](references/generate-report.md) — 报告生成和输出 +- [full-workflow](examples/full-workflow.md) — 完整端到端示例 +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-health/examples/full-workflow.md b/skills/gitlink-health/examples/full-workflow.md new file mode 100644 index 00000000..c83c64aa --- /dev/null +++ b/skills/gitlink-health/examples/full-workflow.md @@ -0,0 +1,185 @@ +# 项目健康度报告 — 完整生成示例 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **适用场景:** AI Agent 端到端生成项目健康度报告,以 `zzx-coder/gitlink-cli` 为例。 + +## 完整流程 + +### 第一步:收集 Issue 数据 + +```bash +# 获取未关闭 Issue +gitlink-cli issue +list --state open --format json + +# 获取已关闭 Issue +gitlink-cli issue +list --state closed --format json + +# AI 从返回中提取: +# - open count: 28 +# - closed count: 1 (我们之前创建的 #29,已关闭) +# - priority 分布: 大部分为 normal (priority_id=2) +# - 平均关闭时间: #29 创建后约 1 分钟关闭(测试 issue) +``` + +### 第二步:收集 PR 数据 + +```bash +# 获取未合并 PR +gitlink-cli pr +list --state open --format json + +# 获取已合并 PR +gitlink-cli pr +list --state merged --format json + +# AI 从返回中提取: +# - merged count: 10 +# - open count: 0 +# - 合并率: 10/11 = 91% +# - 最新合并 PR: #11 (2026-06-04) +# - PR 平均合并时间需逐条计算 +``` + +### 第三步:收集贡献者统计 + +```bash +# Raw API 获取贡献者数据 +gitlink-cli api GET /:owner/:repo/contributors --format json + +# AI 从返回中提取: +# - author_count: 4+ +# - 各贡献者 commit 数和增删行数 +# - 集中度计算 +``` + +### 第四步:AI 计算指标 + +AI 根据 [指标计算规则](../references/health-metrics.md) 处理数据: + +``` +Issue 响应时间: + 平均 0.1 天(仅 1 个已关闭 Issue,样本量不足) + 积压 28 个 → 触发扣分 + +PR 合并效率: + 平均合并时间 ≈ 3.5 天(估算,需 merge_at 字段) + 积压 0 个 ✓ + 合并率 91% + +贡献者活跃度: + mengcheng (camelliamc) — 🔥 高频 + zzx-coder — 🔥 高频 + wbtiger — 🟢 正常 + wangyue789 — 🟡 低频 + +综合评分: + 起始 100 + 核心指标: 0 扣分(响应时间样本不足/PR合并正常/贡献者活跃) + 辅助指标: -10 Issue 积压 (28 open), -5 无近期发布 (已解决) + 最终: 85/100(良好 🔵) +``` + +### 第五步:生成报告草稿 + +AI 套用模板生成并展示给用户: + +```markdown +# 🏥 项目健康度报告 + +**项目**: zzx-coder/gitlink-cli +**报告时间**: 2026-06-14 +**统计周期**: 最近 30 天 + +--- + +## 📊 综合评分 + +| 评分 | 等级 | +|------|------| +| 85/100 | 🔵 良好 | + +| 扣分明细 | 扣分 | 状态 | +|----------|------|------| +| **核心指标** | | | +| Issue 响应时间(样本不足) | 0 | ✅ 跳过 | +| PR 合并时间(平均 ~3.5 天) | 0 | ✅ 正常 | +| 贡献者活跃度(2 位高频活跃) | 0 | ✅ 正常 | +| **辅助指标** | | | +| Issue 积压(当前 28 个) | -10 | ⚠️ 需关注 | +| PR 积压(当前 0 个) | 0 | ✅ 正常 | +| 近期发布(v0.1.18, 今天) | 0 | ✅ 正常 | + +--- + +## 🐛 Issue 分析 + +| 指标 | 数值 | +|------|------| +| 全部 Issue | 29 | +| 已关闭 | 1 | +| 待处理 | 28 | +| 平均关闭时长 | 样本不足 | + +### 按优先级分布 + +| 优先级 | 数量 | 占比 | +|--------|------|------| +| 🔴 紧急 | 0 | 0% | +| 🟠 高 | 0 | 0% | +| 🟡 正常 | 28 | 100% | +| 🟢 低 | 0 | 0% | + +--- + +## 🔀 PR 分析 + +| 指标 | 数值 | +|------|------| +| 全部 PR | 11 | +| 已合并 | 10 | +| 待合并 | 0 | +| 合并率 | 91% | +| 平均合并时长 | ~3.5 天 | + +--- + +## 👥 贡献者活跃度 + +| 贡献者 | Commits | PRs | 增/删 | 活跃度 | +|---------|---------|-----|-------|--------| +| mengcheng | 120 | 5 | +8500/-3200 | 🔥 高频 | +| zzx-coder | 65 | 4 | +3200/-1800 | 🔥 高频 | +| wbtiger | 40 | 1 | +2100/-900 | 🟢 正常 | +| wangyue789 | 5 | 0 | +300/-150 | 🟡 低频 | + +**总贡献者**: 4 | **总 commits**: 230+ | **代码变更**: +14100/-6050 + +--- + +## 💡 改进建议 + +- **Issue 积压(28 个)**:建议安排 Issue Triage,对 28 个 open Issue 进行分类,关闭不再需要的,优先处理高优先级 Issue +- **样本量不足**:仅 1 个已关闭 Issue,无法评估响应时间。建议保持关注,积累更多数据后重新评估 +- **贡献者持续良好**:4 位贡献者中 2 位高频活跃,项目 bus factor 健康 +``` + +### 第六步:用户选择输出 + +生成后给用户三个选择: +1. 终端预览(展示即可) +2. 发布为 Issue(`gitlink-cli issue +create -t "项目健康度报告 — 2026-06-14" -b "..."`) +3. 导出本地文件 + +## AI Agent 执行要点 + +1. **数据收集顺序**:先收集 Issue 和 PR(Shortcut 命令),再收集 contributors(Raw API),避免一次性大量 API 调用 +2. **分页处理**:Issue 和 PR 数量超过单页限制时,用 `--page` 逐页获取 +3. **时间计算**:ISO 8601 格式解析优先,Unix 时间戳更可靠但 PR 的 `pr_created_unix` 仅部分返回 +4. **指标计算容错**:样本不足时标注而非报错,新项目可能仅有少量数据 +5. **评分可按需调整**:新项目无 Release 时,"无近期发布"项自动跳过 +6. **避免 GIGO**:数据异常时(如极长的响应时间),标注并排除 outlier + +## References + +- [SKILL.md](../SKILL.md) — 工作流和模板总览 +- [collect-data](../references/collect-data.md) — 数据收集详细说明 +- [health-metrics](../references/health-metrics.md) — 指标计算和评分规则 +- [generate-report](../references/generate-report.md) — 报告生成和输出 diff --git a/skills/gitlink-health/references/collect-data.md b/skills/gitlink-health/references/collect-data.md new file mode 100644 index 00000000..565c7c9f --- /dev/null +++ b/skills/gitlink-health/references/collect-data.md @@ -0,0 +1,122 @@ +# 收集健康度数据 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +收集项目健康度报告所需的四类数据:Issue、PR、贡献者和项目活动。 + +## 命令 + +### 步骤 1:收集 Issue 数据 + +```bash +# 获取未关闭 Issue(用于统计积压) +gitlink-cli issue +list --state open --format json + +# 获取已关闭 Issue(用于计算响应时间) +gitlink-cli issue +list --state closed --format json +``` + +Issue 返回的关键字段: + +| 字段 | 用途 | +|------|------| +| `project_issues_index` | Issue 编号 | +| `subject` | Issue 标题 | +| `status_id` | 状态:1=新增, 2=正在解决, 3=已解决, 5=关闭 | +| `priority_id` | 优先级:1=低, 2=正常, 3=高, 4=紧急 | +| `created_at` | 创建时间 (ISO 8601) | +| `closed_at` | 关闭时间(仅已关闭 Issue 有此字段) | +| `author.login` | 创建者 | +| `assigners[]` | 负责人列表 | + +### 步骤 2:收集 PR 数据 + +```bash +# 获取未合并 PR(用于统计积压) +gitlink-cli pr +list --state open --format json + +# 获取已合并 PR(用于计算合并效率) +gitlink-cli pr +list --state merged --format json +``` + +PR 返回的关键字段: + +| 字段 | 用途 | +|------|------| +| `pull_request_number` | PR 编号 | +| `name` (title) | PR 标题 | +| `pull_request_status` | 状态:0=open, 1=merged, 2=closed | +| `author_login` | 作者 | +| `pr_full_time` | 创建时间 (ISO 8601) | +| `pr_merged_at` | 合并时间 | +| `pr_created_unix` | 创建时间 (Unix timestamp) | + +> ⚠️ `--state` 参数仅影响 `merged_count`/`open_count`/`closed_count` 汇总计数,API 返回的 issues 列表可能包含所有状态的 PR。需按 `pull_request_status` 客户端过滤。 + +### 步骤 3:收集贡献者统计 + +```bash +# 获取贡献者统计(Raw API,无 Shortcut) +gitlink-cli api GET /:owner/:repo/contributors --format json +``` + +返回关键字段: + +| 字段 | 用途 | +|------|------| +| `author_count` | 总贡献者数 | +| `commit_count` | 总 commit 数 | +| `commit_count_in_all_branches` | 全部分支的 commit 总数 | +| `additions` / `deletions` | 总增删行数 | +| `authors[]` | 每位贡献者的明细 | + +每位贡献者(`authors[]`)字段: + +| 字段 | 用途 | +|------|------| +| `login` / `name` | 贡献者 ID 和昵称 | +| `commits` | commit 数量 | +| `additions` / `deletions` | 增删行数 | + +### 步骤 4:收集项目活动(可选) + +```bash +# 获取项目活动 feed +gitlink-cli api GET /:owner/:repo/activity --format json +``` + +用于补充近期事件(issue 创建/关闭、PR 创建/合并的时间线)。 + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--format` | 否 | 始终建议 `json`,便于 AI 解析 | +| `--state` | 否 | Issue: `open`/`closed`;PR: `open`/`merged`/`closed` | +| `--page` | 否 | 大量数据时分页获取 | +| `--limit` | 否 | 每页条数 | + +## 数据覆盖范围 + +数据收集覆盖以下时间范围: + +- **Issue**: 所有未关闭 + 近期已关闭(默认取最近 100 条) +- **PR**: 所有未合并 + 近期已合并(默认取最近 100 条) +- **Contributors**: 项目全量历史数据 +- **Activity**: 最近 30 天 + +对于大型项目,可通过 `--page` 分页获取更多数据。 + +## 注意事项 + +- 计算响应时间需要 Issue 有 `closed_at` 字段,仅已关闭 Issue 才会返回此字段 +- PR 合并时间需通过 `pr_full_time` 与当前时间对比估算,或结合 PM `/weekly_issues` API +- `contributors` 端点统计基于默认分支(通常为 `master`),不含其他分支 +- `--owner` / `--repo` 在仓库目录下可自动解析 +- 时间计算使用 Unix 时间戳(`pr_created_unix`)比解析字符串格式(`pr_full_time`)更可靠 + +## References + +- [health-metrics](health-metrics.md) — 收集完成后进行指标计算 +- [generate-report](generate-report.md) — 生成报告并输出 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-health/references/generate-report.md b/skills/gitlink-health/references/generate-report.md new file mode 100644 index 00000000..13bef6e4 --- /dev/null +++ b/skills/gitlink-health/references/generate-report.md @@ -0,0 +1,158 @@ +# 生成并输出健康度报告 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +将计算的指标和评分套用模板,生成 Markdown 格式的健康度报告,通过终端、Issue 或文件三种方式输出。 + +## 输出方式 + +### 方式 1:终端预览(默认) + +直接在对话中展示 Markdown 报告,用户确认后决定是否持久化。 + +### 方式 2:创建报告 Issue(需认证) + +将报告发布为项目的 Review Issue,便于团队讨论和跟踪: + +```bash +gitlink-cli issue +create \ + -t "项目健康度报告 — {DATE}" \ + -b "" \ + --label 文档 +``` + +> ⚠️ 此为 Write Operation,创建前必须确认用户意图。 + +### 方式 3:导出 Markdown 文件(本地) + +AI Agent 将报告内容写入本地文件: + +``` +health_reports/health_report_{DATE}.md +``` + +## 模板 + +### 标准模板 + +```markdown +# 🏥 项目健康度报告 + +**项目**: {OWNER}/{REPO} +**报告时间**: {DATE} +**统计周期**: 最近 {PERIOD_DAYS} 天 + +--- + +## 📊 综合评分 + +| 评分 | 等级 | +|------|------| +| {SCORE}/100 | {GRADE_ICON} {GRADE} | + +| 扣分明细 | 扣分 | 状态 | +|----------|------|------| +| Issue 积压(当前 {OPEN_ISSUES} 个) | -{DEDUCTION} | {ISSUE_BACKLOG_STATUS} | +| PR 积压(当前 {OPEN_PRS} 个) | -{DEDUCTION} | {PR_BACKLOG_STATUS} | +| Issue 响应时间(平均 {RESPONSE_TIME}) | -{DEDUCTION} | {RESPONSE_STATUS} | +| PR 合并时间(平均 {MERGE_TIME}) | -{DEDUCTION} | {MERGE_STATUS} | +| 贡献者集中度(最高占比 {TOP_SHARE}) | -{DEDUCTION} | {CONCENTRATION_STATUS} | +| 近期发布(最新 {LAST_RELEASE}) | -{DEDUCTION} | {RELEASE_STATUS} | + +--- + +## 🐛 Issue 分析 + +| 指标 | 数值 | +|------|------| +| 全部 Issue | {TOTAL_ISSUES} | +| 已关闭 | {CLOSED_ISSUES} | +| 待处理 | {OPEN_ISSUES} | +| 平均关闭时长 | {AVG_RESPONSE_TIME} | +| 中位数关闭时长 | {MEDIAN_RESPONSE_TIME} | + +### 按优先级分布 + +| 优先级 | 数量 | 占比 | +|--------|------|------| +| 🔴 紧急 | {URGENT_COUNT} | {URGENT_PCT}% | +| 🟠 高 | {HIGH_COUNT} | {HIGH_PCT}% | +| 🟡 正常 | {NORMAL_COUNT} | {NORMAL_PCT}% | +| 🟢 低 | {LOW_COUNT} | {LOW_PCT}% | + +--- + +## 🔀 PR 分析 + +| 指标 | 数值 | +|------|------| +| 全部 PR | {TOTAL_PRS} | +| 已合并 | {MERGED_PRS} | +| 待合并 | {OPEN_PRS} | +| 合并率 | {MERGE_RATE}% | +| 平均合并时长 | {AVG_MERGE_TIME} | +| 中位数合并时长 | {MEDIAN_MERGE_TIME} | + +--- + +## 👥 贡献者活跃度 + +| 贡献者 | Commits | PRs | Issues | 增/删 | 活跃度 | +|---------|---------|-----|--------|-------|--------| +| {NAME} | {COMMITS} | {PRS} | {ISSUES} | +{ADD}/-{DEL} | {ACTIVITY_ICON} {LEVEL} | + +**总贡献者**: {TOTAL_CONTRIBUTORS} | **总 commits**: {TOTAL_COMMITS} | **代码变更**: +{TOTAL_ADD}/-{TOTAL_DEL} + +--- + +## 💡 改进建议 + +{SUGGESTIONS} + +--- +*报告由 [gitlink-health skill](...) 自动生成* +``` + +## 占位符说明 + +| 占位符 | 来源 | +|--------|------| +| `{OWNER}` / `{REPO}` | git remote 解析 | +| `{DATE}` | 当前日期 | +| `{PERIOD_DAYS}` | 数据覆盖天数(默认 30) | +| `{SCORE}` | 综合评分(0-100) | +| `{GRADE}` / `{GRADE_ICON}` | 评分等级 + 图标 | +| `{OPEN_ISSUES}` | 未关闭 Issue 数量 | +| `{CLOSED_ISSUES}` | 已关闭 Issue 数量 | +| `{AVG_RESPONSE_TIME}` / `{MEDIAN_RESPONSE_TIME}` | Issue 响应时间 | +| `{OPEN_PRS}` | 未合并 PR 数量 | +| `{MERGED_PRS}` | 已合并 PR 数量 | +| `{AVG_MERGE_TIME}` / `{MEDIAN_MERGE_TIME}` | PR 合并时间 | +| `{MERGE_RATE}` | PR 合并率(%) | +| `{TOTAL_CONTRIBUTORS}` | 总贡献者数 | +| `{TOP_SHARE}` | 最高单贡献者占比 | +| `{LAST_RELEASE}` | 最近一次 Release 时间或 "无" | +| `{SUGGESTIONS}` | AI 生成的改进建议列表 | + +## Workflow + +1. **计算**所有指标和评分(参见 [health-metrics](health-metrics.md)) +2. **填充**标准模板,生成 Markdown 报告 +3. **展示**报告草稿给用户审核 +4. **确认**用户选择输出方式(终端 / Issue / 文件) +5. 如需发布为 Issue,确认后执行 `issue +create` + +## 注意事项 + +- 报告的时间范围默认取最近 30 天,用户可指定自定义范围 +- 若项目无 Release(如新项目),扣分项"无近期发布"不适用,总分自动调整 +- 贡献者活跃度需结合近 30 天的活动时间线判断 +- 改进建议应具体、可执行,避免空泛描述 +- 如果数据量不足(如新项目仅有少量 Issue/PR),在报告中标注"样本量小,仅供参考" + +## References + +- [collect-data](collect-data.md) — 收集项目数据 +- [health-metrics](health-metrics.md) — 指标计算和评分规则 +- [full-workflow](../examples/full-workflow.md) — 完整端到端示例 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-health/references/health-metrics.md b/skills/gitlink-health/references/health-metrics.md new file mode 100644 index 00000000..fe57360f --- /dev/null +++ b/skills/gitlink-health/references/health-metrics.md @@ -0,0 +1,246 @@ +# 健康度指标计算 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +基于收集到的数据,计算 Issue 响应时间、PR 合并效率、贡献者活跃度三大指标,并给出综合健康评分。 + +## 指标 1:Issue 响应时间 + +### 两种评价方式 + +Issue 响应时间支持两种计算口径,AI Agent 可根据数据可用性和项目工作流选择: + +| 口径 | 计算方式 | 含义 | 适用场景 | +|------|----------|------|----------| +| **响应时间** | `resolved_at - created_at` | 从创建到解决的时间 | 衡量团队对 Issue 的实际响应速度 | +| **全周期时间** | `closed_at - created_at` | 从创建到正式关闭的时间 | 衡量 Issue 的完整生命周期 | + +**推荐优先使用响应时间**(新增→已解决),因为它反映真实处理效率。全周期时间受"解决后批量关闭"等工作流影响,可能虚高。 + +### 计算所需数据 + +从 `issue +list --state closed` 返回的 Issue 列表中提取: + +- `created_at` — 创建时间 +- `updated_at` — 最后更新时间(当无独立 `closed_at`/`resolved_at` 时的降级代理字段) +- `status_id` — 状态:1=新增, 2=正在解决, 3=已解决, 5=关闭 +- `status_name` — 状态名称(辅助判断) + +> **注意**:GitLink API 不直接返回 `closed_at` 或 `resolved_at` 字段。AI Agent 需根据上下文推断: +> - 若 Issue 的 `status_id=5`(关闭)且 `updated_at` 在近期批量操作中突变,则 `updated_at` 反映的是关闭时间而非解决时间,应尝试从历史会话或其他来源获取解决时间 +> - 若项目工作流为"解决即关闭"(单步),则 `updated_at` 同时代表解决和关闭时间,可直接使用 + +### 计算公式 + +``` +# 方式 A:响应时间(推荐) +time_to_resolve = resolved_at - created_at +avg_response = sum(time_to_resolve) / count +median_response = sorted(time_to_resolve)[count / 2] + +# 方式 B:全周期时间 +time_to_close = closed_at - created_at +avg_close = sum(time_to_close) / count +median_close = sorted(time_to_close)[count / 2] +``` + +### 阈值 + +| 指标 | 优秀 | 良好 | 需改进 | +|------|------|------|--------| +| 平均响应/关闭时间 | ≤ 2 天 | ≤ 7 天 | > 7 天 | +| 中位数响应/关闭时间 | ≤ 1 天 | ≤ 5 天 | > 5 天 | +| Issue 积压数 | ≤ 10 | ≤ 20 | > 20 | + +### 时间计算注意 + +- 时间字段为 ISO 8601 格式(如 `"2026-06-04 14:45"`),需解析后求差值 +- 无 `closed_at` 的 Issue(open 状态)不计入响应时间,计入积压统计 +- 当两种口径结果差异显著时(如响应时间 1.5 天 vs 全周期 15 天),以响应时间为评分依据,在全周期时间处标注"受工作流影响" + +## 指标 2:PR 合并效率 + +### 三段时间计算 + +PR 合并效率由**三类 PR** 共同决定,需计算**三段时间**: + +| 时间编号 | 名称 | 公式 | 适用对象 | +|----------|------|------|----------| +| ① | 已合并 PR 平均合并时长 | `avg(merged_at - created_at)` | status=1(已合并) | +| ② | 开放 PR 平均等待时长 | `avg(now - created_at)` | status=0(开放中) | +| ③ | 加权总平均处理时长 | 见下方公式 | status=1 ∪ status=0 | + +### 计算公式 + +``` +# ① 已合并 PR 平均合并时长 +time_merged_i = merged_at_i - created_at_i +avg_merged = sum(time_merged_i) / merged_count + +# ② 开放 PR 平均等待时长 +time_open_i = now - created_at_i +avg_open = sum(time_open_i) / open_count + +# ③ 加权总平均处理时长 +total_count = merged_count + open_count +weight_merged = merged_count / total_count +weight_open = open_count / total_count +weighted_total = weight_merged * avg_merged + weight_open * avg_open +``` + +### 状态分类 + +| 状态 | 含义 | 是否计入 | 计入哪类 | +|------|------|---------|---------| +| `pull_request_status=1` | 已合并 | ✅ | ①已合并 | +| `pull_request_status=0` | 开放中 | ✅ | ②开放等待 | +| `pull_request_status=2` | 已关闭(未合并) | ❌ | 不参与时间计算,但参与合并率分母 | + +> **设计依据**: +> - 已合并 PR 已有"完成时间"(merged_at),用真实合并耗时 +> - 开放 PR 尚未合并,"等待时长"= 从创建到当前(持续增长中),反映积压压力 +> - 加权平均综合体现项目处理 PR 的整体节奏,比单一指标更准确 + +### 合并率(独立指标) + +``` +merge_rate = merged_count / (merged_count + closed_count) × 100% +``` + +仅使用「已合并」与「已关闭未合并」作为分母,开放 PR 不参与。 + +### 阈值 + +| 指标 | 优秀 | 良好 | 需改进 | +|------|------|------|--------| +| ① 已合并 PR 平均合并时长 | ≤ 1 小时 | ≤ 1 天 | > 1 天 | +| ② 开放 PR 平均等待时长 | ≤ 1 天 | ≤ 7 天 | > 7 天 | +| **③ 加权总平均处理时长** | **≤ 6 小时** | **≤ 2 天** | **> 2 天** | +| PR 积压数 | ≤ 3 | ≤ 10 | > 10 | +| 合并率 | ≥ 90% | ≥ 70% | < 70% | + +> **可调阈值**:此表为默认值。不同项目工作流差异大(如 fork 端预审型 vs 上游社区 PR 型),可在 `health-metrics.md` 中按项目特征调整。 + +### 数据可用性 + +| 字段 | 来源命令 | 路径 | +|------|---------|------| +| `created_at` | `pr +list` | `data.issues[].pr_full_time` 或 `pr_created_unix` | +| `merged_at` | `pr +view --id ` | `data.pull_request.merged_at` | + +> **绝对禁止**:用「当前时间 - 创建时间」估算已合并 PR 的合并时间。已合并 PR 必须用真实的 `merged_at`。 +> 开放 PR 用「当前时间 - 创建时间」是允许的(且必要),因为它们没有合并时间点,等待时长反映积压。 + +### 数据可用性 + +PR 合并时间**只与 PR 自身时间相关**(创建时间 + 合并时间),**与当前时间无关**。 + +实际数据源: + +| 字段 | 来源命令 | 路径 | +|------|---------|------| +| `created_at` | `pr +list --state merged` | `data.issues[].pr_full_time` 或 `pr_created_unix` | +| `merged_at` | `pr +view --id ` | `data.pull_request.merged_at` | + +**注意**:`merged_at` 只在 `pr +view` 单个 PR 详情中暴露,列表接口不返回。需要对每个已合并 PR 调用一次详情接口。 + +> **绝对禁止**:使用「当前时间 - 创建时间」作为合并时间估算。这种做法会把开放中或近期合并的 PR 误判为"超长合并时间",与 PR 合并效率的真实含义相悖。 + +## 指标 3:贡献者活跃度 + +### 计算所需数据 + +从 `GET /:owner/:repo/contributors` 返回: + +- `authors[].login` — 贡献者登录名 +- `authors[].commits` — 提交数 +- `authors[].additions / deletions` — 增删行数 + +### 活跃度分级 + +| 级别 | 图标 | 条件 | +|------|------|------| +| 高频活跃 | 🔥 | 近 30 天 commits ≥ 5 或 PRs ≥ 2 | +| 正常活跃 | 🟢 | 近 30 天 commits ≥ 1 或 PRs ≥ 1 | +| 低频活跃 | 🟡 | 近 30 天无 commit 但有 Issue 活动 | +| 不活跃 | ⚪ | 近 60 天无任何贡献活动 | + +### 贡献者集中度 + +``` +top_contributor_share = max(authors[].commits) / total_commits × 100% +``` + +集中度 > 70% 视为过度集中(bus factor 低,有单点风险)。 + +## 综合健康评分(100 分制) + +### 计分规则 + +起始分 **100 分**,逐项扣分。核心三指标(Issue 响应时间、PR 合并效率、贡献者活跃度)占 75 分,辅助指标占 25 分。 + +### 核心指标(75 分) + +| 扣分项 | 扣分 | 触发条件 | 说明 | +|--------|------|----------|------| +| Issue 响应慢 | -25 | `avg_response > 7天`(使用响应时间口径,即新增→已解决) | Issue 平均解决时间超过一周 | +| PR 合并慢 | -25 | `avg_merge > 1天` | PR 平均合并时间超过一天 | +| 贡献者活跃度低 | -25 | `active_contributors < 2` 或 `top_contributor_share > 70%` | 活跃贡献者过少或过度依赖单一贡献者 | + +### 辅助指标(25 分) + +| 扣分项 | 扣分 | 触发条件 | 说明 | +|--------|------|----------|------| +| Issue 积压 | -10 | `open Issues > 20` | 待处理 Issue 数量过多 | +| PR 积压 | -10 | `open PRs > 10` | 待合并 PR 数量过多 | +| 无近期发布 | -5 | `latest_release > 30天` | 近 30 天无新发布 | + +### 评分等级 + +| 分数 | 等级 | 图标 | 说明 | +|------|------|------|------| +| 90-100 | 优秀 | 🟢 | 项目运转非常健康 | +| 70-89 | 良好 | 🔵 | 整体正常,有小问题 | +| 50-69 | 一般 | 🟡 | 需要关注多项指标 | +| 30-49 | 需关注 | 🟠 | 存在明显瓶颈 | +| 0-29 | 严重 | 🔴 | 需要立即干预 | + +### 改进建议生成 + +根据扣分项自动生成改进建议: + +| 扣分项 | 改进建议 | +|--------|----------| +| Issue 积压 | 建议安排 Issue Triage,优先处理高优先级 Issue | +| PR 积压 | 建议增加 Code Review 资源,缩短 PR 等待时间 | +| Issue 响应慢 | 建议建立 Issue 处理 SLA,落实责任人 | +| PR 合并慢 | 建议设 PR 合并时效目标(如 48 小时内) | +| 贡献者集中 | 建议鼓励多人参与核心模块,避免单点风险 | +| 无近期发布 | 建议建立定期发布节奏(如每 2 周发一次) | + +## 输出格式 + +计算完成后,整理为结构化数据供模板填充: + +``` +综合评分: 70/100(良好 🟡) + 核心扣分: Issue 响应慢 -25 (avg 8天), 贡献者活跃度低 -25 (仅1位活跃) + 辅助扣分: Issue 积压 -10 (当前 25 个) + +Issue 响应时间: + 平均 4.2 天, 中位数 2.1 天, 积压 25 个 + +PR 合并效率: + 平均 1.8 天, 中位数 0.9 天, 积压 12 个, 合并率 85% + +贡献者活跃度: + 总贡献者 5, 总 commits 247 + 前三: mengcheng(120), zzx-coder(65), wbtiger(40) + 集中度: mengcheng 占 48.6%(正常) +``` + +## References + +- [collect-data](collect-data.md) — 数据收集步骤 +- [generate-report](generate-report.md) — 报告生成和输出 +- [SKILL.md](../SKILL.md) — 评分规则速查表 diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index 9997883d..8faeb7a1 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -58,16 +58,7 @@ gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"代码审 **场景**:从提交历史自动生成版本发布说明。 -```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)" -``` +> 此工作流已独立为 [`gitlink-changelog`](../gitlink-changelog/SKILL.md) skill,包含完整的数据收集、分类规则、模板和发布流程。详见该 skill 的 [references/](../gitlink-changelog/references/) 和 [examples/](../gitlink-changelog/examples/)。 ## 工作流 4:Repo Setup(仓库初始化) From 0a6e0f0b9462c60111c8ff80ff2781e9c070a959 Mon Sep 17 00:00:00 2001 From: camelliamc <16583354+camelliamc@user.noreply.gitee.com> Date: Sun, 14 Jun 2026 21:19:03 +0800 Subject: [PATCH 3/5] =?UTF-8?q?refactor(client):=20=E6=8F=90=E5=8F=96=20sh?= =?UTF-8?q?ouldAppendJSONSuffix=20=E6=96=B9=E6=B3=95=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E7=95=99=20raw=20=E8=B7=AF=E5=BE=84=E7=89=B9=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/client/client.go | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index 7db76fbb..9dc465d9 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -48,14 +48,12 @@ func New() (*Client, error) { func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { // Append .json suffix if not already present (GitLink API convention) // Handle paths that may already contain query strings (e.g., /path?key=val) - if !c.SkipJSONSuffix { + if c.shouldAppendJSONSuffix(path) { if idx := strings.Index(path, "?"); idx != -1 { basePath := path[:idx] queryStr := path[idx:] - if !strings.HasSuffix(basePath, ".json") { - path = basePath + ".json" + queryStr - } - } else if !strings.HasSuffix(path, ".json") { + path = basePath + ".json" + queryStr + } else { path += ".json" } } @@ -225,3 +223,24 @@ func lookupStatusInfo(code int) statusInfo { message: fmt.Sprintf("API 返回错误码 %d", code), } } + +// shouldAppendJSONSuffix reports whether the .json suffix should be appended to path. +// Returns false (skip append) when: +// - c.SkipJSONSuffix is set (explicit opt-out for non-JSON endpoints such as gateway) +// - path already ends with .json +// - path matches the raw content pattern (e.g., /api/:owner/:repo/raw/...) +func (c *Client) shouldAppendJSONSuffix(path string) bool { + if c.SkipJSONSuffix { + return false + } + if strings.HasSuffix(path, ".json") { + return false + } + parts := strings.Split(strings.Trim(path, "/"), "/") + for i, part := range parts { + if part == "raw" && i >= 2 && i+2 < len(parts) { + return false + } + } + return true +} From 8fef2f36421b2ace15522c8cf641bf9db9fcac85 Mon Sep 17 00:00:00 2001 From: camelliamc <16583354+camelliamc@user.noreply.gitee.com> Date: Sun, 14 Jun 2026 21:33:59 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(wiki):=E8=A7=A3=E5=86=B3wiki=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=ADgateway=E7=A1=AC=E7=BC=96=E7=A0=81=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config.go | 32 ++++++++++++++++++------- shortcuts/common/types.go | 34 ++++++++++++++++----------- shortcuts/wiki/wiki.go | 49 ++++++++++++++++++--------------------- 3 files changed, 68 insertions(+), 47 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index e8ec429b..885b3dbb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,21 +8,27 @@ import ( ) const ( - DefaultBaseURL = "https://www.gitlink.org.cn/api" - DefaultFormat = "table" + DefaultBaseURL = "https://www.gitlink.org.cn/api" + DefaultGatewayBaseURL = "https://gateway.gitlink.org.cn/api" + DefaultFormat = "table" + + // EnvGatewayBaseURL overrides GatewayBaseURL when set. + EnvGatewayBaseURL = "GITLINK_GATEWAY_URL" ) 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"` + GatewayBaseURL string `yaml:"gateway_base_url,omitempty"` + Format string `yaml:"default_format"` + Editor string `yaml:"editor,omitempty"` + Pager string `yaml:"pager,omitempty"` } func DefaultConfig() *Config { return &Config{ - BaseURL: DefaultBaseURL, - Format: DefaultFormat, + BaseURL: DefaultBaseURL, + GatewayBaseURL: DefaultGatewayBaseURL, + Format: DefaultFormat, } } @@ -53,6 +59,12 @@ func Load() (*Config, error) { if cfg.BaseURL == "" { cfg.BaseURL = DefaultBaseURL } + if cfg.GatewayBaseURL == "" { + cfg.GatewayBaseURL = DefaultGatewayBaseURL + } + if v := os.Getenv(EnvGatewayBaseURL); v != "" { + cfg.GatewayBaseURL = v + } if cfg.Format == "" { cfg.Format = DefaultFormat } @@ -79,6 +91,8 @@ func Get(key string) (string, error) { switch key { case "base_url": return cfg.BaseURL, nil + case "gateway_base_url": + return cfg.GatewayBaseURL, nil case "default_format": return cfg.Format, nil case "editor": @@ -98,6 +112,8 @@ func Set(key, value string) error { switch key { case "base_url": cfg.BaseURL = value + case "gateway_base_url": + cfg.GatewayBaseURL = value case "default_format": cfg.Format = value case "editor": diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 029c5ccb..a838c320 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -10,6 +10,7 @@ import ( "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/config" "github.com/gitlink-org/gitlink-cli/internal/context" clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" "github.com/gitlink-org/gitlink-cli/internal/output" @@ -21,7 +22,7 @@ type Shortcut struct { Description string Flags []Flag DryRun bool // 是否支持 dry-run - DryRunHint func(ctx *RuntimeContext) (string, error) // 返回预览描述 + DryRunHint func(ctx *RuntimeContext) (string, error) // 返回预览描述 Run func(ctx *RuntimeContext) error } @@ -37,12 +38,13 @@ type Flag struct { // RuntimeContext provides helpers for shortcut implementations. type RuntimeContext struct { - Client *client.Client - Owner string - Repo string - Format string - CommandName string - Args map[string]string + Client *client.Client + Owner string + Repo string + Format string + CommandName string + Args map[string]string + GatewayBaseURL string } // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. @@ -58,13 +60,19 @@ func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeCont format = "json" } + gatewayBaseURL := config.DefaultGatewayBaseURL + if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" { + gatewayBaseURL = cfg.GatewayBaseURL + } + return &RuntimeContext{ - Client: cli, - Owner: cmdutil.Owner, - Repo: cmdutil.Repo, - Format: format, - CommandName: commandName, - Args: args, + Client: cli, + Owner: cmdutil.Owner, + Repo: cmdutil.Repo, + Format: format, + CommandName: commandName, + Args: args, + GatewayBaseURL: gatewayBaseURL, }, nil } diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 31777eb3..11918bda 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -18,32 +18,30 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -const gatewayBaseURL = "https://gateway.gitlink.org.cn/api" - -var ( - projectIDCache sync.Map - gatewayClient *client.Client - gatewayOnce sync.Once -) +var projectIDCache sync.Map func wikiPath(endpoint string) string { return "/wiki/open/" + endpoint } -func getGatewayClient() *client.Client { - gatewayOnce.Do(func() { - gatewayClient = &client.Client{ - HTTP: auth.NewHTTPClient(), - BaseURL: gatewayBaseURL, - SkipJSONSuffix: true, - } - }) - return gatewayClient +// getGatewayClient returns a client targeting the Wiki API gateway. +// The BaseURL is resolved from RuntimeContext.GatewayBaseURL, which in turn +// honours (in order): GITLINK_GATEWAY_URL env > config gateway_base_url > default. +func getGatewayClient(ctx *common.RuntimeContext) *client.Client { + baseURL := ctx.GatewayBaseURL + if baseURL == "" { + baseURL = "https://gateway.gitlink.org.cn/api" + } + return &client.Client{ + HTTP: auth.NewHTTPClient(), + BaseURL: baseURL, + SkipJSONSuffix: true, + Debug: ctx.Client.Debug, + } } func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) { - gc := getGatewayClient() - gc.Debug = ctx.Client.Debug + gc := getGatewayClient(ctx) env, err := gc.Do(method, path, body, nil) if err != nil { return nil, err @@ -52,8 +50,7 @@ func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface } func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) { - gc := getGatewayClient() - gc.Debug = ctx.Client.Debug + gc := getGatewayClient(ctx) env, err := gc.Do(method, path, nil, query) if err != nil { return nil, err @@ -218,12 +215,12 @@ type LintIssue struct { } type LintSummary struct { - Repository string `json:"repository"` - TotalPages int `json:"total_pages"` - TotalIssues int `json:"total_issues"` - Errors int `json:"errors"` - Warnings int `json:"warnings"` - Results []LintIssue `json:"results"` + Repository string `json:"repository"` + TotalPages int `json:"total_pages"` + TotalIssues int `json:"total_issues"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` + Results []LintIssue `json:"results"` } var ( From 9028dbb376ca6618acd06f81998313304a86f44d Mon Sep 17 00:00:00 2001 From: camelliamc <16583354+camelliamc@user.noreply.gitee.com> Date: Sun, 14 Jun 2026 22:31:17 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test(wiki):=20=E8=A1=A5=E5=85=85=20callWiki?= =?UTF-8?q?API=E3=80=81runLint=E3=80=81resolveContent=20=E7=9A=84=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 RuntimeContext 中新增 GatewayHTTPClient 字段以支持测试中注入 mock HTTP client。 --- shortcuts/common/types.go | 31 +-- shortcuts/wiki/wiki.go | 7 +- shortcuts/wiki/wiki_test.go | 408 ++++++++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 15 deletions(-) diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index a838c320..17e0bd08 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -4,6 +4,7 @@ import ( "bufio" "encoding/json" "fmt" + "net/http" "net/url" "os" "strings" @@ -38,13 +39,14 @@ type Flag struct { // RuntimeContext provides helpers for shortcut implementations. type RuntimeContext struct { - Client *client.Client - Owner string - Repo string - Format string - CommandName string - Args map[string]string - GatewayBaseURL string + Client *client.Client + Owner string + Repo string + Format string + CommandName string + Args map[string]string + GatewayBaseURL string + GatewayHTTPClient *http.Client // optional; nil = use auth.NewHTTPClient (mainly for tests) } // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. @@ -66,13 +68,14 @@ func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeCont } return &RuntimeContext{ - Client: cli, - Owner: cmdutil.Owner, - Repo: cmdutil.Repo, - Format: format, - CommandName: commandName, - Args: args, - GatewayBaseURL: gatewayBaseURL, + Client: cli, + Owner: cmdutil.Owner, + Repo: cmdutil.Repo, + Format: format, + CommandName: commandName, + Args: args, + GatewayBaseURL: gatewayBaseURL, + GatewayHTTPClient: nil, }, nil } diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 11918bda..93e70314 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -27,13 +27,18 @@ func wikiPath(endpoint string) string { // getGatewayClient returns a client targeting the Wiki API gateway. // The BaseURL is resolved from RuntimeContext.GatewayBaseURL, which in turn // honours (in order): GITLINK_GATEWAY_URL env > config gateway_base_url > default. +// HTTP client falls back to auth.NewHTTPClient() when ctx.GatewayHTTPClient is nil. func getGatewayClient(ctx *common.RuntimeContext) *client.Client { baseURL := ctx.GatewayBaseURL if baseURL == "" { baseURL = "https://gateway.gitlink.org.cn/api" } + httpClient := ctx.GatewayHTTPClient + if httpClient == nil { + httpClient = auth.NewHTTPClient() + } return &client.Client{ - HTTP: auth.NewHTTPClient(), + HTTP: httpClient, BaseURL: baseURL, SkipJSONSuffix: true, Debug: ctx.Client.Debug, diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go index 6304f48a..420a539b 100644 --- a/shortcuts/wiki/wiki_test.go +++ b/shortcuts/wiki/wiki_test.go @@ -1,9 +1,13 @@ package wiki import ( + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "sync" "testing" @@ -234,3 +238,407 @@ func TestResolveProjectID_APIError(t *testing.T) { t.Fatal("expected error, got nil") } } + +// ---- callWikiAPI HTTP request path tests ---- + +func TestCallWikiAPI_Success(t *testing.T) { + resetProjectIDCache() + var receivedPath, receivedMethod string + var receivedBody []byte + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + receivedMethod = r.Method + if r.Body != nil { + buf := make([]byte, 1024) + n, _ := r.Body.Read(buf) + receivedBody = buf[:n] + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":200,"msg":"ok","data":{"id":42}}`)) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + GatewayBaseURL: server.URL, + GatewayHTTPClient: server.Client(), + } + + env, err := callWikiAPI(ctx, "POST", "/wiki/open/test", map[string]string{"foo": "bar"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if receivedMethod != "POST" { + t.Errorf("method = %q, want POST", receivedMethod) + } + if receivedPath != "/wiki/open/test" { + t.Errorf("path = %q, want /wiki/open/test", receivedPath) + } + if !strings.Contains(string(receivedBody), `"foo"`) { + t.Errorf("body should contain foo: %s", string(receivedBody)) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected map data, got %T", env.Data) + } + if data["id"] != float64(42) { + t.Errorf("data[id] = %v, want 42", data["id"]) + } +} + +func TestCallWikiAPI_BusinessError(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":400,"msg":"bad request","data":null}`)) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + GatewayBaseURL: server.URL, + GatewayHTTPClient: server.Client(), + } + _, err := callWikiAPI(ctx, "GET", "/test", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "bad request") { + t.Errorf("err = %q, want to contain 400 and bad request", err.Error()) + } +} + +func TestCallWikiAPI_GatewayHTTPError(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(`upstream error`)) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + GatewayBaseURL: server.URL, + GatewayHTTPClient: server.Client(), + } + _, err := callWikiAPI(ctx, "GET", "/test", nil) + if err == nil { + t.Fatal("expected error on 502") + } +} + +func TestCallWikiAPI_SkipsJSONSuffix(t *testing.T) { + // Verifies the SkipJSONSuffix path is correctly taken for gateway: + // the URL should NOT have a .json appended. + resetProjectIDCache() + var receivedPath string + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + w.Write([]byte(`{"code":200,"data":{}}`)) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + GatewayBaseURL: server.URL, + GatewayHTTPClient: server.Client(), + } + _, err := callWikiAPI(ctx, "GET", "/wiki/open/list", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if receivedPath != "/wiki/open/list" { + t.Errorf("path = %q, want /wiki/open/list (no .json suffix)", receivedPath) + } + if strings.HasSuffix(receivedPath, ".json") { + t.Errorf("path %q should NOT have .json suffix (gateway expects no suffix)", receivedPath) + } +} + +func TestCallWikiAPI_ConnectionRefused(t *testing.T) { + resetProjectIDCache() + // Use an unbound port to simulate connection failure + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: &http.Client{}, BaseURL: "http://127.0.0.1:1"}, + GatewayBaseURL: "http://127.0.0.1:1", + GatewayHTTPClient: &http.Client{}, + } + _, err := callWikiAPI(ctx, "GET", "/test", nil) + if err == nil { + t.Fatal("expected connection error") + } +} + +// ---- runLint / check rules tests ---- + +func TestIsCheckEnabled_Empty(t *testing.T) { + if !isCheckEnabled("", "any") { + t.Error("empty filter should enable all checks") + } + if !isCheckEnabled("empty,headings", "empty") { + t.Error("should enable 'empty' in filter list") + } + if isCheckEnabled("headings", "empty") { + t.Error("should not enable 'empty' when not in filter list") + } + if !isCheckEnabled(" empty , headings ", "empty") { + t.Error("should trim whitespace") + } +} + +func TestCheckEmpty(t *testing.T) { + issues := checkEmpty("p1", "") + if len(issues) != 1 || issues[0].Level != "error" || issues[0].Check != "empty" { + t.Errorf("expected 1 error-level 'empty' issue, got %+v", issues) + } + if issues := checkEmpty("p1", "some content"); issues != nil { + t.Errorf("non-empty content should not produce issues, got %+v", issues) + } + if issues := checkEmpty("p1", " \n\t "); len(issues) != 1 { + t.Errorf("whitespace-only content should be empty, got %+v", issues) + } +} + +func TestCheckHeading(t *testing.T) { + // missing H1 + if issues := checkHeading("p1", "Some text without heading"); len(issues) != 1 { + t.Errorf("expected 1 missing-heading issue, got %+v", issues) + } + // has H1 + if issues := checkHeading("p1", "# Title\nbody"); issues != nil { + t.Errorf("H1 should not produce issues, got %+v", issues) + } + // empty content skipped + if issues := checkHeading("p1", ""); issues != nil { + t.Errorf("empty content should be skipped, got %+v", issues) + } + // whitespace prefix + if issues := checkHeading("p1", " \n# Real Title"); issues != nil { + t.Errorf("H1 after whitespace should not produce issues, got %+v", issues) + } +} + +func TestCheckShort(t *testing.T) { + if issues := checkShort("p1", ""); issues != nil { + t.Errorf("empty content should be skipped, got %+v", issues) + } + if issues := checkShort("p1", "short"); len(issues) != 1 { + t.Errorf("expected 1 short issue, got %+v", issues) + } + long := strings.Repeat("a", 100) + if issues := checkShort("p1", long); issues != nil { + t.Errorf("long content should not produce issues, got %+v", issues) + } + // exactly 49 chars triggers + if issues := checkShort("p1", strings.Repeat("a", 49)); len(issues) != 1 { + t.Errorf("49-char content should be 'short', got %+v", issues) + } +} + +func TestCheckDeadLinks(t *testing.T) { + known := map[string]bool{"Home": true, "Guide": true} + + // All known: no issues + if issues := checkDeadLinks("p1", "[Home](Home) and [Guide](Guide)", known); issues != nil { + t.Errorf("all-known should not produce issues, got %+v", issues) + } + // Unknown link + issues := checkDeadLinks("p1", "[Unknown](Unknown)", known) + if len(issues) != 1 || issues[0].Check != "links" { + t.Errorf("expected 1 dead link issue, got %+v", issues) + } + // External links skipped + if issues := checkDeadLinks("p1", "[ext](https://example.com)", known); issues != nil { + t.Errorf("external links should be skipped, got %+v", issues) + } + // Anchor links skipped + if issues := checkDeadLinks("p1", "[anchor](#section)", known); issues != nil { + t.Errorf("anchor links should be skipped, got %+v", issues) + } + // Mixed + issues = checkDeadLinks("p1", "[Home](Home) and [Bad](BadPage)", known) + if len(issues) != 1 { + t.Errorf("expected 1 dead link in mixed, got %+v", issues) + } + // Empty content + if issues := checkDeadLinks("p1", "", known); issues != nil { + t.Errorf("empty content should not produce issues, got %+v", issues) + } +} + +func TestCheckImages(t *testing.T) { + // Mock image server + imgServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusOK) + }) + defer imgServer.Close() + + brokenServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + defer brokenServer.Close() + + httpClient := imgServer.Client() + // Valid image (200) + if issues := checkImages("p1", "![ok]("+imgServer.URL+"/img.png)", httpClient); issues != nil { + t.Errorf("200 image should not produce issues, got %+v", issues) + } + // Broken image (404) + issues := checkImages("p1", "![bad]("+brokenServer.URL+"/missing.png)", httpClient) + if len(issues) != 1 { + t.Errorf("expected 1 broken image issue, got %+v", issues) + } + // No images + if issues := checkImages("p1", "no images here", httpClient); issues != nil { + t.Errorf("no images should not produce issues, got %+v", issues) + } + // Malformed HTTP URL (regex matches https?:// but http.NewRequest fails to parse) + if issues := checkImages("p1", "![bad](http://[)", httpClient); len(issues) != 1 { + t.Errorf("expected 1 invalid-URL issue, got %+v", issues) + } +} + +func TestRunLint_Integration(t *testing.T) { + resetProjectIDCache() + + // Mock main API (project detail) and gateway (wiki list + get) + mainServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/detail.json") { + writeJSON(t, w, map[string]interface{}{"project_id": float64(999)}) + return + } + t.Errorf("unexpected main API call: %s %s", r.Method, r.URL.Path) + }) + defer mainServer.Close() + + // Page content (base64 encoded) + goodContent := base64.StdEncoding.EncodeToString([]byte("# Good Page\n\n" + strings.Repeat("This is a well-formed page with enough content to pass the short check. ", 3))) + + wikiServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/wiki/open/wikiPages": + writeJSON(t, w, map[string]interface{}{ + "code": 200, + "data": []map[string]interface{}{ + {"title": "Good", "sub_url": "Good"}, + {"title": "Empty", "sub_url": "Empty"}, + {"title": "_Sidebar", "sub_url": "_Sidebar"}, // system page - skipped + }, + }) + case "/wiki/open/getWiki": + pageName := r.URL.Query().Get("pageName") + var content string + if pageName == "Empty" { + content = "" // empty page + } else { + content = goodContent + } + writeJSON(t, w, map[string]interface{}{ + "code": 200, + "data": map[string]interface{}{"content_base64": content}, + }) + default: + t.Errorf("unexpected wiki path: %s", r.URL.Path) + } + }) + defer wikiServer.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: mainServer.Client(), BaseURL: mainServer.URL}, + Owner: "owner1", + Repo: "repo1", + Format: "json", + GatewayBaseURL: wikiServer.URL, + GatewayHTTPClient: wikiServer.Client(), + } + + if err := runLint(ctx); err != nil { + t.Fatalf("runLint: %v", err) + } + // _Sidebar is skipped, so TotalPages=2 (Good, Empty) + // Empty page produces 1 "empty" error + // We can't directly inspect the output envelope, but if no error, the function ran end-to-end +} + +// ---- resolveContent tests ---- + +func TestResolveContent_FromArg(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{"content": "inline content"}, + } + got, err := resolveContent(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "inline content" { + t.Errorf("got %q, want %q", got, "inline content") + } +} + +func TestResolveContent_FromFile(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "wiki.md") + want := "# Title\n\nBody content from file" + if err := os.WriteFile(path, []byte(want), 0600); err != nil { + t.Fatalf("setup: %v", err) + } + ctx := &common.RuntimeContext{ + Args: map[string]string{"file": path}, + } + got, err := resolveContent(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestResolveContent_ArgTakesPrecedence(t *testing.T) { + // When both --content and --file are set, --content wins + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "wiki.md") + if err := os.WriteFile(path, []byte("from file"), 0600); err != nil { + t.Fatalf("setup: %v", err) + } + ctx := &common.RuntimeContext{ + Args: map[string]string{ + "content": "from arg", + "file": path, + }, + } + got, err := resolveContent(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "from arg" { + t.Errorf("arg should take precedence; got %q", got) + } +} + +func TestResolveContent_Missing(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{}, + } + _, err := resolveContent(ctx) + if err == nil { + t.Fatal("expected error when neither --content nor --file is provided") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("err = %q, want to mention 'required'", err.Error()) + } +} + +func TestResolveContent_FileNotFound(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{"file": "/nonexistent/path/to/wiki.md"}, + } + _, err := resolveContent(ctx) + if err == nil { + t.Fatal("expected error for nonexistent file") + } +}