feat(RMGR-WP-0001): complete T05 end-to-end vertical slice
Implement observe/reconcile/update-task-status CLI path: workplan parse, JSON projection index, git-backed task status writeback with correlation events, and E2E pytest plus evidence artifacts. Finish foundation workplan.
This commit is contained in:
parent
3cc67a9bd0
commit
8b5634ff2a
15 changed files with 873 additions and 20 deletions
3
src/repo_manager/parse/__init__.py
Normal file
3
src/repo_manager/parse/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from repo_manager.parse.workplan import ParsedTask, ParsedWorkplan, parse_workplan_file
|
||||
|
||||
__all__ = ["ParsedTask", "ParsedWorkplan", "parse_workplan_file"]
|
||||
114
src/repo_manager/parse/workplan.py
Normal file
114
src/repo_manager/parse/workplan.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Workplan file parser (P0 extract — simplified from State Hub consistency_check)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
|
||||
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
|
||||
|
||||
|
||||
def _parse_yaml_block(raw: str) -> dict[str, Any]:
|
||||
try:
|
||||
data = yaml.safe_load(raw) or {}
|
||||
except yaml.YAMLError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
if not text.startswith("---"):
|
||||
return {}, text
|
||||
parts = text.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
return {}, text
|
||||
return _parse_yaml_block(parts[1].strip()), parts[2]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedTask:
|
||||
id: str | None
|
||||
title: str | None
|
||||
status: str | None
|
||||
state_hub_task_id: str | None
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedWorkplan:
|
||||
path: str
|
||||
id: str | None
|
||||
title: str | None
|
||||
status: str | None
|
||||
state_hub_workstream_id: str | None
|
||||
frontmatter: dict[str, Any]
|
||||
tasks: list[ParsedTask]
|
||||
|
||||
|
||||
def parse_task_blocks(body: str) -> list[ParsedTask]:
|
||||
headings = [
|
||||
(m.start(), len(m.group(1)), m.group(2).strip())
|
||||
for m in _HEADING_RE.finditer(body)
|
||||
]
|
||||
results: list[ParsedTask] = []
|
||||
for m in _TASK_BLOCK_RE.finditer(body):
|
||||
meta = _parse_yaml_block(m.group(1).strip())
|
||||
prev = [(pos, level, text) for pos, level, text in headings if pos < m.start()]
|
||||
title = meta.get("title")
|
||||
if not title and prev:
|
||||
title = prev[-1][2]
|
||||
results.append(
|
||||
ParsedTask(
|
||||
id=str(meta["id"]) if meta.get("id") is not None else None,
|
||||
title=str(title) if title else None,
|
||||
status=str(meta["status"]) if meta.get("status") is not None else None,
|
||||
state_hub_task_id=(
|
||||
str(meta["state_hub_task_id"]).strip().strip('"')
|
||||
if meta.get("state_hub_task_id") is not None
|
||||
else None
|
||||
),
|
||||
raw=meta,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def parse_workplan_text(text: str, *, relative_path: str) -> ParsedWorkplan:
|
||||
fm, body = parse_frontmatter(text)
|
||||
return ParsedWorkplan(
|
||||
path=relative_path,
|
||||
id=str(fm["id"]) if fm.get("id") is not None else None,
|
||||
title=str(fm["title"]) if fm.get("title") is not None else None,
|
||||
status=str(fm["status"]) if fm.get("status") is not None else None,
|
||||
state_hub_workstream_id=(
|
||||
str(fm["state_hub_workstream_id"]).strip().strip('"')
|
||||
if fm.get("state_hub_workstream_id") is not None
|
||||
else None
|
||||
),
|
||||
frontmatter=fm,
|
||||
tasks=parse_task_blocks(body),
|
||||
)
|
||||
|
||||
|
||||
def parse_workplan_file(path: Path, *, repo_root: Path) -> ParsedWorkplan:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = str(path.relative_to(repo_root))
|
||||
return parse_workplan_text(text, relative_path=rel)
|
||||
|
||||
|
||||
def iter_workplan_files(repo_root: Path) -> list[Path]:
|
||||
wp_dir = repo_root / "workplans"
|
||||
if not wp_dir.is_dir():
|
||||
return []
|
||||
files: list[Path] = []
|
||||
for p in sorted(wp_dir.rglob("*.md")):
|
||||
if p.name.startswith("."):
|
||||
continue
|
||||
# skip archived copies optionally still include them
|
||||
files.append(p)
|
||||
return files
|
||||
Loading…
Add table
Add a link
Reference in a new issue