forked from Ikalus1988/MisakaNet
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgen_changelog.py
More file actions
265 lines (222 loc) · 8.56 KB
/
Copy pathgen_changelog.py
File metadata and controls
265 lines (222 loc) · 8.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
#!/usr/bin/env python3
"""Changelog Generator — Issue #1054.
Auto-generates changelog entries from merged PRs since a given date/tag.
Categorises by conventional commit prefix or labels.
Usage:
python3 scripts/gen_changelog.py # since last tag
python3 scripts/gen_changelog.py --since 2025-01-01 # since date
python3 scripts/gen_changelog.py --version 2.16.0 # tag the release
python3 scripts/gen_changelog.py --dry-run # preview only
"""
import argparse
import json
import re
import subprocess
import sys
from collections import defaultdict
from datetime import datetime
from pathlib import Path
CATEGORY_MAP = {
"feat": "Added", "add": "Added", "new": "Added", "introduce": "Added",
"fix": "Fixed", "bugfix": "Fixed", "patch": "Fixed", "resolve": "Fixed",
"refactor": "Changed", "improve": "Changed", "enhance": "Changed", "update": "Changed",
"perf": "Changed", "optim": "Changed", "style": "Changed",
"docs": "Documentation", "doc": "Documentation", "readme": "Documentation",
"test": "Tests", "ci": "Tests", "chore": "Maintenance", "build": "Maintenance",
"revert": "Reverted",
}
LABEL_MAP = {
"bug": "Fixed", "enhancement": "Added", "feature": "Added",
"documentation": "Documentation", "test": "Tests",
"breaking": "Breaking Changes", "maintenance": "Maintenance",
}
def get_last_tag(repo):
try:
r = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True, text=True, cwd=repo, timeout=10,
)
return r.stdout.strip() if r.returncode == 0 else None
except Exception:
return None
def get_merged_prs(repo, since_date=None, owner=None, repo_name=None):
since = since_date or "2020-01-01"
query = f"merged:>={since}"
cmd = [
"gh", "pr", "list",
"--repo", f"{owner}/{repo_name}",
"--search", f"merged:>={since}",
"--state", "merged",
"--json", "number,title,labels,mergedAt,body",
"--limit", "200",
]
try:
r = subprocess.run(cmd, capture_output=True, cwd=repo, timeout=60)
stdout = r.stdout.decode("utf-8", errors="replace") if r.stdout else ""
stderr = r.stderr.decode("utf-8", errors="replace") if r.stderr else ""
if r.returncode != 0:
print(f"Warning: gh pr list failed: {stderr.strip()}", file=sys.stderr)
return []
return json.loads(stdout)
except FileNotFoundError:
print("Error: 'gh' CLI not found. Install with: brew install gh", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error fetching PRs: {e}", file=sys.stderr)
return []
def categorise(pr):
title = pr.get("title", "")
match = re.match(r"^(\w+)[(!:]", title)
if match:
prefix = match.group(1).lower()
if prefix in CATEGORY_MAP:
return CATEGORY_MAP[prefix]
labels = [l.get("name", "").lower() for l in pr.get("labels", [])]
for label in labels:
if label in LABEL_MAP:
return LABEL_MAP[label]
return "Other"
def format_entry(pr):
title = pr.get("title", "")
# Strip conventional commit prefix (feat(scope): etc.)
cleaned = re.sub(r"^\w+(?:\([^)]*\))?[(!:]\s*", "", title).strip()
# Strip trailing PR number references (#123) that may already be in the title
cleaned = re.sub(r"\s*\(#[\d,]+\)\s*$", "", cleaned).strip()
number = pr.get("number", "?")
author = pr.get("user", {}).get("login", "")
if author and "[bot]" not in author:
return f"- {cleaned} (#{number}) @{author}"
return f"- {cleaned} (#{number})"
def generate_changelog(prs):
categories = defaultdict(list)
for pr in prs:
cat = categorise(pr)
entry = format_entry(pr)
categories[cat].append(entry)
order = [
"Breaking Changes", "Added", "Fixed", "Changed",
"Documentation", "Tests", "Maintenance", "Reverted", "Other",
]
lines = []
for cat in order:
entries = categories.get(cat, [])
if not entries:
continue
entries.sort()
lines.append(f"### {cat}\n")
lines.extend(entries)
lines.append("")
for cat in sorted(categories.keys()):
if cat not in order and categories[cat]:
lines.append(f"### {cat}\n")
lines.extend(sorted(categories[cat]))
lines.append("")
return "\n".join(lines)
def categorize_pr(title: str) -> str:
"""Categorize a PR title by conventional commit prefix. Public API for tests."""
match = re.match(r"^(\w+)[(!:]", title)
if match:
prefix = match.group(1).lower()
if prefix in CATEGORY_MAP:
return CATEGORY_MAP[prefix]
return "Other"
def generate_changelog_section(
release_tag: str,
release_name: str,
release_date: str,
prs: list[dict],
) -> str:
"""Generate a full changelog section for a release. Public API for tests."""
date_str = release_date[:10] if release_date else datetime.now().strftime("%Y-%m-%d")
categories = defaultdict(list)
contributors = set()
for pr in prs:
cat = categorise(pr)
entry = format_entry(pr)
author = pr.get("user", {}).get("login", "")
if author and "[bot]" not in author:
contributors.add(author)
entry = f"- #{pr['number']}: {pr.get('title', '')} (@{author})"
categories[cat].append(entry)
order = [
"Breaking Changes", "Added", "Fixed", "Changed",
"Documentation", "Tests", "Maintenance", "Reverted", "Other",
]
lines = [f"## {release_name} — {date_str}\n"]
for cat in order:
entries = categories.get(cat, [])
if not entries:
continue
entries.sort()
lines.append(f"### {cat}\n")
lines.extend(entries)
lines.append("")
if contributors:
lines.append("### Contributors\n")
for c in sorted(contributors):
lines.append(f"- @{c}")
lines.append("")
lines.append("---\n")
return "\n".join(lines)
def update_changelog_file(
changelog_path: Path,
new_section: str,
prepend: bool = True,
) -> None:
"""Insert a new changelog section into an existing CHANGELOG.md."""
if changelog_path.exists():
existing = changelog_path.read_text(encoding="utf-8")
else:
existing = "# Misaka Network — Changelog\n\n"
if prepend:
# Insert after the first heading
heading_end = existing.find("\n\n")
if heading_end != -1:
updated = existing[:heading_end + 2] + new_section + "\n" + existing[heading_end + 2:]
else:
updated = existing + "\n" + new_section
else:
updated = existing.rstrip() + "\n\n" + new_section
changelog_path.write_text(updated, encoding="utf-8")
def main():
parser = argparse.ArgumentParser(description="Generate changelog from merged PRs")
parser.add_argument("--repo", default="Ikalus1988/MisakaNet")
parser.add_argument("--since", help="PR merge date (YYYY-MM-DD)")
parser.add_argument("--version", help="Version tag (e.g. 2.16.0)")
parser.add_argument("--output", type=str, help="Output file path")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
owner, repo_name = args.repo.split("/")
since = args.since
if not since:
tag = get_last_tag(Path(__file__).resolve().parent.parent)
if tag:
print(f"Last tag: {tag}")
since = re.sub(r"[^0-9\-]", "", tag.replace("v", ""))
if len(since) < 10:
since = f"{since}-01-01"
else:
since = "2025-01-01"
print(f"Fetching PRs merged since {since}")
prs = get_merged_prs(
str(Path(__file__).resolve().parent.parent),
since_date=since, owner=owner, repo_name=repo_name,
)
print(f"Found {len(prs)} merged PRs")
if not prs:
print("No PRs found. Check --repo and --since values.")
return
changelog = generate_changelog(prs)
if args.version:
header = f"## [{args.version}] — {datetime.now().strftime('%Y-%m-%d')}\n\n"
changelog = header + changelog
if args.dry_run:
print("\n--- DRY RUN ---\n")
print(changelog)
return
output = Path(args.output) if args.output else Path(__file__).resolve().parent.parent / "CHANGELOG-GENERATED.md"
output.write_text(changelog, encoding='utf-8')
print(f"Changelog written to {output}")
print(f"Categories: {len([l for l in changelog.split(chr(10)) if l.startswith('### ')])}")
if __name__ == "__main__":
main()