1+ import argparse
12import subprocess
23import sys
34from datetime import datetime
@@ -11,11 +12,37 @@ def get_git_log(range_str="HEAD~1..HEAD"):
1112 try :
1213 cmd = ["git" , "log" , range_str , "--pretty=format:%h|%an|%ad|%s%n%b" , "--date=short" ]
1314 result = subprocess .run (cmd , capture_output = True , text = True , check = True )
14- return result .stdout .strip ().split ('\n \n ' ) # Split by double newline to separate commits
15+ output = result .stdout .strip ()
16+ if not output :
17+ return []
18+ # Split by double newline to separate commits
19+ return output .split ("\n \n " )
1520 except subprocess .CalledProcessError :
1621 print (f"Error reading git log for range { range_str } " )
1722 return []
1823
24+ def get_latest_tag ():
25+ """Return the most recent git tag reachable from HEAD."""
26+ try :
27+ cmd = ["git" , "describe" , "--tags" , "--abbrev=0" ]
28+ result = subprocess .run (cmd , capture_output = True , text = True , check = True )
29+ tag = result .stdout .strip ()
30+ return tag or None
31+ except subprocess .CalledProcessError :
32+ return None
33+
34+ def compute_confidence (commits , known_types ):
35+ """Compute confidence based on conventional commit parsing coverage."""
36+ if not commits :
37+ return "low"
38+ known = sum (1 for c in commits if c ["type" ].split ("(" )[0 ] in known_types )
39+ ratio = known / len (commits )
40+ if ratio == 1.0 :
41+ return "high"
42+ if ratio >= 0.6 :
43+ return "medium"
44+ return "low"
45+
1946def parse_commit (commit_text ):
2047 """Parse a single commit block."""
2148 lines = commit_text .strip ().split ('\n ' )
@@ -47,7 +74,7 @@ def parse_commit(commit_text):
4774 "desc" : desc
4875 }
4976
50- def generate_entry (commits ):
77+ def generate_entry (commits , range_str , origin = "generated" , summary = None ):
5178 if not commits :
5279 return ""
5380
@@ -66,9 +93,16 @@ def generate_entry(commits):
6693 # We will put placeholders.
6794
6895 now = datetime .now ().strftime ("%Y-%m-%d" )
96+ known_types = {"feat" , "fix" , "refactor" , "perf" , "test" , "docs" , "chore" }
97+ confidence = compute_confidence (commits , known_types )
6998 output = []
7099 output .append (f"## [Unreleased] - { now } " )
71100 output .append ("" )
101+ output .append (f"**Origin:** { origin .title ()} " )
102+ output .append (f"**Range:** `{ range_str } `" )
103+ output .append (f"**Commit Count:** { len (commits )} " )
104+ output .append (f"**Confidence:** { confidence } " )
105+ output .append ("" )
72106 # Determine intent from the most common type or the first commit
73107 focus = "Routine Maintenance"
74108 if 'feat' in groups :
@@ -79,7 +113,10 @@ def generate_entry(commits):
79113 output .append (f"**Focus:** { focus } " )
80114 output .append ("" )
81115 output .append ("### 🧠 Temporal Context & Intent" )
82- output .append ("> *Auto-generated: Add context about why these changes were made.*" )
116+ if summary :
117+ output .append (f"> { summary } " )
118+ else :
119+ output .append ("> *Auto-generated: Add context about why these changes were made.*" )
83120 output .append ("" )
84121 output .append ("### 🏗️ Architectural Impact" )
85122 output .append ("> *Auto-generated: Describe high-level architectural shifts.*" )
@@ -133,18 +170,58 @@ def generate_entry(commits):
133170
134171def main ():
135172 changelog_path = Path ("CHANGELOG.md" )
136-
137- # Allow range to be passed as argument
138- range_str = "HEAD~5..HEAD"
139- if len (sys .argv ) > 1 :
140- range_str = sys .argv [1 ]
141173
142- # In a real CI, we might compare against the last tag.
143- commits_raw = get_git_log (range_str )
174+ parser = argparse .ArgumentParser (description = "Generate a changelog entry from git history." )
175+ parser .add_argument (
176+ "--range" ,
177+ dest = "range_str" ,
178+ default = None ,
179+ help = "Git commit range (e.g., v2.1.0..HEAD)." ,
180+ )
181+ parser .add_argument (
182+ "--use-last-tag" ,
183+ action = "store_true" ,
184+ help = "Use the latest tag as the start of the range (falls back if none)." ,
185+ )
186+ parser .add_argument (
187+ "--origin" ,
188+ default = "generated" ,
189+ help = "Origin label for the entry (generated or curated)." ,
190+ )
191+ parser .add_argument (
192+ "--summary" ,
193+ default = None ,
194+ help = "Plain-English summary of the changes to include in the entry." ,
195+ )
196+ parser .add_argument (
197+ "--summary-file" ,
198+ default = None ,
199+ help = "Path to a file containing the plain-English summary." ,
200+ )
201+ args = parser .parse_args ()
202+
203+ if args .range_str :
204+ range_str = args .range_str
205+ elif args .use_last_tag :
206+ tag = get_latest_tag ()
207+ range_str = f"{ tag } ..HEAD" if tag else "HEAD~5..HEAD"
208+ else :
209+ range_str = "HEAD~5..HEAD"
210+
211+ # In a real CI, we might compare against the last tag.
212+ commits_raw = get_git_log (range_str )
144213 parsed_commits = [parse_commit (c ) for c in commits_raw if c ]
145214 parsed_commits = [c for c in parsed_commits if c ] # filter Nones
146215
147- new_entry = generate_entry (parsed_commits )
216+ summary = args .summary
217+ if args .summary_file :
218+ try :
219+ summary = Path (args .summary_file ).read_text (encoding = "utf-8" ).strip ()
220+ except OSError as exc :
221+ print (f"Failed to read summary file: { exc } " )
222+ sys .exit (1 )
223+
224+ new_entry = generate_entry (parsed_commits , range_str , origin = args .origin , summary = summary )
148225
149226 if not new_entry :
150227 print ("No commits found to generate changelog." )
0 commit comments