feat(workflow): add release notes command

This commit is contained in:
yanchaomei 2026-07-08 17:23:00 +08:00
parent 9749a4c832
commit 7b60431ac6
14 changed files with 1084 additions and 14 deletions

View File

@ -559,9 +559,11 @@ gitlink-cli profile +contribution --user zhangsan --year 2025
- `workflow +health`
- `workflow +pr-summary`
- `workflow +repo-report`
- `workflow +release-notes`
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
`workflow +release-notes` defaults to `markdown` when `--format` is omitted.
Examples:
@ -634,6 +636,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:
@ -649,6 +657,7 @@ Safety:
- They do not depend on LLM APIs.
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
- `workflow +release-notes` reads compare data and renders release notes without creating releases or comments.
### Dataset

View File

@ -529,6 +529,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 科研数据集(标题、描述、论文内容、许可证、所属项目)。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut {
newHealthShortcut(),
newPRSummaryShortcut(),
newRepoReportShortcut(),
newReleaseNotesShortcut(),
}
}
@ -362,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 == "" {

View File

@ -31,6 +31,9 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) {
if !names["repo-report"] {
t.Fatal("Shortcuts missing repo-report")
}
if !names["release-notes"] {
t.Fatal("Shortcuts missing release-notes")
}
}
func TestRunTriageWithSingleIssueArgs(t *testing.T) {

View File

@ -56,19 +56,31 @@ gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"代码审
## 工作流 3Release 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便于审计和复现。
## 工作流 4Repo Setup仓库初始化
**场景**:创建仓库并完成基础配置。