diff --git a/README.md b/README.md index c3e409f..c0109ed 100644 --- a/README.md +++ b/README.md @@ -485,11 +485,11 @@ gitlink-cli search +users -k "zhangsan" - `workflow +health` - `workflow +pr-summary` - `workflow +repo-report` -- `workflow +review-queue` +- `workflow +release-notes` `workflow +pr-summary` defaults to `table` when `--format` is omitted. `workflow +repo-report` defaults to `markdown` when `--format` is omitted. -`workflow +review-queue` defaults to `table` when `--format` is omitted. +`workflow +release-notes` defaults to `markdown` when `--format` is omitted. Examples: @@ -563,11 +563,11 @@ 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 -# Pull request review queue by read-only GitLink fetch -gitlink-cli workflow +review-queue --owner Gitlink --repo gitlink-cli --limit 20 --format table +# Release notes from merged pull requests fetched in read-only mode +gitlink-cli workflow +release-notes --owner Gitlink --repo gitlink-cli --version v1.2.0 --limit 20 --format markdown -# Pull request review queue from local PR summary inputs -gitlink-cli workflow +review-queue --from review_queue.json --format markdown +# Release notes from a local JSON file +gitlink-cli workflow +release-notes --from release-prs.json --from-ref v1.1.0 --to-ref v1.2.0 --format json ``` Output formats: @@ -583,7 +583,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 +review-queue` only sorts and explains the review queue; it does not comment, approve, reject, or merge pull requests. +- `workflow +release-notes` reads merged PR metadata and generates local release notes only. ### Raw API diff --git a/README.zh-CN.md b/README.zh-CN.md index 69004b6..6e006f4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -455,25 +455,28 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` -### 工作流命令 +### Workflow 自动化 -`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和 PR 审查队列排序。 +`workflow` 提供只读的规则化分析命令,适合维护者和 AI Agent 在不写入远端数据的情况下整理 Issue、PR、仓库健康度和发布材料。 ```bash -# 生成单个 PR 的审查摘要 +# 根据本地参数分析 Issue +gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table + +# 只读拉取远端数据生成 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 -# 根据风险、变更类型、diff 规模和测试信号生成 PR 审查队列 -gitlink-cli workflow +review-queue --owner Gitlink --repo gitlink-cli --limit 20 --format table +# 根据已合并 PR 生成发布说明 +gitlink-cli workflow +release-notes --owner Gitlink --repo gitlink-cli --version v1.2.0 --limit 20 --format markdown -# 从本地 JSON 输入生成 PR 审查队列 -gitlink-cli workflow +review-queue --from review_queue.json --format markdown +# 根据本地 JSON 生成发布说明 +gitlink-cli workflow +release-notes --from release-prs.json --from-ref v1.1.0 --to-ref v1.2.0 --format json ``` -`workflow +review-queue` 默认使用 `table` 输出,适合维护者快速决定先审哪个 PR;它只读取 PR 元数据并进行本地规则分析,不会评论、审批、拒绝或合并 PR。 +`workflow +pr-summary` 默认输出 table,`workflow +repo-report` 和 `workflow +release-notes` 默认输出 markdown。当前 workflow 命令只做本地分析或只读拉取,不会评论、审批、关闭、合并或修改远端数据。 ### Raw API diff --git a/doc/changes/workflow-release-notes.md b/doc/changes/workflow-release-notes.md new file mode 100644 index 0000000..5d3472a --- /dev/null +++ b/doc/changes/workflow-release-notes.md @@ -0,0 +1,65 @@ +# Workflow Release Notes Generator + +## Summary + +Adds `gitlink-cli workflow +release-notes`, a read-only workflow command that turns merged pull request metadata into structured release notes for maintainers, CI jobs, and AI agents. + +## What It Does + +- Reads PR data from a local JSON file or fetches merged pull requests from GitLink in read-only mode. +- Reuses the existing PR summary classifier to group changes into features, fixes, docs, tests, refactors, CI, mixed changes, and unknown changes. +- Highlights notable feature and high-risk PRs. +- Detects common breaking-change wording such as "breaking change", "incompatible", and "migration required". +- Produces suggested verification notes from PR analysis. +- Supports `json`, `table`, and `markdown`; markdown is the default when no global `--format` is set. + +## Examples + +```bash +# Generate markdown release notes from merged PRs. +gitlink-cli workflow +release-notes \ + --owner Gitlink --repo gitlink-cli \ + --version v1.2.0 --limit 20 + +# Generate release notes from local PR metadata for scripts or offline workflows. +gitlink-cli workflow +release-notes \ + --from release-prs.json \ + --from-ref v1.1.0 --to-ref v1.2.0 \ + --format json +``` + +## Input Shape + +`--from` accepts either a `ReleaseNotesInput` object: + +```json +{ + "repository": "Gitlink/gitlink-cli", + "version": "v1.2.0", + "from_ref": "v1.1.0", + "to_ref": "v1.2.0", + "pull_requests": [ + { + "number": 42, + "title": "feat: add export workflow", + "author": "alice", + "changed_files": [ + {"filename": "shortcuts/export/export.go", "additions": 120} + ], + "commits": [ + {"sha": "abc123", "message": "feat: add export workflow"} + ] + } + ] +} +``` + +or a raw `[]PRSummaryInput` array. + +## Tests + +Unit tests cover shortcut registration, local object and array inputs, release note grouping, remote merged PR fetch parameters, markdown rendering, and table rendering. + +## 中文说明 + +`workflow +release-notes` 面向发布前整理变更说明的场景。维护者可以从 GitLink 只读拉取已合并 PR,也可以在 CI 或离线脚本中传入本地 JSON。命令会复用现有 PR 分析规则,把变更自动归入新增能力、修复、文档、测试、重构、CI 等分组,并提取重点变更、潜在破坏性变更和建议验证项。它只生成本地输出,不会评论、审批、合并或修改远端数据,适合放进 Release 发布流程和比赛交付材料中。 diff --git a/shortcuts/workflow/release_notes.go b/shortcuts/workflow/release_notes.go index d7b10f2..5e30daa 100644 --- a/shortcuts/workflow/release_notes.go +++ b/shortcuts/workflow/release_notes.go @@ -1,93 +1,73 @@ package workflow import ( + "bytes" "encoding/json" "fmt" + "net/url" "os" "sort" "strings" + "text/tabwriter" + "time" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -const ( - ReleaseNotesBreaking = "breaking_changes" - ReleaseNotesFeatures = "features" - ReleaseNotesBugFixes = "bug_fixes" - ReleaseNotesDocumentation = "documentation" - ReleaseNotesTests = "tests" - ReleaseNotesRefactoring = "refactoring" - ReleaseNotesChores = "chores" -) - type ReleaseNotesInput struct { - Repository string `json:"repository"` - Version string `json:"version"` - FromRef string `json:"from_ref"` - ToRef string `json:"to_ref"` - PullRequests []ReleaseNotesPR `json:"pull_requests"` - Commits []ReleaseNotesCommit `json:"commits"` - Source string `json:"source"` -} - -type ReleaseNotesPR struct { - Number int `json:"number"` - Title string `json:"title"` - Author string `json:"author"` - URL string `json:"url,omitempty"` - Files []string `json:"files,omitempty"` -} - -type ReleaseNotesCommit struct { - SHA string `json:"sha"` - Message string `json:"message"` - Author string `json:"author"` - URL string `json:"url,omitempty"` - Files []string `json:"files,omitempty"` + Repository string `json:"repository"` + FromRef string `json:"from_ref,omitempty"` + ToRef string `json:"to_ref,omitempty"` + Version string `json:"version,omitempty"` + PullRequests []PRSummaryInput `json:"pull_requests"` + Source string `json:"source"` } type ReleaseNotesResult struct { - Repository string `json:"repository"` - Version string `json:"version"` - FromRef string `json:"from_ref"` - ToRef string `json:"to_ref"` - Summary string `json:"summary"` - Sections []ReleaseNotesSection `json:"sections"` - Contributors []string `json:"contributors"` - CommitsCount int `json:"commits_count"` - PullRequestsCount int `json:"pull_requests_count"` - BreakingChanges []string `json:"breaking_changes"` - Reasoning []string `json:"reasoning"` - Source string `json:"source"` + Repository string `json:"repository"` + Version string `json:"version,omitempty"` + FromRef string `json:"from_ref,omitempty"` + ToRef string `json:"to_ref,omitempty"` + GeneratedAt string `json:"generated_at"` + TotalPRs int `json:"total_prs"` + Sections []ReleaseNotesSection `json:"sections"` + Highlights []string `json:"highlights"` + BreakingChanges []string `json:"breaking_changes,omitempty"` + TestingNotes []string `json:"testing_notes"` + RiskSummary map[string]int `json:"risk_summary"` + Source string `json:"source"` } type ReleaseNotesSection struct { - Key string `json:"key"` - Title string `json:"title"` - Items []ReleaseNotesItem `json:"items"` + Type string `json:"type"` + Title string `json:"title"` + Items []ReleaseNotesEntry `json:"items"` } -type ReleaseNotesItem struct { - Title string `json:"title"` - Author string `json:"author,omitempty"` - PRNumber int `json:"pr_number,omitempty"` - SHA string `json:"sha,omitempty"` - URL string `json:"url,omitempty"` - Reasons []string `json:"reasons,omitempty"` +type ReleaseNotesEntry struct { + Number int `json:"number,omitempty"` + Title string `json:"title"` + Author string `json:"author,omitempty"` + ChangeType string `json:"change_type"` + RiskLevel string `json:"risk_level"` + Files int `json:"files"` + Commits int `json:"commits"` + Notes []string `json:"notes,omitempty"` } func newReleaseNotesShortcut() *common.Shortcut { return &common.Shortcut{ Name: "release-notes", - Description: "Generate deterministic release notes from commits and pull requests", + Description: "Generate release notes from merged pull requests", Flags: []common.Flag{ {Name: "from", Usage: "Read release notes input from a JSON file"}, - {Name: "from-ref", Usage: "Start ref for remote read-only compare fetch"}, - {Name: "to-ref", Usage: "End ref for remote read-only compare fetch", Default: "master"}, - {Name: "version", Usage: "Release version label. Defaults to --to-ref when omitted"}, - {Name: "max-commits", Usage: "Maximum commits to analyze in remote mode", Default: "200"}, - {Name: "include-prs", Usage: "Include pull request signals when available", Bool: true, Default: "true"}, + {Name: "version", Short: "v", Usage: "Release version or title"}, + {Name: "from-ref", Usage: "Previous release tag, branch, or commit for display"}, + {Name: "to-ref", Usage: "Target release tag, branch, or commit for display"}, + {Name: "state", Usage: "Remote pull request state to fetch", Default: "merged"}, + {Name: "page", Short: "p", Usage: "Remote pull request page", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Maximum pull requests to include", Default: "30"}, {Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN}, }, Run: runReleaseNotes, @@ -96,18 +76,11 @@ func newReleaseNotesShortcut() *common.Shortcut { func runReleaseNotes(ctx *common.RuntimeContext) error { lang := normalizeLang(ctx.Arg("lang")) - input, notes, err := collectReleaseNotesInput(ctx) + input, err := collectReleaseNotesInput(ctx) if err != nil { return err } result := AnalyzeReleaseNotes(input, lang) - for _, note := range notes { - if note.Metric == "" && note.Note == "" { - continue - } - result.Reasoning = append(result.Reasoning, fmt.Sprintf("%s: %s", note.Metric, note.Note)) - } - format := ctx.Format if strings.TrimSpace(cmdutil.Format) == "" { format = "markdown" @@ -120,29 +93,54 @@ func runReleaseNotes(ctx *common.RuntimeContext) error { return err } -func collectReleaseNotesInput(ctx *common.RuntimeContext) (ReleaseNotesInput, []ScoringNote, error) { +func collectReleaseNotesInput(ctx *common.RuntimeContext) (ReleaseNotesInput, error) { if path := strings.TrimSpace(ctx.Arg("from")); path != "" { input, err := readReleaseNotesInput(path) if err != nil { - return ReleaseNotesInput{}, nil, err + return ReleaseNotesInput{}, err } + overlayReleaseNotesFlags(ctx, &input) if strings.TrimSpace(input.Source) == "" { input.Source = "local-json" } - return input, nil, nil + return input, nil } - maxCommits, err := parseIntArg(ctx.Arg("max-commits"), 200, "max-commits") + limit, err := parseIntArg(ctx.Arg("limit"), 30, "limit") if err != nil { - return ReleaseNotesInput{}, nil, err + return ReleaseNotesInput{}, err + } + page, err := parseIntArg(ctx.Arg("page"), 1, "page") + if err != nil { + return ReleaseNotesInput{}, err + } + state := strings.TrimSpace(ctx.Arg("state")) + if state == "" { + state = "merged" + } + prs, owner, repo, err := fetchReleaseNotePullRequests(ctx, state, page, limit) + if err != nil { + return ReleaseNotesInput{}, err + } + input := ReleaseNotesInput{ + Repository: fmt.Sprintf("%s/%s", owner, repo), + PullRequests: prs, + Source: "remote-read-only-fetch", + } + overlayReleaseNotesFlags(ctx, &input) + return input, nil +} + +func overlayReleaseNotesFlags(ctx *common.RuntimeContext, input *ReleaseNotesInput) { + if version := strings.TrimSpace(ctx.Arg("version")); version != "" { + input.Version = version + } + if fromRef := strings.TrimSpace(ctx.Arg("from-ref")); fromRef != "" { + input.FromRef = fromRef + } + if toRef := strings.TrimSpace(ctx.Arg("to-ref")); toRef != "" { + input.ToRef = toRef } - return FetchReleaseNotesInput(ctx, ReleaseNotesFetchOptions{ - FromRef: ctx.Arg("from-ref"), - ToRef: ctx.Arg("to-ref"), - Version: ctx.Arg("version"), - MaxCommits: maxCommits, - IncludePRs: parseBoolArgDefault(ctx.Arg("include-prs"), true), - }) } func readReleaseNotesInput(path string) (ReleaseNotesInput, error) { @@ -151,231 +149,380 @@ func readReleaseNotesInput(path string) (ReleaseNotesInput, error) { return ReleaseNotesInput{}, fmt.Errorf("read release notes input: %w", err) } var input ReleaseNotesInput - if err := json.Unmarshal(data, &input); err != nil { - return ReleaseNotesInput{}, fmt.Errorf("parse release notes input: %w", err) + if err := json.Unmarshal(data, &input); err == nil && (len(input.PullRequests) > 0 || strings.TrimSpace(input.Repository) != "") { + return input, nil } - if strings.TrimSpace(input.Repository) == "" && len(input.Commits) == 0 && len(input.PullRequests) == 0 { - return ReleaseNotesInput{}, fmt.Errorf("parse release notes input: expected ReleaseNotesInput root object") + var prs []PRSummaryInput + if err := json.Unmarshal(data, &prs); err != nil { + return ReleaseNotesInput{}, fmt.Errorf("parse release notes input: expected ReleaseNotesInput or []PRSummaryInput: %w", err) } - return input, nil + return ReleaseNotesInput{PullRequests: prs, Source: "local-json"}, nil +} + +func fetchReleaseNotePullRequests(ctx *common.RuntimeContext, state string, page, limit int) ([]PRSummaryInput, string, string, error) { + owner, repo, err := resolveFetchRepo(ctx, "", "") + if err != nil { + return nil, "", "", fmt.Errorf("workflow +release-notes remote mode requires --owner and --repo or a Git remote: %w", err) + } + if limit <= 0 { + limit = 30 + } + if page <= 0 { + page = 1 + } + query := url.Values{} + query.Set("state", state) + query.Set("page", fmt.Sprintf("%d", page)) + query.Set("limit", fmt.Sprintf("%d", limit)) + env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/pulls", query) + if err != nil { + return nil, "", "", fmt.Errorf("fetch pull requests for release notes: %w", err) + } + items := apiList(env.Data) + prs := make([]PRSummaryInput, 0, len(items)) + for _, raw := range items { + item, ok := raw.(map[string]interface{}) + if !ok { + continue + } + input, ok := normalizePRSummaryItem(item) + if !ok { + continue + } + input.Repository = fmt.Sprintf("%s/%s", owner, repo) + input.Source = "remote-read-only-fetch:list-metadata" + if strings.TrimSpace(input.State) == "" { + input.State = state + } + prs = append(prs, input) + if len(prs) >= limit { + break + } + } + return prs, owner, repo, nil } func AnalyzeReleaseNotes(input ReleaseNotesInput, lang string) ReleaseNotesResult { lang = normalizeLang(lang) - version := strings.TrimSpace(input.Version) - if version == "" { - version = strings.TrimSpace(input.ToRef) - } - if version == "" { - version = "unreleased" - } - - sectionsByKey := map[string][]ReleaseNotesItem{} - breakingChanges := []string{} - contributors := []string{} - reasoning := []string{} - - for _, pr := range input.PullRequests { - title := firstLine(pr.Title) - key, reasons := classifyReleaseNotesItem(title, pr.Files) - item := ReleaseNotesItem{ - Title: title, - Author: strings.TrimSpace(pr.Author), - PRNumber: pr.Number, - URL: strings.TrimSpace(pr.URL), - Reasons: reasons, - } - sectionsByKey[key] = append(sectionsByKey[key], item) - if key == ReleaseNotesBreaking { - breakingChanges = append(breakingChanges, title) - } - if item.Author != "" { - contributors = append(contributors, item.Author) - } - reasoning = append(reasoning, fmt.Sprintf("pr #%d classified as %s", pr.Number, key)) - } - - for _, commit := range input.Commits { - title := firstLine(commit.Message) - key, reasons := classifyReleaseNotesItem(title, commit.Files) - item := ReleaseNotesItem{ - Title: title, - Author: strings.TrimSpace(commit.Author), - SHA: shortSHA(commit.SHA), - URL: strings.TrimSpace(commit.URL), - Reasons: reasons, - } - sectionsByKey[key] = append(sectionsByKey[key], item) - if key == ReleaseNotesBreaking { - breakingChanges = append(breakingChanges, title) - } - if item.Author != "" { - contributors = append(contributors, item.Author) - } - if item.SHA != "" { - reasoning = append(reasoning, fmt.Sprintf("commit %s classified as %s", item.SHA, key)) - } - } - source := strings.TrimSpace(input.Source) if source == "" { source = "local" } + repository := strings.TrimSpace(input.Repository) + if repository == "" && len(input.PullRequests) > 0 { + repository = input.PullRequests[0].Repository + } + if repository == "" { + repository = "local" + } - sections := make([]ReleaseNotesSection, 0, len(sectionsByKey)) - for _, key := range releaseNotesSectionOrder() { - items := sectionsByKey[key] + riskSummary := map[string]int{} + grouped := map[string][]ReleaseNotesEntry{} + highlights := []string{} + breaking := []string{} + testing := []string{} + + for _, pr := range input.PullRequests { + summary := AnalyzePRSummary(pr, lang) + entry := releaseNotesEntryFromSummary(summary) + grouped[entry.ChangeType] = append(grouped[entry.ChangeType], entry) + riskSummary[entry.RiskLevel]++ + if entry.ChangeType == PRChangeTypeFeature || entry.RiskLevel == PRRiskHigh || entry.RiskLevel == PRRiskCritical { + highlights = append(highlights, releaseNotesEntryLine(entry)) + } + if isBreakingChange(pr) { + breaking = append(breaking, releaseNotesEntryLine(entry)) + } + testing = append(testing, summary.TestSuggestions...) + } + + sections := buildReleaseNotesSections(grouped, lang) + return ReleaseNotesResult{ + Repository: repository, + Version: strings.TrimSpace(input.Version), + FromRef: strings.TrimSpace(input.FromRef), + ToRef: strings.TrimSpace(input.ToRef), + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + TotalPRs: len(input.PullRequests), + Sections: sections, + Highlights: limitStrings(uniqueStrings(highlights), 10), + BreakingChanges: uniqueStrings(breaking), + TestingNotes: limitStrings(uniqueStrings(testing), 12), + RiskSummary: riskSummary, + Source: source, + } +} + +func releaseNotesEntryFromSummary(summary PRSummaryResult) ReleaseNotesEntry { + notes := []string{} + if summary.RiskLevel == PRRiskHigh || summary.RiskLevel == PRRiskCritical { + notes = append(notes, fmt.Sprintf("risk:%s", summary.RiskLevel)) + } + if len(summary.ReviewFocus) > 0 { + notes = append(notes, summary.ReviewFocus[0]) + } + return ReleaseNotesEntry{ + Number: summary.Number, + Title: summary.Title, + Author: summary.Author, + ChangeType: summary.ChangeType, + RiskLevel: summary.RiskLevel, + Files: summary.ChangedFilesCount, + Commits: summary.CommitCount, + Notes: uniqueStrings(notes), + } +} + +func buildReleaseNotesSections(grouped map[string][]ReleaseNotesEntry, lang string) []ReleaseNotesSection { + order := []string{ + PRChangeTypeFeature, + PRChangeTypeFix, + PRChangeTypeDocs, + PRChangeTypeTest, + PRChangeTypeRefactor, + PRChangeTypeCI, + PRChangeTypeMixed, + PRChangeTypeUnknown, + } + sections := []ReleaseNotesSection{} + for _, changeType := range order { + items := grouped[changeType] if len(items) == 0 { continue } + sort.SliceStable(items, func(i, j int) bool { + if items[i].RiskLevel != items[j].RiskLevel { + return releaseNotesRiskWeight(items[i].RiskLevel) > releaseNotesRiskWeight(items[j].RiskLevel) + } + return items[i].Number < items[j].Number + }) sections = append(sections, ReleaseNotesSection{ - Key: key, - Title: releaseNotesSectionTitle(lang, key), + Type: changeType, + Title: releaseNotesSectionTitle(changeType, lang), Items: items, }) } - - return ReleaseNotesResult{ - Repository: input.Repository, - Version: version, - FromRef: input.FromRef, - ToRef: input.ToRef, - Summary: buildReleaseNotesSummary(lang, len(input.Commits), len(input.PullRequests)), - Sections: sections, - Contributors: sortedUniqueStrings(contributors), - CommitsCount: len(input.Commits), - PullRequestsCount: len(input.PullRequests), - BreakingChanges: uniqueStrings(breakingChanges), - Reasoning: uniqueStrings(reasoning), - Source: source, - } + return sections } -func classifyReleaseNotesItem(text string, files []string) (string, []string) { - corpus := strings.ToLower(strings.TrimSpace(text)) - normalizedFiles := make([]string, 0, len(files)) - for _, file := range files { - normalizedFiles = append(normalizedFiles, normalizedPath(file)) - } - - if containsAny(corpus, []string{"breaking change", "breaking:"}) || strings.Contains(corpus, "!:") { - return ReleaseNotesBreaking, []string{"keyword:breaking"} - } - if containsAny(corpus, []string{"feat", "feature", "add", "support", "implement"}) { - return ReleaseNotesFeatures, []string{"keyword:feature"} - } - if containsAny(corpus, []string{"fix", "bug", "resolve", "crash", "error"}) { - return ReleaseNotesBugFixes, []string{"keyword:fix"} - } - if containsAny(corpus, []string{"docs", "doc", "readme", "guide", "example"}) || releaseNotesTouchesDocs(normalizedFiles) { - return ReleaseNotesDocumentation, []string{"keyword:docs"} - } - if containsAny(corpus, []string{"test", "tests", "coverage"}) || releaseNotesTouchesTests(normalizedFiles) { - return ReleaseNotesTests, []string{"keyword:test"} - } - if containsAny(corpus, []string{"refactor", "cleanup", "simplify", "restructure"}) { - return ReleaseNotesRefactoring, []string{"keyword:refactor"} - } - return ReleaseNotesChores, []string{"fallback:chore"} -} - -func releaseNotesSectionTitle(lang, key string) string { - zh := normalizeLang(lang) == langZH - switch key { - case ReleaseNotesBreaking: - if zh { - return "破坏性变更" - } - return "Breaking Changes" - case ReleaseNotesFeatures: - if zh { - return "新功能" - } - return "Features" - case ReleaseNotesBugFixes: - if zh { - return "问题修复" - } - return "Bug Fixes" - case ReleaseNotesDocumentation: - if zh { - return "文档" - } - return "Documentation" - case ReleaseNotesTests: - if zh { - return "测试" - } - return "Tests" - case ReleaseNotesRefactoring: - if zh { - return "重构" - } - return "Refactoring" - case ReleaseNotesChores: - if zh { - return "维护" - } - return "Chores" +func releaseNotesRiskWeight(risk string) int { + switch risk { + case PRRiskCritical: + return 4 + case PRRiskHigh: + return 3 + case PRRiskMedium: + return 2 + case PRRiskLow: + return 1 default: - return key + return 0 } } -func releaseNotesSectionOrder() []string { - return []string{ - ReleaseNotesBreaking, - ReleaseNotesFeatures, - ReleaseNotesBugFixes, - ReleaseNotesDocumentation, - ReleaseNotesTests, - ReleaseNotesRefactoring, - ReleaseNotesChores, - } -} - -func buildReleaseNotesSummary(lang string, commits int, prs int) string { +func releaseNotesSectionTitle(changeType, lang string) string { if normalizeLang(lang) == langZH { - return fmt.Sprintf("共分析 %d 个 commit、%d 个 PR。", commits, prs) + switch changeType { + case PRChangeTypeFeature: + return "新增能力" + case PRChangeTypeFix: + return "问题修复" + case PRChangeTypeDocs: + return "文档" + case PRChangeTypeTest: + return "测试" + case PRChangeTypeRefactor: + return "重构" + case PRChangeTypeCI: + return "工程与 CI" + case PRChangeTypeMixed: + return "综合变更" + default: + return "其他变更" + } + } + switch changeType { + case PRChangeTypeFeature: + return "Features" + case PRChangeTypeFix: + return "Fixes" + case PRChangeTypeDocs: + return "Documentation" + case PRChangeTypeTest: + return "Tests" + case PRChangeTypeRefactor: + return "Refactoring" + case PRChangeTypeCI: + return "Build and CI" + case PRChangeTypeMixed: + return "Mixed Changes" + default: + return "Other Changes" } - return fmt.Sprintf("Analyzed %d %s and %d %s.", commits, pluralizeReleaseNotesNoun(commits, "commit"), prs, pluralizeReleaseNotesNoun(prs, "pull request")) } -func pluralizeReleaseNotesNoun(count int, singular string) string { - if count == 1 { - return singular - } - return singular + "s" -} - -func releaseNotesTouchesDocs(files []string) bool { - for _, file := range files { - if isDocsPath(file) { +func isBreakingChange(pr PRSummaryInput) bool { + text := strings.ToLower(prTextCorpus(pr, true)) + for _, keyword := range []string{"breaking change", "breaking-change", "incompatible", "migration required", "remove deprecated", "drop support"} { + if strings.Contains(text, keyword) { return true } } return false } -func releaseNotesTouchesTests(files []string) bool { - for _, file := range files { - if isTestPath(file) { - return true +func releaseNotesEntryLine(entry ReleaseNotesEntry) string { + prefix := "" + if entry.Number > 0 { + prefix = fmt.Sprintf("#%d ", entry.Number) + } + if strings.TrimSpace(entry.Author) != "" { + return fmt.Sprintf("%s%s (@%s)", prefix, entry.Title, entry.Author) + } + return prefix + entry.Title +} + +func limitStrings(values []string, limit int) []string { + if limit <= 0 || len(values) <= limit { + return values + } + return values[:limit] +} + +func RenderReleaseNotes(result ReleaseNotesResult, 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 := writeReleaseNotesMarkdown(&buf, result, lang); err != nil { + return "", err + } + case "table": + if err := writeReleaseNotesTable(&buf, result); err != nil { + return "", err + } + default: + return "", fmt.Errorf("unsupported workflow output format %q", format) + } + return buf.String(), nil +} + +func writeReleaseNotesMarkdown(buf *bytes.Buffer, result ReleaseNotesResult, lang string) error { + title := "Release Notes" + if normalizeLang(lang) == langZH { + title = "发布说明" + } + if strings.TrimSpace(result.Version) != "" { + title = fmt.Sprintf("%s %s", title, result.Version) + } + if _, err := fmt.Fprintf(buf, "# %s\n\n", title); err != nil { + return err + } + if _, err := fmt.Fprintf(buf, "- Repository: `%s`\n- Pull requests: `%d`\n- Source: `%s`\n", result.Repository, result.TotalPRs, result.Source); err != nil { + return err + } + if result.FromRef != "" || result.ToRef != "" { + if _, err := fmt.Fprintf(buf, "- Range: `%s` -> `%s`\n", result.FromRef, result.ToRef); err != nil { + return err } } - return false -} - -func shortSHA(sha string) string { - sha = strings.TrimSpace(sha) - if len(sha) <= 7 { - return sha + if _, err := fmt.Fprintln(buf); err != nil { + return err } - return sha[:7] + if len(result.Highlights) > 0 { + if _, err := fmt.Fprintln(buf, "## Highlights"); err != nil { + return err + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + for _, item := range result.Highlights { + if _, err := fmt.Fprintf(buf, "- %s\n", item); err != nil { + return err + } + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + } + if len(result.BreakingChanges) > 0 { + if _, err := fmt.Fprintln(buf, "## Breaking Changes"); err != nil { + return err + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + for _, item := range result.BreakingChanges { + if _, err := fmt.Fprintf(buf, "- %s\n", item); err != nil { + return err + } + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + } + for _, section := range result.Sections { + if _, err := fmt.Fprintf(buf, "## %s\n\n", section.Title); err != nil { + return err + } + for _, item := range section.Items { + line := releaseNotesEntryLine(item) + meta := []string{item.RiskLevel} + if item.Files > 0 { + meta = append(meta, fmt.Sprintf("%d files", item.Files)) + } + if item.Commits > 0 { + meta = append(meta, fmt.Sprintf("%d commits", item.Commits)) + } + if _, err := fmt.Fprintf(buf, "- %s `%s`\n", line, strings.Join(meta, ", ")); err != nil { + return err + } + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + } + if len(result.TestingNotes) > 0 { + if _, err := fmt.Fprintln(buf, "## Suggested Verification"); err != nil { + return err + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + for _, item := range result.TestingNotes { + if _, err := fmt.Fprintf(buf, "- %s\n", item); err != nil { + return err + } + } + } + return nil } -func sortedUniqueStrings(values []string) []string { - result := uniqueStrings(values) - sort.Strings(result) - return result +func writeReleaseNotesTable(buf *bytes.Buffer, result ReleaseNotesResult) error { + tw := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "TYPE\tPR\tTITLE\tRISK\tFILES\tCOMMITS"); err != nil { + return err + } + for _, section := range result.Sections { + for _, item := range section.Items { + number := "-" + if item.Number > 0 { + number = fmt.Sprintf("#%d", item.Number) + } + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%d\n", + section.Type, + number, + truncateTableText(item.Title, 80), + item.RiskLevel, + item.Files, + item.Commits, + ); err != nil { + return err + } + } + } + return tw.Flush() } diff --git a/shortcuts/workflow/release_notes_test.go b/shortcuts/workflow/release_notes_test.go index 48bc522..c6f4de4 100644 --- a/shortcuts/workflow/release_notes_test.go +++ b/shortcuts/workflow/release_notes_test.go @@ -1,203 +1,164 @@ package workflow import ( - "encoding/json" + "bytes" "net/http" "net/http/httptest" + "path/filepath" + "strings" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func TestAnalyzeReleaseNotesCategorizesEntries(t *testing.T) { - result := AnalyzeReleaseNotes(sampleReleaseNotesInput(), "en") - if result.Version != "v1.2.0" { - t.Fatalf("Version = %q, want v1.2.0", result.Version) - } - if result.CommitsCount != 2 || result.PullRequestsCount != 2 { - t.Fatalf("counts = commits %d prs %d, want 2 and 2", result.CommitsCount, result.PullRequestsCount) - } - if result.Summary != "Analyzed 2 commits and 2 pull requests." { - t.Fatalf("Summary = %q, want pluralized English summary", result.Summary) - } - if sectionItemCount(result, "features") == 0 { - t.Fatalf("features section empty: %+v", result.Sections) - } - if sectionItemCount(result, "bug_fixes") == 0 { - t.Fatalf("bug fixes section empty: %+v", result.Sections) - } - if sectionItemCount(result, "documentation") == 0 { - t.Fatalf("documentation section empty: %+v", result.Sections) - } - if len(result.BreakingChanges) == 0 { - t.Fatalf("BreakingChanges empty") - } -} - -func TestAnalyzeReleaseNotesEnglishSummarySingular(t *testing.T) { +func TestAnalyzeReleaseNotesGroupsByChangeType(t *testing.T) { result := AnalyzeReleaseNotes(ReleaseNotesInput{ - Repository: "owner/repo", - Version: "v1.2.0", - ToRef: "master", - Commits: []ReleaseNotesCommit{ - {SHA: "abc123", Message: "fix: normalize compare response", Author: "bob"}, - }, - PullRequests: []ReleaseNotesPR{ - {Number: 10, Title: "feat: add workflow release notes", Author: "alice"}, - }, - }, "en") - - if result.Summary != "Analyzed 1 commit and 1 pull request." { - t.Fatalf("Summary = %q, want singular English summary", result.Summary) - } -} - -func sampleReleaseNotesInput() ReleaseNotesInput { - return ReleaseNotesInput{ Repository: "owner/repo", Version: "v1.2.0", FromRef: "v1.1.0", - ToRef: "master", - PullRequests: []ReleaseNotesPR{ - {Number: 10, Title: "feat: add workflow release notes", Author: "alice"}, - {Number: 11, Title: "fix: normalize compare response", Author: "bob"}, - }, - Commits: []ReleaseNotesCommit{ - {SHA: "abc123", Message: "docs: update workflow guide", Author: "carol"}, - {SHA: "def456", Message: "BREAKING CHANGE: rename release flag", Author: "dave"}, + ToRef: "v1.2.0", + PullRequests: []PRSummaryInput{ + { + Number: 1, + Title: "feat: add project export", + Author: "alice", + ChangedFiles: []PRChangedFile{ + {Filename: "shortcuts/export/export.go", Additions: 120}, + }, + Commits: []PRCommit{{SHA: "abc", Message: "feat: add project export"}}, + }, + { + Number: 2, + Title: "fix: avoid nil panic", + Author: "bob", + Body: "Fixes a crash without breaking change.", + ChangedFiles: []PRChangedFile{ + {Filename: "internal/client/client.go", Additions: 5, Deletions: 2}, + }, + }, + { + Number: 3, + Title: "docs: update README", + ChangedFiles: []PRChangedFile{ + {Filename: "README.md", Additions: 8}, + }, + }, }, Source: "local-json", + }, "en") + + if result.TotalPRs != 3 { + t.Fatalf("TotalPRs = %d, want 3", result.TotalPRs) + } + if result.Repository != "owner/repo" || result.Version != "v1.2.0" { + t.Fatalf("unexpected metadata: %+v", result) + } + if len(result.Sections) < 3 { + t.Fatalf("expected feature/fix/docs sections, got %+v", result.Sections) + } + if result.Sections[0].Type != PRChangeTypeFeature { + t.Fatalf("first section = %q, want feature", result.Sections[0].Type) + } + if len(result.Highlights) == 0 || !strings.Contains(result.Highlights[0], "#1") { + t.Fatalf("expected feature highlight, got %#v", result.Highlights) } } -func sectionItemCount(result ReleaseNotesResult, key string) int { - for _, section := range result.Sections { - if section.Key == key { - return len(section.Items) - } - } - return 0 -} - -func TestReadReleaseNotesInput(t *testing.T) { - input, err := readReleaseNotesInput("testdata/release_notes.json") - if err != nil { - t.Fatalf("readReleaseNotesInput returned error: %v", err) - } - if input.Repository != "owner/repo" || len(input.Commits) == 0 { - t.Fatalf("input = %+v, want populated fixture", input) - } -} - -func TestReleaseNotesShortcutFromJSONFile(t *testing.T) { - restoreFormat := setCommandFormatForTest(t, "json") - defer restoreFormat() - - ctx := &common.RuntimeContext{ - Format: "json", - Args: map[string]string{ - "from": "testdata/release_notes.json", - "lang": "en", - }, - } - - output := captureStdout(t, func() error { - return findWorkflowShortcut(t, "release-notes").Run(ctx) +func TestReadReleaseNotesInputObjectAndArray(t *testing.T) { + objectPath := filepath.Join(t.TempDir(), "release.json") + writeJSONFixture(t, objectPath, ReleaseNotesInput{ + Repository: "owner/repo", + PullRequests: []PRSummaryInput{{Number: 1, Title: "feat: one"}}, }) - var result ReleaseNotesResult - if err := json.Unmarshal([]byte(output), &result); err != nil { - t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, output) + objectInput, err := readReleaseNotesInput(objectPath) + if err != nil { + t.Fatalf("read object input: %v", err) } - if result.Repository != "owner/repo" { - t.Fatalf("Repository = %q, want owner/repo", result.Repository) + if objectInput.Repository != "owner/repo" || len(objectInput.PullRequests) != 1 { + t.Fatalf("unexpected object input: %+v", objectInput) + } + + arrayPath := filepath.Join(t.TempDir(), "prs.json") + writeJSONFixture(t, arrayPath, []PRSummaryInput{{Number: 2, Title: "fix: two"}}) + arrayInput, err := readReleaseNotesInput(arrayPath) + if err != nil { + t.Fatalf("read array input: %v", err) + } + if len(arrayInput.PullRequests) != 1 || arrayInput.PullRequests[0].Number != 2 { + t.Fatalf("unexpected array input: %+v", arrayInput) } } -func TestReleaseNotesShortcutRemoteFetch(t *testing.T) { - restoreFormat := setCommandFormatForTest(t, "json") - defer restoreFormat() - +func TestRunReleaseNotesRemoteModeFetchesMergedPRs(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/compare.json" { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls.json" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - if r.URL.Query().Get("from") != "v1.1.0" || r.URL.Query().Get("to") != "master" { - t.Fatalf("query = %s, want from/to refs", r.URL.RawQuery) + if r.URL.Query().Get("state") != "merged" { + t.Fatalf("state = %q, want merged", r.URL.Query().Get("state")) + } + if r.URL.Query().Get("page") != "2" || r.URL.Query().Get("limit") != "5" { + t.Fatalf("unexpected pagination: %s", r.URL.RawQuery) } writeWorkflowJSON(t, w, map[string]interface{}{ - "commits": []map[string]interface{}{ - {"sha": "abc123", "message": "feat: add release notes", "author": map[string]interface{}{"name": "alice"}}, - {"sha": "def456", "message": "fix: compare fallback", "author": map[string]interface{}{"name": "bob"}}, + "pulls": []map[string]interface{}{ + { + "number": 9, + "title": "feat: release notes", + "description": "Add release notes generator", + "state": "merged", + "author": map[string]interface{}{"login": "alice"}, + }, }, }) })) defer server.Close() ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, Owner: "owner", Repo: "repo", Format: "json", Args: map[string]string{ - "from-ref": "v1.1.0", - "to-ref": "master", - "version": "v1.2.0", - "max-commits": "200", - "include-prs": "true", - "lang": "en", + "state": "merged", + "page": "2", + "limit": "5", + "lang": "en", }, } - - output := captureStdout(t, func() error { - return findWorkflowShortcut(t, "release-notes").Run(ctx) - }) - var result ReleaseNotesResult - if err := json.Unmarshal([]byte(output), &result); err != nil { - t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, output) - } - if result.Source != "remote-read-only-fetch" || result.CommitsCount != 2 { - t.Fatalf("result = %+v, want remote source with 2 commits", result) + if err := runReleaseNotes(ctx); err != nil { + t.Fatalf("runReleaseNotes returned error: %v", err) } } -func TestReleaseNotesShortcutRemoteFetchDefaultsToIncludingPullRequests(t *testing.T) { - restoreFormat := setCommandFormatForTest(t, "json") - defer restoreFormat() +func TestRenderReleaseNotesMarkdownAndTable(t *testing.T) { + result := AnalyzeReleaseNotes(ReleaseNotesInput{ + Repository: "owner/repo", + Version: "v1.0.0", + PullRequests: []PRSummaryInput{{ + Number: 1, + Title: "feat: export reports", + Author: "alice", + }}, + }, "en") - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeWorkflowJSON(t, w, map[string]interface{}{ - "commits": []map[string]interface{}{ - {"sha": "abc123", "message": "fix: compare fallback", "author": map[string]interface{}{"name": "bob"}}, - }, - "pull_requests": []map[string]interface{}{ - {"number": 12, "title": "feat: add release note workflow", "author": map[string]interface{}{"login": "alice"}}, - }, - }) - })) - defer server.Close() - - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: map[string]string{ - "from-ref": "v1.1.0", - "to-ref": "master", - "max-commits": "200", - "lang": "en", - }, + markdown, err := RenderReleaseNotes(result, "markdown", "en") + if err != nil { + t.Fatalf("RenderReleaseNotes markdown: %v", err) + } + if !strings.Contains(markdown, "# Release Notes v1.0.0") || !strings.Contains(markdown, "Features") { + t.Fatalf("unexpected markdown output:\n%s", markdown) } - output := captureStdout(t, func() error { - return findWorkflowShortcut(t, "release-notes").Run(ctx) - }) - var result ReleaseNotesResult - if err := json.Unmarshal([]byte(output), &result); err != nil { - t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, output) + table, err := RenderReleaseNotes(result, "table", "en") + if err != nil { + t.Fatalf("RenderReleaseNotes table: %v", err) } - if result.PullRequestsCount != 1 { - t.Fatalf("PullRequestsCount = %d, want default include-prs to include one PR", result.PullRequestsCount) + if !bytes.Contains([]byte(table), []byte("TYPE")) || !strings.Contains(table, "#1") { + t.Fatalf("unexpected table output:\n%s", table) } } diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go index 6ad2aa7..fc10ff1 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut { newHealthShortcut(), newPRSummaryShortcut(), newRepoReportShortcut(), - newReviewQueueShortcut(), + newReleaseNotesShortcut(), } } diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go index bdd3d23..6766114 100644 --- a/shortcuts/workflow/workflow_test.go +++ b/shortcuts/workflow/workflow_test.go @@ -31,8 +31,8 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) { if !names["repo-report"] { t.Fatal("Shortcuts missing repo-report") } - if !names["review-queue"] { - t.Fatal("Shortcuts missing review-queue") + if !names["release-notes"] { + t.Fatal("Shortcuts missing release-notes") } }