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/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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue