Skip to content

Commit 973adb9

Browse files
committed
feat: Add CHANGELOG generation capability (chu gen changelog)
Generate CHANGELOG entries from conventional commits - Parse git history (feat, fix, docs, etc) - Group by commit type - LLM improves clarity and professionalism - Supports tag ranges (v1.0.0..v1.1.0) - E2E test with real git repo Autonomy: 66% (42/64 scenarios)
1 parent 1896040 commit 973adb9

File tree

4 files changed

+378
-10
lines changed

4 files changed

+378
-10
lines changed

cmd/chu/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ $0-5/month vs $20-30/month subscriptions.
5555
5656
## SPECIALIZED TOOLS
5757
chu gen test <file> - Generate unit tests for a file
58+
chu gen changelog - Generate CHANGELOG from git commits
5859
chu tdd - Test-driven development mode
5960
chu feature "desc" - Generate tests + implementation
6061
chu review [target] - Code review for bugs, security, improvements

docs/reference/capabilities.md

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -205,17 +205,28 @@ Not implemented:
205205

206206
---
207207

208-
### Documentation Updates (0/3 scenarios)
208+
### 🟡 Documentation Updates (1/3 scenarios)
209209

210-
Chuchu cannot automatically:
210+
**Implemented:**
211+
212+
- ✅ Generate CHANGELOG entries (`chu gen changelog`)
213+
214+
**Not yet implemented:**
211215

212216
- Update README files
213-
- Generate CHANGELOG entries
214217
- Update API documentation
215218

216-
**Why:** Documentation requires understanding of user impact and communication style. Coming soon.
219+
**Example:**
220+
```bash
221+
chu gen changelog # All commits since last tag
222+
chu gen changelog v1.0.0 # From v1.0.0 to HEAD
223+
```
224+
225+
**Limitations:**
226+
- README and API docs require contextual understanding
227+
- Uses conventional commits format
217228

218-
**Workaround:** Use `chu chat` mode to draft documentation, then review and commit manually.
229+
**Workaround:** Use `chu chat` mode to draft README updates and API docs.
219230

220231
---
221232

@@ -235,9 +246,9 @@ Chuchu cannot automatically:
235246
- Coverage gap identification
236247
- Mock generation
237248

238-
**Phase 9: Documentation (3 scenarios)**
249+
**Phase 9: Documentation (2 remaining scenarios)**
250+
- ✅ CHANGELOG generation (DONE)
239251
- README updates
240-
- CHANGELOG generation
241252
- API docs synchronization
242253

243254
---
@@ -268,7 +279,8 @@ Skipped tests (t.Skip()) represent features not yet implemented.
268279
- ✅ CI failure handling
269280
- ✅ PR review iteration
270281
- ✅ Unit test generation
271-
- **Autonomy:** 41/64 (64%)
282+
- ✅ CHANGELOG generation
283+
- **Autonomy:** 42/64 (66%)
272284
- **MVAA Critical Path:** 17/17 (100%)
273285

274286
### Future Releases

internal/changelog/generator.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
package changelog
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os/exec"
7+
"regexp"
8+
"sort"
9+
"strings"
10+
"time"
11+
12+
"chuchu/internal/llm"
13+
)
14+
15+
type ChangelogGenerator struct {
16+
provider llm.Provider
17+
model string
18+
workDir string
19+
}
20+
21+
type CommitGroup struct {
22+
Type string
23+
Commits []Commit
24+
}
25+
26+
type Commit struct {
27+
Hash string
28+
Type string
29+
Scope string
30+
Message string
31+
Body string
32+
Breaking bool
33+
}
34+
35+
func NewChangelogGenerator(provider llm.Provider, model, workDir string) *ChangelogGenerator {
36+
return &ChangelogGenerator{
37+
provider: provider,
38+
model: model,
39+
workDir: workDir,
40+
}
41+
}
42+
43+
var commitPattern = regexp.MustCompile(`^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$`)
44+
45+
func (g *ChangelogGenerator) Generate(ctx context.Context, fromTag, toTag string) (string, error) {
46+
commits, err := g.getCommits(fromTag, toTag)
47+
if err != nil {
48+
return "", fmt.Errorf("failed to get commits: %w", err)
49+
}
50+
51+
if len(commits) == 0 {
52+
return "", fmt.Errorf("no commits found between %s and %s", fromTag, toTag)
53+
}
54+
55+
parsed := g.parseCommits(commits)
56+
grouped := g.groupCommits(parsed)
57+
58+
changelog := g.formatChangelog(grouped, fromTag, toTag)
59+
60+
improved, err := g.improveWithLLM(ctx, changelog, commits)
61+
if err != nil {
62+
return changelog, nil
63+
}
64+
65+
return improved, nil
66+
}
67+
68+
func (g *ChangelogGenerator) getCommits(fromTag, toTag string) ([]string, error) {
69+
var args []string
70+
if fromTag == "" {
71+
args = []string{"log", "--pretty=format:%H|||%s|||%b", toTag}
72+
} else {
73+
args = []string{"log", "--pretty=format:%H|||%s|||%b", fmt.Sprintf("%s..%s", fromTag, toTag)}
74+
}
75+
76+
cmd := exec.Command("git", args...)
77+
cmd.Dir = g.workDir
78+
output, err := cmd.Output()
79+
if err != nil {
80+
return nil, fmt.Errorf("git log failed: %w", err)
81+
}
82+
83+
lines := strings.Split(string(output), "\n")
84+
var commits []string
85+
for _, line := range lines {
86+
if strings.TrimSpace(line) != "" {
87+
commits = append(commits, line)
88+
}
89+
}
90+
91+
return commits, nil
92+
}
93+
94+
func (g *ChangelogGenerator) parseCommits(commits []string) []Commit {
95+
var parsed []Commit
96+
97+
for _, commit := range commits {
98+
parts := strings.Split(commit, "|||")
99+
if len(parts) < 2 {
100+
continue
101+
}
102+
103+
hash := parts[0]
104+
subject := parts[1]
105+
body := ""
106+
if len(parts) > 2 {
107+
body = parts[2]
108+
}
109+
110+
matches := commitPattern.FindStringSubmatch(subject)
111+
if matches == nil {
112+
continue
113+
}
114+
115+
commitType := matches[1]
116+
scope := matches[2]
117+
breaking := matches[3] == "!"
118+
message := matches[4]
119+
120+
if strings.Contains(body, "BREAKING CHANGE:") {
121+
breaking = true
122+
}
123+
124+
parsed = append(parsed, Commit{
125+
Hash: hash[:7],
126+
Type: commitType,
127+
Scope: scope,
128+
Message: message,
129+
Body: body,
130+
Breaking: breaking,
131+
})
132+
}
133+
134+
return parsed
135+
}
136+
137+
func (g *ChangelogGenerator) groupCommits(commits []Commit) map[string][]Commit {
138+
groups := make(map[string][]Commit)
139+
140+
for _, commit := range commits {
141+
groups[commit.Type] = append(groups[commit.Type], commit)
142+
}
143+
144+
return groups
145+
}
146+
147+
func (g *ChangelogGenerator) formatChangelog(groups map[string][]Commit, fromTag, toTag string) string {
148+
var sb strings.Builder
149+
150+
version := toTag
151+
if version == "HEAD" || version == "" {
152+
version = "Unreleased"
153+
}
154+
155+
sb.WriteString(fmt.Sprintf("## [%s] - %s\n\n", version, time.Now().Format("2006-01-02")))
156+
157+
typeOrder := []string{"feat", "fix", "perf", "refactor", "docs", "test", "chore", "build", "ci"}
158+
typeNames := map[string]string{
159+
"feat": "Features",
160+
"fix": "Bug Fixes",
161+
"perf": "Performance",
162+
"refactor": "Code Refactoring",
163+
"docs": "Documentation",
164+
"test": "Tests",
165+
"chore": "Chores",
166+
"build": "Build System",
167+
"ci": "CI/CD",
168+
}
169+
170+
breaking := []Commit{}
171+
for _, commits := range groups {
172+
for _, commit := range commits {
173+
if commit.Breaking {
174+
breaking = append(breaking, commit)
175+
}
176+
}
177+
}
178+
179+
if len(breaking) > 0 {
180+
sb.WriteString("### ⚠ BREAKING CHANGES\n\n")
181+
for _, commit := range breaking {
182+
sb.WriteString(fmt.Sprintf("- **%s**: %s (%s)\n", commit.Scope, commit.Message, commit.Hash))
183+
}
184+
sb.WriteString("\n")
185+
}
186+
187+
for _, typ := range typeOrder {
188+
commits, ok := groups[typ]
189+
if !ok || len(commits) == 0 {
190+
continue
191+
}
192+
193+
sort.Slice(commits, func(i, j int) bool {
194+
return commits[i].Scope < commits[j].Scope
195+
})
196+
197+
name := typeNames[typ]
198+
if name == "" {
199+
if len(typ) > 0 {
200+
name = strings.ToUpper(typ[:1]) + typ[1:]
201+
}
202+
}
203+
204+
sb.WriteString(fmt.Sprintf("### %s\n\n", name))
205+
206+
for _, commit := range commits {
207+
if commit.Scope != "" {
208+
sb.WriteString(fmt.Sprintf("- **%s**: %s (%s)\n", commit.Scope, commit.Message, commit.Hash))
209+
} else {
210+
sb.WriteString(fmt.Sprintf("- %s (%s)\n", commit.Message, commit.Hash))
211+
}
212+
}
213+
sb.WriteString("\n")
214+
}
215+
216+
return sb.String()
217+
}
218+
219+
func (g *ChangelogGenerator) improveWithLLM(ctx context.Context, changelog string, commits []string) (string, error) {
220+
prompt := fmt.Sprintf(`You are a technical writer. Improve this CHANGELOG entry for clarity and professionalism.
221+
222+
Rules:
223+
- Keep the structure (headings, bullets)
224+
- Keep commit hashes
225+
- Improve wording for clarity
226+
- Group related changes if appropriate
227+
- Add brief context where helpful
228+
- Keep it concise
229+
230+
Original CHANGELOG:
231+
%s
232+
233+
Return ONLY the improved CHANGELOG, no explanations.`, changelog)
234+
235+
resp, err := g.provider.Chat(ctx, llm.ChatRequest{
236+
UserPrompt: prompt,
237+
Model: g.model,
238+
})
239+
240+
if err != nil {
241+
return "", err
242+
}
243+
244+
return strings.TrimSpace(resp.Text), nil
245+
}

0 commit comments

Comments
 (0)