#!/usr/bin/env python3 """Cut a new release: collect DONE items from Backlog into a new Version section. Usage: poetry run python scripts/release.py major poetry run python scripts/release.py minor """ import re import subprocess import sys from pathlib import Path PROJECT_FILE = Path("PROJECT.org") CHANGELOG_FILE = Path("CHANGELOG.org") PYPROJECT_FILE = Path("pyproject.toml") BACKLOG_RE = re.compile(r"^\* Backlog\s+\[(\d+)/(\d+)\](.*)$") VERSION_RE = re.compile(r"^\* Version\s+(\d+\.\d+)\s+\[\d+/\d+\]") ITEM_HEADER_RE = re.compile(r"^\*\* ") def parse_done_line(line): """Extract a clean title from a ** DONE line, stripping priority and tags.""" rest = line[8:].strip() # remove "** DONE " # strip priority marker like [#A] rest = re.sub(r"^\[#[A-C]\]\s+", "", rest, count=1) # strip org-mode tags at end (space-colon-tags) rest = re.sub(r"\s+:\S.*:\s*$", "", rest) return rest def bump_version(current_major, current_minor, kind): if kind == "major": return current_major + 1, 0 elif kind == "minor": return current_major, current_minor + 1 else: raise ValueError(f"Unknown bump kind: {kind}") def collect_done_items(backlog_lines): """Split Backlog into items; return done items and kept items. Each item is a list of lines (without the section header). """ # Split Backlog into items at each ** line (skip the section header) items = [] # list of (start_idx, end_idx, is_done) item_start = None for i in range(1, len(backlog_lines)): if ITEM_HEADER_RE.match(backlog_lines[i]): if item_start is not None: items.append( (item_start, i, backlog_lines[item_start].startswith("** DONE")) ) item_start = i if item_start is not None: items.append( ( item_start, len(backlog_lines), backlog_lines[item_start].startswith("** DONE"), ) ) done_items = [backlog_lines[s:e] for s, e, is_done in items if is_done] kept_items = [backlog_lines[s:e] for s, e, is_done in items if not is_done] return done_items, kept_items def main(): if len(sys.argv) < 2 or sys.argv[1] not in ("major", "minor"): print(f"Usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) kind = sys.argv[1] changelog_lines = CHANGELOG_FILE.read_text().splitlines(keepends=True) project_lines = PROJECT_FILE.read_text().splitlines(keepends=True) # --------------------------------------------------------------- # 1. Parse current version from the first * Version header in CHANGELOG.org # --------------------------------------------------------------- version_start = None for i, line in enumerate(changelog_lines): if VERSION_RE.match(line.strip()): version_start = i break if version_start is None: print( "ERROR: no parseable Version header found in CHANGELOG.org", file=sys.stderr ) sys.exit(1) vm = VERSION_RE.match(changelog_lines[version_start].strip()) current_version = vm.group(1) major_str, minor_str = current_version.split(".") current_major = int(major_str) current_minor = int(minor_str) new_major, new_minor = bump_version(current_major, current_minor, kind) new_version = f"{new_major}.{new_minor}" # --------------------------------------------------------------- # 2. Identify the Backlog section in PROJECT.org # --------------------------------------------------------------- section_starts = [] for i, line in enumerate(project_lines): if line.startswith("* ") and not line.startswith("** "): section_starts.append(i) section_starts.append(len(project_lines)) backlog_idx = None for idx, start in enumerate(section_starts[:-1]): if project_lines[start].strip().startswith("* Backlog"): backlog_idx = idx if backlog_idx is None: print("ERROR: no Backlog section found in PROJECT.org", file=sys.stderr) sys.exit(1) backlog_start = section_starts[backlog_idx] backlog_end = section_starts[backlog_idx + 1] backlog_lines = project_lines[backlog_start:backlog_end] done_items, kept_items = collect_done_items(backlog_lines) if not done_items: print("No DONE items found in Backlog — nothing to release.") sys.exit(1) # --------------------------------------------------------------- # 3. Build the new Version section text # --------------------------------------------------------------- version_section_lines = [ f"* Version {new_version} [{len(done_items)}/{len(done_items)}]\n" ] for item in done_items: version_section_lines.extend(item) version_section_lines.append("\n") # --------------------------------------------------------------- # 4. Build updated Backlog section # --------------------------------------------------------------- backlog_header_line = backlog_lines[0] bm = BACKLOG_RE.match(backlog_header_line.strip()) if not bm: print( f"ERROR: could not parse backlog header: {backlog_header_line!r}", file=sys.stderr, ) sys.exit(1) tags = bm.group(3) new_done = 0 new_total = len(kept_items) new_backlog_header = f"* Backlog [{new_done}/{new_total}]{tags}\n" backlog_body = [] for item in kept_items: backlog_body.extend(item) # --------------------------------------------------------------- # 5. Assemble the new PROJECT.org (Backlog + DONE items removed) # --------------------------------------------------------------- before_backlog = project_lines[:backlog_start] output_project = before_backlog + [new_backlog_header] + backlog_body PROJECT_FILE.write_text("".join(output_project)) # --------------------------------------------------------------- # 6. Insert the new Version section into CHANGELOG.org after the title # --------------------------------------------------------------- output_changelog = ( changelog_lines[:version_start] + version_section_lines + changelog_lines[version_start:] ) CHANGELOG_FILE.write_text("".join(output_changelog)) # --------------------------------------------------------------- # 7. Update pyproject.toml # --------------------------------------------------------------- pyproject = PYPROJECT_FILE.read_text() pyproject = re.sub( r'^version = "[\d.]+"', f'version = "{new_version}"', pyproject, count=1, flags=re.MULTILINE, ) PYPROJECT_FILE.write_text(pyproject) # --------------------------------------------------------------- # 8. Build commit body from done item titles # --------------------------------------------------------------- commit_lines = [] for item in done_items: title = parse_done_line(item[0]) if title: commit_lines.append(f"- {title}") commit_body = "\n".join(commit_lines) commit_message = f"[release] Bump to version {new_version}\n\n{commit_body}" # --------------------------------------------------------------- # 9. Git commit + tag # --------------------------------------------------------------- subprocess.run( ["git", "add", str(PROJECT_FILE), str(CHANGELOG_FILE), str(PYPROJECT_FILE)], check=True, ) subprocess.run(["git", "commit", "-m", commit_message], check=True) subprocess.run(["git", "tag", new_version], check=True) print(f"\nReleased v{new_version} — tag {new_version} created.") print(f"Moved {len(done_items)} DONE item(s) from Backlog to CHANGELOG.org.") if __name__ == "__main__": main()