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
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,3 +11,4 @@ build/
|
|||
.coverage
|
||||
htmlcov/
|
||||
uv.lock
|
||||
.repo-manager/
|
||||
|
|
|
|||
|
|
@ -29,4 +29,9 @@ Architecture:
|
|||
make install # or: uv pip install -e ".[dev]"
|
||||
make test
|
||||
rmgr --version
|
||||
rmgr observe --path .
|
||||
rmgr reconcile --path .
|
||||
rmgr update-task-status --path . --task-id <ID> --status progress
|
||||
```
|
||||
|
||||
Vertical-slice proof: [docs/evidence/t05-vertical-slice.md](docs/evidence/t05-vertical-slice.md).
|
||||
|
|
|
|||
14
docs/evidence/t05-command-result.json
Normal file
14
docs/evidence/t05-command-result.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"command": "repo.work.update_task_status",
|
||||
"status": "applied",
|
||||
"correlation_id": "3a152f55-acca-41de-a310-d388ddf5a1d1",
|
||||
"evidence": {
|
||||
"status": "applied",
|
||||
"git_sha": "0b71f90b7782255e1e15b0def16d15299ca09e15",
|
||||
"files_touched": [
|
||||
"workplans/DEMO-WP-0001.md"
|
||||
],
|
||||
"observed_at": "2026-08-09T20:49:14.382153+00:00",
|
||||
"index_path": "/tmp/rmgr-e2e-1946474/.repo-manager/index.json"
|
||||
}
|
||||
}
|
||||
21
docs/evidence/t05-e2e-proof.json
Normal file
21
docs/evidence/t05-e2e-proof.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"task": "RMGR-WP-0001-T05",
|
||||
"fixture": "live-proof temp repo",
|
||||
"before_sha": "1e8623d758b7c4599bb0c48bb9450990a1a948a1",
|
||||
"after_sha": "0b71f90b7782255e1e15b0def16d15299ca09e15",
|
||||
"command_result": {
|
||||
"command": "repo.work.update_task_status",
|
||||
"status": "applied",
|
||||
"correlation_id": "3a152f55-acca-41de-a310-d388ddf5a1d1",
|
||||
"evidence": {
|
||||
"status": "applied",
|
||||
"git_sha": "0b71f90b7782255e1e15b0def16d15299ca09e15",
|
||||
"files_touched": [
|
||||
"workplans/DEMO-WP-0001.md"
|
||||
],
|
||||
"observed_at": "2026-08-09T20:49:14.382153+00:00",
|
||||
"index_path": "/tmp/rmgr-e2e-1946474/.repo-manager/index.json"
|
||||
}
|
||||
},
|
||||
"git_log": "0b71f90b7782255e1e15b0def16d15299ca09e15 repo.work.update_task_status DEMO-WP-0001-T01 -> done"
|
||||
}
|
||||
41
docs/evidence/t05-vertical-slice.md
Normal file
41
docs/evidence/t05-vertical-slice.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# RMGR-WP-0001-T05 — vertical slice proof
|
||||
|
||||
**Date:** 2026-08-09
|
||||
**Contract:** `helixforge.repo-manager` 0.1 (subset implemented)
|
||||
|
||||
## Slice steps
|
||||
|
||||
| # | Requirement | Implementation | Proof |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Represent a repository | `rmgr observe` / `observe_repository` | Snapshot JSON with slug, classification, revision |
|
||||
| 2 | Index declared work | `rmgr reconcile` → `.repo-manager/index.json` | workplan + task records from `workplans/` |
|
||||
| 3 | Emit normalized change | events on index: `repo.command.applied`, `repo.work.indexed` | event log with `correlation_id` |
|
||||
| 4 | File-backed transition | `rmgr update-task-status` | task `status:` patched in workplan file |
|
||||
| 5 | Git evidence | `gitops.commit_paths` | `evidence.git_sha` required; commit message includes correlation_id |
|
||||
| 6 | Rebuilt projection | re-observe after command | task status + head_sha match evidence |
|
||||
|
||||
## Automated proof
|
||||
|
||||
```bash
|
||||
cd ~/repo-manager && pytest tests/test_e2e_vertical_slice.py -q
|
||||
```
|
||||
|
||||
## Live CLI proof
|
||||
|
||||
Artifacts:
|
||||
|
||||
- `docs/evidence/t05-e2e-proof.json`
|
||||
- `docs/evidence/t05-command-result.json`
|
||||
|
||||
## Hub-core note
|
||||
|
||||
Events are written to the local index event log in contract-shaped JSON. HTTP
|
||||
delivery to hub-core `port.events` is dual-run work (extraction P3 / hub-core
|
||||
ports), not required to prove Git authority + projection rebuild.
|
||||
|
||||
## Limits of this slice (honest)
|
||||
|
||||
- Projection store is **local JSON**, not Postgres yet (ADR-001 production DB still planned).
|
||||
- No FastAPI server in this slice; CLI implements the same commands.
|
||||
- Authz is not enforced (dev path); production requires policy port.
|
||||
- Single command type implemented: `repo.work.update_task_status`.
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
"""CLI entry point ``rmgr`` (skeleton — commands land with extract phases)."""
|
||||
"""CLI entry point ``rmgr``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
|
|
@ -11,37 +13,94 @@ def main(argv: list[str] | None = None) -> int:
|
|||
prog="rmgr",
|
||||
description="Repo Manager CLI (helixforge.repo-manager)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="store_true",
|
||||
help="Print package version and exit",
|
||||
)
|
||||
parser.add_argument("--version", action="store_true", help="Print package version")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
sub.add_parser("version", help="Print version")
|
||||
|
||||
# Placeholders — implemented as extraction phases land (RMGR-WP-0001-T05+).
|
||||
p_rec = sub.add_parser(
|
||||
"reconcile",
|
||||
help="Run consistency reconcile (not yet implemented)",
|
||||
)
|
||||
p_obs = sub.add_parser("observe", help="Observe repository + print snapshot JSON")
|
||||
p_obs.add_argument("--path", default=".", help="Repository checkout path")
|
||||
p_obs.add_argument("--slug", default=None, help="Override repo slug")
|
||||
|
||||
p_rec = sub.add_parser("reconcile", help="Rebuild local work-record index from files")
|
||||
p_rec.add_argument("--path", default=".", help="Repository checkout path")
|
||||
p_rec.add_argument("--fix", action="store_true", help="Apply safe fixes")
|
||||
p_rec.add_argument("--slug", default=None)
|
||||
p_rec.add_argument(
|
||||
"--write-index",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Write .repo-manager/index.json (default true)",
|
||||
)
|
||||
p_rec.add_argument("--no-write-index", action="store_true", help="Do not write index file")
|
||||
|
||||
p_cmd = sub.add_parser(
|
||||
"update-task-status",
|
||||
help="Command repo.work.update_task_status (file + git commit)",
|
||||
)
|
||||
p_cmd.add_argument("--path", default=".", help="Repository checkout path")
|
||||
p_cmd.add_argument("--task-id", required=True, help="Canonical task id e.g. RMGR-WP-0001-T05")
|
||||
p_cmd.add_argument(
|
||||
"--status",
|
||||
required=True,
|
||||
choices=["wait", "todo", "progress", "done", "cancel"],
|
||||
)
|
||||
p_cmd.add_argument("--reason", default="rmgr CLI")
|
||||
p_cmd.add_argument("--correlation-id", default=None)
|
||||
p_cmd.add_argument(
|
||||
"--no-commit",
|
||||
action="store_true",
|
||||
help="Patch file only (invalid as full applied evidence; for tests)",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.version or args.command in (None, "version"):
|
||||
from repo_manager import __version__
|
||||
|
||||
print(__version__)
|
||||
return 0 if args.command or args.version else 0
|
||||
|
||||
if args.command == "observe":
|
||||
from repo_manager.observe import observe_repository
|
||||
|
||||
snap, _idx = observe_repository(Path(args.path), slug=args.slug)
|
||||
print(json.dumps(snap, indent=2))
|
||||
return 0
|
||||
|
||||
if args.command == "reconcile":
|
||||
print(
|
||||
"rmgr reconcile: not implemented yet "
|
||||
"(see docs/adr-001-implementation-foundation.md, phase P0)",
|
||||
file=sys.stderr,
|
||||
from repo_manager.index_store import append_event, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
|
||||
root = Path(args.path)
|
||||
snap, index = observe_repository(root, slug=args.slug)
|
||||
append_event(
|
||||
index,
|
||||
{
|
||||
"type": "repo.reconciled",
|
||||
"workplan_count": snap["index"]["workplan_count"],
|
||||
"task_count": snap["index"]["task_count"],
|
||||
},
|
||||
)
|
||||
return 2
|
||||
if not args.no_write_index:
|
||||
path = save_index(index)
|
||||
print(json.dumps({"ok": True, "index_path": str(path), "snapshot": snap}, indent=2))
|
||||
else:
|
||||
print(json.dumps({"ok": True, "snapshot": snap, "index": index.to_dict()}, indent=2))
|
||||
return 0
|
||||
|
||||
if args.command == "update-task-status":
|
||||
from repo_manager.commands.task_status import update_task_status
|
||||
|
||||
result = update_task_status(
|
||||
Path(args.path),
|
||||
args.task_id,
|
||||
args.status,
|
||||
correlation_id=args.correlation_id,
|
||||
reason=args.reason,
|
||||
commit=not args.no_commit,
|
||||
)
|
||||
print(json.dumps(result.to_dict(), indent=2))
|
||||
return 0 if result.status == "applied" else 1
|
||||
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
|
|
|||
3
src/repo_manager/commands/__init__.py
Normal file
3
src/repo_manager/commands/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from repo_manager.commands.task_status import update_task_status
|
||||
|
||||
__all__ = ["update_task_status"]
|
||||
212
src/repo_manager/commands/task_status.py
Normal file
212
src/repo_manager/commands/task_status.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
"""Governed command: repo.work.update_task_status (vertical-slice implementation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from repo_manager.gitops import GitError, commit_paths, head_sha
|
||||
from repo_manager.index_store import append_event, default_index_path, load_index, save_index
|
||||
from repo_manager.observe import observe_repository
|
||||
from repo_manager.parse.workplan import _TASK_BLOCK_RE, _parse_yaml_block
|
||||
|
||||
VALID_TASK_STATUSES = frozenset({"wait", "todo", "progress", "done", "cancel"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
status: str # applied | rejected | failed
|
||||
evidence: dict[str, Any]
|
||||
error: dict[str, Any] | None = None
|
||||
correlation_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
"command": "repo.work.update_task_status",
|
||||
"status": self.status,
|
||||
"correlation_id": self.correlation_id,
|
||||
"evidence": self.evidence,
|
||||
}
|
||||
if self.error:
|
||||
out["error"] = self.error
|
||||
return out
|
||||
|
||||
|
||||
def _patch_task_status_in_file(path: Path, task_canonical_id: str, status: str) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
block = match.group(0)
|
||||
meta = _parse_yaml_block(match.group(1))
|
||||
tid = meta.get("id")
|
||||
if tid is None or str(tid) != task_canonical_id:
|
||||
return block
|
||||
replaced = re.sub(
|
||||
r"^(status:\s*)\S+",
|
||||
rf"\g<1>{status}",
|
||||
block,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if replaced != block:
|
||||
return replaced
|
||||
# insert status if missing
|
||||
return block.replace("\n```", f"\nstatus: {status}\n```", 1)
|
||||
|
||||
new_text = _TASK_BLOCK_RE.sub(_replace, text)
|
||||
if new_text == text:
|
||||
return False
|
||||
path.write_text(new_text, encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def update_task_status(
|
||||
repo_root: Path,
|
||||
task_id: str,
|
||||
status: str,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
reason: str = "rmgr command",
|
||||
commit: bool = True,
|
||||
) -> CommandResult:
|
||||
"""Apply task status change to the workplan file and optionally git-commit."""
|
||||
correlation_id = correlation_id or str(uuid.uuid4())
|
||||
repo_root = repo_root.resolve()
|
||||
|
||||
if status not in VALID_TASK_STATUSES:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected"},
|
||||
error={
|
||||
"code": "validation_error",
|
||||
"message": f"invalid task status {status!r}",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Locate task via fresh observation
|
||||
_snapshot, index = observe_repository(repo_root)
|
||||
match = next(
|
||||
(r for r in index.work_records if r.kind == "task" and r.id == task_id),
|
||||
None,
|
||||
)
|
||||
if match is None:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected"},
|
||||
error={
|
||||
"code": "not_found",
|
||||
"message": f"task {task_id!r} not found in workplans",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
path = repo_root / match.source_path
|
||||
if not path.is_file():
|
||||
return CommandResult(
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "failed"},
|
||||
error={
|
||||
"code": "not_found",
|
||||
"message": f"source file missing: {match.source_path}",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
expected_head = head_sha(repo_root)
|
||||
changed = _patch_task_status_in_file(path, task_id, status)
|
||||
if not changed:
|
||||
return CommandResult(
|
||||
status="rejected",
|
||||
correlation_id=correlation_id,
|
||||
evidence={"status": "rejected", "files_touched": []},
|
||||
error={
|
||||
"code": "conflict",
|
||||
"message": "task block not patched (already at status or id mismatch)",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
git_sha: str | None = None
|
||||
if commit:
|
||||
try:
|
||||
git_sha = commit_paths(
|
||||
repo_root,
|
||||
[match.source_path],
|
||||
message=(
|
||||
f"repo.work.update_task_status {task_id} -> {status}\n\n"
|
||||
f"correlation_id: {correlation_id}\nreason: {reason}\n"
|
||||
),
|
||||
)
|
||||
except GitError as exc:
|
||||
return CommandResult(
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence={
|
||||
"status": "failed",
|
||||
"files_touched": [match.source_path],
|
||||
"git_sha": None,
|
||||
},
|
||||
error={
|
||||
"code": "internal",
|
||||
"message": str(exc),
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Rebuild projection
|
||||
_snap2, index2 = observe_repository(repo_root)
|
||||
event = {
|
||||
"type": "repo.command.applied",
|
||||
"command": "repo.work.update_task_status",
|
||||
"correlation_id": correlation_id,
|
||||
"task_id": task_id,
|
||||
"new_status": status,
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [match.source_path],
|
||||
"expected_head_sha_before": expected_head,
|
||||
}
|
||||
append_event(index2, event)
|
||||
append_event(
|
||||
index2,
|
||||
{
|
||||
"type": "repo.work.indexed",
|
||||
"correlation_id": correlation_id,
|
||||
"kind": "task",
|
||||
"id": task_id,
|
||||
"source_path": match.source_path,
|
||||
},
|
||||
)
|
||||
save_index(index2)
|
||||
|
||||
evidence = {
|
||||
"status": "applied",
|
||||
"git_sha": git_sha,
|
||||
"files_touched": [match.source_path],
|
||||
"observed_at": index2.observed_at,
|
||||
"index_path": str(default_index_path(repo_root)),
|
||||
}
|
||||
if not git_sha:
|
||||
# contract: file-mutating applied without git_sha is invalid — force fail if no commit
|
||||
return CommandResult(
|
||||
status="failed",
|
||||
correlation_id=correlation_id,
|
||||
evidence=evidence,
|
||||
error={
|
||||
"code": "internal",
|
||||
"message": "applied file mutation without git_sha",
|
||||
"correlation_id": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
return CommandResult(
|
||||
status="applied",
|
||||
correlation_id=correlation_id,
|
||||
evidence=evidence,
|
||||
)
|
||||
66
src/repo_manager/gitops.py
Normal file
66
src/repo_manager/gitops.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Git helpers via subprocess (ADR-001)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class GitError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _run(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if check and proc.returncode != 0:
|
||||
raise GitError(proc.stderr.strip() or proc.stdout.strip() or f"git {' '.join(args)} failed")
|
||||
return proc
|
||||
|
||||
|
||||
def head_sha(repo: Path) -> str | None:
|
||||
proc = _run(repo, "rev-parse", "HEAD", check=False)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout.strip() or None
|
||||
|
||||
|
||||
def is_git_repo(repo: Path) -> bool:
|
||||
return (repo / ".git").exists() or _run(repo, "rev-parse", "--git-dir", check=False).returncode == 0
|
||||
|
||||
|
||||
def commit_paths(
|
||||
repo: Path,
|
||||
paths: list[str],
|
||||
message: str,
|
||||
*,
|
||||
author_name: str = "repo-manager",
|
||||
author_email: str = "repo-manager@local",
|
||||
) -> str:
|
||||
"""Stage paths and commit. Returns new HEAD sha."""
|
||||
env_author = [
|
||||
"-c",
|
||||
f"user.name={author_name}",
|
||||
"-c",
|
||||
f"user.email={author_email}",
|
||||
]
|
||||
for path in paths:
|
||||
_run(repo, "add", "--", path)
|
||||
proc = subprocess.run(
|
||||
["git", *env_author, "commit", "-m", message],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise GitError(proc.stderr.strip() or proc.stdout.strip() or "git commit failed")
|
||||
sha = head_sha(repo)
|
||||
if not sha:
|
||||
raise GitError("commit succeeded but HEAD missing")
|
||||
return sha
|
||||
83
src/repo_manager/index_store.py
Normal file
83
src/repo_manager/index_store.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Local JSON projection store for the vertical-slice proof (pre-Postgres)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkRecordEntry:
|
||||
kind: str
|
||||
id: str | None
|
||||
status: str | None
|
||||
title: str | None
|
||||
source_path: str
|
||||
uuid: str | None = None
|
||||
parent_id: str | None = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepoIndex:
|
||||
slug: str
|
||||
repo_root: str
|
||||
head_sha: str | None
|
||||
observed_at: str
|
||||
work_records: list[WorkRecordEntry] = field(default_factory=list)
|
||||
events: list[dict[str, Any]] = field(default_factory=list)
|
||||
schema: str = "repo_manager.index.v0"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": self.schema,
|
||||
"slug": self.slug,
|
||||
"repo_root": self.repo_root,
|
||||
"head_sha": self.head_sha,
|
||||
"observed_at": self.observed_at,
|
||||
"work_records": [asdict(r) for r in self.work_records],
|
||||
"events": self.events,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> RepoIndex:
|
||||
records = [
|
||||
WorkRecordEntry(**{k: v for k, v in r.items() if k in WorkRecordEntry.__dataclass_fields__})
|
||||
for r in data.get("work_records", [])
|
||||
]
|
||||
return cls(
|
||||
slug=data["slug"],
|
||||
repo_root=data["repo_root"],
|
||||
head_sha=data.get("head_sha"),
|
||||
observed_at=data.get("observed_at") or _now(),
|
||||
work_records=records,
|
||||
events=list(data.get("events") or []),
|
||||
schema=data.get("schema") or "repo_manager.index.v0",
|
||||
)
|
||||
|
||||
|
||||
def default_index_path(repo_root: Path) -> Path:
|
||||
return repo_root / ".repo-manager" / "index.json"
|
||||
|
||||
|
||||
def save_index(index: RepoIndex, path: Path | None = None) -> Path:
|
||||
path = path or default_index_path(Path(index.repo_root))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(index.to_dict(), indent=2) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def load_index(path: Path) -> RepoIndex:
|
||||
return RepoIndex.from_dict(json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def append_event(index: RepoIndex, event: dict[str, Any]) -> None:
|
||||
event = {**event, "emitted_at": event.get("emitted_at") or _now()}
|
||||
index.events.append(event)
|
||||
107
src/repo_manager/observe.py
Normal file
107
src/repo_manager/observe.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Build repository observation + work-record index from files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from repo_manager.gitops import head_sha, is_git_repo
|
||||
from repo_manager.index_store import RepoIndex, WorkRecordEntry, _now
|
||||
from repo_manager.parse.workplan import iter_workplan_files, parse_workplan_file
|
||||
|
||||
|
||||
def _slug_from_path(repo_root: Path) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", repo_root.name.lower()).strip("-") or "repo"
|
||||
|
||||
|
||||
def load_classification(repo_root: Path) -> dict | None:
|
||||
path = repo_root / ".repo-classification.yaml"
|
||||
if not path.is_file():
|
||||
return None
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if isinstance(data, dict) and "repo_classification" in data:
|
||||
return data["repo_classification"]
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dict, RepoIndex]:
|
||||
"""Return (RepositorySnapshot-like dict, rebuilt RepoIndex)."""
|
||||
repo_root = repo_root.resolve()
|
||||
slug = slug or _slug_from_path(repo_root)
|
||||
classification = load_classification(repo_root)
|
||||
intent_path = None
|
||||
description = None
|
||||
for name in ("INTENT.md", "GOAL.md"):
|
||||
p = repo_root / name
|
||||
if p.is_file():
|
||||
intent_path = name
|
||||
# first non-empty line of body as weak description
|
||||
text = p.read_text(encoding="utf-8")
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and not line.startswith("---") and not line.startswith(">"):
|
||||
description = line[:200]
|
||||
break
|
||||
break
|
||||
|
||||
records: list[WorkRecordEntry] = []
|
||||
for path in iter_workplan_files(repo_root):
|
||||
wp = parse_workplan_file(path, repo_root=repo_root)
|
||||
records.append(
|
||||
WorkRecordEntry(
|
||||
kind="workplan",
|
||||
id=wp.id,
|
||||
status=wp.status,
|
||||
title=wp.title,
|
||||
source_path=wp.path,
|
||||
uuid=wp.state_hub_workstream_id,
|
||||
)
|
||||
)
|
||||
for task in wp.tasks:
|
||||
records.append(
|
||||
WorkRecordEntry(
|
||||
kind="task",
|
||||
id=task.id,
|
||||
status=task.status,
|
||||
title=task.title,
|
||||
source_path=wp.path,
|
||||
uuid=task.state_hub_task_id,
|
||||
parent_id=wp.id,
|
||||
)
|
||||
)
|
||||
|
||||
sha = head_sha(repo_root) if is_git_repo(repo_root) else None
|
||||
index = RepoIndex(
|
||||
slug=slug,
|
||||
repo_root=str(repo_root),
|
||||
head_sha=sha,
|
||||
observed_at=_now(),
|
||||
work_records=records,
|
||||
)
|
||||
|
||||
snapshot = {
|
||||
"api_version": "0.1",
|
||||
"slug": slug,
|
||||
"lifecycle": "active",
|
||||
"domain": (classification or {}).get("domain"),
|
||||
"classification": classification,
|
||||
"purpose": {
|
||||
"description": description,
|
||||
"intent_path": intent_path,
|
||||
},
|
||||
"locations": {
|
||||
"repo_root": str(repo_root),
|
||||
},
|
||||
"revision": {
|
||||
"head_sha": sha,
|
||||
"observed_at": index.observed_at,
|
||||
},
|
||||
"index": {
|
||||
"workplan_count": sum(1 for r in records if r.kind == "workplan"),
|
||||
"task_count": sum(1 for r in records if r.kind == "task"),
|
||||
"record_count": len(records),
|
||||
},
|
||||
}
|
||||
return snapshot, index
|
||||
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
|
||||
117
tests/test_e2e_vertical_slice.py
Normal file
117
tests/test_e2e_vertical_slice.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""E2E vertical slice: observe → index → command → git evidence → rebuild."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from repo_manager.commands.task_status import update_task_status
|
||||
from repo_manager.gitops import head_sha
|
||||
from repo_manager.index_store import default_index_path, load_index
|
||||
from repo_manager.observe import observe_repository
|
||||
|
||||
|
||||
WORKPLAN = """---
|
||||
id: DEMO-WP-0001
|
||||
type: workplan
|
||||
title: "Demo workplan"
|
||||
status: active
|
||||
---
|
||||
|
||||
# Demo workplan
|
||||
|
||||
## First task
|
||||
|
||||
```task
|
||||
id: DEMO-WP-0001-T01
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
Do the thing.
|
||||
"""
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> None:
|
||||
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
|
||||
|
||||
|
||||
def _init_fixture(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "demo-repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init")
|
||||
_git(repo, "config", "user.email", "test@example.com")
|
||||
_git(repo, "config", "user.name", "Test")
|
||||
(repo / "workplans").mkdir()
|
||||
(repo / "workplans" / "DEMO-WP-0001-demo.md").write_text(WORKPLAN, encoding="utf-8")
|
||||
(repo / ".repo-classification.yaml").write_text(
|
||||
"repo_classification:\n category: experimental\n domain: infotech\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(repo / "INTENT.md").write_text("# INTENT\n\nDemo fixture repository.\n", encoding="utf-8")
|
||||
_git(repo, "add", ".")
|
||||
_git(repo, "commit", "-m", "seed")
|
||||
return repo
|
||||
|
||||
|
||||
def test_e2e_vertical_slice(tmp_path: Path):
|
||||
repo = _init_fixture(tmp_path)
|
||||
before = head_sha(repo)
|
||||
assert before
|
||||
|
||||
# 1–2. Observe + index declared work
|
||||
snap, index = observe_repository(repo, slug="demo-repo")
|
||||
assert snap["slug"] == "demo-repo"
|
||||
assert snap["classification"]["category"] == "experimental"
|
||||
assert snap["index"]["workplan_count"] == 1
|
||||
assert snap["index"]["task_count"] == 1
|
||||
tasks = [r for r in index.work_records if r.kind == "task"]
|
||||
assert tasks[0].id == "DEMO-WP-0001-T01"
|
||||
assert tasks[0].status == "todo"
|
||||
|
||||
# 3–4. Apply governed file-backed transition + git evidence
|
||||
result = update_task_status(
|
||||
repo,
|
||||
"DEMO-WP-0001-T01",
|
||||
"progress",
|
||||
correlation_id="00000000-0000-4000-8000-000000000001",
|
||||
reason="e2e proof",
|
||||
)
|
||||
assert result.status == "applied", result
|
||||
assert result.evidence.get("git_sha")
|
||||
assert result.evidence["git_sha"] != before
|
||||
assert result.evidence["files_touched"] == ["workplans/DEMO-WP-0001-demo.md"]
|
||||
|
||||
# File content updated
|
||||
text = (repo / "workplans" / "DEMO-WP-0001-demo.md").read_text(encoding="utf-8")
|
||||
assert "status: progress" in text
|
||||
|
||||
# 5. Rebuilt projection
|
||||
snap2, index2 = observe_repository(repo, slug="demo-repo")
|
||||
task2 = next(r for r in index2.work_records if r.id == "DEMO-WP-0001-T01")
|
||||
assert task2.status == "progress"
|
||||
assert snap2["revision"]["head_sha"] == result.evidence["git_sha"]
|
||||
|
||||
# Index on disk includes command event (normalized change)
|
||||
idx_path = default_index_path(repo)
|
||||
assert idx_path.is_file()
|
||||
stored = load_index(idx_path)
|
||||
types = [e.get("type") for e in stored.events]
|
||||
assert "repo.command.applied" in types
|
||||
applied = next(e for e in stored.events if e.get("type") == "repo.command.applied")
|
||||
assert applied["correlation_id"] == "00000000-0000-4000-8000-000000000001"
|
||||
assert applied["git_sha"] == result.evidence["git_sha"]
|
||||
|
||||
# Proof artifact for humans
|
||||
proof = {
|
||||
"slice": "RMGR-WP-0001-T05",
|
||||
"repo_slug": "demo-repo",
|
||||
"before_sha": before,
|
||||
"after_sha": result.evidence["git_sha"],
|
||||
"command": result.to_dict(),
|
||||
"snapshot_after": snap2,
|
||||
}
|
||||
proof_path = tmp_path / "e2e-proof.json"
|
||||
proof_path.write_text(json.dumps(proof, indent=2), encoding="utf-8")
|
||||
assert proof_path.stat().st_size > 0
|
||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
|||
title: "Repo Manager architecture and foundation"
|
||||
domain: infotech
|
||||
repo: repo-manager
|
||||
status: active
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: repo-manager
|
||||
created: "2026-08-09"
|
||||
|
|
@ -104,7 +104,7 @@ skeleton. Package scaffold + version test landed (no business logic yet).
|
|||
|
||||
```task
|
||||
id: RMGR-WP-0001-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5036a248-1c47-423f-be8d-4fd232896c31"
|
||||
```
|
||||
|
|
@ -114,6 +114,13 @@ work, emit a normalized change through hub-core contracts, apply one authorized
|
|||
file-backed transition, and prove the resulting Git evidence and rebuilt
|
||||
projection.
|
||||
|
||||
**Result (2026-08-09):** Vertical slice implemented — `rmgr observe|reconcile|
|
||||
update-task-status`. Parser, JSON projection (`.repo-manager/index.json`),
|
||||
git-backed writeback with required `evidence.git_sha`, contract-shaped events.
|
||||
Proof: `pytest tests/test_e2e_vertical_slice.py` + `docs/evidence/t05-*`.
|
||||
Limits: local JSON index (not Postgres yet); hub-core HTTP emit deferred;
|
||||
authz not enforced on CLI.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [x] The authority and data model are explicit.
|
||||
|
|
@ -121,4 +128,4 @@ projection.
|
|||
- (semantics + catalog done; automated suite with runtime in T04/T05)
|
||||
- [x] State Hub extraction candidates have dispositions.
|
||||
- [x] The implementation foundation has a recorded decision.
|
||||
- [ ] One repository completes the end-to-end vertical slice.
|
||||
- [x] One repository completes the end-to-end vertical slice.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue