Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .bumpy/github-changelog-enhancements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@varlock/bumpy': patch
---

Enhance GitHub changelog formatter with PR/commit links and contributor attribution.

- 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 slug from `gh` CLI when not configured
223 changes: 193 additions & 30 deletions packages/bumpy/src/core/changelog-github.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
import { tryRunArgs } from '../utils/shell.ts';
import type { ChangelogContext, ChangelogFormatter } from './changelog.ts';

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

/**
* GitHub-enhanced changelog formatter.
* Adds PR links and author attribution when git/gh info is available.
* Adds PR links, commit links, and contributor attribution when git/gh info is available.
*
* Usage in config:
* "changelog": "github"
* "changelog": ["github", { "repo": "dmno-dev/bumpy" }]
* "changelog": ["github", { "repo": "dmno-dev/bumpy", "internalAuthors": ["theoephraim"] }]
*/
export function createGithubFormatter(options: GithubOptions = {}): ChangelogFormatter {
export function createGithubFormatter(options: GithubChangelogOptions = {}): ChangelogFormatter {
const internalAuthorsSet = new Set((options.internalAuthors ?? []).map((a) => a.toLowerCase()));

return async (ctx: ChangelogContext) => {
const { release, changesets, date } = ctx;
const repoSlug = options.repo ?? detectRepo();
const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com';

const lines: string[] = [];
lines.push(`## ${release.newVersion}`);
lines.push('');
Expand All @@ -27,21 +36,25 @@ export function createGithubFormatter(options: GithubOptions = {}): ChangelogFor
if (relevantChangesets.length > 0) {
for (const cs of relevantChangesets) {
if (!cs.summary) continue;
const firstLine = cs.summary.split('\n')[0]!;

// Try to find a PR associated with this changeset
const prInfo = await findPrForChangeset(cs.id, options.repo);
if (prInfo) {
lines.push(`- ${firstLine} ([#${prInfo.number}](${prInfo.url})) by @${prInfo.author}`);
} else {
lines.push(`- ${firstLine}`);
}

// Extract metadata overrides from summary (pr, commit, author lines)
const { cleanSummary, overrides } = extractSummaryMeta(cs.summary);

// Look up git/PR info, with overrides taking precedence
const gitInfo = resolveChangesetInfo(cs.id, repoSlug, serverUrl, overrides);

const summaryLines = cleanSummary.split('\n');
const firstLine = linkifyIssueRefs(summaryLines[0]!, serverUrl, repoSlug);

// Build the prefix: PR link, commit link, thanks
const prefix = formatPrefix(gitInfo, serverUrl, repoSlug, internalAuthorsSet);

lines.push(`-${prefix ? ` ${prefix} -` : ''} ${firstLine}`);

// Include continuation lines
const summaryLines = cs.summary.split('\n');
for (let i = 1; i < summaryLines.length; i++) {
if (summaryLines[i]!.trim()) {
lines.push(` ${summaryLines[i]}`);
lines.push(` ${linkifyIssueRefs(summaryLines[i]!, serverUrl, repoSlug)}`);
}
}
}
Expand All @@ -60,20 +73,112 @@ export function createGithubFormatter(options: GithubOptions = {}): ChangelogFor
};
}

interface PrInfo {
number: number;
url: string;
author: string;
// ---- Types ----

interface ChangesetGitInfo {
prNumber?: number;
prUrl?: string;
commitHash?: string;
author?: string;
}

interface SummaryOverrides {
pr?: number;
commit?: string;
authors?: string[];
}

// ---- Metadata extraction from changeset summary ----

/**
* Extract metadata lines (pr, commit, author) from a changeset summary.
* These override git-derived info, matching the behavior of @changesets/changelog-github.
*/
function extractSummaryMeta(summary: string): { cleanSummary: string; overrides: SummaryOverrides } {
const overrides: SummaryOverrides = {};

const cleaned = summary
.replace(/^\s*(?:pr|pull|pull\s+request):\s*#?(\d+)/im, (_, pr) => {
const num = Number(pr);
if (!isNaN(num)) overrides.pr = num;
return '';
})
.replace(/^\s*commit:\s*([^\s]+)/im, (_, commit) => {
overrides.commit = commit;
return '';
})
.replace(/^\s*(?:author|user):\s*@?([^\s]+)/gim, (_, user) => {
overrides.authors ??= [];
overrides.authors.push(user);
return '';
})
.trim();

return { cleanSummary: cleaned, overrides };
}

// ---- Git/PR info resolution ----

/**
* Resolve PR, commit, and author info for a changeset.
* Summary overrides take precedence over git-derived info.
*/
function resolveChangesetInfo(
changesetId: string,
repo: string | undefined,
serverUrl: string,
overrides: SummaryOverrides,
): ChangesetGitInfo {
// If we have a PR override, look it up directly
if (overrides.pr !== undefined) {
const prInfo = lookupPr(overrides.pr, repo);
return {
prNumber: overrides.pr,
prUrl: prInfo?.url ?? `${serverUrl}/${repo}/pull/${overrides.pr}`,
commitHash: overrides.commit ?? prInfo?.commitHash,
author: overrides.authors?.[0] ?? prInfo?.author,
};
}

// Otherwise, find the commit that added this changeset file
const gitInfo = findChangesetCommitInfo(changesetId, repo);

return {
prNumber: gitInfo?.prNumber,
prUrl: gitInfo?.prUrl,
commitHash: overrides.commit ?? gitInfo?.commitHash,
author: overrides.authors?.[0] ?? gitInfo?.author,
};
}

/** Look up a PR by number using gh CLI */
function lookupPr(prNumber: number, repo?: string): { url: string; author?: string; commitHash?: string } | null {
try {
const ghArgs = ['gh', 'pr', 'view', String(prNumber), '--json', 'url,author,mergeCommit'];
if (repo) ghArgs.push('--repo', repo);

const result = tryRunArgs(ghArgs);
if (!result) return null;

const pr = JSON.parse(result);
return {
url: pr.url,
author: pr.author?.login,
commitHash: pr.mergeCommit?.oid,
};
} catch {
return null;
}
}

/**
* Find the PR that introduced a changeset file by checking git log
* for the commit that added the file, then looking up the PR.
*/
async function findPrForChangeset(changesetId: string, repo?: string): Promise<PrInfo | null> {
function findChangesetCommitInfo(changesetId: string, repo?: string): ChangesetGitInfo | null {
try {
// Find the commit that added this changeset file
const commitHash = tryRunArgs([
const commitOutput = tryRunArgs([
'git',
'log',
'--diff-filter=A',
Expand All @@ -82,18 +187,18 @@ async function findPrForChangeset(changesetId: string, repo?: string): Promise<P
`.bumpy/${changesetId}.md`,
`.changeset/${changesetId}.md`,
]);
if (!commitHash) return null;
if (!commitOutput) return null;

const hash = commitHash.split('\n')[0]!.trim();
if (!hash) return null;
const commitHash = commitOutput.split('\n')[0]!.trim();
if (!commitHash) return null;

// Look up the PR for this commit
const ghArgs = [
'gh',
'pr',
'list',
'--search',
hash,
commitHash,
'--state',
'merged',
'--json',
Expand All @@ -104,17 +209,75 @@ async function findPrForChangeset(changesetId: string, repo?: string): Promise<P
if (repo) ghArgs.push('--repo', repo);

const prJson = tryRunArgs(ghArgs);
if (!prJson) return null;
if (!prJson) {
return { commitHash };
}

const pr = JSON.parse(prJson);
if (!pr.number) return null;
if (!pr.number) {
return { commitHash };
}

return {
number: pr.number,
url: pr.url,
author: pr.author?.login || 'unknown',
prNumber: pr.number,
prUrl: pr.url,
commitHash,
author: pr.author?.login,
};
} catch {
return null;
}
}

// ---- Formatting helpers ----

/**
* Build the prefix portion of a changelog line: PR link, commit link, thanks.
* Matches the format used by @changesets/changelog-github.
*/
function formatPrefix(
info: ChangesetGitInfo,
serverUrl: string,
repo: string | undefined,
internalAuthors: Set<string>,
): string {
const parts: string[] = [];

if (info.prNumber && info.prUrl) {
parts.push(`[#${info.prNumber}](${info.prUrl})`);
}

if (info.commitHash && repo) {
const short = info.commitHash.slice(0, 7);
parts.push(`[\`${short}\`](${serverUrl}/${repo}/commit/${info.commitHash})`);
}

if (info.author && !internalAuthors.has(info.author.toLowerCase())) {
parts.push(`Thanks [@${info.author}](${serverUrl}/${info.author})!`);
}

return parts.join(' ');
}

/**
* Linkify bare issue/PR references like #123 in text,
* but skip references already inside markdown links.
*/
function linkifyIssueRefs(line: string, serverUrl: string, repo?: string): string {
if (!repo) return line;
// "match what you skip, capture what you want" pattern:
// the left alternative consumes markdown links so the right alternative only matches bare refs
return line.replace(/\[.*?\]\(.*?\)|\B#([1-9]\d*)\b/g, (match, issue) =>
issue ? `[#${issue}](${serverUrl}/${repo}/issues/${issue})` : match,
);
}

/** Try to detect the repo slug from the gh CLI */
function detectRepo(): string | undefined {
try {
const result = tryRunArgs(['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']);
return result?.trim() || undefined;
} catch {
return undefined;
}
}
2 changes: 1 addition & 1 deletion packages/bumpy/src/core/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export async function loadFormatter(changelog: BumpyConfig['changelog'], rootDir
// Built-in with options (e.g., ["github", { repo: "..." }])
if (name === 'github') {
const { createGithubFormatter } = await import('./changelog-github.ts');
return createGithubFormatter(options as Record<string, unknown>);
return createGithubFormatter(options as import('./changelog-github.ts').GithubChangelogOptions);
}

// Custom module
Expand Down
1 change: 1 addition & 0 deletions packages/bumpy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { assembleReleasePlan } from './core/release-plan.ts';
export { applyReleasePlan } from './core/apply-release-plan.ts';
export { generateChangelogEntry, loadFormatter, defaultFormatter, prependToChangelog } from './core/changelog.ts';
export type { ChangelogFormatter, ChangelogContext } from './core/changelog.ts';
export type { GithubChangelogOptions } from './core/changelog-github.ts';
export { bumpVersion, satisfies, stripProtocol } from './core/semver.ts';
export { publishPackages } from './core/publish-pipeline.ts';
export * from './types.ts';