#!/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") 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+\]") DONE_HEADER_RE = re.compile(r"^(\*\* DONE\s+)(.*)$") 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 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] lines = PROJECT_FILE.read_text().splitlines(keepends=True) # --------------------------------------------------------------- # 1. Identify top-level sections # --------------------------------------------------------------- section_starts = [] for i, line in enumerate(lines): if line.startswith("* ") and not line.startswith("** "): section_starts.append(i) section_starts.append(len(lines)) backlog_idx = None version_idx = None for idx, start in enumerate(section_starts[:-1]): header = lines[start].strip() if header.startswith("* Backlog"): backlog_idx = idx if header.startswith("* Version"): version_idx = idx # last occurrence wins if backlog_idx is None: print("ERROR: no Backlog section found", file=sys.stderr) sys.exit(1) if version_idx is None: print("ERROR: no Version section found", file=sys.stderr) sys.exit(1) backlog_start = section_starts[backlog_idx] backlog_end = section_starts[backlog_idx + 1] # Find the newest Version section (first after Backlog) that matches # our expected format (e.g. "37.0" not "0.11.4"). version_start = None for idx in range(backlog_idx + 1, version_idx + 1): header = lines[section_starts[idx]].strip() if VERSION_RE.match(header): version_start = section_starts[idx] break if version_start is None: print("ERROR: no parseable Version header found", file=sys.stderr) sys.exit(1) version_header = lines[version_start].strip() # --------------------------------------------------------------- # 2. Parse current version from the newest * Version header # --------------------------------------------------------------- vm = VERSION_RE.match(version_header) 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}" # --------------------------------------------------------------- # 3. Collect ** DONE items from the Backlog section # --------------------------------------------------------------- backlog_lines = lines[backlog_start:backlog_end] # 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 = [(s, e) for s, e, is_done in items if is_done] kept_items = [(s, e) for s, e, is_done in items if not is_done] if not done_items: print("No DONE items found in Backlog — nothing to release.") sys.exit(1) # --------------------------------------------------------------- # 4. Build the new Version section text # --------------------------------------------------------------- version_section_lines = [f"* Version {new_version} [{len(done_items)}/{len(done_items)}]\n"] for s, e in done_items: version_section_lines.extend(backlog_lines[s:e]) # --------------------------------------------------------------- # 5. 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) done_count = int(bm.group(1)) total_count = int(bm.group(2)) tags = bm.group(3) new_done = done_count - len(done_items) new_total = total_count - len(done_items) new_backlog_header = f"* Backlog [{new_done}/{new_total}]{tags}\n" backlog_body = [] for s, e in kept_items: backlog_body.extend(backlog_lines[s:e]) # --------------------------------------------------------------- # 6. Assemble the new file # --------------------------------------------------------------- before_backlog = lines[:backlog_start] after_backlog = lines[backlog_end:version_start] # Everything from the first Version section onwards from_version = lines[version_start:] output = ( before_backlog + [new_backlog_header] + backlog_body + version_section_lines + ["\n"] + after_backlog + from_version ) # --------------------------------------------------------------- # 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, ) # --------------------------------------------------------------- # 8. Write files # --------------------------------------------------------------- PROJECT_FILE.write_text("".join(output)) PYPROJECT_FILE.write_text(pyproject) # --------------------------------------------------------------- # 9. Build commit body from done item titles # --------------------------------------------------------------- commit_lines = [] for s, e in done_items: title = parse_done_line(backlog_lines[s]) 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}" # --------------------------------------------------------------- # 10. Git commit + tag # --------------------------------------------------------------- subprocess.run(["git", "add", str(PROJECT_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 Version section.") if __name__ == "__main__": main()