diff --git a/.bumpy/github-changelog-enhancements.md b/.bumpy/github-changelog-enhancements.md new file mode 100644 index 0000000..787e61f --- /dev/null +++ b/.bumpy/github-changelog-enhancements.md @@ -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 diff --git a/packages/bumpy/src/core/changelog-github.ts b/packages/bumpy/src/core/changelog-github.ts index 6c05bd6..6395b89 100644 --- a/packages/bumpy/src/core/changelog-github.ts +++ b/packages/bumpy/src/core/changelog-github.ts @@ -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(''); @@ -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)}`); } } } @@ -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 { +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', @@ -82,10 +187,10 @@ async function findPrForChangeset(changesetId: string, repo?: string): Promise

, +): 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; + } +} diff --git a/packages/bumpy/src/core/changelog.ts b/packages/bumpy/src/core/changelog.ts index 9ca1a76..df1cf20 100644 --- a/packages/bumpy/src/core/changelog.ts +++ b/packages/bumpy/src/core/changelog.ts @@ -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); + return createGithubFormatter(options as import('./changelog-github.ts').GithubChangelogOptions); } // Custom module diff --git a/packages/bumpy/src/index.ts b/packages/bumpy/src/index.ts index c8eb7cb..2dba2b7 100644 --- a/packages/bumpy/src/index.ts +++ b/packages/bumpy/src/index.ts @@ -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';