diff --git a/README.md b/README.md index e2cf9b8..592f6ca 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index b3b341e..eb1fcab 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 科研数据集(标题、描述、论文内容、许可证、所属项目)。 diff --git a/docs/pr-draft.md b/docs/pr-draft.md index 48bc8c6..c8b3189 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -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 ``` diff --git a/docs/workflow-agent-design.md b/docs/workflow-agent-design.md index 59c63ee..aba4c16 100644 --- a/docs/workflow-agent-design.md +++ b/docs/workflow-agent-design.md @@ -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=&to=`. +- 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 diff --git a/docs/workflow-agent-test-report.md b/docs/workflow-agent-test-report.md index c166148..2c16a06 100644 --- a/docs/workflow-agent-test-report.md +++ b/docs/workflow-agent-test-report.md @@ -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 ``` diff --git a/shortcuts/workflow/release_notes.go b/shortcuts/workflow/release_notes.go new file mode 100644 index 0000000..d7b10f2 --- /dev/null +++ b/shortcuts/workflow/release_notes.go @@ -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 +} diff --git a/shortcuts/workflow/release_notes_fetch.go b/shortcuts/workflow/release_notes_fetch.go new file mode 100644 index 0000000..1ead9b2 --- /dev/null +++ b/shortcuts/workflow/release_notes_fetch.go @@ -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 +} diff --git a/shortcuts/workflow/release_notes_test.go b/shortcuts/workflow/release_notes_test.go new file mode 100644 index 0000000..48bc522 --- /dev/null +++ b/shortcuts/workflow/release_notes_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/render.go b/shortcuts/workflow/render.go index 366c2e2..06b127c 100644 --- a/shortcuts/workflow/render.go +++ b/shortcuts/workflow/render.go @@ -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 diff --git a/shortcuts/workflow/render_test.go b/shortcuts/workflow/render_test.go index 6e26b49..2725c19 100644 --- a/shortcuts/workflow/render_test.go +++ b/shortcuts/workflow/render_test.go @@ -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", diff --git a/shortcuts/workflow/testdata/release_notes.json b/shortcuts/workflow/testdata/release_notes.json new file mode 100644 index 0000000..64fe80e --- /dev/null +++ b/shortcuts/workflow/testdata/release_notes.json @@ -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" +} diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go index 4eb981a..dd8d1a3 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -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 == "" { diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go index 54d0ef5..b6d66f9 100644 --- a/shortcuts/workflow/workflow_test.go +++ b/shortcuts/workflow/workflow_test.go @@ -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) { diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index a2b587b..fd4122a 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -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(仓库初始化) **场景**:创建仓库并完成基础配置。