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:
wbtiger 2026-07-14 22:49:59 +08:00
commit 7591bf7b83
7 changed files with 616 additions and 440 deletions

View File

@ -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

View File

@ -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

View File

@ -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 发布流程和比赛交付材料中。

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut {
newHealthShortcut(),
newPRSummaryShortcut(),
newRepoReportShortcut(),
newReviewQueueShortcut(),
newReleaseNotesShortcut(),
}
}

View File

@ -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")
}
}