11import { tryRunArgs } from '../utils/shell.ts' ;
22import 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 * (?: p r | p u l l | p u l l \s + r e q u e s t ) : \s * # ? ( \d + ) / im, ( _ , pr ) => {
102+ const num = Number ( pr ) ;
103+ if ( ! isNaN ( num ) ) overrides . pr = num ;
104+ return '' ;
105+ } )
106+ . replace ( / ^ \s * c o m m i t : \s * ( [ ^ \s ] + ) / im, ( _ , commit ) => {
107+ overrides . commit = commit ;
108+ return '' ;
109+ } )
110+ . replace ( / ^ \s * (?: a u t h o r | u s e r ) : \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+ }
0 commit comments