diff --git a/docs/mcp-menu.md b/docs/mcp-menu.md index e028a40..a88d304 100644 --- a/docs/mcp-menu.md +++ b/docs/mcp-menu.md @@ -40,6 +40,7 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with | Option | Description | |--------|-------------| | Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. | +| Workflow dashboard (queue, leases, next safe action) | Documents the read-only `gitea_workflow_dashboard` MCP tool (#605): live PR/issue queues, leases by role, terminal review lock, blocked items, and exact next-safe prompts. **Does not assign work** — assignment still uses `gitea_allocate_next_work`. Never presents blocked/terminal-locked items as safe. The shell entry is documentation only (no Gitea mutation). | | Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. | | Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). | | Merger workflow prompts | PR merge prompt (merge gates and explicit approval). | @@ -50,6 +51,22 @@ The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with | Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. | | Exit | Quit the menu. | +### Workflow dashboard MCP tool (#605) + +From any healthy Gitea MCP namespace with `gitea.read`: + +```text +gitea_workflow_dashboard( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", +) +``` + +Response includes `human_summary` plus structured queues, `active_leases_by_role`, +`terminal_review_lock`, `blocked_items`, `next_safe_by_role`, and +`primary_next_safe_action`. Incomplete inventory fails closed. + ## Placeholder-only entries **Proxmox deployment** and **Create Proxmox LXC** are placeholders until diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index b512966..8b2f72d 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -1784,6 +1784,7 @@ import allocator_service # noqa: E402 import allocator_dependencies # noqa: E402 import control_plane_db # noqa: E402 import lease_lifecycle # noqa: E402 +import workflow_dashboard # noqa: E402 # #605 live queue/lease dashboard import incident_bridge # noqa: E402 import sentry_observability # noqa: E402 (#606 optional Sentry observability) import agent_temp_artifacts @@ -18447,6 +18448,141 @@ def gitea_list_workflow_leases( return result +@mcp.tool() +def gitea_workflow_dashboard( + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + include_issues: bool = True, + include_prs: bool = True, + include_non_active_leases: bool = True, + candidates_json: str | None = None, + limit: int = 100, +) -> dict: + """Read-only MCP workflow dashboard: queues, leases, blockers, next safe action (#605). + + Machine-readable and human-readable. Never assigns work — exclusive work + still requires ``gitea_allocate_next_work``. Never presents blocked or + terminal-locked items as safe next work. + + *candidates_json* may inject a JSON list of candidate dicts (tests / + offline fixtures). When omitted, open issues/PRs are loaded from Gitea. + Incomplete live inventory fails closed (no safe suggestions). + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "read_only": True, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + "primary_next_safe_action": None, + } + + try: + h, o, r = _resolve(remote, host, org, repo) + except ValueError as exc: + return { + "success": False, + "read_only": True, + "reasons": [str(exc)], + "primary_next_safe_action": None, + } + + inv_reasons: list[str] = [] + candidates: list[Any] = [] + inventory_complete = True + if candidates_json: + try: + raw = json.loads(candidates_json) + if not isinstance(raw, list): + raise ValueError("candidates_json must be a JSON list") + for item in raw: + if not isinstance(item, dict): + continue + candidates.append(allocator_service.candidate_from_dict(item)) + except Exception as exc: # noqa: BLE001 + return { + "success": False, + "read_only": True, + "inventory_complete": False, + "reasons": [ + f"invalid candidates_json: {_redact(str(exc))} (fail closed)" + ], + "primary_next_safe_action": None, + } + else: + candidates, inv_reasons, inventory_complete = _allocator_candidates_from_gitea( + remote=remote, + host=host, + org=o, + repo=r, + include_issues=include_issues, + include_prs=include_prs, + ) + + leases: list[dict] = [] + terminal_pr: int | None = None + terminal_lock: dict | None = None + db, db_errs = _control_plane_db_or_error() + if db is None: + inv_reasons.extend(db_errs or ["control-plane DB unavailable"]) + # Leases/terminal lock unavailable — still render queues fail-closed + # for safe suggestions when inventory is incomplete; when inventory is + # complete, surface lease gap in reasons without inventing leases. + inv_reasons.append( + "control-plane DB unavailable; lease/terminal sections empty" + ) + else: + try: + lease_result = lease_lifecycle.list_active_leases( + db, + remote=remote if remote in REMOTES else remote, + org=o, + repo=r, + role=None, + include_non_active=bool(include_non_active_leases), + limit=max(1, int(limit)), + ) + leases = list(lease_result.get("leases") or []) + except Exception as exc: # noqa: BLE001 + inv_reasons.append( + f"lease listing failed: {_redact(str(exc))}" + ) + try: + terminal = db.get_active_terminal_lock( + remote=remote if remote in REMOTES else remote, + org=o, + repo=r, + ) + if terminal: + terminal_lock = dict(terminal) + terminal_pr = int(terminal["terminal_pr"]) + except Exception as exc: # noqa: BLE001 + inv_reasons.append( + f"terminal lock lookup failed: {_redact(str(exc))}" + ) + + snap = workflow_dashboard.build_workflow_dashboard( + candidates=candidates, + remote=remote if remote in REMOTES else remote, + org=o, + repo=r, + leases=leases, + terminal_pr=terminal_pr, + terminal_lock=terminal_lock, + inventory_complete=inventory_complete, + inventory_reasons=inv_reasons, + ) + payload = snap.as_dict() + payload["inventory_source"] = ( + "candidates_json" if candidates_json else "gitea_live" + ) + payload["limit_applies_to"] = "lease_listing_only" + return payload + + @mcp.tool() def gitea_inspect_workflow_lease( lease_id: str, diff --git a/mcp-menu.sh b/mcp-menu.sh index 215f017..22aae84 100755 --- a/mcp-menu.sh +++ b/mcp-menu.sh @@ -19,6 +19,32 @@ print_banner() { printf 'Safe by default — destructive actions require explicit confirmation.\n\n' } +show_workflow_dashboard_help() { + printf '\n--- Workflow dashboard (queue, leases, next safe action) ---\n\n' + printf 'Read-only operational view (#605). Does NOT assign work.\n' + printf 'Exclusive assignment still requires gitea_allocate_next_work.\n\n' + printf 'Canonical MCP tool (any healthy Gitea namespace with gitea.read):\n\n' + printf ' gitea_workflow_dashboard(\n' + printf ' remote=\"prgs\",\n' + printf ' org=\"Scaled-Tech-Consulting\",\n' + printf ' repo=\"Gitea-Tools\",\n' + printf ' )\n\n' + printf 'Returns machine-readable sections:\n' + printf ' - open_pr_queue / open_issue_queue\n' + printf ' - active_leases_by_role / stale_or_expired_leases\n' + printf ' - terminal_review_lock\n' + printf ' - blocked_items (never presented as safe)\n' + printf ' - review_ready_prs / merge_ready_prs / author_remediation\n' + printf ' - discussion_issues / controller_needed\n' + printf ' - next_safe_by_role + primary_next_safe_action with exact prompts\n' + printf ' - human_summary (copy-friendly multi-line text)\n\n' + printf 'Safety:\n' + printf ' - Never suggests blocked or terminal-locked items as safe.\n' + printf ' - Incomplete inventory fails closed (no safe suggestions).\n' + printf ' - This menu entry is documentation only; it does not call Gitea.\n' + pause +} + show_root_checkout_health() { printf '\n--- Project status / root checkout health ---\n\n' printf 'Current directory: %s\n' "$(pwd)" @@ -241,25 +267,27 @@ main_menu() { while true; do print_banner printf ' 1) Project status / root checkout health\n' - printf ' 2) Author workflow prompts\n' - printf ' 3) Reviewer workflow prompts\n' - printf ' 4) Merger workflow prompts\n' - printf ' 5) Reconciler workflow prompts\n' - printf ' 6) Onboarding new project to this MCP workflow\n' - printf ' 7) Proxmox deployment menu placeholder\n' - printf ' 8) Create Proxmox LXC placeholder\n' - printf ' 9) Run tests\n' + printf ' 2) Workflow dashboard (queue, leases, next safe action)\n' + printf ' 3) Author workflow prompts\n' + printf ' 4) Reviewer workflow prompts\n' + printf ' 5) Merger workflow prompts\n' + printf ' 6) Reconciler workflow prompts\n' + printf ' 7) Onboarding new project to this MCP workflow\n' + printf ' 8) Proxmox deployment menu placeholder\n' + printf ' 9) Create Proxmox LXC placeholder\n' + printf ' t) Run tests\n' printf ' 0) Exit\n' read -r -p 'Choice: ' choice case "$choice" in 1) show_root_checkout_health ;; - 2) show_author_prompts ;; - 3) show_reviewer_prompts ;; - 4) show_merger_prompts ;; - 5) show_reconciler_prompts ;; - 6) show_onboarding_prompt ;; - 7|8) show_proxmox_placeholder ;; - 9) run_tests ;; + 2) show_workflow_dashboard_help ;; + 3) show_author_prompts ;; + 4) show_reviewer_prompts ;; + 5) show_merger_prompts ;; + 6) show_reconciler_prompts ;; + 7) show_onboarding_prompt ;; + 8|9) show_proxmox_placeholder ;; + t|T|tests) run_tests ;; 0) printf 'Goodbye.\n'; exit 0 ;; *) printf 'Invalid choice.\n'; pause ;; esac diff --git a/tests/test_mcp_menu_script.py b/tests/test_mcp_menu_script.py index b52ecad..b3af1cd 100644 --- a/tests/test_mcp_menu_script.py +++ b/tests/test_mcp_menu_script.py @@ -10,6 +10,7 @@ DOCS = REPO_ROOT / "docs" / "mcp-menu.md" REQUIRED_MENU_LABELS = ( "Project status / root checkout health", + "Workflow dashboard (queue, leases, next safe action)", "Author workflow prompts", "Reviewer workflow prompts", "Merger workflow prompts", @@ -105,6 +106,23 @@ class TestMcpMenuScript(unittest.TestCase): self.assertIn("./mcp-menu.sh", docs_text) self.assertIn("placeholder", docs_text.lower()) + def test_workflow_dashboard_menu_entry_is_read_only(self): + # #605: dashboard entry documents gitea_workflow_dashboard and never + # mutates Gitea / assigns work from the shell menu. + label = "Workflow dashboard (queue, leases, next safe action)" + self.assertIn(label, self.content) + dash_fn = self._extract_function("show_workflow_dashboard_help") + self.assertIn("gitea_workflow_dashboard", dash_fn) + self.assertIn("gitea_allocate_next_work", dash_fn) + self.assertIn("Read-only", dash_fn) + self.assertIn("never presented as safe", dash_fn.lower()) + for bad in ("gitea_merge_pr", "gitea_submit_pr_review", "git push"): + with self.subTest(bad=bad): + self.assertNotIn(bad, dash_fn) + docs_text = DOCS.read_text(encoding="utf-8") + self.assertIn("gitea_workflow_dashboard", docs_text) + self.assertIn("Workflow dashboard", docs_text) + def test_reviewer_skip_stale_request_changes_prompt_discoverable(self): # #482: the skip-already-reviewed-stale-REQUEST_CHANGES reviewer prompt # must be reachable from the reviewer menu and documented. diff --git a/tests/test_workflow_dashboard.py b/tests/test_workflow_dashboard.py new file mode 100644 index 0000000..b865f72 --- /dev/null +++ b/tests/test_workflow_dashboard.py @@ -0,0 +1,274 @@ +"""Hermetic tests for workflow dashboard (#605). + +Covers terminal-blocked queue shapes in the spirit of #593/#592/#587 where an +active terminal-review lock must suppress other PRs as safe review/merge work. +""" + +from __future__ import annotations + +import unittest + +from allocator_service import WorkCandidate +from workflow_dashboard import ( + DASHBOARD_VERSION, + build_workflow_dashboard, + format_human_summary, +) + + +def _issue( + number: int, + *, + title: str = "", + labels: tuple[str, ...] = ("status:ready",), + priority: int = 20, + blocked: bool = False, + dependency_unmet: bool = False, + dependency_reason: str | None = None, + claimed: bool = False, +) -> WorkCandidate: + return WorkCandidate( + kind="issue", + number=number, + title=title or f"issue {number}", + labels=labels, + priority=priority, + blocked=blocked, + dependency_unmet=dependency_unmet, + dependency_reason=dependency_reason, + already_claimed_elsewhere=claimed, + ) + + +def _pr( + number: int, + *, + title: str = "", + head_sha: str = "abc123", + request_changes: bool = False, + approved: bool = False, + mergeable: bool = False, + contaminated: bool = False, + approval_stale: bool = False, + priority: int = 5, +) -> WorkCandidate: + return WorkCandidate( + kind="pr", + number=number, + title=title or f"pr {number}", + head_sha=head_sha, + request_changes_current_head=request_changes, + approval_on_current_head=approved, + mergeable=mergeable, + approval_contaminated=contaminated, + approval_stale=approval_stale, + priority=priority, + ) + + +class TestWorkflowDashboard(unittest.TestCase): + def test_version_and_read_only_payload(self): + snap = build_workflow_dashboard( + candidates=[_issue(605)], + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + payload = snap.as_dict() + self.assertTrue(payload["read_only"]) + self.assertEqual(payload["dashboard_version"], DASHBOARD_VERSION) + self.assertTrue(payload["success"]) + self.assertTrue(payload["inventory_complete"]) + self.assertIn("human_summary", payload) + + def test_never_marks_blocked_as_safe(self): + candidates = [ + _issue(10, blocked=True, labels=("status:blocked",)), + _issue(11, dependency_unmet=True, dependency_reason="depends on #9"), + _issue(12, claimed=True), + _issue(605, labels=("status:ready",)), + ] + snap = build_workflow_dashboard(candidates=candidates) + blocked_numbers = {e.number for e in snap.blocked_items} + self.assertIn(10, blocked_numbers) + self.assertIn(11, blocked_numbers) + self.assertIn(12, blocked_numbers) + for entry in snap.blocked_items: + self.assertFalse(entry.as_dict()["is_safe"]) + self.assertEqual(entry.safe_for_roles, ()) + self.assertIsNotNone(entry.block_reason) + + author = snap.next_safe_by_role["author"] + self.assertEqual(author.status, "safe") + self.assertEqual(author.target_number, 605) + self.assertNotIn(author.target_number, blocked_numbers) + summary = format_human_summary(snap) + self.assertIn("NOT safe", summary) + self.assertIn("issue#10", summary.replace(" ", "")) + + def test_author_prefers_oldest_ready_issue(self): + candidates = [ + _issue(620, labels=("status:ready",)), + _issue(605, labels=("status:ready",)), + _issue(610, labels=("status:ready",)), + ] + snap = build_workflow_dashboard(candidates=candidates) + author = snap.next_safe_by_role["author"] + self.assertEqual(author.status, "safe") + self.assertEqual(author.target_number, 605) + self.assertIn("gitea_allocate_next_work", author.prompt) + self.assertIn("role='author'", author.prompt) + + def test_review_and_merge_ready_buckets(self): + candidates = [ + _pr(100, head_sha="r1"), # review-ready + _pr(101, approved=True, mergeable=True, head_sha="m1", priority=8), + _pr(102, request_changes=True, head_sha="a1", priority=10), + ] + snap = build_workflow_dashboard(candidates=candidates) + self.assertEqual([e.number for e in snap.review_ready_prs], [100]) + self.assertEqual([e.number for e in snap.merge_ready_prs], [101]) + self.assertEqual([e.number for e in snap.author_remediation], [102]) + + reviewer = snap.next_safe_by_role["reviewer"] + self.assertEqual(reviewer.status, "safe") + self.assertEqual(reviewer.target_number, 100) + self.assertEqual(reviewer.head_sha, "r1") + + merger = snap.next_safe_by_role["merger"] + self.assertEqual(merger.status, "safe") + self.assertEqual(merger.target_number, 101) + self.assertEqual(merger.head_sha, "m1") + + author = snap.next_safe_by_role["author"] + self.assertEqual(author.status, "safe") + self.assertEqual(author.target_number, 102) + + def test_terminal_lock_blocks_other_prs_as_safe( + self, + ): + """#593/#592/#587-style: terminal lock ⇒ other PRs are not safe.""" + candidates = [ + _pr(587, head_sha="deadbeef", priority=5), + _pr(592, approved=True, mergeable=True, head_sha="cafebabe", priority=8), + _pr(593, head_sha="terminalhead", priority=9), + _issue(605, labels=("status:ready",)), + ] + snap = build_workflow_dashboard( + candidates=candidates, + terminal_pr=593, + terminal_lock={"terminal_pr": 593, "active": True, "state": "locked"}, + ) + + # Non-terminal PRs must appear blocked, never in safe buckets. + blocked_prs = { + e.number for e in snap.blocked_items if e.kind == "pr" + } + self.assertIn(587, blocked_prs) + self.assertIn(592, blocked_prs) + self.assertNotIn(593, blocked_prs) # terminal PR itself may still be routeable + + # Terminal PR itself may remain review-ready; others must not. + self.assertEqual([e.number for e in snap.review_ready_prs], [593]) + self.assertEqual(snap.merge_ready_prs, []) + self.assertNotIn(587, [e.number for e in snap.review_ready_prs]) + self.assertNotIn(592, [e.number for e in snap.merge_ready_prs]) + + for entry in snap.blocked_items: + if entry.number in (587, 592): + self.assertIn("terminal-review lock", entry.block_reason or "") + self.assertEqual(entry.safe_for_roles, ()) + self.assertFalse(entry.as_dict()["is_safe"]) + + reviewer = snap.next_safe_by_role["reviewer"] + # Reviewer may only target the terminal PR — never 587/592. + self.assertEqual(reviewer.status, "safe") + self.assertEqual(reviewer.target_number, 593) + self.assertEqual(reviewer.head_sha, "terminalhead") + self.assertNotEqual(reviewer.target_number, 587) + self.assertNotEqual(reviewer.target_number, 592) + + merger = snap.next_safe_by_role["merger"] + # Merge-ready #592 is NOT safe while terminal lock is on #593. + self.assertNotEqual(merger.target_number, 592) + self.assertIn("593", merger.prompt) + self.assertIn( + merger.status, + ("blocked_terminal", "idle", "safe"), + ) + if merger.status == "safe": + self.assertEqual(merger.target_number, 593) + + # Author issue work remains visible (issues are not terminal-blocked). + author = snap.next_safe_by_role["author"] + self.assertEqual(author.status, "safe") + self.assertEqual(author.target_number, 605) + + summary = format_human_summary(snap) + self.assertIn("Terminal review lock: ACTIVE on PR #593", summary) + self.assertIn("Do not treat other open PRs as safe", summary) + + def test_incomplete_inventory_fails_closed(self): + snap = build_workflow_dashboard( + candidates=[_issue(605)], + inventory_complete=False, + inventory_reasons=["page truncated"], + ) + payload = snap.as_dict() + self.assertFalse(payload["inventory_complete"]) + self.assertEqual(payload["review_ready_prs"], []) + self.assertEqual(payload["merge_ready_prs"], []) + for action in snap.next_safe_by_role.values(): + self.assertEqual(action.status, "none") + self.assertIsNone(action.target_number) + self.assertIn("inventory incomplete", action.prompt.lower()) + self.assertFalse(action.as_dict()["is_safe"]) + + def test_leases_partition_active_vs_stale(self): + leases = [ + {"lease_id": "L1", "role": "author", "status": "active", "work_number": 605}, + {"lease_id": "L2", "role": "reviewer", "status": "expired", "work_number": 99}, + {"lease_id": "L3", "role": "merger", "stale": True, "work_number": 88}, + ] + snap = build_workflow_dashboard(candidates=[], leases=leases) + self.assertEqual(len(snap.active_leases_by_role["author"]), 1) + self.assertEqual(len(snap.stale_or_expired_leases), 2) + + def test_discussion_and_controller_needed(self): + candidates = [ + _issue(1, labels=("discussion", "type:discussion")), + _pr(2, contaminated=True, head_sha="x"), + ] + snap = build_workflow_dashboard(candidates=candidates) + self.assertEqual([e.number for e in snap.discussion_issues], [1]) + self.assertTrue(any(e.number == 2 for e in snap.controller_needed)) + recon = snap.next_safe_by_role["reconciler"] + self.assertEqual(recon.status, "safe") + self.assertEqual(recon.target_number, 2) + + def test_human_summary_includes_exact_prompts(self): + snap = build_workflow_dashboard( + candidates=[_issue(605)], + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + text = format_human_summary(snap) + self.assertIn("gitea_allocate_next_work", text) + self.assertIn("prgs/Scaled-Tech-Consulting/Gitea-Tools", text) + self.assertIn("never self-selects", text.lower()) + self.assertIn("Primary next:", text) + + def test_missing_pr_head_sha_is_blocked(self): + candidates = [_pr(50, head_sha="")] + # WorkCandidate allows empty head; dashboard must block it. + c = candidates[0] + c.head_sha = "" + snap = build_workflow_dashboard(candidates=[c]) + self.assertEqual(len(snap.blocked_items), 1) + self.assertIn("head_sha", snap.blocked_items[0].block_reason or "") + self.assertEqual(snap.review_ready_prs, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/workflow_dashboard.py b/workflow_dashboard.py new file mode 100644 index 0000000..8b63449 --- /dev/null +++ b/workflow_dashboard.py @@ -0,0 +1,680 @@ +"""Read-only workflow dashboard for live queue / lease / next-safe-action (#605). + +Builds a machine-readable + human-readable operational view so humans and LLMs +can see what is safe to work on without reconstructing state from comments. + +Design rules: +* Read-only: never assigns work. Assignment still goes through + ``gitea_allocate_next_work`` (#600). +* Never present blocked / terminal-locked / dependency-unmet items as safe. +* Prefer pure classification so unit tests can inject inventory (including + terminal-blocked queues from #593/#592/#587-style scenarios). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterable, Sequence + +from allocator_service import ( + ROLE_AUTHOR, + ROLE_CONTROLLER, + ROLE_MERGER, + ROLE_RECONCILER, + ROLE_REVIEWER, + WorkCandidate, + classify_skip, + expected_role_for_candidate, + sort_candidates, +) + +DASHBOARD_VERSION = "1.0.0-issue-605" + +# Roles the dashboard surfaces next-safe prompts for. +DASHBOARD_ROLES: tuple[str, ...] = ( + ROLE_AUTHOR, + ROLE_REVIEWER, + ROLE_MERGER, + ROLE_RECONCILER, + ROLE_CONTROLLER, +) + +# Exact operator prompts (fill-in tokens only — no self-selection). +PROMPT_AUTHOR = ( + "AUTHOR session: call gitea_allocate_next_work(apply=true, role='author') " + "for {remote}/{org}/{repo}, then implement only the assigned issue under " + "branches/ and open/update its PR. Do not self-select outside the allocator." +) +PROMPT_REVIEWER = ( + "REVIEWER session: call gitea_allocate_next_work(apply=true, role='reviewer') " + "for {remote}/{org}/{repo}, pin the assigned PR head SHA, submit exactly one " + "formal review verdict for that head. Do not merge." +) +PROMPT_MERGER = ( + "MERGER session: call gitea_allocate_next_work(apply=true, role='merger') " + "for {remote}/{org}/{repo}, reassess the assigned approved head, and merge " + "only that exact head via gitea_merge_pr. Do not review." +) +PROMPT_RECONCILER = ( + "RECONCILER session: call gitea_allocate_next_work(apply=true, role='reconciler') " + "for {remote}/{org}/{repo}, then perform only the assigned terminal " + "reconciliation (already-landed / post-merge cleanup). Do not approve or merge." +) +PROMPT_CONTROLLER = ( + "CONTROLLER session: inspect gitea_workflow_dashboard + control-plane leases, " + "diagnose blocked/terminal-locked items for {remote}/{org}/{repo}, and schedule " + "exactly one fresh role-scoped cycle. Do not implement, review, or merge in-band." +) +PROMPT_IDLE = ( + "IDLE: no safe assignable work for role '{role}' on {remote}/{org}/{repo}. " + "Do not self-select. Re-run gitea_workflow_dashboard on the next cycle." +) +PROMPT_TERMINAL_BLOCK = ( + "BLOCKED by terminal-review lock on PR #{terminal_pr} for {remote}/{org}/{repo}. " + "Resolve the terminal path for that exact PR before any other review/merge work. " + "Do not treat other open PRs as safe." +) +PROMPT_BLOCKED_ITEM = ( + "NOT SAFE: {kind}#{number} is blocked ({reason}). Never present as next safe work." +) + + +@dataclass(frozen=True) +class QueueEntry: + kind: str + number: int + title: str + expected_role: str + safe_for_roles: tuple[str, ...] + badges: tuple[str, ...] + block_reason: str | None = None + head_sha: str | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "kind": self.kind, + "number": self.number, + "title": self.title, + "expected_role": self.expected_role, + "safe_for_roles": list(self.safe_for_roles), + "badges": list(self.badges), + "block_reason": self.block_reason, + "head_sha": self.head_sha, + "is_safe": self.block_reason is None and bool(self.safe_for_roles), + } + + +@dataclass(frozen=True) +class RoleNextAction: + role: str + status: str # safe | idle | blocked_terminal | none + target_kind: str | None + target_number: int | None + head_sha: str | None + prompt: str + reasons: tuple[str, ...] = () + + def as_dict(self) -> dict[str, Any]: + return { + "role": self.role, + "status": self.status, + "target_kind": self.target_kind, + "target_number": self.target_number, + "head_sha": self.head_sha, + "prompt": self.prompt, + "reasons": list(self.reasons), + "is_safe": self.status == "safe", + } + + +@dataclass +class DashboardSnapshot: + remote: str + org: str + repo: str + inventory_complete: bool + candidate_count: int + open_prs: list[QueueEntry] = field(default_factory=list) + open_issues: list[QueueEntry] = field(default_factory=list) + review_ready_prs: list[QueueEntry] = field(default_factory=list) + merge_ready_prs: list[QueueEntry] = field(default_factory=list) + author_remediation: list[QueueEntry] = field(default_factory=list) + discussion_issues: list[QueueEntry] = field(default_factory=list) + blocked_items: list[QueueEntry] = field(default_factory=list) + controller_needed: list[QueueEntry] = field(default_factory=list) + active_leases_by_role: dict[str, list[dict[str, Any]]] = field(default_factory=dict) + stale_or_expired_leases: list[dict[str, Any]] = field(default_factory=list) + terminal_review_lock: dict[str, Any] | None = None + next_safe_by_role: dict[str, RoleNextAction] = field(default_factory=dict) + primary_next_safe_action: RoleNextAction | None = None + reasons: list[str] = field(default_factory=list) + dashboard_version: str = DASHBOARD_VERSION + + def as_dict(self) -> dict[str, Any]: + return { + "success": self.inventory_complete and not any( + r.startswith("inventory incomplete") for r in self.reasons + ), + "read_only": True, + "dashboard_version": self.dashboard_version, + "remote": self.remote, + "org": self.org, + "repo": self.repo, + "inventory_complete": self.inventory_complete, + "candidate_count": self.candidate_count, + "open_pr_queue": [e.as_dict() for e in self.open_prs], + "open_issue_queue": [e.as_dict() for e in self.open_issues], + "review_ready_prs": [e.as_dict() for e in self.review_ready_prs], + "merge_ready_prs": [e.as_dict() for e in self.merge_ready_prs], + "author_remediation": [e.as_dict() for e in self.author_remediation], + "discussion_issues": [e.as_dict() for e in self.discussion_issues], + "blocked_items": [e.as_dict() for e in self.blocked_items], + "controller_needed": [e.as_dict() for e in self.controller_needed], + "active_leases_by_role": { + role: list(items) for role, items in self.active_leases_by_role.items() + }, + "stale_or_expired_leases": list(self.stale_or_expired_leases), + "terminal_review_lock": self.terminal_review_lock, + "next_safe_by_role": { + role: action.as_dict() for role, action in self.next_safe_by_role.items() + }, + "primary_next_safe_action": ( + self.primary_next_safe_action.as_dict() + if self.primary_next_safe_action + else None + ), + "reasons": list(self.reasons), + "human_summary": format_human_summary(self), + } + + +def _scope_tokens(remote: str, org: str, repo: str) -> dict[str, str]: + return {"remote": remote, "org": org, "repo": repo} + + +def _prompt_for_role( + role: str, + *, + remote: str, + org: str, + repo: str, + terminal_pr: int | None = None, + idle: bool = False, +) -> str: + scope = _scope_tokens(remote, org, repo) + if terminal_pr is not None and role in (ROLE_REVIEWER, ROLE_MERGER): + return PROMPT_TERMINAL_BLOCK.format(terminal_pr=terminal_pr, **scope) + if idle: + return PROMPT_IDLE.format(role=role, **scope) + templates = { + ROLE_AUTHOR: PROMPT_AUTHOR, + ROLE_REVIEWER: PROMPT_REVIEWER, + ROLE_MERGER: PROMPT_MERGER, + ROLE_RECONCILER: PROMPT_RECONCILER, + ROLE_CONTROLLER: PROMPT_CONTROLLER, + } + return templates.get(role, PROMPT_CONTROLLER).format(**scope) + + +def _badges_for_candidate(c: WorkCandidate, *, terminal_pr: int | None) -> tuple[str, ...]: + badges: list[str] = [] + if c.kind == "pr": + if c.request_changes_current_head: + badges.append("request-changes") + if c.approval_on_current_head and c.mergeable: + badges.append("merge-ready") + elif c.approval_on_current_head and not c.mergeable: + badges.append("approved-not-mergeable") + if c.approval_stale: + badges.append("approval-stale") + if c.approval_contaminated: + badges.append("contaminated") + if not c.approval_on_current_head and not c.request_changes_current_head: + badges.append("review-ready") + if terminal_pr is not None and c.number == terminal_pr: + badges.append("terminal-lock") + if terminal_pr is not None and c.number != terminal_pr: + badges.append("blocked-by-terminal") + else: + labels = set(c.labels) + if "status:ready" in labels: + badges.append("ready") + if "status:in-progress" in labels: + badges.append("in-progress") + if "status:blocked" in labels or c.blocked: + badges.append("blocked") + if "discussion" in labels or "type:discussion" in labels: + badges.append("discussion") + if c.dependency_unmet: + badges.append("dependency-unmet") + if c.already_claimed_elsewhere: + badges.append("claimed") + if c.blocked: + badges.append("blocked") + # de-dupe preserve order + seen: set[str] = set() + out: list[str] = [] + for b in badges: + if b not in seen: + seen.add(b) + out.append(b) + return tuple(out) + + +def _entry_for_candidate( + c: WorkCandidate, + *, + terminal_pr: int | None, +) -> QueueEntry: + expected = expected_role_for_candidate(c) + badges = _badges_for_candidate(c, terminal_pr=terminal_pr) + safe_roles: list[str] = [] + block_reason: str | None = None + + # Global hard blocks (never safe for any worker role). + if c.blocked or "status:blocked" in c.labels: + block_reason = "status blocked" + elif c.dependency_unmet: + block_reason = c.dependency_reason or "unmet dependency" + elif c.already_claimed_elsewhere: + block_reason = "already claimed elsewhere" + elif c.kind == "pr" and not (c.head_sha or "").strip(): + block_reason = "missing head_sha pin" + elif ( + terminal_pr is not None + and c.kind == "pr" + and c.number != terminal_pr + ): + # Other PRs remain visible but are not safe for review/merge while a + # terminal lock is active (#593/#592/#587-style queue). + block_reason = f"active terminal-review lock on PR #{terminal_pr}" + + if block_reason is None: + # Safe only for the expected role, and only when classify_skip agrees. + skip = classify_skip(c, role=expected, terminal_pr=terminal_pr) + if skip is None: + safe_roles.append(expected) + else: + block_reason = skip + + return QueueEntry( + kind=c.kind, + number=c.number, + title=c.title or "", + expected_role=expected, + safe_for_roles=tuple(safe_roles), + badges=badges, + block_reason=block_reason, + head_sha=c.head_sha, + ) + + +def _partition_leases( + leases: Sequence[dict[str, Any]] | None, +) -> tuple[dict[str, list[dict[str, Any]]], list[dict[str, Any]]]: + by_role: dict[str, list[dict[str, Any]]] = {r: [] for r in DASHBOARD_ROLES} + stale: list[dict[str, Any]] = [] + for raw in leases or (): + if not isinstance(raw, dict): + continue + role = str(raw.get("role") or raw.get("owner_role") or "unknown").strip().lower() + status = str(raw.get("status") or raw.get("lease_status") or "active").strip().lower() + entry = dict(raw) + if status in ("expired", "stale", "released", "moot") or raw.get("stale") or raw.get( + "expired" + ): + stale.append(entry) + continue + if role in by_role: + by_role[role].append(entry) + else: + by_role.setdefault(role, []).append(entry) + return by_role, stale + + +def _first_safe_for_role( + entries: Iterable[QueueEntry], + role: str, +) -> QueueEntry | None: + for entry in entries: + if role in entry.safe_for_roles and entry.block_reason is None: + return entry + return None + + +def _role_next_action( + role: str, + *, + entries: Sequence[QueueEntry], + remote: str, + org: str, + repo: str, + terminal_pr: int | None, +) -> RoleNextAction: + # Terminal lock blocks reviewer/merger from non-terminal work. + if terminal_pr is not None and role in (ROLE_REVIEWER, ROLE_MERGER): + terminal_entry = next( + ( + e + for e in entries + if e.kind == "pr" and e.number == terminal_pr and role in e.safe_for_roles + ), + None, + ) + if terminal_entry is None: + return RoleNextAction( + role=role, + status="blocked_terminal", + target_kind="pr", + target_number=terminal_pr, + head_sha=None, + prompt=_prompt_for_role( + role, + remote=remote, + org=org, + repo=repo, + terminal_pr=terminal_pr, + ), + reasons=( + f"active terminal-review lock on PR #{terminal_pr}; " + "no other review/merge target is safe", + ), + ) + return RoleNextAction( + role=role, + status="safe", + target_kind="pr", + target_number=terminal_pr, + head_sha=terminal_entry.head_sha, + prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo), + reasons=(f"terminal-path PR #{terminal_pr} is the only safe target",), + ) + + if role == ROLE_CONTROLLER: + needed = [e for e in entries if e.expected_role == ROLE_CONTROLLER or e.block_reason] + if not needed: + return RoleNextAction( + role=role, + status="idle", + target_kind=None, + target_number=None, + head_sha=None, + prompt=_prompt_for_role( + role, remote=remote, org=org, repo=repo, idle=True + ), + reasons=("no controller-needed items",), + ) + target = needed[0] + return RoleNextAction( + role=role, + status="safe", + target_kind=target.kind, + target_number=target.number, + head_sha=target.head_sha, + prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo), + reasons=(target.block_reason or "controller diagnosis required",), + ) + + hit = _first_safe_for_role(entries, role) + if hit is None: + return RoleNextAction( + role=role, + status="idle", + target_kind=None, + target_number=None, + head_sha=None, + prompt=_prompt_for_role( + role, remote=remote, org=org, repo=repo, idle=True + ), + reasons=(f"no safe assignable work for role '{role}'",), + ) + return RoleNextAction( + role=role, + status="safe", + target_kind=hit.kind, + target_number=hit.number, + head_sha=hit.head_sha, + prompt=_prompt_for_role(role, remote=remote, org=org, repo=repo), + reasons=(f"highest-ranked safe candidate for role '{role}'",), + ) + + +def build_workflow_dashboard( + *, + candidates: Sequence[WorkCandidate], + remote: str = "prgs", + org: str = "Scaled-Tech-Consulting", + repo: str = "Gitea-Tools", + leases: Sequence[dict[str, Any]] | None = None, + terminal_pr: int | None = None, + terminal_lock: dict[str, Any] | None = None, + inventory_complete: bool = True, + inventory_reasons: Sequence[str] | None = None, +) -> DashboardSnapshot: + """Build a full dashboard snapshot from injected inventory (pure).""" + reasons = [str(r) for r in (inventory_reasons or ()) if str(r).strip()] + if not inventory_complete: + reasons.append( + "inventory incomplete: refuse to present partial queues as complete " + "(fail closed, #605/#758)" + ) + + ranked = sort_candidates(list(candidates)) + entries = [ + _entry_for_candidate(c, terminal_pr=terminal_pr) for c in ranked + ] + + open_prs = [e for e in entries if e.kind == "pr"] + open_issues = [e for e in entries if e.kind == "issue"] + review_ready = [ + e + for e in open_prs + if "review-ready" in e.badges + and e.block_reason is None + and ROLE_REVIEWER in e.safe_for_roles + ] + merge_ready = [ + e + for e in open_prs + if "merge-ready" in e.badges + and e.block_reason is None + and ROLE_MERGER in e.safe_for_roles + ] + author_remediation = [ + e + for e in open_prs + if "request-changes" in e.badges + and e.block_reason is None + and ROLE_AUTHOR in e.safe_for_roles + ] + discussion = [ + e + for e in open_issues + if "discussion" in e.badges + ] + blocked = [e for e in entries if e.block_reason is not None] + controller_needed = [ + e + for e in entries + if e.expected_role == ROLE_CONTROLLER + or (e.block_reason and "contaminated" in (e.badges or ())) + or "contaminated" in e.badges + ] + + leases_by_role, stale_leases = _partition_leases(leases) + + term_payload = None + if terminal_lock is not None: + term_payload = dict(terminal_lock) + elif terminal_pr is not None: + term_payload = { + "active": True, + "terminal_pr": terminal_pr, + "state": "locked", + } + + next_by_role: dict[str, RoleNextAction] = {} + for role in DASHBOARD_ROLES: + next_by_role[role] = _role_next_action( + role, + entries=entries, + remote=remote, + org=org, + repo=repo, + terminal_pr=terminal_pr, + ) + + # Primary next action prefers in-flight PR work, then author issues. + primary: RoleNextAction | None = None + for role in (ROLE_REVIEWER, ROLE_MERGER, ROLE_AUTHOR, ROLE_RECONCILER, ROLE_CONTROLLER): + action = next_by_role[role] + if action.status == "safe": + primary = action + break + if primary is None: + # Prefer an explicit terminal block signal over generic idle. + for role in (ROLE_REVIEWER, ROLE_MERGER): + if next_by_role[role].status == "blocked_terminal": + primary = next_by_role[role] + break + if primary is None: + primary = next_by_role[ROLE_AUTHOR] + + # Incomplete inventory: strip all safe flags / never suggest work. + if not inventory_complete: + for role, action in list(next_by_role.items()): + next_by_role[role] = RoleNextAction( + role=role, + status="none", + target_kind=None, + target_number=None, + head_sha=None, + prompt=( + f"BLOCKED: inventory incomplete for {remote}/{org}/{repo}; " + "do not select work. Re-run after a complete listing." + ), + reasons=tuple(reasons) or ("inventory incomplete",), + ) + primary = next_by_role[ROLE_CONTROLLER] + review_ready = [] + merge_ready = [] + author_remediation = [] + + return DashboardSnapshot( + remote=remote, + org=org, + repo=repo, + inventory_complete=inventory_complete, + candidate_count=len(ranked), + open_prs=open_prs, + open_issues=open_issues, + review_ready_prs=review_ready, + merge_ready_prs=merge_ready, + author_remediation=author_remediation, + discussion_issues=discussion, + blocked_items=blocked, + controller_needed=controller_needed, + active_leases_by_role=leases_by_role, + stale_or_expired_leases=stale_leases, + terminal_review_lock=term_payload, + next_safe_by_role=next_by_role, + primary_next_safe_action=primary, + reasons=reasons, + ) + + +def format_human_summary(snapshot: DashboardSnapshot) -> str: + """Compact human-readable multi-line summary for menus and operators.""" + lines: list[str] = [] + lines.append( + f"Workflow dashboard v{snapshot.dashboard_version} — " + f"{snapshot.remote}/{snapshot.org}/{snapshot.repo}" + ) + lines.append( + f"Inventory: complete={snapshot.inventory_complete} " + f"candidates={snapshot.candidate_count}" + ) + if snapshot.terminal_review_lock: + tpr = snapshot.terminal_review_lock.get("terminal_pr") + lines.append(f"Terminal review lock: ACTIVE on PR #{tpr}") + else: + lines.append("Terminal review lock: none") + + lines.append( + f"Open PRs: {len(snapshot.open_prs)} | Open issues: {len(snapshot.open_issues)}" + ) + lines.append( + f"Review-ready: {len(snapshot.review_ready_prs)} | " + f"Merge-ready: {len(snapshot.merge_ready_prs)} | " + f"Author remediation: {len(snapshot.author_remediation)}" + ) + lines.append( + f"Blocked: {len(snapshot.blocked_items)} | " + f"Controller-needed: {len(snapshot.controller_needed)} | " + f"Discussion: {len(snapshot.discussion_issues)}" + ) + + active_counts = { + role: len(items) + for role, items in snapshot.active_leases_by_role.items() + if items + } + if active_counts: + parts = [f"{role}={n}" for role, n in sorted(active_counts.items())] + lines.append("Active leases by role: " + ", ".join(parts)) + else: + lines.append("Active leases by role: none") + lines.append( + f"Stale/expired leases: {len(snapshot.stale_or_expired_leases)}" + ) + + # Never list blocked items as safe. + if snapshot.blocked_items: + lines.append("Blocked (NOT safe):") + for entry in snapshot.blocked_items[:12]: + lines.append( + f" - {entry.kind}#{entry.number}: {entry.block_reason}" + ) + lines.append( + " " + + PROMPT_BLOCKED_ITEM.format( + kind=entry.kind, + number=entry.number, + reason=entry.block_reason or "blocked", + ) + ) + + lines.append("Next safe action by role:") + for role in DASHBOARD_ROLES: + action = snapshot.next_safe_by_role.get(role) + if action is None: + continue + target = ( + f"{action.target_kind}#{action.target_number}" + if action.target_number is not None + else "none" + ) + lines.append( + f" - {role}: status={action.status} target={target} " + f"safe={action.status == 'safe'}" + ) + lines.append(f" prompt: {action.prompt}") + + if snapshot.primary_next_safe_action: + p = snapshot.primary_next_safe_action + lines.append( + f"Primary next: role={p.role} status={p.status} " + f"target={p.target_kind}#{p.target_number if p.target_number else 'none'}" + ) + lines.append(f" prompt: {p.prompt}") + + if snapshot.reasons: + lines.append("Notes:") + for r in snapshot.reasons: + lines.append(f" - {r}") + + lines.append( + "Assignment still requires gitea_allocate_next_work; " + "this dashboard never self-selects exclusive work." + ) + return "\n".join(lines)