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.
681 lines
24 KiB
Python
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)
|