Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions project/ticket-075/README.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions project/ticket-075/intent.json
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 10 additions & 5 deletions src/monag/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
78 changes: 63 additions & 15 deletions src/monag/triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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("<primary>", "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
77 changes: 70 additions & 7 deletions tests/test_triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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):
Expand Down Expand Up @@ -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
Loading