diff --git a/README.md b/README.md index b43dcd1..e7f9d7a 100644 --- a/README.md +++ b/README.md @@ -485,9 +485,11 @@ gitlink-cli search +users -k "zhangsan" - `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: @@ -560,6 +562,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: @@ -575,6 +589,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. ### Raw API diff --git a/README.zh-CN.md b/README.zh-CN.md index 265ac98..f057ecb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -455,6 +455,32 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### 工作流命令 + +`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和发布就绪度检查。 + +```bash +# 生成单个 PR 的审查摘要 +gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown + +# 生成仓库工作流报告 +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown + +# 根据本地门禁信号判断发布是否可以推进 +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 + +# 从 JSON 门禁文件生成发布就绪度报告 +gitlink-cli workflow +release-readiness --from release_readiness.json --format markdown +``` + +`workflow +release-readiness` 默认使用 `table` 输出,会综合版本号、变更摘要、已知阻塞项、测试结果、发布制品、破坏性变更回滚方案和依赖风险,给出 `pass/review/block` 门禁结论;命令只分析本地输入,不会创建 tag、Release 或附件。 + ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: 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 4dc69e5..6d5f01e 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut { newHealthShortcut(), newPRSummaryShortcut(), newRepoReportShortcut(), + newReleaseReadinessShortcut(), } }