@@ -94,6 +94,43 @@ impl CommitType {
9494 }
9595}
9696
97+ /// Parse a GitHub remote URL into (org, repo)
98+ fn parse_github_remote ( url : & str ) -> Option < ( String , String ) > {
99+ let trimmed = url. trim ( ) . trim_end_matches ( ".git" ) . trim_end_matches ( '/' ) ;
100+
101+ let repo_part =
if let Some ( ssh
) = trimmed
. strip_prefix ( "[email protected] :" ) { 102+ ssh
103+ } else if let Some ( ssh
) = trimmed
. strip_prefix ( "ssh://[email protected] /" ) { 104+ ssh
105+ } else if let Some ( https) = trimmed. strip_prefix ( "https://github.com/" ) {
106+ https
107+ } else {
108+ return None ;
109+ } ;
110+
111+ let mut parts = repo_part. split ( '/' ) ;
112+ let org = parts. next ( ) ?;
113+ let repo = parts. next ( ) ?;
114+
115+ Some ( ( org. to_string ( ) , repo. to_string ( ) ) )
116+ }
117+
118+ /// Detect GitHub repository from the git remote
119+ pub fn detect_github_repo ( workspace_root : & Path ) -> Option < ( String , String ) > {
120+ let output = Command :: new ( "git" )
121+ . current_dir ( workspace_root)
122+ . args ( [ "config" , "--get" , "remote.origin.url" ] )
123+ . output ( )
124+ . ok ( ) ?;
125+
126+ if !output. status . success ( ) {
127+ return None ;
128+ }
129+
130+ let url = String :: from_utf8_lossy ( & output. stdout ) ;
131+ parse_github_remote ( & url)
132+ }
133+
97134/// Parse commit type from string
98135fn parse_commit_type ( input : & mut & str ) -> PResult < CommitType > {
99136 alt ( (
@@ -126,6 +163,30 @@ fn parse_description<'a>(input: &mut &'a str) -> PResult<&'a str> {
126163 preceded ( ( ':' , space0) , till_line_ending) . parse_next ( input)
127164}
128165
166+ /// Extract PR references (#123) from text
167+ fn extract_pr_numbers ( text : & str ) -> Vec < u32 > {
168+ let mut prs = Vec :: new ( ) ;
169+
170+ for word in text. split_whitespace ( ) {
171+ if let Some ( num_str) = word. strip_prefix ( "(#" ) . and_then ( |s| s. strip_suffix ( ')' ) )
172+ && let Ok ( num) = num_str. parse :: < u32 > ( ) {
173+ prs. push ( num) ;
174+ continue ;
175+ }
176+
177+ if let Some ( num_str) = word. strip_prefix ( '#' ) {
178+ let numeric = num_str. trim_end_matches ( |c : char | !c. is_ascii_digit ( ) ) ;
179+ if let Ok ( num) = numeric. parse :: < u32 > ( ) {
180+ prs. push ( num) ;
181+ }
182+ }
183+ }
184+
185+ prs. sort_unstable ( ) ;
186+ prs. dedup ( ) ;
187+ prs
188+ }
189+
129190/// Parse a conventional commit message
130191///
131192/// Format: type(scope)!: description
@@ -179,12 +240,83 @@ pub fn parse_conventional_commit<'a>(sha: &'a str, subject: &'a str, body: Optio
179240/// Changelog generator
180241pub struct ChangelogGenerator {
181242 workspace_root : std:: path:: PathBuf ,
243+ github_repo : Option < ( String , String ) > ,
182244}
183245
184246impl ChangelogGenerator {
185247 pub fn new ( workspace_root : & Path ) -> Self {
186248 Self {
187249 workspace_root : workspace_root. to_path_buf ( ) ,
250+ github_repo : detect_github_repo ( workspace_root) ,
251+ }
252+ }
253+
254+ pub fn github_repo ( & self ) -> Option < & ( String , String ) > {
255+ self . github_repo . as_ref ( )
256+ }
257+
258+ fn short_sha < ' a > ( & self , sha : & ' a str ) -> & ' a str {
259+ sha. get ( ..7 ) . unwrap_or ( sha)
260+ }
261+
262+ fn format_sha ( & self , sha : & str ) -> String {
263+ if let Some ( ( org, repo) ) = & self . github_repo {
264+ return format ! (
265+ "[{}](https://github.com/{}/{}/commit/{})" ,
266+ self . short_sha( sha) ,
267+ org,
268+ repo,
269+ sha
270+ ) ;
271+ }
272+
273+ self . short_sha ( sha) . to_string ( )
274+ }
275+
276+ fn format_pr_links ( & self , commit : & ConventionalCommit ) -> Option < String > {
277+ let mut text = commit. description . to_string ( ) ;
278+ if let Some ( body) = commit. body {
279+ text. push ( ' ' ) ;
280+ text. push_str ( body) ;
281+ }
282+
283+ let prs = extract_pr_numbers ( & text) ;
284+ if prs. is_empty ( ) {
285+ return None ;
286+ }
287+
288+ if let Some ( ( org, repo) ) = & self . github_repo {
289+ let links = prs
290+ . iter ( )
291+ . map ( |n| format ! ( "[#{}](https://github.com/{}/{}/pull/{})" , n, org, repo, n) )
292+ . collect :: < Vec < _ > > ( )
293+ . join ( " " ) ;
294+ Some ( links)
295+ } else {
296+ let refs = prs. iter ( ) . map ( |n| format ! ( "#{}" , n) ) . collect :: < Vec < _ > > ( ) . join ( " " ) ;
297+ Some ( refs)
298+ }
299+ }
300+
301+ fn format_description ( & self , commit : & ConventionalCommit ) -> String {
302+ let mut desc = commit. description . trim ( ) . to_string ( ) ;
303+
304+ if commit. breaking {
305+ desc = format ! ( "[**breaking**] {}" , desc) ;
306+ }
307+
308+ desc
309+ }
310+
311+ fn format_entry ( & self , commit : & ConventionalCommit ) -> String {
312+ let scope_prefix = commit. scope . map ( |s| format ! ( "**{}**: " , s) ) . unwrap_or_default ( ) ;
313+ let sha = self . format_sha ( commit. sha ) ;
314+ let desc = self . format_description ( commit) ;
315+
316+ if let Some ( prs) = self . format_pr_links ( commit) {
317+ format ! ( "- {}{} {} ({})\n " , scope_prefix, desc, prs, sha)
318+ } else {
319+ format ! ( "- {}{} ({})\n " , scope_prefix, desc, sha)
188320 }
189321 }
190322
@@ -233,7 +365,7 @@ impl ChangelogGenerator {
233365 CommitType :: Breaking . section_title( )
234366 ) ) ;
235367 for commit in breaking {
236- changelog. push_str ( & format ! ( "- {} ({}) \n " , commit . description . trim ( ) , & commit. sha [ .. 7 ] ) ) ;
368+ changelog. push_str ( & self . format_entry ( commit) ) ;
237369 }
238370 changelog. push ( '\n' ) ;
239371 }
@@ -246,13 +378,7 @@ impl ChangelogGenerator {
246378 CommitType :: Feature . section_title( )
247379 ) ) ;
248380 for commit in features {
249- let scope_prefix = commit. scope . map ( |s| format ! ( "**{}**: " , s) ) . unwrap_or_default ( ) ;
250- changelog. push_str ( & format ! (
251- "- {}{} ({})\n " ,
252- scope_prefix,
253- commit. description. trim( ) ,
254- & commit. sha[ ..7 ]
255- ) ) ;
381+ changelog. push_str ( & self . format_entry ( commit) ) ;
256382 }
257383 changelog. push ( '\n' ) ;
258384 }
@@ -265,13 +391,7 @@ impl ChangelogGenerator {
265391 CommitType :: Fix . section_title( )
266392 ) ) ;
267393 for commit in fixes {
268- let scope_prefix = commit. scope . map ( |s| format ! ( "**{}**: " , s) ) . unwrap_or_default ( ) ;
269- changelog. push_str ( & format ! (
270- "- {}{} ({})\n " ,
271- scope_prefix,
272- commit. description. trim( ) ,
273- & commit. sha[ ..7 ]
274- ) ) ;
394+ changelog. push_str ( & self . format_entry ( commit) ) ;
275395 }
276396 changelog. push ( '\n' ) ;
277397 }
@@ -288,13 +408,7 @@ impl ChangelogGenerator {
288408 commit_type. section_title( )
289409 ) ) ;
290410 for commit in commits {
291- let scope_prefix = commit. scope . map ( |s| format ! ( "**{}**: " , s) ) . unwrap_or_default ( ) ;
292- changelog. push_str ( & format ! (
293- "- {}{} ({})\n " ,
294- scope_prefix,
295- commit. description. trim( ) ,
296- & commit. sha[ ..7 ]
297- ) ) ;
411+ changelog. push_str ( & self . format_entry ( commit) ) ;
298412 }
299413 changelog. push ( '\n' ) ;
300414 }
@@ -420,4 +534,77 @@ mod tests {
420534 assert_eq ! ( commit. commit_type, CommitType :: Other ) ;
421535 assert_eq ! ( commit. description, "Update README" ) ;
422536 }
537+
538+ #[ test]
539+ fn parse_github_remote_supports_common_patterns ( ) {
540+ assert_eq ! (
541+ parse_github_remote
( "[email protected] :org/repo.git" ) , 542+ Some ( ( "org" . to_string( ) , "repo" . to_string( ) ) )
543+ ) ;
544+
545+ assert_eq ! (
546+ parse_github_remote( "https://github.com/org/repo" ) ,
547+ Some ( ( "org" . to_string( ) , "repo" . to_string( ) ) )
548+ ) ;
549+
550+ assert_eq ! (
551+ parse_github_remote
( "ssh://[email protected] /org/repo" ) , 552+ Some ( ( "org" . to_string( ) , "repo" . to_string( ) ) )
553+ ) ;
554+ }
555+
556+ #[ test]
557+ fn parse_github_remote_non_github_returns_none ( ) {
558+ assert_eq ! ( parse_github_remote
( "[email protected] :org/repo.git" ) , None ) ; 559+ }
560+
561+ #[ test]
562+ fn extract_pr_numbers_supports_common_patterns ( ) {
563+ let prs = extract_pr_numbers ( "feat(auth): add login (#12) closes #34 and refs #34" ) ;
564+ assert_eq ! ( prs, vec![ 12 , 34 ] ) ;
565+ }
566+
567+ #[ test]
568+ fn format_entry_includes_pr_and_links_when_available ( ) {
569+ let commit = ConventionalCommit {
570+ commit_type : CommitType :: Feature ,
571+ scope : Some ( "api" ) ,
572+ breaking : false ,
573+ description : "redesign REST endpoints (#123)" ,
574+ body : Some ( "closes #456" ) ,
575+ sha : "abcdef1234567890" ,
576+ } ;
577+
578+ let generator = ChangelogGenerator {
579+ workspace_root : std:: path:: PathBuf :: new ( ) ,
580+ github_repo : Some ( ( "org" . to_string ( ) , "repo" . to_string ( ) ) ) ,
581+ } ;
582+
583+ let line = generator. format_entry ( & commit) ;
584+
585+ assert ! ( line. contains( "[#123](https://github.com/org/repo/pull/123)" ) ) ;
586+ assert ! ( line. contains( "[#456](https://github.com/org/repo/pull/456)" ) ) ;
587+ assert ! ( line. contains( "**api**: redesign REST endpoints (#123)" ) ) ;
588+ assert ! ( line. contains( "[abcdef1](https://github.com/org/repo/commit/abcdef1234567890)" ) ) ;
589+ }
590+
591+ #[ test]
592+ fn format_entry_marks_breaking_inline ( ) {
593+ let commit = ConventionalCommit {
594+ commit_type : CommitType :: Breaking ,
595+ scope : None ,
596+ breaking : true ,
597+ description : "change API" ,
598+ body : None ,
599+ sha : "1234567" ,
600+ } ;
601+
602+ let generator = ChangelogGenerator {
603+ workspace_root : std:: path:: PathBuf :: new ( ) ,
604+ github_repo : None ,
605+ } ;
606+
607+ let line = generator. format_entry ( & commit) ;
608+ assert ! ( line. contains( "[**breaking**] change API" ) ) ;
609+ }
423610}
0 commit comments