|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Convert project changelog to Debian changelog and RPM %changelog format. |
| 4 | +
|
| 5 | +Data sources (priority order): |
| 6 | + 1. docs/CHANGELOG.md - parsed entries with full descriptions |
| 7 | + 2. git tags - date + commit summaries between tags |
| 8 | + 3. Fallback - "New upstream release vX.Y.Z" |
| 9 | +""" |
| 10 | + |
| 11 | +import re |
| 12 | +import subprocess |
| 13 | +import sys |
| 14 | +from datetime import datetime |
| 15 | +from pathlib import Path |
| 16 | + |
| 17 | + |
| 18 | +def normalize_version(version): |
| 19 | + """Normalize version string: '0.7' -> '0.7.0', '0.10.0' -> '0.10.0'.""" |
| 20 | + parts = version.split('.') |
| 21 | + while len(parts) < 3: |
| 22 | + parts.append('0') |
| 23 | + return '.'.join(parts) |
| 24 | + |
| 25 | + |
| 26 | +def run_git(*args): |
| 27 | + """Run a git command and return stdout, or None on failure.""" |
| 28 | + try: |
| 29 | + result = subprocess.run( |
| 30 | + ['git'] + list(args), |
| 31 | + capture_output=True, text=True, timeout=10 |
| 32 | + ) |
| 33 | + if result.returncode == 0: |
| 34 | + return result.stdout.strip() |
| 35 | + except (subprocess.TimeoutExpired, FileNotFoundError): |
| 36 | + pass |
| 37 | + return None |
| 38 | + |
| 39 | + |
| 40 | +def parse_changelog_md(changelog_path): |
| 41 | + """Parse docs/CHANGELOG.md and extract version entries.""" |
| 42 | + if not changelog_path.exists(): |
| 43 | + return {} |
| 44 | + |
| 45 | + with open(changelog_path, 'r') as f: |
| 46 | + content = f.read() |
| 47 | + |
| 48 | + version_pattern = r'- \*\*\[(\d{4})/(\d{2})\]\*\* Released \[v([^\]]+)\]' |
| 49 | + versions = {} |
| 50 | + |
| 51 | + lines = content.split('\n') |
| 52 | + i = 0 |
| 53 | + |
| 54 | + while i < len(lines): |
| 55 | + line = lines[i] |
| 56 | + match = re.search(version_pattern, line) |
| 57 | + |
| 58 | + if match: |
| 59 | + year, month, version = match.group(1), match.group(2), match.group(3) |
| 60 | + date = f"{year}-{month}-01" |
| 61 | + entries = [] |
| 62 | + i += 1 |
| 63 | + |
| 64 | + while i < len(lines) and lines[i].strip() == '': |
| 65 | + i += 1 |
| 66 | + |
| 67 | + while i < len(lines): |
| 68 | + line = lines[i] |
| 69 | + if re.search(version_pattern, line): |
| 70 | + break |
| 71 | + |
| 72 | + if line.strip().startswith('- '): |
| 73 | + entry = line.strip()[2:].strip() |
| 74 | + i += 1 |
| 75 | + while i < len(lines): |
| 76 | + next_line = lines[i] |
| 77 | + if next_line.startswith(' ') and not next_line.strip().startswith('-'): |
| 78 | + entry += ' ' + next_line.strip() |
| 79 | + i += 1 |
| 80 | + else: |
| 81 | + break |
| 82 | + # Remove markdown formatting |
| 83 | + entry = re.sub(r'\*([^*]+)\*', r'\1', entry) |
| 84 | + entry = re.sub(r'_([^_]+)_', r'\1', entry) |
| 85 | + entry = re.sub(r'`([^`]+)`', r'\1', entry) |
| 86 | + entries.append(entry) |
| 87 | + elif line.strip() == '': |
| 88 | + i += 1 |
| 89 | + peek = i |
| 90 | + while peek < len(lines) and lines[peek].strip() == '': |
| 91 | + peek += 1 |
| 92 | + if peek < len(lines) and re.search(version_pattern, lines[peek]): |
| 93 | + break |
| 94 | + else: |
| 95 | + i += 1 |
| 96 | + |
| 97 | + norm_ver = normalize_version(version) |
| 98 | + versions[norm_ver] = { |
| 99 | + 'version': norm_ver, |
| 100 | + 'date': date, |
| 101 | + 'entries': entries, |
| 102 | + 'source': 'changelog.md' |
| 103 | + } |
| 104 | + else: |
| 105 | + i += 1 |
| 106 | + |
| 107 | + return versions |
| 108 | + |
| 109 | + |
| 110 | +def get_git_tags(): |
| 111 | + """Get all version tags sorted by version (newest first).""" |
| 112 | + output = run_git('tag', '-l', 'v*', '--sort=-v:refname', |
| 113 | + '--format=%(refname:short) %(creatordate:short)') |
| 114 | + if not output: |
| 115 | + return [] |
| 116 | + |
| 117 | + tags = [] |
| 118 | + for line in output.strip().split('\n'): |
| 119 | + parts = line.split(' ', 1) |
| 120 | + if len(parts) == 2: |
| 121 | + tag, date = parts |
| 122 | + version = tag.lstrip('v') |
| 123 | + tags.append({'tag': tag, 'version': version, 'date': date}) |
| 124 | + return tags |
| 125 | + |
| 126 | + |
| 127 | +def get_commits_between_tags(tag_old, tag_new): |
| 128 | + """Get commit summaries between two tags.""" |
| 129 | + range_spec = f"{tag_old}..{tag_new}" if tag_old else tag_new |
| 130 | + output = run_git('log', range_spec, '--oneline', '--no-merges', |
| 131 | + '--format=%s') |
| 132 | + if not output: |
| 133 | + return [] |
| 134 | + |
| 135 | + commits = [] |
| 136 | + for line in output.strip().split('\n'): |
| 137 | + line = line.strip() |
| 138 | + if not line: |
| 139 | + continue |
| 140 | + # Skip Dependabot bumps and trivial commits |
| 141 | + if line.startswith('Bump ') or line.startswith('Merge '): |
| 142 | + continue |
| 143 | + # Clean up [TAG] prefixes for readability |
| 144 | + clean = re.sub(r'^\[(CRL|PAL|UIL|CICD|CI|Others)\]\s*', '', line) |
| 145 | + # Remove PR number suffix |
| 146 | + clean = re.sub(r'\s*\(#\d+\)$', '', clean) |
| 147 | + if clean: |
| 148 | + commits.append(clean) |
| 149 | + return commits |
| 150 | + |
| 151 | + |
| 152 | +def build_version_list(changelog_md_path): |
| 153 | + """Build complete version list from CHANGELOG.md + git tags.""" |
| 154 | + # Source 1: CHANGELOG.md |
| 155 | + md_versions = parse_changelog_md(changelog_md_path) |
| 156 | + print(f" CHANGELOG.md: {len(md_versions)} version(s)") |
| 157 | + |
| 158 | + # Source 2: git tags |
| 159 | + tags = get_git_tags() |
| 160 | + print(f" git tags: {len(tags)} tag(s)") |
| 161 | + |
| 162 | + # Merge: use CHANGELOG.md entries when available, git tag fallback otherwise |
| 163 | + all_versions = [] |
| 164 | + seen = set() |
| 165 | + |
| 166 | + for i, tag_info in enumerate(tags): |
| 167 | + version = normalize_version(tag_info['version']) |
| 168 | + if version in seen: |
| 169 | + continue |
| 170 | + seen.add(version) |
| 171 | + |
| 172 | + if version in md_versions: |
| 173 | + all_versions.append(md_versions[version]) |
| 174 | + else: |
| 175 | + # Fallback: generate from git |
| 176 | + prev_tag = tags[i + 1]['tag'] if i + 1 < len(tags) else None |
| 177 | + commits = get_commits_between_tags(prev_tag, tag_info['tag']) |
| 178 | + |
| 179 | + entries = commits if commits else [f"New upstream release v{version}"] |
| 180 | + |
| 181 | + all_versions.append({ |
| 182 | + 'version': version, |
| 183 | + 'date': tag_info['date'], |
| 184 | + 'entries': entries, |
| 185 | + 'source': 'git-tag' |
| 186 | + }) |
| 187 | + |
| 188 | + # Also include CHANGELOG.md versions that have no git tag |
| 189 | + for version, data in md_versions.items(): |
| 190 | + if version not in seen: |
| 191 | + all_versions.append(data) |
| 192 | + seen.add(version) |
| 193 | + |
| 194 | + # Sort by version descending |
| 195 | + def version_key(v): |
| 196 | + parts = v['version'].split('.') |
| 197 | + return tuple(int(p) for p in parts if p.isdigit()) |
| 198 | + |
| 199 | + all_versions.sort(key=version_key, reverse=True) |
| 200 | + return all_versions |
| 201 | + |
| 202 | + |
| 203 | +def generate_debian_changelog(versions, output_path): |
| 204 | + """Generate Debian changelog format.""" |
| 205 | + lines = [] |
| 206 | + |
| 207 | + for v in versions: |
| 208 | + date_obj = datetime.strptime(v['date'], '%Y-%m-%d') |
| 209 | + deb_date = date_obj.strftime('%a, %d %b %Y 10:00:00 +0800') |
| 210 | + |
| 211 | + lines.append(f"flagcx ({v['version']}-1) unstable; urgency=medium") |
| 212 | + lines.append("") |
| 213 | + |
| 214 | + for entry in v['entries']: |
| 215 | + lines.append(f" * {entry}") |
| 216 | + |
| 217 | + lines.append("") |
| 218 | + lines.append(f" -- FlagOS Contributors <contact@flagos.io> {deb_date}") |
| 219 | + lines.append("") |
| 220 | + |
| 221 | + with open(output_path, 'w') as f: |
| 222 | + f.write('\n'.join(lines)) |
| 223 | + |
| 224 | + print(f" Generated Debian changelog: {output_path}") |
| 225 | + |
| 226 | + |
| 227 | +def generate_rpm_changelog(versions): |
| 228 | + """Generate RPM %changelog format.""" |
| 229 | + lines = [] |
| 230 | + |
| 231 | + for v in versions: |
| 232 | + date_obj = datetime.strptime(v['date'], '%Y-%m-%d') |
| 233 | + rpm_date = date_obj.strftime('%a %b %d %Y') |
| 234 | + |
| 235 | + lines.append(f"* {rpm_date} FlagOS Contributors <contact@flagos.io> - {v['version']}-1") |
| 236 | + |
| 237 | + for entry in v['entries']: |
| 238 | + lines.append(f"- {entry}") |
| 239 | + |
| 240 | + lines.append("") |
| 241 | + |
| 242 | + return '\n'.join(lines) |
| 243 | + |
| 244 | + |
| 245 | +def update_rpm_spec(spec_path, changelog_content): |
| 246 | + """Update %changelog section in RPM spec file.""" |
| 247 | + if not spec_path.exists(): |
| 248 | + print(f" Warning: RPM spec not found: {spec_path}") |
| 249 | + return |
| 250 | + |
| 251 | + with open(spec_path, 'r') as f: |
| 252 | + spec_content = f.read() |
| 253 | + |
| 254 | + changelog_pattern = r'%changelog.*$' |
| 255 | + new_spec = re.sub(changelog_pattern, f'%changelog\n{changelog_content}', |
| 256 | + spec_content, flags=re.DOTALL) |
| 257 | + |
| 258 | + with open(spec_path, 'w') as f: |
| 259 | + f.write(new_spec) |
| 260 | + |
| 261 | + print(f" Updated RPM spec changelog: {spec_path}") |
| 262 | + |
| 263 | + |
| 264 | +def main(): |
| 265 | + project_root = Path(__file__).parent.parent |
| 266 | + changelog_md = project_root / 'docs' / 'CHANGELOG.md' |
| 267 | + debian_changelog = project_root / 'packaging' / 'debian' / 'changelog' |
| 268 | + rpm_spec = project_root / 'packaging' / 'rpm' / 'specs' / 'flagcx.spec' |
| 269 | + |
| 270 | + print("Collecting version data...") |
| 271 | + versions = build_version_list(changelog_md) |
| 272 | + |
| 273 | + if not versions: |
| 274 | + print("Error: No versions found from any source") |
| 275 | + sys.exit(1) |
| 276 | + |
| 277 | + print(f"\nTotal: {len(versions)} version(s)") |
| 278 | + for v in versions: |
| 279 | + src = v.get('source', '?') |
| 280 | + print(f" v{v['version']} ({v['date']}) [{src}] - {len(v['entries'])} entries") |
| 281 | + |
| 282 | + print("\nGenerating changelogs...") |
| 283 | + generate_debian_changelog(versions, debian_changelog) |
| 284 | + |
| 285 | + rpm_changelog = generate_rpm_changelog(versions) |
| 286 | + update_rpm_spec(rpm_spec, rpm_changelog) |
| 287 | + |
| 288 | + print("\nDone!") |
| 289 | + |
| 290 | + |
| 291 | +if __name__ == '__main__': |
| 292 | + main() |
0 commit comments