Split version history out of PROJECT.org into CHANGELOG.org
This commit is contained in:
@ -12,11 +12,11 @@ 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+\]")
|
||||
DONE_HEADER_RE = re.compile(r"^(\*\* DONE\s+)(.*)$")
|
||||
ITEM_HEADER_RE = re.compile(r"^\*\* ")
|
||||
|
||||
|
||||
@ -39,6 +39,35 @@ def bump_version(current_major, current_minor, kind):
|
||||
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]} <major|minor>", file=sys.stderr)
|
||||
@ -46,56 +75,25 @@ def main():
|
||||
|
||||
kind = sys.argv[1]
|
||||
|
||||
lines = PROJECT_FILE.read_text().splitlines(keepends=True)
|
||||
changelog_lines = CHANGELOG_FILE.read_text().splitlines(keepends=True)
|
||||
project_lines = PROJECT_FILE.read_text().splitlines(keepends=True)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. Identify top-level sections
|
||||
# 1. Parse current version from the first * Version header in CHANGELOG.org
|
||||
# ---------------------------------------------------------------
|
||||
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]
|
||||
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", file=sys.stderr)
|
||||
print(
|
||||
"ERROR: no parseable Version header found in CHANGELOG.org", 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)
|
||||
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)
|
||||
@ -104,73 +102,80 @@ def main():
|
||||
new_version = f"{new_major}.{new_minor}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. Collect ** DONE items from the Backlog section
|
||||
# 2. Identify the Backlog section in PROJECT.org
|
||||
# ---------------------------------------------------------------
|
||||
backlog_lines = lines[backlog_start:backlog_end]
|
||||
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))
|
||||
|
||||
# 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")))
|
||||
backlog_idx = None
|
||||
for idx, start in enumerate(section_starts[:-1]):
|
||||
if project_lines[start].strip().startswith("* Backlog"):
|
||||
backlog_idx = idx
|
||||
|
||||
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 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)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. Build the new Version section text
|
||||
# 3. 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])
|
||||
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")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 5. Build updated Backlog section
|
||||
# 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)
|
||||
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_done = 0
|
||||
new_total = len(kept_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])
|
||||
for item in kept_items:
|
||||
backlog_body.extend(item)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 6. Assemble the new file
|
||||
# 5. Assemble the new PROJECT.org (Backlog + DONE items removed)
|
||||
# ---------------------------------------------------------------
|
||||
before_backlog = lines[:backlog_start]
|
||||
after_backlog = lines[backlog_end:version_start]
|
||||
before_backlog = project_lines[:backlog_start]
|
||||
output_project = before_backlog + [new_backlog_header] + backlog_body
|
||||
PROJECT_FILE.write_text("".join(output_project))
|
||||
|
||||
# Everything from the first Version section onwards
|
||||
from_version = lines[version_start:]
|
||||
|
||||
output = (
|
||||
before_backlog
|
||||
+ [new_backlog_header]
|
||||
+ backlog_body
|
||||
# ---------------------------------------------------------------
|
||||
# 6. Insert the new Version section into CHANGELOG.org after the title
|
||||
# ---------------------------------------------------------------
|
||||
output_changelog = (
|
||||
changelog_lines[:version_start]
|
||||
+ version_section_lines
|
||||
+ ["\n"]
|
||||
+ after_backlog
|
||||
+ from_version
|
||||
+ changelog_lines[version_start:]
|
||||
)
|
||||
CHANGELOG_FILE.write_text("".join(output_changelog))
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 7. Update pyproject.toml
|
||||
@ -183,19 +188,14 @@ def main():
|
||||
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
|
||||
# 8. Build commit body from done item titles
|
||||
# ---------------------------------------------------------------
|
||||
commit_lines = []
|
||||
for s, e in done_items:
|
||||
title = parse_done_line(backlog_lines[s])
|
||||
for item in done_items:
|
||||
title = parse_done_line(item[0])
|
||||
if title:
|
||||
commit_lines.append(f"- {title}")
|
||||
|
||||
@ -203,14 +203,17 @@ def main():
|
||||
commit_message = f"[release] Bump to version {new_version}\n\n{commit_body}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 10. Git commit + tag
|
||||
# 9. Git commit + tag
|
||||
# ---------------------------------------------------------------
|
||||
subprocess.run(["git", "add", str(PROJECT_FILE), str(PYPROJECT_FILE)], check=True)
|
||||
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 Version section.")
|
||||
print(f"Moved {len(done_items)} DONE item(s) from Backlog to CHANGELOG.org.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user