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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user