forked from Gitlink/gitlink-cli
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 +pr-summary`
|
||||||
- `workflow +review-context`
|
- `workflow +review-context`
|
||||||
- `workflow +repo-report`
|
- `workflow +repo-report`
|
||||||
|
- `workflow +release-notes`
|
||||||
|
|
||||||
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
||||||
`workflow +repo-report` defaults to `markdown` 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:
|
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
|
# Repository workflow report from a local JSON file
|
||||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
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:
|
Output formats:
|
||||||
|
|
@ -792,6 +800,7 @@ Safety:
|
||||||
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
|
- `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 +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 +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
|
### Dataset
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -680,6 +680,49 @@ gitlink-cli profile +activity
|
||||||
gitlink-cli profile +contribution --user zhangsan --year 2025
|
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 科研数据集(标题、描述、论文内容、许可证、所属项目)。
|
`dataset` 管理并查询 GitLink 科研数据集(标题、描述、论文内容、许可证、所属项目)。
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,6 @@ Coverage includes:
|
||||||
|
|
||||||
## Known Limitations
|
## Known Limitations
|
||||||
|
|
||||||
- `workflow +release-notes` is not implemented.
|
|
||||||
- `workflow +stale` is not implemented.
|
- `workflow +stale` is not implemented.
|
||||||
- Real GitLink API shapes may require follow-up normalization.
|
- 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 +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 +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 +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:
|
Additional workflow commands:
|
||||||
- `workflow +pr-summary`: done
|
- `workflow +pr-summary`: done
|
||||||
- `workflow +repo-report`: done
|
- `workflow +repo-report`: done
|
||||||
- `workflow +release-notes`: planned
|
- `workflow +release-notes`: done
|
||||||
- `workflow +stale`: planned
|
- `workflow +stale`: planned
|
||||||
|
|
||||||
Current implementation status:
|
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
|
- 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,
|
- Repo report command: done with local JSON input, partial read-only fetch aggregation,
|
||||||
scoring, renderers, and tests
|
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
|
## Current Repository Findings
|
||||||
|
|
||||||
|
|
@ -437,22 +439,40 @@ Safety:
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
- `--from`
|
- `--from`
|
||||||
- `--to`
|
- `--from-ref`
|
||||||
- optional `--tag`
|
- `--to-ref`
|
||||||
|
- optional `--version`
|
||||||
|
- optional `--max-commits`
|
||||||
|
- optional `--include-prs`
|
||||||
- optional `--lang`
|
- optional `--lang`
|
||||||
|
|
||||||
Data:
|
Data:
|
||||||
- PR titles
|
- PR titles
|
||||||
- commit messages
|
- commit messages
|
||||||
|
- changed file paths when present in compare data
|
||||||
|
|
||||||
Markdown categories:
|
Markdown categories:
|
||||||
|
- Breaking Changes
|
||||||
- Features
|
- Features
|
||||||
- Bug Fixes
|
- Bug Fixes
|
||||||
- Documentation
|
- Documentation
|
||||||
- Tests
|
- Tests
|
||||||
- Refactoring
|
- Refactoring
|
||||||
- Chores
|
- 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`
|
### `workflow +stale`
|
||||||
|
|
||||||
|
|
@ -487,7 +507,7 @@ Planned fetch-layer extension:
|
||||||
- `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode.
|
- `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.
|
- `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.
|
- `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.
|
- Unknown or missing fields should stay explicit in JSON output so Agents can decide how to proceed.
|
||||||
|
|
||||||
## Implementation Order
|
## Implementation Order
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ Results:
|
||||||
- repo report returns a partial report when at least one enabled section succeeds
|
- 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 returns an error when all enabled fetched sections fail
|
||||||
- repo report issue and PR limits are covered
|
- 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
|
## 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 +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 --owner Gitlink --repo gitlink-cli --format markdown
|
||||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
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
|
## 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 +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 +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 +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;
|
- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata only;
|
||||||
detailed file and commit analysis remains available through `workflow +pr-summary --number`.
|
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.
|
- `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 +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 +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 +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:
|
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 +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 +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 +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
|
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 {
|
func normalizeFormat(format string) string {
|
||||||
format = strings.ToLower(strings.TrimSpace(format))
|
format = strings.ToLower(strings.TrimSpace(format))
|
||||||
if format == "" {
|
if format == "" {
|
||||||
|
|
@ -183,6 +204,19 @@ func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) err
|
||||||
return tw.Flush()
|
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 {
|
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 {
|
if _, err := fmt.Fprintf(w, "# Issue Triage Report\n\nRepository: `%s`\n\n", report.Repository); err != nil {
|
||||||
return err
|
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"))
|
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 {
|
func writeCountMapMarkdown(w io.Writer, title string, values map[string]int) error {
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
return nil
|
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 {
|
func samplePRSummaryResult() PRSummaryResult {
|
||||||
return PRSummaryResult{
|
return PRSummaryResult{
|
||||||
Repository: "owner/repo",
|
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(),
|
newHealthShortcut(),
|
||||||
newPRSummaryShortcut(),
|
newPRSummaryShortcut(),
|
||||||
newRepoReportShortcut(),
|
newRepoReportShortcut(),
|
||||||
newReviewContextShortcut(),
|
newReleaseNotesShortcut(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,6 +363,13 @@ func parseBoolArg(value string) bool {
|
||||||
return err == nil && parsed
|
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) {
|
func parseIntArg(value string, defaultValue int, name string) (int, error) {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) {
|
||||||
if !names["repo-report"] {
|
if !names["repo-report"] {
|
||||||
t.Fatal("Shortcuts missing repo-report")
|
t.Fatal("Shortcuts missing repo-report")
|
||||||
}
|
}
|
||||||
|
if !names["release-notes"] {
|
||||||
|
t.Fatal("Shortcuts missing release-notes")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunTriageWithSingleIssueArgs(t *testing.T) {
|
func TestRunTriageWithSingleIssueArgs(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -53,19 +53,31 @@ gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"代码审
|
||||||
|
|
||||||
## 工作流 3:Release Notes 生成
|
## 工作流 3:Release Notes 生成
|
||||||
|
|
||||||
**场景**:从提交历史自动生成版本发布说明。
|
**场景**:从提交历史和 PR 信号自动生成版本发布说明,适合维护者发版前检查、Agent 生成 changelog 草稿、竞赛材料展示等场景。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 获取两个版本之间的提交
|
# 只读远程模式:从 GitLink compare 数据生成 Markdown
|
||||||
gitlink-cli api GET /:owner/:repo/compare/:base...:head --format json
|
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
|
# 本地 JSON 模式:供 Agent 流水线、测试夹具或离线复现使用
|
||||||
gitlink-cli issue +list --state closed --format json
|
gitlink-cli workflow +release-notes \
|
||||||
|
--from shortcuts/workflow/testdata/release_notes.json \
|
||||||
# 3. 生成 Release Notes 并创建发布
|
--format json
|
||||||
gitlink-cli release +create --tag v1.2.0 --name "v1.2.0" --body "## What's Changed\n- feat: 新功能 (#123)\n- fix: 修复问题 (#456)"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 优先使用 `workflow +release-notes` 生成草稿,再由维护者决定是否创建 Release。
|
||||||
|
- 使用 `--format json` 作为 Agent 间传递格式;使用 `--format markdown` 作为人类可读发布说明。
|
||||||
|
- 远程模式只读取 compare 数据,不创建 Release,不评论、不打标签、不合并。
|
||||||
|
- `--include-prs` 默认开启;当 compare 响应包含 PR 信号时会一起分类。
|
||||||
|
- 分类规则是确定性的,不依赖 LLM API,便于审计和复现。
|
||||||
|
|
||||||
## 工作流 4:Repo Setup(仓库初始化)
|
## 工作流 4:Repo Setup(仓库初始化)
|
||||||
|
|
||||||
**场景**:创建仓库并完成基础配置。
|
**场景**:创建仓库并完成基础配置。
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue