(args: Args, ctx: ToolContext)
| 53 | argsSchema = ReleaseNotesArgs; |
| 54 | |
| 55 | async execute(args: Args, ctx: ToolContext): Promise<ToolResult> { |
| 56 | if (!await isGitRepo(ctx.cwd, ctx.signal)) { |
| 57 | return { content: '[NOT_A_GIT_REPO] Current directory is not a git working tree.', isError: true }; |
| 58 | } |
| 59 | |
| 60 | const to = args.to ?? 'HEAD'; |
| 61 | const scope = args.scope ?? 'user'; |
| 62 | const format = args.format ?? 'markdown'; |
| 63 | const maxCommits = args.max_commits ?? 500; |
| 64 | |
| 65 | // Resolve `from`. If omitted, find latest tag reachable from `to`; else fall back to repo root. |
| 66 | let from = args.from; |
| 67 | let fromSource = 'user-provided'; |
| 68 | if (!from) { |
| 69 | const r = await git(['describe', '--tags', '--abbrev=0', to], { cwd: ctx.cwd, signal: ctx.signal }); |
| 70 | if (r.exitCode === 0 && r.stdout.trim()) { |
| 71 | from = r.stdout.trim(); |
| 72 | fromSource = `latest tag: ${from}`; |
| 73 | } else { |
| 74 | // No tag — use root commit |
| 75 | try { |
| 76 | const root = (await gitOrThrow(['rev-list', '--max-parents=0', to], { cwd: ctx.cwd, signal: ctx.signal })).trim().split('\n')[0]; |
| 77 | if (!root) { |
| 78 | return { content: '[EMPTY_REPO] Could not find any commits.', isError: true }; |
| 79 | } |
| 80 | from = root; |
| 81 | fromSource = `repo root (no tags found)`; |
| 82 | } catch (e: any) { |
| 83 | return { content: `[ERROR] Could not resolve starting commit: ${e.message}`, isError: true }; |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // Build the range expression. `from..to` excludes `from` itself — desired behaviour |
| 89 | // when `from` is a tag (we don't want to re-list its commits). |
| 90 | const range = `${from}..${to}`; |
| 91 | |
| 92 | const commits = await readCommits(ctx.cwd, range, maxCommits, ctx.signal); |
| 93 | if (commits.length === 0) { |
| 94 | return { |
| 95 | content: `[NO_CHANGES] No commits in range ${range} (${fromSource}).`, |
| 96 | metadata: { range, fromSource, count: 0 }, |
| 97 | }; |
| 98 | } |
| 99 | |
| 100 | const buckets = bucket(commits); |
| 101 | const heading = args.heading ?? deriveHeading(to); |
| 102 | const md = formatMarkdown(buckets, { scope, heading, range }); |
| 103 | |
| 104 | let written: string[] = []; |
| 105 | if (args.write_to_changelog) { |
| 106 | const changelogPath = path.join(ctx.cwd, 'CHANGELOG.md'); |
| 107 | const existing = await readIfExists(changelogPath); |
| 108 | const header = existing.startsWith('# ') ? '' : '# Changelog\n\n'; |
| 109 | const next = header + md + (existing ? '\n' + existing.replace(/^# Changelog\s*\n+/, '') : ''); |
| 110 | await fs.writeFile(changelogPath, next, 'utf-8'); |
| 111 | written.push('CHANGELOG.md'); |
| 112 | ctx.emit({ type: 'diff', path: changelogPath, before: existing || null, after: next }); |
nothing calls this directly
no test coverage detected