Merge pull request 'feat: add workflow dashboard for queue, leases, and next safe action (Closes #605)' (#762) from feat/issue-605-workflow-dashboard into master

This commit was merged in pull request #762.
This commit is contained in:
2026-07-19 19:20:19 -05:00
6 changed files with 1168 additions and 15 deletions
+17
View File
@@ -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
+136
View File
@@ -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,
+43 -15
View File
@@ -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
+18
View File
@@ -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.
+274
View File
@@ -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()
+680
View File
@@ -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)