Files
Gitea-Tools/workflow_dashboard.py
T
sysadmin 7f2b9f36de feat: add workflow dashboard for queue, leases, and next safe action (Closes #605)
Read-only MCP tool gitea_workflow_dashboard plus mcp-menu entry so operators
and LLMs can see PR/issue queues, leases, terminal locks, blockers, and exact
next-safe prompts without reconstructing state from comments. Never assigns
work or presents blocked/terminal-locked items as safe.
2026-07-19 14:43:10 -04:00

681 lines
24 KiB
Python

"""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)