diff --git a/project/ticket-075/README.md b/project/ticket-075/README.md new file mode 100644 index 0000000..a6106a0 --- /dev/null +++ b/project/ticket-075/README.md @@ -0,0 +1,15 @@ +# ticket-075 — Persist triage feed per project + +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Owner**: codex:monag-triage-cache-20260919 + +SESSION_EXECUTION_AUTHORIZATION: execute confirmed report/cache defects; Planfile PLF-027, GitHub sync pending quota reset. + +Scope: persist deduplicated Planfile records in each existing owning project, validate paths, report actual IDs and partial failures. No implicit remote sync or allocation of implementation ownership. + +- [x] AC-01: Feed persists and reads back project-local ticket IDs using native Planfile. +- [x] AC-02: Repeat feed deduplicates; missing projects/dependencies and partial failures are truthful. +- [x] AC-03: CLI output reflects persisted results; full tests/governance pass. + +Validation: 343 full pytest tests and 19 focused triage/feed tests pass. Native installed Planfile persisted two separate project-local IDs and returned those same IDs on repeat. Governance, Ruff and whitespace checks pass. diff --git a/project/ticket-075/intent.json b/project/ticket-075/intent.json new file mode 100644 index 0000000..b7d07b2 --- /dev/null +++ b/project/ticket-075/intent.json @@ -0,0 +1,24 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-075", + "summary": "Persist triage feed in each owning project Planfile", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-075/**", + "src/monag/cli.py", + "src/monag/triage.py", + "tests/test_triage.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null +} diff --git a/src/monag/cli.py b/src/monag/cli.py index 95c80ba..d43a029 100644 --- a/src/monag/cli.py +++ b/src/monag/cli.py @@ -720,12 +720,17 @@ def display_report(document): return 0 if getattr(args, 'feed_planfile', False): feed_res = triage.feed_to_planfile(data, root=root, sprint=getattr(args, 'sprint', 'current')) - if feed_res.get('success'): - print(f"Planfile: wygenerowano {feed_res.get('tasks_count', 0)} zadań dla sprintu '{args.sprint}'.") - return 0 + if output_format == 'json': + print(json.dumps(feed_res, ensure_ascii=False, indent=2)) else: - print(f"Planfile feed FAILED: {feed_res.get('reason')}", file=sys.stderr) - return 1 + print(f"Planfile: potwierdzono zapis {feed_res.get('tasks_count', 0)} ticketów dla sprintu '{args.sprint}'.") + for ticket in feed_res.get('tickets', []): + print(f" {ticket['repository']}: {ticket['id']}") + for error in feed_res.get('errors', []): + print(f" {error['repository']}: {error['error']}", file=sys.stderr) + if not feed_res.get('success'): + print(f"Planfile feed FAILED: {feed_res.get('reason')}", file=sys.stderr) + return 0 if feed_res.get('success') else 1 if output_format == 'json': print(json.dumps(data, ensure_ascii=False, indent=2)) else: diff --git a/src/monag/triage.py b/src/monag/triage.py index 88928f5..ffb0009 100644 --- a/src/monag/triage.py +++ b/src/monag/triage.py @@ -7,6 +7,9 @@ from __future__ import annotations from datetime import datetime, timezone +import hashlib +import json +import shutil from pathlib import Path import shlex import subprocess @@ -445,19 +448,64 @@ def export_planfile_tasks(report: Dict[str, Any]) -> List[Dict[str, Any]]: def feed_to_planfile(report: Dict[str, Any], root: Path, sprint: str = "current") -> Dict[str, Any]: - """Ingest guidance steps into workspace Planfile backlog/sprint.""" + """Persist project-local inspection tickets through the installed Planfile CLI.""" tasks = export_planfile_tasks(report) - planfile_dir = root / ".planfile" - if not planfile_dir.is_dir(): - planfile_dir = root / "semcod" / "monag" / ".planfile" - - if not planfile_dir.is_dir(): - return {"success": False, "reason": "No .planfile directory found", "tasks_count": len(tasks)} - - sprint_file = planfile_dir / "sprints" / f"{sprint}.yaml" - return { - "success": True, - "target": str(sprint_file), - "tasks_count": len(tasks), - "tasks": tasks, - } + result = {"success": True, "tasks_count": 0, "requested_count": len(tasks), + "tickets": [], "errors": [], "remote_sync_performed": False} + if not tasks: + return result + binary = shutil.which("planfile") + if not binary: + return dict(result, success=False, reason="planfile CLI not found on PATH") + root = root.resolve() + for task in tasks: + repository = task["repository"] + try: + relative = Path(repository) + if not repository or relative.is_absolute() or ".." in relative.parts: + raise ValueError("invalid project path") + project = (root / relative).resolve() + if (root / ".git").exists() and repository == f"{root.parent.name}/{root.name}": + project = root + if not project.is_relative_to(root): + raise ValueError("project escapes scan root") + if not (project / ".git").exists() or not (project / ".planfile").is_dir(): + raise ValueError("owning project Git checkout and .planfile are required") + digest = hashlib.sha256(json.dumps( + [repository, task["title"], task["command"]], ensure_ascii=False + ).encode()).hexdigest()[:32] + label = "dedupe:monag-" + digest + description = "\n".join([task["description"], task["command"], *task["guardrails"]]) + description = description.replace("", "registered primary checkout") + priority = {"P0": "critical", "P1": "high"}.get(task["priority"], "normal") + create = subprocess.run( + [binary, "ticket", "create", "--priority", priority, "--sprint", sprint, + "--source", "monag-triage", "--label", label, + "--description", description, "--", task["title"]], + cwd=str(project), capture_output=True, text=True, timeout=30, + ) + if create.returncode: + raise ValueError(f"Planfile creation failed (exit {create.returncode})") + readback = subprocess.run( + [binary, "ticket", "list", "--sprint", "all", "--label", label, "--format", "json"], + cwd=str(project), capture_output=True, text=True, timeout=30, + ) + if readback.returncode: + raise ValueError(f"Planfile readback failed (exit {readback.returncode})") + records = json.loads(readback.stdout) + if not isinstance(records, list): + raise ValueError("Planfile readback must be a ticket list") + matches = [row for row in records if isinstance(row, dict) + and label in row.get("labels", []) and row.get("id") + and row.get("status") not in {"done", "canceled"}] + if len(matches) != 1: + raise ValueError("Planfile did not return one persisted dedupe owner") + result["tickets"].append({"repository": repository, "id": matches[0]["id"], + "project": str(project)}) + result["tasks_count"] += 1 + except (OSError, ValueError, subprocess.TimeoutExpired) as error: + result["errors"].append({"repository": repository, "error": str(error)}) + result["success"] = not result["errors"] + if result["errors"]: + result["reason"] = "Some tickets could not be persisted or verified; inspect errors and retry safely" + return result diff --git a/tests/test_triage.py b/tests/test_triage.py index f7ca45c..6cd4d01 100644 --- a/tests/test_triage.py +++ b/tests/test_triage.py @@ -134,7 +134,7 @@ def test_guidance_synthesis_and_markdown(): assert "🛡️ Safe" not in md -def test_planfile_export_and_feed(tmp_path): +def test_planfile_export_and_feed(tmp_path, monkeypatch): """Verify exporting guidance steps to Planfile format.""" mock_report = { "guidance_steps": [ @@ -156,12 +156,11 @@ def test_planfile_export_and_feed(tmp_path): assert tasks[0]["repository"] == "semcod/algocode" assert tasks[0]["priority"] == "P1" - # Feed to planfile test - planfile_dir = tmp_path / ".planfile" / "sprints" - planfile_dir.mkdir(parents=True) - res = triage.feed_to_planfile(mock_report, root=tmp_path, sprint="current") - assert res["success"] - assert res["tasks_count"] == 1 + monkeypatch.setattr(triage.shutil, "which", lambda _: None) + result = triage.feed_to_planfile(mock_report, root=tmp_path) + assert not result["success"] + assert result["tasks_count"] == 0 + assert result["requested_count"] == 1 def test_advise_holistic_delegation(tmp_path): @@ -259,3 +258,67 @@ def test_suggested_inspection_command_quotes_checkout_path(): path = "/workspace/a $(touch bad); repo" result = triage.synthesize_triage_action({"path": path}, "", "org/repo", {}) assert shlex.split(result["suggested_command"]) == ["git", "-C", path, "status", "--short"] + + + +def feed_fixture(*repositories): + return {"guidance_steps": [{"category": triage.CATEGORY_CORE_FOUNDATION, + "repo": repository, "title": "Inspect local evidence", "action": "Review ownership", + "command": "git status --short", "guardrails": [], "score": 1200} + for repository in repositories]} + + +def test_feed_routes_projects_and_reads_back_ids(tmp_path, monkeypatch): + import subprocess + monkeypatch.setattr(triage.shutil, "which", lambda _: "/bin/planfile-fixture") + for name in ["one", "two"]: + root = tmp_path / "org" / name + (root / ".git").mkdir(parents=True) + (root / ".planfile").mkdir() + stored = {} + def native(args, cwd, **kwargs): + assert "--sync" not in args + label = args[args.index("--label") + 1] + if args[2] == "create": + stored.setdefault((cwd, label), {"id": "PLF-001", "labels": [label], "status": "open"}) + return subprocess.CompletedProcess(args, 0, "Created", "") + return subprocess.CompletedProcess(args, 0, json.dumps([stored[(cwd, label)]]), "") + monkeypatch.setattr(triage.subprocess, "run", native) + for _ in range(2): + result = triage.feed_to_planfile(feed_fixture("org/one", "org/two"), tmp_path) + assert result["success"] + assert result["tasks_count"] == 2 + assert {row["repository"] for row in result["tickets"]} == {"org/one", "org/two"} + assert len(stored) == 2 + + +def test_feed_rejects_missing_and_escaping_projects(tmp_path, monkeypatch): + monkeypatch.setattr(triage.shutil, "which", lambda _: "/bin/planfile-fixture") + with mock.patch.object(triage.subprocess, "run") as run: + result = triage.feed_to_planfile(feed_fixture("org/missing", "../outside", "/absolute"), tmp_path) + assert not result["success"] + assert len(result["errors"]) == 3 + assert result["tasks_count"] == 0 + run.assert_not_called() + + +def test_feed_cannot_claim_success_from_command_exit_alone(tmp_path, monkeypatch): + import subprocess + monkeypatch.setattr(triage.shutil, "which", lambda _: "/bin/planfile-fixture") + (tmp_path / "org/repo/.git").mkdir(parents=True) + (tmp_path / "org/repo/.planfile").mkdir() + with mock.patch.object(triage.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "[]", "")): + result = triage.feed_to_planfile(feed_fixture("org/repo"), tmp_path) + assert not result["success"] + assert result["tasks_count"] == 0 + assert "persisted dedupe owner" in result["errors"][0]["error"] + + +def test_feed_cli_json_reports_partial_failure(tmp_path, capsys): + from monag import cli + result = {"success": False, "tasks_count": 1, "tickets": [{"repository": "org/repo", "id": "PLF-001"}], + "errors": [{"repository": "org/missing", "error": "missing store"}]} + with mock.patch.object(triage, "run_holistic_triage", return_value={}): + with mock.patch.object(triage, "feed_to_planfile", return_value=result): + assert cli.main(["--root", str(tmp_path), "--json", "triage", "--feed-planfile"]) == 1 + assert json.loads(capsys.readouterr().out) == result