forked from Gitlink/gitlink-cli
feat(workflow): 新增发布就绪度检查命令
This commit is contained in:
parent
52b7093846
commit
6e5eaacd59
15
README.md
15
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 直接调用:
|
||||
|
|
|
|||
|
|
@ -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 注册。
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newHealthShortcut(),
|
||||
newPRSummaryShortcut(),
|
||||
newRepoReportShortcut(),
|
||||
newReleaseReadinessShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue