playbook/scripts/main_loop.py

316 lines
9.8 KiB
Python

#!/usr/bin/env python3
import platform
import re
import sys
from pathlib import Path
from typing import Optional
PLAN_STATUS_START = "<!-- plan-status:start -->"
PLAN_STATUS_END = "<!-- plan-status:end -->"
PLAN_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-.+\.md$")
PLAN_LINE_RE = re.compile(
r"^- \[(?P<check>[ xX])\] `(?P<plan>[^`]+)` "
r"(?P<status>done|blocked|pending|in-progress|skipped)"
r"(?:: (?P<note>.*))?$"
)
FINISH_STATUSES = {"done", "blocked", "skipped"}
ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$")
def usage() -> str:
return (
"Usage:\n"
" python scripts/main_loop.py claim -plans <dir> -progress <file>\n"
" python scripts/main_loop.py finish -plan <path> -status <status> "
"-progress <file> [-note <text>]\n"
" python scripts/main_loop.py -h\n"
"Options:\n"
" -plans DIR\n"
" -plan PATH\n"
" -status done|blocked|skipped\n"
" -progress FILE\n"
" -note TEXT\n"
" -h, -help Show this help.\n"
)
def parse_flags(args: list[str]) -> dict[str, str]:
flags: dict[str, str] = {}
idx = 0
while idx < len(args):
arg = args[idx]
if arg in ("-h", "-help"):
raise ValueError("help")
if not arg.startswith("-"):
raise ValueError(f"unexpected arg: {arg}")
if idx + 1 >= len(args):
raise ValueError(f"missing value for {arg}")
flags[arg] = args[idx + 1]
idx += 2
return flags
def normalize_plan_key(plan_value: str) -> str:
raw = plan_value.strip().replace("\\", "/")
raw = raw.lstrip("./")
if raw.startswith("docs/plans/"):
return raw[len("docs/plans/") :]
marker = "/docs/plans/"
if marker in raw:
return raw.split(marker, 1)[1]
return raw
def normalize_note(note: str) -> str:
return note.replace("\n", " ").replace("\r", " ").replace("`", "'").strip()
def render_plan_line(plan_key: str, status: str, note: Optional[str]) -> str:
checked = "x" if status == "done" else " "
suffix = status
if note:
suffix += f": {note}"
return f"- [{checked}] `{plan_key}` {suffix}"
def list_plan_files(plans_dir: Path) -> list[str]:
entries: list[str] = []
for path in plans_dir.iterdir():
if not path.is_file():
continue
if not PLAN_FILE_RE.match(path.name):
continue
entries.append(path.name)
return sorted(entries)
def find_block(lines: list[str]) -> Optional[tuple[int, int]]:
start_idx = None
for idx, line in enumerate(lines):
if line.strip() == PLAN_STATUS_START:
start_idx = idx
break
if start_idx is None:
return None
for idx in range(start_idx + 1, len(lines)):
if lines[idx].strip() == PLAN_STATUS_END:
return start_idx, idx
return None
def parse_entries(
lines: list[str], start_idx: int, end_idx: int
) -> list[tuple[str, str, Optional[str], int]]:
entries: list[tuple[str, str, Optional[str], int]] = []
for idx in range(start_idx + 1, end_idx):
line = lines[idx].strip()
match = PLAN_LINE_RE.match(line)
if not match:
continue
plan_key = normalize_plan_key(match.group("plan"))
status = match.group("status")
note = match.group("note")
entries.append((plan_key, status, note, idx))
return entries
def render_progress_lines(plans: list[str]) -> list[str]:
lines = ["# Plan 状态", "", PLAN_STATUS_START]
for plan_key in plans:
lines.append(render_plan_line(plan_key, "pending", None))
lines.append(PLAN_STATUS_END)
return lines
def parse_env_blocked_note(note: Optional[str]) -> Optional[tuple[str, str]]:
if not note:
return None
match = ENV_BLOCKED_RE.match(note)
if not match:
return None
return match.group(1), match.group(2)
def detect_env() -> Optional[str]:
mapping = {"windows": "windows", "linux": "linux", "darwin": "darwin"}
return mapping.get(platform.system().lower())
def load_progress_lines(progress_path: Path) -> list[str]:
progress_path.parent.mkdir(parents=True, exist_ok=True)
if progress_path.exists():
return progress_path.read_text(encoding="utf-8").splitlines()
return []
def write_progress_lines(progress_path: Path, lines: list[str]) -> None:
progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def ensure_plan_block(
lines: list[str], progress_path: Path, plan_keys: list[str]
) -> tuple[list[str], int, int]:
block = find_block(lines)
if not block:
lines = render_progress_lines(plan_keys)
write_progress_lines(progress_path, lines)
block = find_block(lines)
if not block:
raise ValueError("failed to create plan status block")
return lines, block[0], block[1]
def ensure_all_plans_present(
lines: list[str], start_idx: int, end_idx: int, progress_path: Path, plan_keys: list[str]
) -> list[tuple[str, str, Optional[str], int]]:
entries = parse_entries(lines, start_idx, end_idx)
existing = {plan_key for plan_key, _, _, _ in entries}
missing = [plan_key for plan_key in plan_keys if plan_key not in existing]
if missing:
insert_lines = [render_plan_line(plan_key, "pending", None) for plan_key in missing]
lines[end_idx:end_idx] = insert_lines
write_progress_lines(progress_path, lines)
end_idx += len(insert_lines)
entries = parse_entries(lines, start_idx, end_idx)
return entries
def choose_claim_entry(
entries: list[tuple[str, str, Optional[str], int]], current_env: Optional[str]
) -> Optional[tuple[str, Optional[str], int]]:
for plan_key, status, note, idx in entries:
if status == "in-progress":
return plan_key, note, idx
for plan_key, status, note, idx in entries:
if status == "pending":
return plan_key, note, idx
if current_env:
for plan_key, status, note, idx in entries:
if status != "blocked":
continue
env_info = parse_env_blocked_note(note)
if env_info and env_info[0] == current_env:
return plan_key, note, idx
return None
def claim_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
if not plans_dir.is_dir():
return 2, f"ERROR: plans dir not found: {plans_dir}"
plan_keys = list_plan_files(plans_dir)
if not plan_keys:
return 2, "ERROR: no plan files found"
lines = load_progress_lines(progress_path)
try:
lines, start_idx, end_idx = ensure_plan_block(lines, progress_path, plan_keys)
except ValueError as exc:
return 2, f"ERROR: {exc}"
entries = ensure_all_plans_present(lines, start_idx, end_idx, progress_path, plan_keys)
chosen = choose_claim_entry(entries, detect_env())
if not chosen:
return 2, "ERROR: no claimable plans"
plan_key, note, idx = chosen
lines[idx] = render_plan_line(plan_key, "in-progress", note)
write_progress_lines(progress_path, lines)
output = [f"PLAN={(plans_dir / plan_key).as_posix()}"]
if note:
output.append(f"NOTE={note}")
return 0, "\n".join(output)
def finish_plan(
plan: str, status: str, progress_path: Path, note: Optional[str]
) -> tuple[int, str]:
if status not in FINISH_STATUSES:
return 2, f"ERROR: invalid status: {status}"
if not plan:
return 2, "ERROR: plan is required"
lines = load_progress_lines(progress_path)
plan_key = normalize_plan_key(plan)
try:
lines, start_idx, end_idx = ensure_plan_block(lines, progress_path, [plan_key])
except ValueError as exc:
return 2, f"ERROR: {exc}"
entries = parse_entries(lines, start_idx, end_idx)
rendered_note = normalize_note(note) if note else None
updated_line = render_plan_line(plan_key, status, rendered_note)
for entry_plan, _, _, idx in entries:
if entry_plan == plan_key:
lines[idx] = updated_line
write_progress_lines(progress_path, lines)
return 0, updated_line
lines[end_idx:end_idx] = [updated_line]
write_progress_lines(progress_path, lines)
return 0, updated_line
def main(argv: list[str]) -> int:
if not argv:
print(usage(), file=sys.stderr)
return 2
if argv[0] in ("-h", "-help"):
print(usage())
return 0
mode = argv[0]
if mode not in {"claim", "finish"}:
print(f"ERROR: unknown mode: {mode}", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
try:
flags = parse_flags(argv[1:])
except ValueError as exc:
if str(exc) == "help":
print(usage())
return 0
print(f"ERROR: {exc}", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
if mode == "claim":
plans = flags.get("-plans")
progress = flags.get("-progress")
if not plans or not progress:
print("ERROR: -plans and -progress are required", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
code, message = claim_plan(Path(plans), Path(progress))
if code != 0:
print(message, file=sys.stderr)
return code
print(message)
return 0
plan = flags.get("-plan")
status = flags.get("-status")
progress = flags.get("-progress")
note = flags.get("-note")
if not plan or not status or not progress:
print("ERROR: -plan, -status, and -progress are required", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
code, message = finish_plan(plan, status, Path(progress), note)
if code != 0:
print(message, file=sys.stderr)
return code
print(message)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))