diff --git a/README.md b/README.md
index bfd49b0..99110a2 100644
--- a/README.md
+++ b/README.md
@@ -604,9 +604,11 @@ gitlink-cli profile +contribution --user zhangsan --year 2025
- `workflow +health`
- `workflow +pr-summary`
- `workflow +repo-report`
+- `workflow +release-readiness`
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
+`workflow +release-readiness` defaults to `table` when `--format` is omitted.
Examples:
@@ -679,6 +681,18 @@ gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format ma
# Repository workflow report from a local JSON file
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
+
+# Evaluate whether a release is ready to publish
+gitlink-cli workflow +release-readiness \
+ --version v1.2.0 \
+ --changes "Add workflow gate,Fix release upload" \
+ --tests "go test ./...=passed,go build ./...=passed" \
+ --artifacts "windows zip=passed,linux tar=passed" \
+ --rollback-plan "Revert tag and restore previous release assets" \
+ --format table
+
+# Evaluate release readiness from a JSON gate file
+gitlink-cli workflow +release-readiness --from release_readiness.json --format markdown
```
Output formats:
@@ -694,6 +708,7 @@ Safety:
- They do not depend on LLM APIs.
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
+- `workflow +release-readiness` evaluates local gate signals only; it does not create tags, releases, or assets.
### Dataset
diff --git a/README.zh-CN.md b/README.zh-CN.md
index a61a528..f057ecb 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -5,7 +5,7 @@
[](https://golang.org)
[](https://www.npmjs.com/package/@gitlink-ai/cli)
-[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/README.md)。
+[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
**[English](./README.md)**
@@ -78,19 +78,11 @@
jiangtx
-
-
## 为什么选择 gitlink-cli?
-- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/README.md),兼容 Claude Code — Agent 零配置即可操作 GitLink
+- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
@@ -103,8 +95,8 @@
| 分类 | 能力 |
|------|------|
-| 📦 仓库 | 列出、创建、Fork、删除仓库,读取和搜索文件、批量提交文件变更,查看仓库信息、洞察数据和互动状态 |
-| 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue |
+| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 |
+| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
@@ -113,11 +105,8 @@
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
-| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔍 搜索 | 搜索仓库、用户 |
-| 📊 数据集 | 按项目查询科研数据集 |
| 👤 用户 | 查看用户资料和信息 |
-| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
@@ -234,26 +223,10 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus
# 读取仓库 README
gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master
-# 读取指定仓库文件及元数据
-gitlink-cli repo +file --owner Gitlink --repo forgeplus --path README.md --ref main
-
# 列出仓库根目录或指定目录文件
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master
gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main
-# 按文件名搜索仓库文件
-gitlink-cli repo +files --owner Gitlink --repo forgeplus --search README --ref main
-
-# 提交单个文件变更
-gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
- --branch main --message "docs: update README" \
- --path README.md --content "# Project"
-
-# 从 JSON 操作文件一次提交多个文件变更
-gitlink-cli repo +commit-files --owner Gitlink --repo forgeplus \
- --branch main --new-branch docs/batch-update \
- --message "docs: batch update" --ops changes.json --dry-run
-
# 查看语言占比
gitlink-cli repo +languages --owner Gitlink --repo forgeplus
@@ -301,28 +274,6 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
-### Wiki 管理
-
-```bash
-# 列出 Wiki 页面(目录结构)
-gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
-
-# 查看 Wiki 页面
-gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
-
-# 创建 Wiki 页面
-gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
- -n getting-started -t "快速开始" -c "# 快速开始指南"
-
-# 更新 Wiki 页面标题和/或内容
-gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题"
-gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容"
-gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" -c "新内容"
-
-# 删除 Wiki 页面
-gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
-```
-
### 成员管理
```bash
@@ -372,14 +323,6 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
# 从 CSV 文件批量关闭 Issue
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
-# 按 API issue id 预览批量更新元数据
-# 注意:--ids 是 API issue id,不是网页 URL 中的 Issue 编号。
-gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run
-
-# 危险批量删除必须先 dry-run,真实执行还要显式 --yes
-gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run
-gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes
-
# 添加评论
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
@@ -460,25 +403,6 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
```
-### 分支管理
-
-```bash
-# 列出分支,支持关键词和删除分支过滤
-gitlink-cli branch +list --owner Gitlink --repo forgeplus
-gitlink-cli branch +list --owner Gitlink --repo forgeplus --keyword fix --state deleted
-
-# 无分页列出全部分支
-gitlink-cli branch +all --owner Gitlink --repo forgeplus
-
-# 创建 / 删除分支
-gitlink-cli branch +create --owner Gitlink --repo forgeplus --name feature/new-feature
-gitlink-cli branch +delete --owner Gitlink --repo forgeplus --name feature/old-feature
-
-# 设置默认分支或恢复已删除分支(先 dry-run 预览)
-gitlink-cli branch +set-default --owner Gitlink --repo forgeplus --name main --dry-run
-gitlink-cli branch +restore --owner Gitlink --repo forgeplus --id 7 --name feature/old --dry-run
-```
-
### 发布管理
```bash
@@ -521,16 +445,6 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
-### 忽略文件模板
-
-```bash
-# 列出所有可用的 .gitignore 模板
-gitlink-cli ignore +list
-
-# 按名称筛选模板
-gitlink-cli ignore +list --name Go
-```
-
### 搜索
```bash
@@ -541,50 +455,32 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
-### 用户画像
+### 工作流命令
-`profile` 暴露 GitLink 原生的用户画像统计(开发能力、角色定位、专业定位、近期活动、贡献热力图)。
-省略 `--user` 时默认使用当前认证用户。
+`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和发布就绪度检查。
```bash
-# 开发能力评分 + 语言分布
-gitlink-cli profile +ability --user zhangsan
+# 生成单个 PR 的审查摘要
+gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
-# 角色定位 / 专业(学科)定位
-gitlink-cli profile +role --user zhangsan
-gitlink-cli profile +major --user zhangsan
+# 生成仓库工作流报告
+gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
-# 指定时间范围的开发能力(Unix 时间戳)
-gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600
+# 根据本地门禁信号判断发布是否可以推进
+gitlink-cli workflow +release-readiness \
+ --version v1.2.0 \
+ --changes "新增工作流门禁,修复 Release 上传" \
+ --tests "go test ./...=passed,go build ./...=passed" \
+ --artifacts "windows zip=passed,linux tar=passed" \
+ --rollback-plan "回滚 tag 并恢复上一版制品" \
+ --format table
-# 当前用户的近期活动(每日 疑修 / 合并请求 / 提交)
-gitlink-cli profile +activity
-
-# 指定年份的贡献热力图
-gitlink-cli profile +contribution --user zhangsan --year 2025
+# 从 JSON 门禁文件生成发布就绪度报告
+gitlink-cli workflow +release-readiness --from release_readiness.json --format markdown
```
-### 数据集
+`workflow +release-readiness` 默认使用 `table` 输出,会综合版本号、变更摘要、已知阻塞项、测试结果、发布制品、破坏性变更回滚方案和依赖风险,给出 `pass/review/block` 门禁结论;命令只分析本地输入,不会创建 tag、Release 或附件。
-`dataset` 管理并查询 GitLink 科研数据集(标题、描述、论文内容、许可证、所属项目)。
-
-```bash
-# 按数字项目 ID 列出一个或多个项目的数据集
-gitlink-cli dataset +list --ids 5988
-
-# 查看仓库的数据集及其附件
-gitlink-cli dataset +view --owner Gitlink --repo forgeplus
-
-# 创建 / 更新仓库数据集(先用 --dry-run 预览)
-gitlink-cli dataset +create --owner me --repo proj -t "我的数据集" -d "..." --license-id 359 --dry-run
-gitlink-cli dataset +update --owner me --repo proj -t "我的数据集" -d "更新"
-
-# 删除数据集附件(破坏性:先预览,再用 --yes 确认)
-gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --dry-run
-gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --yes
-```
-
-> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404),待平台上线后即可生效。
### Raw API
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:
@@ -604,20 +500,6 @@ Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-s
# 带查询参数
gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
-
-# 从 git 上下文或显式 owner/repo 渲染路径占位符
-gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
-
-# 在单次请求中复用模板变量
-gitlink-cli api POST /v1/{{owner}}/{{repo}}/issues/{{number}}/journals \
- --body '{"notes":"handled by {{actor}}"}' \
- --var owner=Gitlink --var repo=gitlink-cli --var number=42 --var actor=bot
-
-# 先预览渲染后的单次请求,再决定是否真正发送
-gitlink-cli api POST /v1/{{owner}}/{{repo}}/issues \
- --body-file issue.json \
- --var owner=Gitlink --var repo=gitlink-cli \
- --dry-run
```
## 全局参数
@@ -655,13 +537,13 @@ git push gitlink
`skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
-详见 [skills/README.md](./skills/README.md)
+详见 [skills/README.md](skills/README.md)
| Skill | 说明 |
|-------|------|
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork、洞察数据等) |
-| `gitlink-issue` | Issue 操作(创建、更新、关闭、批量更新/删除、评论等) |
+| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) |
| `gitlink-member` | 仓库成员与邀请链接管理 |
| `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) |
@@ -723,7 +605,7 @@ gitlink-cli/
## 文档
-- [Skills 使用指南](./skills/README.md) — AI Agent Skills 详细说明
+- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明
- [设计文档](doc/design.md) — 架构设计和开发计划
## 常见问题
@@ -786,7 +668,7 @@ gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Creden
### Q: 如何查看完整的 API 参考?
-查看 [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md)
+查看 [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md)
## 许可证
diff --git a/doc/changes/workflow-release-readiness.md b/doc/changes/workflow-release-readiness.md
new file mode 100644
index 0000000..a3c7656
--- /dev/null
+++ b/doc/changes/workflow-release-readiness.md
@@ -0,0 +1,7 @@
+# 新增发布就绪度检查工作流
+
+`gitlink-cli workflow +release-readiness` 新增了发布前门禁检查能力,用于判断一次发布是否可以推进、需要人工复核,还是应先阻塞处理。命令既支持通过 flags 传入版本号、变更摘要、测试结果、制品状态、已知阻塞项、回滚方案和依赖风险,也支持从 JSON 文件读取完整门禁输入。
+
+该功能与发布说明生成不同,重点不是撰写 changelog,而是把发布前散落的信息转成可审查的 gate 结论。评分规则会对缺失版本号、缺少变更摘要、失败测试、缺失制品、已知阻塞项、破坏性变更但没有回滚方案、依赖风险等情况扣分,并输出 `pass`、`review` 或 `block`。结果支持 `table`、`markdown` 和 `json`,适合终端发版检查、发布评审记录或 CI 脚本消费。
+
+命令保持只读安全边界,不会创建 tag、Release、附件或远端评论。本次变更包含命令注册、flags/JSON 输入解析、门禁评分、中文和英文输出、README 示例、中文 README 说明以及单元测试;测试覆盖通过、阻塞、flags 解析、JSON 输入、markdown/table 渲染和 shortcut 注册。
diff --git a/shortcuts/workflow/release_readiness.go b/shortcuts/workflow/release_readiness.go
new file mode 100644
index 0000000..24d4561
--- /dev/null
+++ b/shortcuts/workflow/release_readiness.go
@@ -0,0 +1,432 @@
+package workflow
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "text/tabwriter"
+
+ "github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+const (
+ ReleaseGatePass = "pass"
+ ReleaseGateReview = "review"
+ ReleaseGateBlock = "block"
+)
+
+type ReleaseReadinessInput struct {
+ Repository string `json:"repository,omitempty"`
+ Version string `json:"version"`
+ TargetDate string `json:"target_date,omitempty"`
+ Changes []string `json:"changes,omitempty"`
+ BreakingChanges bool `json:"breaking_changes,omitempty"`
+ KnownBlockers []string `json:"known_blockers,omitempty"`
+ Tests []ReleaseCheckItem `json:"tests,omitempty"`
+ Artifacts []ReleaseCheckItem `json:"artifacts,omitempty"`
+ RollbackPlan string `json:"rollback_plan,omitempty"`
+ DependencyRisk string `json:"dependency_risk,omitempty"`
+ Source string `json:"source,omitempty"`
+}
+
+type ReleaseCheckItem struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Details string `json:"details,omitempty"`
+}
+
+type ReleaseReadinessResult struct {
+ Repository string `json:"repository"`
+ Version string `json:"version"`
+ TargetDate string `json:"target_date,omitempty"`
+ Gate string `json:"gate"`
+ Score int `json:"score"`
+ PassedChecks int `json:"passed_checks"`
+ WarningChecks int `json:"warning_checks"`
+ FailedChecks int `json:"failed_checks"`
+ MissingChecks int `json:"missing_checks"`
+ Findings []ReleaseReadinessFinding `json:"findings"`
+ Recommendations []string `json:"recommendations"`
+ Source string `json:"source"`
+}
+
+type ReleaseReadinessFinding struct {
+ Severity string `json:"severity"`
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+func newReleaseReadinessShortcut() *common.Shortcut {
+ return &common.Shortcut{
+ Name: "release-readiness",
+ Description: "Evaluate release readiness from local gate signals",
+ Flags: []common.Flag{
+ {Name: "from", Usage: "Read release readiness input from a JSON file"},
+ {Name: "repository", Usage: "Repository name, for example owner/repo"},
+ {Name: "version", Usage: "Release version or tag"},
+ {Name: "target-date", Usage: "Planned release date"},
+ {Name: "changes", Usage: "Comma-separated release change highlights"},
+ {Name: "breaking-changes", Usage: "Whether the release contains breaking changes", Bool: true, Default: "false"},
+ {Name: "known-blockers", Usage: "Comma-separated known release blockers"},
+ {Name: "tests", Usage: "Comma-separated checks, for example 'go test ./...=passed,go build ./...=passed'"},
+ {Name: "artifacts", Usage: "Comma-separated artifact checks, for example 'windows zip=passed,linux tar=missing'"},
+ {Name: "rollback-plan", Usage: "Rollback or mitigation plan summary"},
+ {Name: "dependency-risk", Usage: "Dependency risk level: clean, low, medium, high"},
+ {Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
+ },
+ Run: runReleaseReadiness,
+ }
+}
+
+func runReleaseReadiness(ctx *common.RuntimeContext) error {
+ lang := normalizeLang(ctx.Arg("lang"))
+ input, err := collectReleaseReadinessInput(ctx)
+ if err != nil {
+ return err
+ }
+ result := AnalyzeReleaseReadiness(input, lang)
+ format := ctx.Format
+ if strings.TrimSpace(cmdutil.Format) == "" {
+ format = "table"
+ }
+ rendered, err := RenderReleaseReadiness(result, format, lang)
+ if err != nil {
+ return err
+ }
+ _, err = fmt.Fprint(os.Stdout, rendered)
+ return err
+}
+
+func collectReleaseReadinessInput(ctx *common.RuntimeContext) (ReleaseReadinessInput, error) {
+ if path := strings.TrimSpace(ctx.Arg("from")); path != "" {
+ input, err := readReleaseReadinessInput(path)
+ if err != nil {
+ return ReleaseReadinessInput{}, err
+ }
+ if repo := strings.TrimSpace(ctx.Arg("repository")); repo != "" {
+ input.Repository = repo
+ }
+ if input.Repository == "" {
+ input.Repository = repositoryFromContext(ctx, "")
+ }
+ if input.Source == "" {
+ input.Source = "local-json"
+ }
+ return input, nil
+ }
+ return ReleaseReadinessInput{
+ Repository: repositoryFromContext(ctx, strings.TrimSpace(ctx.Arg("repository"))),
+ Version: strings.TrimSpace(ctx.Arg("version")),
+ TargetDate: strings.TrimSpace(ctx.Arg("target-date")),
+ Changes: parseCSV(ctx.Arg("changes")),
+ BreakingChanges: parseBoolArg(ctx.Arg("breaking-changes")),
+ KnownBlockers: parseCSV(ctx.Arg("known-blockers")),
+ Tests: parseReleaseCheckItems(ctx.Arg("tests")),
+ Artifacts: parseReleaseCheckItems(ctx.Arg("artifacts")),
+ RollbackPlan: strings.TrimSpace(ctx.Arg("rollback-plan")),
+ DependencyRisk: strings.TrimSpace(ctx.Arg("dependency-risk")),
+ Source: "local-flags",
+ }, nil
+}
+
+func readReleaseReadinessInput(path string) (ReleaseReadinessInput, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ReleaseReadinessInput{}, fmt.Errorf("read release readiness input: %w", err)
+ }
+ var input ReleaseReadinessInput
+ if err := json.Unmarshal(data, &input); err != nil {
+ return ReleaseReadinessInput{}, fmt.Errorf("parse release readiness input: %w", err)
+ }
+ return input, nil
+}
+
+func parseReleaseCheckItems(value string) []ReleaseCheckItem {
+ parts := parseCSV(value)
+ items := make([]ReleaseCheckItem, 0, len(parts))
+ for _, part := range parts {
+ name := part
+ status := "unknown"
+ if before, after, ok := strings.Cut(part, "="); ok {
+ name = strings.TrimSpace(before)
+ status = normalizeReleaseCheckStatus(after)
+ }
+ if name == "" {
+ continue
+ }
+ items = append(items, ReleaseCheckItem{Name: name, Status: status})
+ }
+ return items
+}
+
+func normalizeReleaseCheckStatus(status string) string {
+ switch strings.ToLower(strings.TrimSpace(status)) {
+ case "pass", "passed", "ok", "success", "green":
+ return "passed"
+ case "fail", "failed", "error", "red":
+ return "failed"
+ case "missing", "todo", "none":
+ return "missing"
+ case "skip", "skipped":
+ return "skipped"
+ default:
+ return "unknown"
+ }
+}
+
+func AnalyzeReleaseReadiness(input ReleaseReadinessInput, lang string) ReleaseReadinessResult {
+ lang = normalizeLang(lang)
+ result := ReleaseReadinessResult{
+ Repository: strings.TrimSpace(input.Repository),
+ Version: strings.TrimSpace(input.Version),
+ TargetDate: strings.TrimSpace(input.TargetDate),
+ Score: 100,
+ Source: strings.TrimSpace(input.Source),
+ }
+ if result.Repository == "" {
+ result.Repository = "local"
+ }
+ if result.Source == "" {
+ result.Source = "local"
+ }
+ if result.Version == "" {
+ result.addReleaseFinding(lang, "high", "missing_version", "release version is missing")
+ result.Score -= 20
+ }
+ if len(input.Changes) == 0 {
+ result.addReleaseFinding(lang, "medium", "missing_changes", "release changes are not summarized")
+ result.Score -= 12
+ }
+ if len(input.KnownBlockers) > 0 {
+ for _, blocker := range input.KnownBlockers {
+ result.addReleaseFinding(lang, "critical", "known_blocker", fmt.Sprintf("known blocker: %s", blocker))
+ }
+ result.Score -= 30
+ }
+ result.scoreReleaseChecks(lang, "test", input.Tests)
+ result.scoreReleaseChecks(lang, "artifact", input.Artifacts)
+ if input.BreakingChanges && strings.TrimSpace(input.RollbackPlan) == "" {
+ result.addReleaseFinding(lang, "high", "missing_rollback_plan", "breaking changes require a rollback or mitigation plan")
+ result.Score -= 15
+ }
+ switch strings.ToLower(strings.TrimSpace(input.DependencyRisk)) {
+ case "high", "critical":
+ result.addReleaseFinding(lang, "high", "dependency_risk", "dependency risk is high")
+ result.Score -= 15
+ case "medium":
+ result.addReleaseFinding(lang, "medium", "dependency_risk", "dependency risk needs review")
+ result.Score -= 8
+ case "low":
+ result.addReleaseFinding(lang, "low", "dependency_risk", "dependency risk is low")
+ result.Score -= 3
+ }
+ if result.Score < 0 {
+ result.Score = 0
+ }
+ result.Gate = releaseGate(result)
+ result.Recommendations = releaseReadinessRecommendations(result, lang)
+ return result
+}
+
+func (result *ReleaseReadinessResult) scoreReleaseChecks(lang, prefix string, checks []ReleaseCheckItem) {
+ if len(checks) == 0 {
+ result.addReleaseFinding(lang, "medium", "missing_"+prefix+"_checks", prefix+" checks are missing")
+ result.MissingChecks++
+ result.Score -= 10
+ return
+ }
+ for _, check := range checks {
+ switch normalizeReleaseCheckStatus(check.Status) {
+ case "passed":
+ result.PassedChecks++
+ case "failed":
+ result.FailedChecks++
+ result.addReleaseFinding(lang, "critical", prefix+"_failed", fmt.Sprintf("%s failed: %s", prefix, check.Name))
+ result.Score -= 25
+ case "missing":
+ result.MissingChecks++
+ result.addReleaseFinding(lang, "high", prefix+"_missing", fmt.Sprintf("%s missing: %s", prefix, check.Name))
+ result.Score -= 12
+ case "skipped":
+ result.WarningChecks++
+ result.addReleaseFinding(lang, "medium", prefix+"_skipped", fmt.Sprintf("%s skipped: %s", prefix, check.Name))
+ result.Score -= 8
+ default:
+ result.WarningChecks++
+ result.addReleaseFinding(lang, "medium", prefix+"_unknown", fmt.Sprintf("%s status unknown: %s", prefix, check.Name))
+ result.Score -= 8
+ }
+ }
+}
+
+func (result *ReleaseReadinessResult) addReleaseFinding(lang, severity, code, message string) {
+ if normalizeLang(lang) == langZH {
+ switch code {
+ case "missing_version":
+ message = "缺少发布版本号。"
+ case "missing_changes":
+ message = "缺少发布变更摘要。"
+ case "known_blocker":
+ message = strings.Replace(message, "known blocker:", "已知阻塞项:", 1)
+ case "missing_rollback_plan":
+ message = "包含破坏性变更时需要回滚或缓解方案。"
+ case "dependency_risk":
+ message = "依赖风险需要在发布前确认。"
+ default:
+ message = strings.ReplaceAll(message, "test", "测试")
+ message = strings.ReplaceAll(message, "artifact", "制品")
+ message = strings.ReplaceAll(message, "failed", "失败")
+ message = strings.ReplaceAll(message, "missing", "缺失")
+ message = strings.ReplaceAll(message, "skipped", "已跳过")
+ message = strings.ReplaceAll(message, "status unknown", "状态未知")
+ }
+ }
+ result.Findings = append(result.Findings, ReleaseReadinessFinding{
+ Severity: severity,
+ Code: code,
+ Message: message,
+ })
+}
+
+func releaseGate(result ReleaseReadinessResult) string {
+ if result.FailedChecks > 0 || result.Score < 60 {
+ return ReleaseGateBlock
+ }
+ for _, finding := range result.Findings {
+ if finding.Severity == "critical" {
+ return ReleaseGateBlock
+ }
+ }
+ if result.Score < 85 || result.WarningChecks > 0 || result.MissingChecks > 0 {
+ return ReleaseGateReview
+ }
+ return ReleaseGatePass
+}
+
+func releaseReadinessRecommendations(result ReleaseReadinessResult, lang string) []string {
+ zh := normalizeLang(lang) == langZH
+ switch result.Gate {
+ case ReleaseGatePass:
+ if zh {
+ return []string{"发布门禁已通过,发布前保留测试日志和制品校验记录。"}
+ }
+ return []string{"Release gate passed; keep test logs and artifact verification records before publishing."}
+ case ReleaseGateBlock:
+ if zh {
+ return []string{"先解决阻塞项或失败检查,再重新生成发布就绪度报告。"}
+ }
+ return []string{"Resolve blockers or failed checks first, then regenerate the readiness report."}
+ default:
+ if zh {
+ return []string{"需要维护者复核缺失或未知检查项,确认后再推进发布。"}
+ }
+ return []string{"Maintainer review is required for missing or unknown checks before release."}
+ }
+}
+
+func RenderReleaseReadiness(result ReleaseReadinessResult, format string, lang string) (string, error) {
+ var buf bytes.Buffer
+ switch normalizeFormat(format) {
+ case "json":
+ if err := writeJSON(&buf, result); err != nil {
+ return "", err
+ }
+ case "markdown":
+ if err := writeReleaseReadinessMarkdown(&buf, result, lang); err != nil {
+ return "", err
+ }
+ case "table":
+ if err := writeReleaseReadinessTable(&buf, result); err != nil {
+ return "", err
+ }
+ default:
+ return "", fmt.Errorf("unsupported workflow output format %q", format)
+ }
+ return buf.String(), nil
+}
+
+func writeReleaseReadinessMarkdown(buf *bytes.Buffer, result ReleaseReadinessResult, lang string) error {
+ title := "Release Readiness"
+ if normalizeLang(lang) == langZH {
+ title = "发布就绪度检查"
+ }
+ if _, err := fmt.Fprintf(buf, "# %s\n\n", title); err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintf(buf, "- Repository: `%s`\n- Version: `%s`\n- Gate: `%s`\n- Score: `%d`\n- Source: `%s`\n\n",
+ result.Repository, releaseTextOrDash(result.Version), result.Gate, result.Score, result.Source); err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintln(buf, "## Findings"); err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintln(buf); err != nil {
+ return err
+ }
+ if len(result.Findings) == 0 {
+ if _, err := fmt.Fprintln(buf, "- No readiness findings."); err != nil {
+ return err
+ }
+ } else {
+ for _, finding := range result.Findings {
+ if _, err := fmt.Fprintf(buf, "- `%s` `%s`: %s\n", finding.Severity, finding.Code, finding.Message); err != nil {
+ return err
+ }
+ }
+ }
+ if _, err := fmt.Fprintln(buf); err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintln(buf, "## Recommendations"); err != nil {
+ return err
+ }
+ for _, rec := range result.Recommendations {
+ if _, err := fmt.Fprintf(buf, "- %s\n", rec); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func writeReleaseReadinessTable(buf *bytes.Buffer, result ReleaseReadinessResult) error {
+ tw := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0)
+ if _, err := fmt.Fprintln(tw, "VERSION\tGATE\tSCORE\tPASSED\tWARNINGS\tFAILED\tMISSING\tREPOSITORY"); err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%d\t%d\t%d\t%s\n",
+ releaseTextOrDash(result.Version),
+ result.Gate,
+ result.Score,
+ result.PassedChecks,
+ result.WarningChecks,
+ result.FailedChecks,
+ result.MissingChecks,
+ result.Repository,
+ ); err != nil {
+ return err
+ }
+ if len(result.Findings) > 0 {
+ if _, err := fmt.Fprintln(tw); err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintln(tw, "SEVERITY\tCODE\tMESSAGE"); err != nil {
+ return err
+ }
+ for _, finding := range result.Findings {
+ if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\n", finding.Severity, finding.Code, finding.Message); err != nil {
+ return err
+ }
+ }
+ }
+ return tw.Flush()
+}
+
+func releaseTextOrDash(value string) string {
+ if strings.TrimSpace(value) == "" {
+ return "-"
+ }
+ return value
+}
diff --git a/shortcuts/workflow/release_readiness_test.go b/shortcuts/workflow/release_readiness_test.go
new file mode 100644
index 0000000..a331d24
--- /dev/null
+++ b/shortcuts/workflow/release_readiness_test.go
@@ -0,0 +1,138 @@
+package workflow
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+func TestAnalyzeReleaseReadinessPassesCleanRelease(t *testing.T) {
+ result := AnalyzeReleaseReadiness(ReleaseReadinessInput{
+ Repository: "owner/repo",
+ Version: "v1.2.0",
+ Changes: []string{"Add workflow release gate"},
+ Tests: []ReleaseCheckItem{{Name: "go test ./...", Status: "passed"}},
+ Artifacts: []ReleaseCheckItem{{Name: "windows zip", Status: "passed"}},
+ RollbackPlan: "Revert tag and restore previous release assets.",
+ DependencyRisk: "clean",
+ }, "en")
+
+ if result.Gate != ReleaseGatePass {
+ t.Fatalf("Gate = %q, want pass: %+v", result.Gate, result.Findings)
+ }
+ if result.Score != 100 || result.PassedChecks != 2 || len(result.Findings) != 0 {
+ t.Fatalf("result = %+v, want clean score", result)
+ }
+}
+
+func TestAnalyzeReleaseReadinessBlocksKnownFailures(t *testing.T) {
+ result := AnalyzeReleaseReadiness(ReleaseReadinessInput{
+ Version: "v1.2.0",
+ Changes: []string{"Breaking config migration"},
+ BreakingChanges: true,
+ KnownBlockers: []string{"Windows artifact is not signed"},
+ Tests: []ReleaseCheckItem{{Name: "go test ./...", Status: "failed"}},
+ Artifacts: []ReleaseCheckItem{{Name: "linux tar", Status: "missing"}},
+ DependencyRisk: "high",
+ }, "en")
+
+ if result.Gate != ReleaseGateBlock {
+ t.Fatalf("Gate = %q, want block", result.Gate)
+ }
+ if result.FailedChecks != 1 || result.MissingChecks != 1 || result.Score >= 60 {
+ t.Fatalf("failed/missing/score = %d/%d/%d", result.FailedChecks, result.MissingChecks, result.Score)
+ }
+ if !hasReleaseFinding(result.Findings, "known_blocker") || !hasReleaseFinding(result.Findings, "missing_rollback_plan") {
+ t.Fatalf("findings missing expected blockers: %+v", result.Findings)
+ }
+}
+
+func TestCollectReleaseReadinessInputFromFlags(t *testing.T) {
+ ctx := &common.RuntimeContext{
+ Args: map[string]string{
+ "repository": "owner/repo",
+ "version": "v1.0.0",
+ "changes": "feature A,fix B",
+ "breaking-changes": "true",
+ "tests": "go test ./...=passed,go build ./...=passed",
+ "artifacts": "windows zip=missing",
+ "rollback-plan": "Revert tag",
+ "dependency-risk": "medium",
+ },
+ }
+ input, err := collectReleaseReadinessInput(ctx)
+ if err != nil {
+ t.Fatalf("collectReleaseReadinessInput returned error: %v", err)
+ }
+ if input.Repository != "owner/repo" || input.Version != "v1.0.0" || !input.BreakingChanges {
+ t.Fatalf("input = %+v", input)
+ }
+ if len(input.Changes) != 2 || len(input.Tests) != 2 || input.Artifacts[0].Status != "missing" {
+ t.Fatalf("parsed lists = changes:%v tests:%v artifacts:%v", input.Changes, input.Tests, input.Artifacts)
+ }
+}
+
+func TestReadReleaseReadinessInputFromJSON(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "release_readiness.json")
+ writeJSONFixture(t, path, ReleaseReadinessInput{
+ Repository: "owner/repo",
+ Version: "v2.0.0",
+ Changes: []string{"Major release"},
+ Tests: []ReleaseCheckItem{{Name: "go test ./...", Status: "passed"}},
+ Artifacts: []ReleaseCheckItem{{Name: "linux tar", Status: "passed"}},
+ })
+
+ input, err := readReleaseReadinessInput(path)
+ if err != nil {
+ t.Fatalf("readReleaseReadinessInput returned error: %v", err)
+ }
+ if input.Version != "v2.0.0" || len(input.Tests) != 1 {
+ t.Fatalf("input = %+v", input)
+ }
+}
+
+func TestRenderReleaseReadinessMarkdownAndTable(t *testing.T) {
+ result := AnalyzeReleaseReadiness(ReleaseReadinessInput{
+ Version: "v1.0.0",
+ Changes: []string{"Initial release"},
+ Tests: []ReleaseCheckItem{{Name: "go test ./...", Status: "passed"}},
+ Artifacts: []ReleaseCheckItem{{Name: "linux tar", Status: "passed"}},
+ }, "zh-CN")
+
+ markdown, err := RenderReleaseReadiness(result, "markdown", "zh-CN")
+ if err != nil {
+ t.Fatalf("RenderReleaseReadiness markdown returned error: %v", err)
+ }
+ if !strings.Contains(markdown, "# 发布就绪度检查") || !strings.Contains(markdown, "v1.0.0") {
+ t.Fatalf("markdown output missing expected content:\n%s", markdown)
+ }
+
+ table, err := RenderReleaseReadiness(result, "table", "en")
+ if err != nil {
+ t.Fatalf("RenderReleaseReadiness table returned error: %v", err)
+ }
+ if !strings.Contains(table, "VERSION") || !strings.Contains(table, "pass") {
+ t.Fatalf("table output missing expected content:\n%s", table)
+ }
+}
+
+func TestShortcutsExposeReleaseReadiness(t *testing.T) {
+ names := map[string]bool{}
+ for _, shortcut := range Shortcuts() {
+ names[shortcut.Name] = true
+ }
+ if !names["release-readiness"] {
+ t.Fatal("Shortcuts missing release-readiness")
+ }
+}
+
+func hasReleaseFinding(findings []ReleaseReadinessFinding, code string) bool {
+ for _, finding := range findings {
+ if finding.Code == code {
+ return true
+ }
+ }
+ return false
+}
diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go
index 0f0ff9d..6d5f01e 100644
--- a/shortcuts/workflow/workflow.go
+++ b/shortcuts/workflow/workflow.go
@@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut {
newHealthShortcut(),
newPRSummaryShortcut(),
newRepoReportShortcut(),
- newStaleShortcut(),
+ newReleaseReadinessShortcut(),
}
}