Merge PR #184: Add workflow release notes generator
# Conflicts: # README.md # README.zh-CN.md # shortcuts/workflow/release_notes.go # shortcuts/workflow/release_notes_test.go # shortcuts/workflow/workflow.go # shortcuts/workflow/workflow_test.go
This commit is contained in:
commit
7591bf7b83
14
README.md
14
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 发布流程和比赛交付材料中。
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newHealthShortcut(),
|
||||
newPRSummaryShortcut(),
|
||||
newRepoReportShortcut(),
|
||||
newReviewQueueShortcut(),
|
||||
newReleaseNotesShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue