From 5d98b036836aa41055378e9836274111f53c5ad9 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Wed, 10 Jun 2026 09:04:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20=E6=96=B0=E5=A2=9E=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E8=AF=B4=E6=98=8E=E7=94=9F=E6=88=90=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 + README.zh-CN.md | 23 + doc/changes/workflow-release-notes.md | 65 +++ shortcuts/workflow/release_notes.go | 528 +++++++++++++++++++++++ shortcuts/workflow/release_notes_test.go | 164 +++++++ shortcuts/workflow/workflow.go | 1 + shortcuts/workflow/workflow_test.go | 3 + 7 files changed, 793 insertions(+) create mode 100644 doc/changes/workflow-release-notes.md create mode 100644 shortcuts/workflow/release_notes.go create mode 100644 shortcuts/workflow/release_notes_test.go diff --git a/README.md b/README.md index b43dcd1..c0109ed 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-notes` `workflow +pr-summary` defaults to `table` when `--format` is omitted. `workflow +repo-report` defaults to `markdown` when `--format` is omitted. +`workflow +release-notes` defaults to `markdown` when `--format` is omitted. Examples: @@ -560,6 +562,12 @@ 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 + +# 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 + +# 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: @@ -575,6 +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 +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 265ac98..6e006f4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -455,6 +455,29 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### Workflow 自动化 + +`workflow` 提供只读的规则化分析命令,适合维护者和 AI Agent 在不写入远端数据的情况下整理 Issue、PR、仓库健康度和发布材料。 + +```bash +# 根据本地参数分析 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 + +# 根据已合并 PR 生成发布说明 +gitlink-cli workflow +release-notes --owner Gitlink --repo gitlink-cli --version v1.2.0 --limit 20 --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 +pr-summary` 默认输出 table,`workflow +repo-report` 和 `workflow +release-notes` 默认输出 markdown。当前 workflow 命令只做本地分析或只读拉取,不会评论、审批、关闭、合并或修改远端数据。 + ### Raw API Shortcuts 未覆盖的接口可通过 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 new file mode 100644 index 0000000..5e30daa --- /dev/null +++ b/shortcuts/workflow/release_notes.go @@ -0,0 +1,528 @@ +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" +) + +type ReleaseNotesInput struct { + 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,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 { + Type string `json:"type"` + Title string `json:"title"` + Items []ReleaseNotesEntry `json:"items"` +} + +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 release notes from merged pull requests", + Flags: []common.Flag{ + {Name: "from", Usage: "Read release notes input from a JSON file"}, + {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, + } +} + +func runReleaseNotes(ctx *common.RuntimeContext) error { + lang := normalizeLang(ctx.Arg("lang")) + input, err := collectReleaseNotesInput(ctx) + if err != nil { + return err + } + result := AnalyzeReleaseNotes(input, lang) + format := ctx.Format + if strings.TrimSpace(cmdutil.Format) == "" { + format = "markdown" + } + rendered, err := RenderReleaseNotes(result, format, lang) + if err != nil { + return err + } + _, err = fmt.Fprint(os.Stdout, rendered) + return err +} + +func collectReleaseNotesInput(ctx *common.RuntimeContext) (ReleaseNotesInput, error) { + if path := strings.TrimSpace(ctx.Arg("from")); path != "" { + input, err := readReleaseNotesInput(path) + if err != nil { + return ReleaseNotesInput{}, err + } + overlayReleaseNotesFlags(ctx, &input) + if strings.TrimSpace(input.Source) == "" { + input.Source = "local-json" + } + return input, nil + } + + limit, err := parseIntArg(ctx.Arg("limit"), 30, "limit") + if err != nil { + 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 + } +} + +func readReleaseNotesInput(path string) (ReleaseNotesInput, error) { + data, err := os.ReadFile(path) + if err != nil { + return ReleaseNotesInput{}, fmt.Errorf("read release notes input: %w", err) + } + var input ReleaseNotesInput + if err := json.Unmarshal(data, &input); err == nil && (len(input.PullRequests) > 0 || strings.TrimSpace(input.Repository) != "") { + return input, nil + } + 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 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) + 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" + } + + 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{ + Type: changeType, + Title: releaseNotesSectionTitle(changeType, lang), + Items: items, + }) + } + return sections +} + +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 0 + } +} + +func releaseNotesSectionTitle(changeType, lang string) string { + if normalizeLang(lang) == langZH { + 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" + } +} + +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 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 + } + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + 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 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 new file mode 100644 index 0000000..c6f4de4 --- /dev/null +++ b/shortcuts/workflow/release_notes_test.go @@ -0,0 +1,164 @@ +package workflow + +import ( + "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 TestAnalyzeReleaseNotesGroupsByChangeType(t *testing.T) { + result := AnalyzeReleaseNotes(ReleaseNotesInput{ + Repository: "owner/repo", + Version: "v1.2.0", + FromRef: "v1.1.0", + 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 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"}}, + }) + objectInput, err := readReleaseNotesInput(objectPath) + if err != nil { + t.Fatalf("read object input: %v", err) + } + 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 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/pulls.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + 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{}{ + "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, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: map[string]string{ + "state": "merged", + "page": "2", + "limit": "5", + "lang": "en", + }, + } + if err := runReleaseNotes(ctx); err != nil { + t.Fatalf("runReleaseNotes returned error: %v", err) + } +} + +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") + + 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) + } + + table, err := RenderReleaseNotes(result, "table", "en") + if err != nil { + t.Fatalf("RenderReleaseNotes table: %v", err) + } + 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 4dc69e5..fc10ff1 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut { newHealthShortcut(), newPRSummaryShortcut(), newRepoReportShortcut(), + newReleaseNotesShortcut(), } } diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go index d8cd3ef..6766114 100644 --- a/shortcuts/workflow/workflow_test.go +++ b/shortcuts/workflow/workflow_test.go @@ -31,6 +31,9 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) { if !names["repo-report"] { t.Fatal("Shortcuts missing repo-report") } + if !names["release-notes"] { + t.Fatal("Shortcuts missing release-notes") + } } func TestRunTriageWithSingleIssueArgs(t *testing.T) {