Merge PR #414: feat(workflow): add release notes command
# Conflicts: # shortcuts/workflow/workflow.go
This commit is contained in:
commit
f36fbd13f3
|
|
@ -698,9 +698,11 @@ gitlink-cli profile +contribution --user zhangsan --year 2025
|
|||
- `workflow +pr-summary`
|
||||
- `workflow +review-context`
|
||||
- `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:
|
||||
|
||||
|
|
@ -776,6 +778,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 by read-only GitLink compare fetch
|
||||
gitlink-cli workflow +release-notes --owner Gitlink --repo gitlink-cli --from-ref v0.1.0 --to-ref master --version v0.2.0 --format markdown
|
||||
|
||||
# Release notes from a local JSON file
|
||||
gitlink-cli workflow +release-notes --from shortcuts/workflow/testdata/release_notes.json --format json
|
||||
```
|
||||
|
||||
Output formats:
|
||||
|
|
@ -792,6 +800,7 @@ Safety:
|
|||
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
|
||||
- `workflow +review-context` bundles repository, PR, file, review, issue, and label context without remote writes.
|
||||
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
|
||||
- `workflow +release-notes` reads compare data and renders release notes without creating releases or comments.
|
||||
|
||||
### Dataset
|
||||
|
||||
|
|
|
|||
|
|
@ -680,6 +680,49 @@ gitlink-cli profile +activity
|
|||
gitlink-cli profile +contribution --user zhangsan --year 2025
|
||||
```
|
||||
|
||||
### Workflow Agent 命令
|
||||
|
||||
`workflow` 提供面向维护者和 AI Agent 的规则化仓库分析能力,目前支持:
|
||||
|
||||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
- `workflow +release-notes`
|
||||
|
||||
`workflow +pr-summary` 在未指定 `--format` 时默认输出 `table`。
|
||||
`workflow +repo-report` 和 `workflow +release-notes` 在未指定 `--format` 时默认输出 `markdown`。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
# Issue 分诊
|
||||
gitlink-cli workflow +triage --title "安装失败" --body "运行 go install 时报错" --format table
|
||||
|
||||
# 仓库健康度
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --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
|
||||
|
||||
# 基于只读 compare fetch 生成 Release Notes
|
||||
gitlink-cli workflow +release-notes --owner Gitlink --repo gitlink-cli --from-ref v0.1.0 --to-ref master --version v0.2.0 --format markdown
|
||||
|
||||
# 从本地 JSON 生成 Release Notes
|
||||
gitlink-cli workflow +release-notes --from shortcuts/workflow/testdata/release_notes.json --format json
|
||||
```
|
||||
|
||||
安全边界:
|
||||
|
||||
- 当前 workflow 命令默认只读,可读取 GitLink 数据或本地 JSON。
|
||||
- 不依赖 LLM API。
|
||||
- `workflow +pr-summary` 不评论、不 approve/reject、不合并 PR。
|
||||
- `workflow +repo-report` 聚合健康度、Issue 分诊和 PR 摘要信号,不写远端。
|
||||
- `workflow +release-notes` 只读取 compare 数据并渲染版本说明,不创建 Release 或评论。
|
||||
|
||||
### 数据集
|
||||
|
||||
`dataset` 管理并查询 GitLink 科研数据集(标题、描述、论文内容、许可证、所属项目)。
|
||||
|
|
|
|||
|
|
@ -91,7 +91,6 @@ Coverage includes:
|
|||
|
||||
## Known Limitations
|
||||
|
||||
- `workflow +release-notes` is not implemented.
|
||||
- `workflow +stale` is not implemented.
|
||||
- Real GitLink API shapes may require follow-up normalization.
|
||||
|
||||
|
|
@ -102,4 +101,5 @@ gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json -
|
|||
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
|
||||
gitlink-cli workflow +release-notes --from shortcuts/workflow/testdata/release_notes.json --format markdown
|
||||
```
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ First PR:
|
|||
Additional workflow commands:
|
||||
- `workflow +pr-summary`: done
|
||||
- `workflow +repo-report`: done
|
||||
- `workflow +release-notes`: planned
|
||||
- `workflow +release-notes`: done
|
||||
- `workflow +stale`: planned
|
||||
|
||||
Current implementation status:
|
||||
|
|
@ -38,6 +38,8 @@ Current implementation status:
|
|||
- PR summary command: done with local JSON input, read-only fetch, rules, renderers, and tests
|
||||
- Repo report command: done with local JSON input, partial read-only fetch aggregation,
|
||||
scoring, renderers, and tests
|
||||
- Release notes command: done with local JSON input, read-only compare fetch,
|
||||
deterministic section classification, renderers, and tests
|
||||
|
||||
## Current Repository Findings
|
||||
|
||||
|
|
@ -437,22 +439,40 @@ Safety:
|
|||
|
||||
Inputs:
|
||||
- `--from`
|
||||
- `--to`
|
||||
- optional `--tag`
|
||||
- `--from-ref`
|
||||
- `--to-ref`
|
||||
- optional `--version`
|
||||
- optional `--max-commits`
|
||||
- optional `--include-prs`
|
||||
- optional `--lang`
|
||||
|
||||
Data:
|
||||
- PR titles
|
||||
- commit messages
|
||||
- changed file paths when present in compare data
|
||||
|
||||
Markdown categories:
|
||||
- Breaking Changes
|
||||
- Features
|
||||
- Bug Fixes
|
||||
- Documentation
|
||||
- Tests
|
||||
- Refactoring
|
||||
- Chores
|
||||
- Breaking Changes
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +release-notes --from shortcuts/workflow/testdata/release_notes.json --format markdown
|
||||
gitlink-cli workflow +release-notes --owner Gitlink --repo gitlink-cli --from-ref v0.1.0 --to-ref master --version v0.2.0 --format json
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Use local JSON input when `--from` is set.
|
||||
- In remote mode, read `GET /v1/:owner/:repo/compare?from=<from-ref>&to=<to-ref>`.
|
||||
- Classify commits and PRs with deterministic rules.
|
||||
- Render `json`, `table`, or `markdown`; default output is `markdown`.
|
||||
- Do not create releases, comments, labels, reviews, or merges.
|
||||
|
||||
### `workflow +stale`
|
||||
|
||||
|
|
@ -487,7 +507,7 @@ Planned fetch-layer extension:
|
|||
- `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode.
|
||||
- `pr_fetch.go` now reuses the same stable DTO and message patterns for read-only PR metadata, changed files, and commits.
|
||||
- `repo_report_fetch.go` composes the existing fetch helpers and records partial failures instead of failing the whole report.
|
||||
- Future `release-notes` should reuse the same normalization and renderer patterns.
|
||||
- `release_notes_fetch.go` reuses the same normalization and renderer patterns for read-only compare data.
|
||||
- Unknown or missing fields should stay explicit in JSON output so Agents can decide how to proceed.
|
||||
|
||||
## Implementation Order
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ Results:
|
|||
- repo report returns a partial report when at least one enabled section succeeds
|
||||
- repo report returns an error when all enabled fetched sections fail
|
||||
- repo report issue and PR limits are covered
|
||||
- release notes local JSON input, deterministic categorization, markdown/table/json renderers, and compare fetch are covered
|
||||
|
||||
## Manual Command Examples
|
||||
|
||||
|
|
@ -111,6 +112,8 @@ gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 -
|
|||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||
gitlink-cli workflow +release-notes --from shortcuts/workflow/testdata/release_notes.json --format markdown
|
||||
gitlink-cli workflow +release-notes --owner Gitlink --repo gitlink-cli --from-ref v0.1.0 --to-ref master --version v0.2.0 --format json
|
||||
```
|
||||
|
||||
## Remote Manual Verification
|
||||
|
|
@ -128,6 +131,7 @@ gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report
|
|||
- `workflow +health` still supports local parameters or a local JSON file via `--from`.
|
||||
- `workflow +pr-summary` supports local JSON input and read-only GitLink fetch mode.
|
||||
- `workflow +repo-report` supports local JSON input and partial read-only GitLink fetch aggregation.
|
||||
- `workflow +release-notes` supports local JSON input and read-only GitLink compare fetch mode.
|
||||
- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata only;
|
||||
detailed file and commit analysis remains available through `workflow +pr-summary --number`.
|
||||
- `json/table/markdown` are rendered inside the workflow package, not by the global formatter.
|
||||
|
|
@ -162,6 +166,7 @@ gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json -
|
|||
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
|
||||
gitlink-cli workflow +release-notes --from shortcuts/workflow/testdata/release_notes.json --format markdown
|
||||
```
|
||||
|
||||
Read-only remote smoke commands:
|
||||
|
|
@ -171,4 +176,5 @@ gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --l
|
|||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
gitlink-cli workflow +release-notes --owner Gitlink --repo gitlink-cli --from-ref v0.1.0 --to-ref master --format markdown
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,381 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type ReleaseNotesSection struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Items []ReleaseNotesItem `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"`
|
||||
}
|
||||
|
||||
func newReleaseNotesShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "release-notes",
|
||||
Description: "Generate deterministic release notes from commits and 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: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
|
||||
},
|
||||
Run: runReleaseNotes,
|
||||
}
|
||||
}
|
||||
|
||||
func runReleaseNotes(ctx *common.RuntimeContext) error {
|
||||
lang := normalizeLang(ctx.Arg("lang"))
|
||||
input, notes, 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"
|
||||
}
|
||||
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, []ScoringNote, error) {
|
||||
if path := strings.TrimSpace(ctx.Arg("from")); path != "" {
|
||||
input, err := readReleaseNotesInput(path)
|
||||
if err != nil {
|
||||
return ReleaseNotesInput{}, nil, err
|
||||
}
|
||||
if strings.TrimSpace(input.Source) == "" {
|
||||
input.Source = "local-json"
|
||||
}
|
||||
return input, nil, nil
|
||||
}
|
||||
|
||||
maxCommits, err := parseIntArg(ctx.Arg("max-commits"), 200, "max-commits")
|
||||
if err != nil {
|
||||
return ReleaseNotesInput{}, nil, err
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
return ReleaseNotesInput{}, fmt.Errorf("parse release notes input: %w", err)
|
||||
}
|
||||
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")
|
||||
}
|
||||
return input, 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"
|
||||
}
|
||||
|
||||
sections := make([]ReleaseNotesSection, 0, len(sectionsByKey))
|
||||
for _, key := range releaseNotesSectionOrder() {
|
||||
items := sectionsByKey[key]
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, ReleaseNotesSection{
|
||||
Key: key,
|
||||
Title: releaseNotesSectionTitle(lang, key),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func releaseNotesSectionOrder() []string {
|
||||
return []string{
|
||||
ReleaseNotesBreaking,
|
||||
ReleaseNotesFeatures,
|
||||
ReleaseNotesBugFixes,
|
||||
ReleaseNotesDocumentation,
|
||||
ReleaseNotesTests,
|
||||
ReleaseNotesRefactoring,
|
||||
ReleaseNotesChores,
|
||||
}
|
||||
}
|
||||
|
||||
func buildReleaseNotesSummary(lang string, commits int, prs int) string {
|
||||
if normalizeLang(lang) == langZH {
|
||||
return fmt.Sprintf("共分析 %d 个 commit、%d 个 PR。", commits, prs)
|
||||
}
|
||||
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) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func releaseNotesTouchesTests(files []string) bool {
|
||||
for _, file := range files {
|
||||
if isTestPath(file) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shortSHA(sha string) string {
|
||||
sha = strings.TrimSpace(sha)
|
||||
if len(sha) <= 7 {
|
||||
return sha
|
||||
}
|
||||
return sha[:7]
|
||||
}
|
||||
|
||||
func sortedUniqueStrings(values []string) []string {
|
||||
result := uniqueStrings(values)
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type ReleaseNotesFetchOptions struct {
|
||||
Owner string
|
||||
Repo string
|
||||
FromRef string
|
||||
ToRef string
|
||||
Version string
|
||||
MaxCommits int
|
||||
IncludePRs bool
|
||||
}
|
||||
|
||||
func FetchReleaseNotesInput(ctx *common.RuntimeContext, opts ReleaseNotesFetchOptions) (ReleaseNotesInput, []ScoringNote, error) {
|
||||
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
|
||||
if err != nil {
|
||||
return ReleaseNotesInput{}, nil, fmt.Errorf("workflow +release-notes remote mode requires --owner and --repo or a Git remote: %w", err)
|
||||
}
|
||||
fromRef := strings.TrimSpace(opts.FromRef)
|
||||
if fromRef == "" {
|
||||
return ReleaseNotesInput{}, nil, fmt.Errorf("workflow +release-notes remote mode requires --from-ref")
|
||||
}
|
||||
toRef := strings.TrimSpace(opts.ToRef)
|
||||
if toRef == "" {
|
||||
toRef = "master"
|
||||
}
|
||||
if opts.MaxCommits <= 0 {
|
||||
opts.MaxCommits = 200
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("from", fromRef)
|
||||
query.Set("to", toRef)
|
||||
env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/compare", query)
|
||||
if err != nil {
|
||||
return ReleaseNotesInput{}, nil, fmt.Errorf("fetch release notes compare: %w\nhint: use --from release_notes.json for local release notes generation", err)
|
||||
}
|
||||
|
||||
commits := releaseNotesCommitsFromData(env.Data, opts.MaxCommits)
|
||||
prs := []ReleaseNotesPR{}
|
||||
if opts.IncludePRs {
|
||||
prs = releaseNotesPRsFromData(env.Data)
|
||||
}
|
||||
input := ReleaseNotesInput{
|
||||
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
||||
Version: strings.TrimSpace(opts.Version),
|
||||
FromRef: fromRef,
|
||||
ToRef: toRef,
|
||||
PullRequests: prs,
|
||||
Commits: commits,
|
||||
Source: "remote-read-only-fetch",
|
||||
}
|
||||
notes := []ScoringNote{}
|
||||
if len(commits) == opts.MaxCommits {
|
||||
notes = append(notes, ScoringNote{Metric: "release_notes_commits", Note: fmt.Sprintf("commit list truncated to %d entries", opts.MaxCommits)})
|
||||
}
|
||||
if len(commits) == 0 && len(prs) == 0 {
|
||||
notes = append(notes, ScoringNote{Metric: "release_notes_compare", Note: "compare response contained no commits or pull requests"})
|
||||
}
|
||||
return input, uniqueScoringNotes(notes), nil
|
||||
}
|
||||
|
||||
func releaseNotesCommitsFromData(data interface{}, limit int) []ReleaseNotesCommit {
|
||||
items := releaseNotesListFromData(data, []string{"commits", "commit_list"})
|
||||
commits := make([]ReleaseNotesCommit, 0, len(items))
|
||||
for _, raw := range items {
|
||||
commit, ok := normalizeReleaseNotesCommit(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
commits = append(commits, commit)
|
||||
if limit > 0 && len(commits) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return commits
|
||||
}
|
||||
|
||||
func releaseNotesPRsFromData(data interface{}) []ReleaseNotesPR {
|
||||
items := releaseNotesListFromData(data, []string{"pull_requests", "pulls", "prs", "merge_requests"})
|
||||
prs := make([]ReleaseNotesPR, 0, len(items))
|
||||
for _, raw := range items {
|
||||
pr, ok := normalizeReleaseNotesPR(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
prs = append(prs, pr)
|
||||
}
|
||||
return prs
|
||||
}
|
||||
|
||||
func releaseNotesListFromData(data interface{}, keys []string) []interface{} {
|
||||
normalized, err := normalizeAPIData(data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch value := normalized.(type) {
|
||||
case []interface{}:
|
||||
return value
|
||||
case map[string]interface{}:
|
||||
for _, key := range keys {
|
||||
if raw, ok := value[key]; ok {
|
||||
if items := apiList(raw); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"data", "compare", "result"} {
|
||||
if raw, ok := value[key]; ok {
|
||||
if items := releaseNotesListFromData(raw, keys); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeReleaseNotesCommit(raw interface{}) (ReleaseNotesCommit, bool) {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return ReleaseNotesCommit{}, false
|
||||
}
|
||||
sha := firstPRString(item, "sha", "id")
|
||||
message := firstPRString(item, "message", "title", "subject")
|
||||
if strings.TrimSpace(sha) == "" && strings.TrimSpace(message) == "" {
|
||||
return ReleaseNotesCommit{}, false
|
||||
}
|
||||
return ReleaseNotesCommit{
|
||||
SHA: sha,
|
||||
Message: firstLine(message),
|
||||
Author: firstPRCommitAuthor(item),
|
||||
URL: firstPRString(item, "html_url", "url"),
|
||||
Files: releaseNotesFiles(item),
|
||||
}, true
|
||||
}
|
||||
|
||||
func normalizeReleaseNotesPR(raw interface{}) (ReleaseNotesPR, bool) {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return ReleaseNotesPR{}, false
|
||||
}
|
||||
number := firstPRInt(item, "number", "iid", "pull_request_number")
|
||||
title := firstPRString(item, "title", "subject")
|
||||
if number == 0 && strings.TrimSpace(title) == "" {
|
||||
return ReleaseNotesPR{}, false
|
||||
}
|
||||
return ReleaseNotesPR{
|
||||
Number: number,
|
||||
Title: title,
|
||||
Author: firstPRAuthor(item),
|
||||
URL: firstPRString(item, "html_url", "url"),
|
||||
Files: releaseNotesFiles(item),
|
||||
}, true
|
||||
}
|
||||
|
||||
func releaseNotesFiles(item map[string]interface{}) []string {
|
||||
rawItems := apiList(item["files"])
|
||||
files := make([]string, 0, len(rawItems))
|
||||
for _, raw := range rawItems {
|
||||
switch typed := raw.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) != "" {
|
||||
files = append(files, typed)
|
||||
}
|
||||
case map[string]interface{}:
|
||||
if file := firstPRString(typed, "filename", "file", "path", "new_path"); file != "" {
|
||||
files = append(files, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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) {
|
||||
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"},
|
||||
},
|
||||
Source: "local-json",
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
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.Repository != "owner/repo" {
|
||||
t.Fatalf("Repository = %q, want owner/repo", result.Repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNotesShortcutRemoteFetch(t *testing.T) {
|
||||
restoreFormat := setCommandFormatForTest(t, "json")
|
||||
defer restoreFormat()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/compare.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)
|
||||
}
|
||||
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"}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
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",
|
||||
"version": "v1.2.0",
|
||||
"max-commits": "200",
|
||||
"include-prs": "true",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseNotesShortcutRemoteFetchDefaultsToIncludingPullRequests(t *testing.T) {
|
||||
restoreFormat := setCommandFormatForTest(t, "json")
|
||||
defer restoreFormat()
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
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.PullRequestsCount != 1 {
|
||||
t.Fatalf("PullRequestsCount = %d, want default include-prs to include one PR", result.PullRequestsCount)
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +78,27 @@ func RenderRepoReport(result RepoReportResult, format string, lang string) (stri
|
|||
return buf.String(), nil
|
||||
}
|
||||
|
||||
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, lang); err != nil {
|
||||
return "", err
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported workflow output format %q", format)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func normalizeFormat(format string) string {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" {
|
||||
|
|
@ -183,6 +204,19 @@ func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) err
|
|||
return tw.Flush()
|
||||
}
|
||||
|
||||
func writeReleaseNotesTable(w io.Writer, result ReleaseNotesResult, lang string) error {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
if _, err := fmt.Fprintln(tw, "SECTION\tCOUNT"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, section := range result.Sections {
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%d\n", releaseNotesSectionTitle(lang, section.Key), len(section.Items)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func writeTriageMarkdown(w io.Writer, report TriageReport) error {
|
||||
if _, err := fmt.Fprintf(w, "# Issue Triage Report\n\nRepository: `%s`\n\n", report.Repository); err != nil {
|
||||
return err
|
||||
|
|
@ -286,6 +320,108 @@ func writeRepoReportMarkdown(w io.Writer, result RepoReportResult, lang string)
|
|||
return writeRepoReportMarkdownList(w, repoReportText(lang, "reasoning"), result.Reasoning, repoReportText(lang, "not_available"))
|
||||
}
|
||||
|
||||
func writeReleaseNotesMarkdown(w io.Writer, result ReleaseNotesResult, lang string) error {
|
||||
lang = normalizeLang(lang)
|
||||
if _, err := fmt.Fprintf(w, "# %s: %s\n\n", releaseNotesRenderText(lang, "title"), result.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
lines := []string{
|
||||
fmt.Sprintf("- Repository: `%s`", result.Repository),
|
||||
fmt.Sprintf("- Range: `%s...%s`", result.FromRef, result.ToRef),
|
||||
fmt.Sprintf("- Commits: `%d`", result.CommitsCount),
|
||||
fmt.Sprintf("- Pull requests: `%d`", result.PullRequestsCount),
|
||||
fmt.Sprintf("- Source: `%s`", result.Source),
|
||||
}
|
||||
for _, line := range lines {
|
||||
if _, err := fmt.Fprintln(w, line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "\n## %s\n\n- %s\n", releaseNotesRenderText(lang, "highlights"), result.Summary); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, section := range result.Sections {
|
||||
if len(section.Items) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "\n## %s\n\n", releaseNotesSectionTitle(lang, section.Key)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range section.Items {
|
||||
if _, err := fmt.Fprintf(w, "- %s\n", formatReleaseNotesMarkdownItem(item)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := writeReleaseNotesMarkdownList(w, releaseNotesRenderText(lang, "contributors"), result.Contributors, releaseNotesRenderText(lang, "none")); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeReleaseNotesMarkdownList(w, releaseNotesRenderText(lang, "reasoning"), result.Reasoning, releaseNotesRenderText(lang, "none"))
|
||||
}
|
||||
|
||||
func formatReleaseNotesMarkdownItem(item ReleaseNotesItem) string {
|
||||
parts := []string{item.Title}
|
||||
if item.PRNumber > 0 {
|
||||
parts = append(parts, fmt.Sprintf("(#%d)", item.PRNumber))
|
||||
}
|
||||
if item.SHA != "" {
|
||||
parts = append(parts, fmt.Sprintf("(`%s`)", item.SHA))
|
||||
}
|
||||
if item.Author != "" {
|
||||
parts = append(parts, "by @"+strings.TrimPrefix(item.Author, "@"))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func writeReleaseNotesMarkdownList(w io.Writer, title string, values []string, fallback string) error {
|
||||
if _, err := fmt.Fprintf(w, "\n## %s\n\n", title); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) == 0 {
|
||||
_, err := fmt.Fprintf(w, "- %s\n", fallback)
|
||||
return err
|
||||
}
|
||||
for _, value := range values {
|
||||
if _, err := fmt.Fprintf(w, "- %s\n", value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func releaseNotesRenderText(lang, key string) string {
|
||||
zh := normalizeLang(lang) == langZH
|
||||
switch key {
|
||||
case "title":
|
||||
if zh {
|
||||
return "版本说明"
|
||||
}
|
||||
return "Release Notes"
|
||||
case "highlights":
|
||||
if zh {
|
||||
return "亮点"
|
||||
}
|
||||
return "Highlights"
|
||||
case "contributors":
|
||||
if zh {
|
||||
return "贡献者"
|
||||
}
|
||||
return "Contributors"
|
||||
case "reasoning":
|
||||
if zh {
|
||||
return "判断依据"
|
||||
}
|
||||
return "Reasoning"
|
||||
case "none":
|
||||
if zh {
|
||||
return "无"
|
||||
}
|
||||
return "None"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func writeCountMapMarkdown(w io.Writer, title string, values map[string]int) error {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -607,6 +607,49 @@ func TestRenderHealthResultJSON(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRenderReleaseNotesMarkdown(t *testing.T) {
|
||||
result := AnalyzeReleaseNotes(sampleReleaseNotesInput(), "en")
|
||||
rendered, err := RenderReleaseNotes(result, "markdown", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderReleaseNotes returned error: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Release Notes", "Features", "Bug Fixes", "Contributors"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("markdown output missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderReleaseNotesJSON(t *testing.T) {
|
||||
result := AnalyzeReleaseNotes(sampleReleaseNotesInput(), "en")
|
||||
rendered, err := RenderReleaseNotes(result, "json", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderReleaseNotes returned error: %v", err)
|
||||
}
|
||||
var parsed ReleaseNotesResult
|
||||
if err := json.Unmarshal([]byte(rendered), &parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderReleaseNotesTable(t *testing.T) {
|
||||
result := AnalyzeReleaseNotes(sampleReleaseNotesInput(), "en")
|
||||
rendered, err := RenderReleaseNotes(result, "table", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderReleaseNotes returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(rendered, "SECTION") || !strings.Contains(rendered, "COUNT") {
|
||||
t.Fatalf("table output = %q, want headers", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderReleaseNotesUnknownFormat(t *testing.T) {
|
||||
_, err := RenderReleaseNotes(AnalyzeReleaseNotes(sampleReleaseNotesInput(), "en"), "xml", "en")
|
||||
if err == nil {
|
||||
t.Fatal("RenderReleaseNotes returned nil error for unknown format")
|
||||
}
|
||||
}
|
||||
|
||||
func samplePRSummaryResult() PRSummaryResult {
|
||||
return PRSummaryResult{
|
||||
Repository: "owner/repo",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"repository": "owner/repo",
|
||||
"version": "v1.2.0",
|
||||
"from_ref": "v1.1.0",
|
||||
"to_ref": "master",
|
||||
"pull_requests": [
|
||||
{
|
||||
"number": 10,
|
||||
"title": "feat: add workflow release notes",
|
||||
"author": "alice"
|
||||
}
|
||||
],
|
||||
"commits": [
|
||||
{
|
||||
"sha": "abc123456789",
|
||||
"message": "fix: normalize compare response",
|
||||
"author": "bob"
|
||||
},
|
||||
{
|
||||
"sha": "def456789012",
|
||||
"message": "docs: update workflow guide",
|
||||
"author": "carol"
|
||||
}
|
||||
],
|
||||
"source": "local-json"
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newHealthShortcut(),
|
||||
newPRSummaryShortcut(),
|
||||
newRepoReportShortcut(),
|
||||
newReviewContextShortcut(),
|
||||
newReleaseNotesShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -363,6 +363,13 @@ func parseBoolArg(value string) bool {
|
|||
return err == nil && parsed
|
||||
}
|
||||
|
||||
func parseBoolArgDefault(value string, defaultValue bool) bool {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return parseBoolArg(value)
|
||||
}
|
||||
|
||||
func parseIntArg(value string, defaultValue int, name string) (int, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,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) {
|
||||
|
|
|
|||
|
|
@ -53,19 +53,31 @@ gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"代码审
|
|||
|
||||
## 工作流 3:Release Notes 生成
|
||||
|
||||
**场景**:从提交历史自动生成版本发布说明。
|
||||
**场景**:从提交历史和 PR 信号自动生成版本发布说明,适合维护者发版前检查、Agent 生成 changelog 草稿、竞赛材料展示等场景。
|
||||
|
||||
```bash
|
||||
# 1. 获取两个版本之间的提交
|
||||
gitlink-cli api GET /:owner/:repo/compare/:base...:head --format json
|
||||
# 只读远程模式:从 GitLink compare 数据生成 Markdown
|
||||
gitlink-cli workflow +release-notes \
|
||||
--owner Gitlink \
|
||||
--repo gitlink-cli \
|
||||
--from-ref v1.1.0 \
|
||||
--to-ref master \
|
||||
--version v1.2.0 \
|
||||
--format markdown
|
||||
|
||||
# 2. 获取已关闭的 Issue
|
||||
gitlink-cli issue +list --state closed --format json
|
||||
|
||||
# 3. 生成 Release Notes 并创建发布
|
||||
gitlink-cli release +create --tag v1.2.0 --name "v1.2.0" --body "## What's Changed\n- feat: 新功能 (#123)\n- fix: 修复问题 (#456)"
|
||||
# 本地 JSON 模式:供 Agent 流水线、测试夹具或离线复现使用
|
||||
gitlink-cli workflow +release-notes \
|
||||
--from shortcuts/workflow/testdata/release_notes.json \
|
||||
--format json
|
||||
```
|
||||
|
||||
规则:
|
||||
- 优先使用 `workflow +release-notes` 生成草稿,再由维护者决定是否创建 Release。
|
||||
- 使用 `--format json` 作为 Agent 间传递格式;使用 `--format markdown` 作为人类可读发布说明。
|
||||
- 远程模式只读取 compare 数据,不创建 Release,不评论、不打标签、不合并。
|
||||
- `--include-prs` 默认开启;当 compare 响应包含 PR 信号时会一起分类。
|
||||
- 分类规则是确定性的,不依赖 LLM API,便于审计和复现。
|
||||
|
||||
## 工作流 4:Repo Setup(仓库初始化)
|
||||
|
||||
**场景**:创建仓库并完成基础配置。
|
||||
|
|
|
|||
Loading…
Reference in New Issue