Skip to content

Commit 742eac8

Browse files
theoephraimclaude
andcommitted
Enhance GitHub changelog formatter with PR/commit links and contributor thanks
- Add commit hash links alongside PR links in changelog entries - Add "Thanks @username!" attribution (matching @changesets/changelog-github format) - Add `internalAuthors` option to suppress thanks for team members - Support metadata overrides in changeset summaries (pr, commit, author lines) - Linkify bare #123 issue references in summary text - Auto-detect repo from gh CLI when not configured - Export GithubChangelogOptions type for custom formatter use Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f1bd965 commit 742eac8

4 files changed

Lines changed: 207 additions & 31 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@varlock/bumpy': patch
3+
---
4+
5+
Enhance GitHub changelog formatter with PR/commit links and contributor attribution.
6+
7+
- Add commit hash links alongside PR links in changelog entries
8+
- Add "Thanks @username!" attribution (matching `@changesets/changelog-github` format)
9+
- Add `internalAuthors` option to suppress thanks for team members
10+
- Support metadata overrides in changeset summaries (`pr:`, `commit:`, `author:` lines)
11+
- Linkify bare `#123` issue references in summary text
12+
- Auto-detect repo slug from `gh` CLI when not configured
Lines changed: 193 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,30 @@
11
import { tryRunArgs } from '../utils/shell.ts';
22
import type { ChangelogContext, ChangelogFormatter } from './changelog.ts';
33

4-
interface GithubOptions {
5-
repo?: string; // "owner/repo" — auto-detected if not provided
4+
export interface GithubChangelogOptions {
5+
/** "owner/repo" — auto-detected from gh CLI if not provided */
6+
repo?: string;
7+
/** GitHub usernames (without @) to skip "Thanks" messages for (e.g. internal team members) */
8+
internalAuthors?: string[];
69
}
710

811
/**
912
* GitHub-enhanced changelog formatter.
10-
* Adds PR links and author attribution when git/gh info is available.
13+
* Adds PR links, commit links, and contributor attribution when git/gh info is available.
1114
*
1215
* Usage in config:
1316
* "changelog": "github"
1417
* "changelog": ["github", { "repo": "dmno-dev/bumpy" }]
18+
* "changelog": ["github", { "repo": "dmno-dev/bumpy", "internalAuthors": ["theoephraim"] }]
1519
*/
16-
export function createGithubFormatter(options: GithubOptions = {}): ChangelogFormatter {
20+
export function createGithubFormatter(options: GithubChangelogOptions = {}): ChangelogFormatter {
21+
const internalAuthorsSet = new Set((options.internalAuthors ?? []).map((a) => a.toLowerCase()));
22+
1723
return async (ctx: ChangelogContext) => {
1824
const { release, changesets, date } = ctx;
25+
const repoSlug = options.repo ?? detectRepo();
26+
const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com';
27+
1928
const lines: string[] = [];
2029
lines.push(`## ${release.newVersion}`);
2130
lines.push('');
@@ -27,21 +36,25 @@ export function createGithubFormatter(options: GithubOptions = {}): ChangelogFor
2736
if (relevantChangesets.length > 0) {
2837
for (const cs of relevantChangesets) {
2938
if (!cs.summary) continue;
30-
const firstLine = cs.summary.split('\n')[0]!;
31-
32-
// Try to find a PR associated with this changeset
33-
const prInfo = await findPrForChangeset(cs.id, options.repo);
34-
if (prInfo) {
35-
lines.push(`- ${firstLine} ([#${prInfo.number}](${prInfo.url})) by @${prInfo.author}`);
36-
} else {
37-
lines.push(`- ${firstLine}`);
38-
}
39+
40+
// Extract metadata overrides from summary (pr, commit, author lines)
41+
const { cleanSummary, overrides } = extractSummaryMeta(cs.summary);
42+
43+
// Look up git/PR info, with overrides taking precedence
44+
const gitInfo = resolveChangesetInfo(cs.id, repoSlug, serverUrl, overrides);
45+
46+
const summaryLines = cleanSummary.split('\n');
47+
const firstLine = linkifyIssueRefs(summaryLines[0]!, serverUrl, repoSlug);
48+
49+
// Build the prefix: PR link, commit link, thanks
50+
const prefix = formatPrefix(gitInfo, serverUrl, repoSlug, internalAuthorsSet);
51+
52+
lines.push(`-${prefix ? ` ${prefix} -` : ''} ${firstLine}`);
3953

4054
// Include continuation lines
41-
const summaryLines = cs.summary.split('\n');
4255
for (let i = 1; i < summaryLines.length; i++) {
4356
if (summaryLines[i]!.trim()) {
44-
lines.push(` ${summaryLines[i]}`);
57+
lines.push(` ${linkifyIssueRefs(summaryLines[i]!, serverUrl, repoSlug)}`);
4558
}
4659
}
4760
}
@@ -60,20 +73,112 @@ export function createGithubFormatter(options: GithubOptions = {}): ChangelogFor
6073
};
6174
}
6275

63-
interface PrInfo {
64-
number: number;
65-
url: string;
66-
author: string;
76+
// ---- Types ----
77+
78+
interface ChangesetGitInfo {
79+
prNumber?: number;
80+
prUrl?: string;
81+
commitHash?: string;
82+
author?: string;
83+
}
84+
85+
interface SummaryOverrides {
86+
pr?: number;
87+
commit?: string;
88+
authors?: string[];
89+
}
90+
91+
// ---- Metadata extraction from changeset summary ----
92+
93+
/**
94+
* Extract metadata lines (pr, commit, author) from a changeset summary.
95+
* These override git-derived info, matching the behavior of @changesets/changelog-github.
96+
*/
97+
function extractSummaryMeta(summary: string): { cleanSummary: string; overrides: SummaryOverrides } {
98+
const overrides: SummaryOverrides = {};
99+
100+
const cleaned = summary
101+
.replace(/^\s*(?:pr|pull|pull\s+request):\s*#?(\d+)/im, (_, pr) => {
102+
const num = Number(pr);
103+
if (!isNaN(num)) overrides.pr = num;
104+
return '';
105+
})
106+
.replace(/^\s*commit:\s*([^\s]+)/im, (_, commit) => {
107+
overrides.commit = commit;
108+
return '';
109+
})
110+
.replace(/^\s*(?:author|user):\s*@?([^\s]+)/gim, (_, user) => {
111+
overrides.authors ??= [];
112+
overrides.authors.push(user);
113+
return '';
114+
})
115+
.trim();
116+
117+
return { cleanSummary: cleaned, overrides };
118+
}
119+
120+
// ---- Git/PR info resolution ----
121+
122+
/**
123+
* Resolve PR, commit, and author info for a changeset.
124+
* Summary overrides take precedence over git-derived info.
125+
*/
126+
function resolveChangesetInfo(
127+
changesetId: string,
128+
repo: string | undefined,
129+
serverUrl: string,
130+
overrides: SummaryOverrides,
131+
): ChangesetGitInfo {
132+
// If we have a PR override, look it up directly
133+
if (overrides.pr !== undefined) {
134+
const prInfo = lookupPr(overrides.pr, repo);
135+
return {
136+
prNumber: overrides.pr,
137+
prUrl: prInfo?.url ?? `${serverUrl}/${repo}/pull/${overrides.pr}`,
138+
commitHash: overrides.commit ?? prInfo?.commitHash,
139+
author: overrides.authors?.[0] ?? prInfo?.author,
140+
};
141+
}
142+
143+
// Otherwise, find the commit that added this changeset file
144+
const gitInfo = findChangesetCommitInfo(changesetId, repo);
145+
146+
return {
147+
prNumber: gitInfo?.prNumber,
148+
prUrl: gitInfo?.prUrl,
149+
commitHash: overrides.commit ?? gitInfo?.commitHash,
150+
author: overrides.authors?.[0] ?? gitInfo?.author,
151+
};
152+
}
153+
154+
/** Look up a PR by number using gh CLI */
155+
function lookupPr(prNumber: number, repo?: string): { url: string; author?: string; commitHash?: string } | null {
156+
try {
157+
const ghArgs = ['gh', 'pr', 'view', String(prNumber), '--json', 'url,author,mergeCommit'];
158+
if (repo) ghArgs.push('--repo', repo);
159+
160+
const result = tryRunArgs(ghArgs);
161+
if (!result) return null;
162+
163+
const pr = JSON.parse(result);
164+
return {
165+
url: pr.url,
166+
author: pr.author?.login,
167+
commitHash: pr.mergeCommit?.oid,
168+
};
169+
} catch {
170+
return null;
171+
}
67172
}
68173

69174
/**
70175
* Find the PR that introduced a changeset file by checking git log
71176
* for the commit that added the file, then looking up the PR.
72177
*/
73-
async function findPrForChangeset(changesetId: string, repo?: string): Promise<PrInfo | null> {
178+
function findChangesetCommitInfo(changesetId: string, repo?: string): ChangesetGitInfo | null {
74179
try {
75180
// Find the commit that added this changeset file
76-
const commitHash = tryRunArgs([
181+
const commitOutput = tryRunArgs([
77182
'git',
78183
'log',
79184
'--diff-filter=A',
@@ -82,18 +187,18 @@ async function findPrForChangeset(changesetId: string, repo?: string): Promise<P
82187
`.bumpy/${changesetId}.md`,
83188
`.changeset/${changesetId}.md`,
84189
]);
85-
if (!commitHash) return null;
190+
if (!commitOutput) return null;
86191

87-
const hash = commitHash.split('\n')[0]!.trim();
88-
if (!hash) return null;
192+
const commitHash = commitOutput.split('\n')[0]!.trim();
193+
if (!commitHash) return null;
89194

90195
// Look up the PR for this commit
91196
const ghArgs = [
92197
'gh',
93198
'pr',
94199
'list',
95200
'--search',
96-
hash,
201+
commitHash,
97202
'--state',
98203
'merged',
99204
'--json',
@@ -104,17 +209,75 @@ async function findPrForChangeset(changesetId: string, repo?: string): Promise<P
104209
if (repo) ghArgs.push('--repo', repo);
105210

106211
const prJson = tryRunArgs(ghArgs);
107-
if (!prJson) return null;
212+
if (!prJson) {
213+
return { commitHash };
214+
}
108215

109216
const pr = JSON.parse(prJson);
110-
if (!pr.number) return null;
217+
if (!pr.number) {
218+
return { commitHash };
219+
}
111220

112221
return {
113-
number: pr.number,
114-
url: pr.url,
115-
author: pr.author?.login || 'unknown',
222+
prNumber: pr.number,
223+
prUrl: pr.url,
224+
commitHash,
225+
author: pr.author?.login,
116226
};
117227
} catch {
118228
return null;
119229
}
120230
}
231+
232+
// ---- Formatting helpers ----
233+
234+
/**
235+
* Build the prefix portion of a changelog line: PR link, commit link, thanks.
236+
* Matches the format used by @changesets/changelog-github.
237+
*/
238+
function formatPrefix(
239+
info: ChangesetGitInfo,
240+
serverUrl: string,
241+
repo: string | undefined,
242+
internalAuthors: Set<string>,
243+
): string {
244+
const parts: string[] = [];
245+
246+
if (info.prNumber && info.prUrl) {
247+
parts.push(`[#${info.prNumber}](${info.prUrl})`);
248+
}
249+
250+
if (info.commitHash && repo) {
251+
const short = info.commitHash.slice(0, 7);
252+
parts.push(`[\`${short}\`](${serverUrl}/${repo}/commit/${info.commitHash})`);
253+
}
254+
255+
if (info.author && !internalAuthors.has(info.author.toLowerCase())) {
256+
parts.push(`Thanks [@${info.author}](${serverUrl}/${info.author})!`);
257+
}
258+
259+
return parts.join(' ');
260+
}
261+
262+
/**
263+
* Linkify bare issue/PR references like #123 in text,
264+
* but skip references already inside markdown links.
265+
*/
266+
function linkifyIssueRefs(line: string, serverUrl: string, repo?: string): string {
267+
if (!repo) return line;
268+
// "match what you skip, capture what you want" pattern:
269+
// the left alternative consumes markdown links so the right alternative only matches bare refs
270+
return line.replace(/\[.*?\]\(.*?\)|\B#([1-9]\d*)\b/g, (match, issue) =>
271+
issue ? `[#${issue}](${serverUrl}/${repo}/issues/${issue})` : match,
272+
);
273+
}
274+
275+
/** Try to detect the repo slug from the gh CLI */
276+
function detectRepo(): string | undefined {
277+
try {
278+
const result = tryRunArgs(['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']);
279+
return result?.trim() || undefined;
280+
} catch {
281+
return undefined;
282+
}
283+
}

packages/bumpy/src/core/changelog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export async function loadFormatter(changelog: BumpyConfig['changelog'], rootDir
8787
// Built-in with options (e.g., ["github", { repo: "..." }])
8888
if (name === 'github') {
8989
const { createGithubFormatter } = await import('./changelog-github.ts');
90-
return createGithubFormatter(options as Record<string, unknown>);
90+
return createGithubFormatter(options as import('./changelog-github.ts').GithubChangelogOptions);
9191
}
9292

9393
// Custom module

packages/bumpy/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export { assembleReleasePlan } from './core/release-plan.ts';
77
export { applyReleasePlan } from './core/apply-release-plan.ts';
88
export { generateChangelogEntry, loadFormatter, defaultFormatter, prependToChangelog } from './core/changelog.ts';
99
export type { ChangelogFormatter, ChangelogContext } from './core/changelog.ts';
10+
export type { GithubChangelogOptions } from './core/changelog-github.ts';
1011
export { bumpVersion, satisfies, stripProtocol } from './core/semver.ts';
1112
export { publishPackages } from './core/publish-pipeline.ts';
1213
export * from './types.ts';

0 commit comments

Comments
 (0)