diff --git a/PROJECT.org b/PROJECT.org index 13f973d..4a45d34 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -93,7 +93,7 @@ fetching and simple saving. :LOGBOOK: CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20 :END: -* Backlog [37/53] :vrobbler:project:personal: +* Backlog [0/16] :vrobbler:project:personal: ** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment: :PROPERTIES: :ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85 @@ -489,6 +489,21 @@ to GMT to save it in the database. It would be nice to not duplicate comments that exist on a task when it's first scrobbled. +* Version 38.0 [38/38] +** DONE [#A] Fix release flow to be easier to trigger :pyproject:release:tooling: +:PROPERTIES: +:ID: e321f8a3-26ea-b316-8493-294b50c0b516 +:END: + +*** Description + +We should have a command like `just relesae ` that can cut new +releases on command by updating the PROJECT.org file with a release header for +all DONE tasks and creating the appropriate release commits and tags based on +either the new release version type. + +It should also update the pyproject file. + ** DONE [#A] Move imported retroarch lrtl files to processed/ directory on WebDAV :webdav:retroarch:importers: :PROPERTIES: :ID: 881b5af6-e96f-4e71-9e66-2d30824e5504 @@ -864,6 +879,7 @@ urllib.error.HTTPError: HTTP Error 500: Internal Server Error - Note taken on [2025-09-30 Tue 09:33] This may have already been resolved ... need to just confirm it. + * Version 37.0 [4/4] ** DONE [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks: :PROPERTIES: diff --git a/justfile b/justfile index 8f989cf..1e33b8c 100644 --- a/justfile +++ b/justfile @@ -14,3 +14,6 @@ celery: celery-beat: poetry run celery -A vrobbler beat -l info + +release kind="minor": + poetry run python scripts/release.py {{kind}} diff --git a/pyproject.toml b/pyproject.toml index 9a41142..bbae5a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "vrobbler" -version = "0.16.1" +version = "38.0" description = "" authors = ["Colin Powell "] diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..5d593b5 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,217 @@ +#!/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(0) + + # --------------------------------------------------------------- + # 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()