feat: controller-owned allocator API on control-plane DB (Closes #600)
Add gitea_allocate_next_work and allocator_service routing policy on top of the #613 ControlPlaneDB substrate. Workers get atomic assign+lease results (or wait/no_safe_work/terminal-path outcomes) without self-selecting work via file locks or comment-only leases. #612 remains downstream. Closes #600
This commit is contained in:
@@ -0,0 +1,610 @@
|
||||
"""Controller-owned work allocator policy (#600).
|
||||
|
||||
Builds on the #613 control-plane DB substrate (``ControlPlaneDB.assign_and_lease``).
|
||||
|
||||
Workers must not self-select exclusive work under the standard multi-LLM
|
||||
workflow. They call ``gitea_allocate_next_work`` which:
|
||||
|
||||
1. Inspects candidate Gitea issues/PRs (never raw monitoring incidents).
|
||||
2. Applies ADR routing policy (role, terminal path, leases, blocked, deps).
|
||||
3. Atomically assigns + leases the selected item in one DB transaction.
|
||||
|
||||
This module is pure selection + substrate orchestration. Gitea I/O for live
|
||||
inventory lives in the MCP tool wrapper so tests can inject candidates.
|
||||
|
||||
#612 remains downstream: bridge-created Gitea issues become candidates only
|
||||
after they exist as normal issues; this module never assigns incidents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Sequence
|
||||
|
||||
from control_plane_db import (
|
||||
ControlPlaneDB,
|
||||
ControlPlaneError,
|
||||
InvalidWorkKindError,
|
||||
LeaseRequiredError,
|
||||
WORK_KINDS,
|
||||
)
|
||||
|
||||
# Outcomes required by #600 / ADR §5.
|
||||
OUTCOME_ASSIGNED = "assigned_work"
|
||||
OUTCOME_WAIT = "wait"
|
||||
OUTCOME_BLOCKED_TERMINAL = "blocked_by_terminal_path"
|
||||
OUTCOME_BLOCKED_LEASE = "blocked_by_active_lease"
|
||||
OUTCOME_NEEDS_CONTROLLER = "needs_controller"
|
||||
OUTCOME_NO_SAFE = "no_safe_work"
|
||||
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
||||
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
||||
|
||||
ROLE_AUTHOR = "author"
|
||||
ROLE_REVIEWER = "reviewer"
|
||||
ROLE_MERGER = "merger"
|
||||
ROLE_RECONCILER = "reconciler"
|
||||
ROLE_CONTROLLER = "controller"
|
||||
|
||||
VALID_ROLES = frozenset(
|
||||
{ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER}
|
||||
)
|
||||
|
||||
# Default action matrices by role (mutation gate will re-check).
|
||||
ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
|
||||
ROLE_AUTHOR: (
|
||||
("implement", "comment", "push", "create_pr"),
|
||||
("approve", "merge", "request_changes", "self_select_without_assignment"),
|
||||
),
|
||||
ROLE_REVIEWER: (
|
||||
("review", "comment", "approve", "request_changes"),
|
||||
("merge", "push", "create_pr", "self_select_without_assignment"),
|
||||
),
|
||||
ROLE_MERGER: (
|
||||
("merge", "comment"),
|
||||
("approve", "request_changes", "push", "create_pr", "self_select_without_assignment"),
|
||||
),
|
||||
ROLE_RECONCILER: (
|
||||
("comment", "diagnose", "cleanup"),
|
||||
("approve", "merge", "push", "create_pr", "self_select_without_assignment"),
|
||||
),
|
||||
ROLE_CONTROLLER: (
|
||||
("comment", "diagnose", "allocate"),
|
||||
("approve", "merge", "push", "create_pr"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkCandidate:
|
||||
"""One assignable Gitea issue or PR presented to the allocator."""
|
||||
|
||||
kind: str # issue | pr
|
||||
number: int
|
||||
state: str = "open"
|
||||
labels: tuple[str, ...] = ()
|
||||
title: str = ""
|
||||
priority: int = 0
|
||||
head_sha: str | None = None
|
||||
# Routing signals (callers derive from Gitea / review feedback).
|
||||
request_changes_current_head: bool = False
|
||||
approval_on_current_head: bool = False
|
||||
approval_stale: bool = False
|
||||
approval_contaminated: bool = False
|
||||
mergeable: bool = False
|
||||
blocked: bool = False
|
||||
dependency_unmet: bool = False
|
||||
dependency_reason: str | None = None
|
||||
already_claimed_elsewhere: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.kind = (self.kind or "").strip().lower()
|
||||
self.state = (self.state or "open").strip().lower()
|
||||
self.labels = tuple(
|
||||
str(x).strip().lower() for x in (self.labels or ()) if str(x).strip()
|
||||
)
|
||||
if self.kind not in WORK_KINDS:
|
||||
raise InvalidWorkKindError(
|
||||
f"candidate kind '{self.kind}' is not assignable; only "
|
||||
f"{sorted(WORK_KINDS)} (never raw incidents)"
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"number": self.number,
|
||||
"state": self.state,
|
||||
"labels": list(self.labels),
|
||||
"title": self.title,
|
||||
"priority": self.priority,
|
||||
"head_sha": self.head_sha,
|
||||
"request_changes_current_head": self.request_changes_current_head,
|
||||
"approval_on_current_head": self.approval_on_current_head,
|
||||
"approval_stale": self.approval_stale,
|
||||
"approval_contaminated": self.approval_contaminated,
|
||||
"mergeable": self.mergeable,
|
||||
"blocked": self.blocked,
|
||||
"dependency_unmet": self.dependency_unmet,
|
||||
"dependency_reason": self.dependency_reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkipRecord:
|
||||
kind: str
|
||||
number: int
|
||||
reason: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"kind": self.kind, "number": self.number, "reason": self.reason}
|
||||
|
||||
|
||||
def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
|
||||
"""Map profile/role strings to a canonical allocator role."""
|
||||
raw = (role or "").strip().lower()
|
||||
if raw in VALID_ROLES:
|
||||
return raw
|
||||
prof = (profile_name or "").strip().lower()
|
||||
for token in VALID_ROLES:
|
||||
if token in prof or prof.endswith(f"-{token}"):
|
||||
return token
|
||||
if "author" in raw:
|
||||
return ROLE_AUTHOR
|
||||
if "review" in raw:
|
||||
return ROLE_REVIEWER
|
||||
if "merg" in raw:
|
||||
return ROLE_MERGER
|
||||
if "reconcil" in raw:
|
||||
return ROLE_RECONCILER
|
||||
if "control" in raw:
|
||||
return ROLE_CONTROLLER
|
||||
raise ControlPlaneError(
|
||||
f"unknown allocator role '{role}' (profile={profile_name!r}); "
|
||||
f"expected one of {sorted(VALID_ROLES)}"
|
||||
)
|
||||
|
||||
|
||||
def expected_role_for_candidate(c: WorkCandidate) -> str:
|
||||
"""ADR §5.3 routing: which role should take this work next."""
|
||||
if c.kind == "pr":
|
||||
if c.approval_contaminated:
|
||||
return ROLE_RECONCILER
|
||||
if c.request_changes_current_head:
|
||||
return ROLE_AUTHOR
|
||||
if c.approval_stale:
|
||||
return ROLE_REVIEWER
|
||||
if c.approval_on_current_head and c.mergeable:
|
||||
return ROLE_MERGER
|
||||
# Open PR without terminal verdict → reviewer
|
||||
return ROLE_REVIEWER
|
||||
# Issues: ready work → author by default; blocked stays controller/none
|
||||
labels = set(c.labels)
|
||||
if "status:blocked" in labels or c.blocked:
|
||||
return ROLE_CONTROLLER
|
||||
return ROLE_AUTHOR
|
||||
|
||||
|
||||
def role_actions(role: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
return ROLE_ACTIONS.get(role, ROLE_ACTIONS[ROLE_AUTHOR])
|
||||
|
||||
|
||||
def classify_skip(
|
||||
c: WorkCandidate,
|
||||
*,
|
||||
role: str,
|
||||
terminal_pr: int | None,
|
||||
) -> str | None:
|
||||
"""Return skip reason, or None if candidate is selectable for *role*."""
|
||||
if c.state in ("merged", "closed"):
|
||||
return f"{c.kind}#{c.number} is {c.state}; never assign"
|
||||
if c.blocked or "status:blocked" in c.labels:
|
||||
return f"{c.kind}#{c.number} is blocked"
|
||||
if c.dependency_unmet:
|
||||
return (
|
||||
c.dependency_reason
|
||||
or f"{c.kind}#{c.number} has unmet dependencies"
|
||||
)
|
||||
if c.already_claimed_elsewhere:
|
||||
return f"{c.kind}#{c.number} already claimed elsewhere"
|
||||
if c.kind == "pr" and not (c.head_sha or "").strip():
|
||||
return f"pr#{c.number} missing head_sha pin"
|
||||
|
||||
# Terminal path first: when an active terminal PR exists, only that PR
|
||||
# (or controller diagnosis) is assignable for review-path roles.
|
||||
if terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr:
|
||||
if role in (ROLE_REVIEWER, ROLE_MERGER):
|
||||
return (
|
||||
f"pr#{c.number} skipped: active terminal-review lock on "
|
||||
f"PR #{terminal_pr} must be resolved first"
|
||||
)
|
||||
|
||||
expected = expected_role_for_candidate(c)
|
||||
if role == ROLE_CONTROLLER:
|
||||
# Controller may inspect anything but only assigns diagnosis targets
|
||||
# when contaminated / blocked.
|
||||
if expected == ROLE_RECONCILER or c.blocked:
|
||||
return None
|
||||
return f"{c.kind}#{c.number} does not require controller (expected {expected})"
|
||||
|
||||
if role != expected:
|
||||
return (
|
||||
f"{c.kind}#{c.number} expects role '{expected}', active role is '{role}'"
|
||||
)
|
||||
|
||||
# Ready-gate for issues: prefer status:ready when labels present.
|
||||
if c.kind == "issue" and c.labels:
|
||||
if "status:ready" not in c.labels and "status:in-progress" not in c.labels:
|
||||
# Allow unlabeled open issues; only skip explicit non-ready states.
|
||||
if any(l.startswith("status:") for l in c.labels):
|
||||
return f"issue#{c.number} not status:ready ({','.join(c.labels)})"
|
||||
return None
|
||||
|
||||
|
||||
def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
|
||||
"""Higher priority first; then lower number (older issues) for stability."""
|
||||
return sorted(
|
||||
candidates,
|
||||
key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
|
||||
)
|
||||
|
||||
|
||||
def allocate_next_work(
|
||||
db: ControlPlaneDB,
|
||||
*,
|
||||
session_id: str,
|
||||
role: str,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
candidates: Sequence[WorkCandidate],
|
||||
apply: bool = False,
|
||||
profile_name: str | None = None,
|
||||
username: str | None = None,
|
||||
lease_ttl_seconds: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Select and optionally reserve the next work unit via control-plane DB.
|
||||
|
||||
*apply=False* (default): dry-run selection only — no lease/assignment.
|
||||
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
||||
|
||||
Never uses file locks or comment-only leases as the assignment source.
|
||||
"""
|
||||
if db is None:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"reasons": [
|
||||
"control-plane DB substrate unavailable (fail closed, #600/#613)"
|
||||
],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
try:
|
||||
role_norm = normalize_role(role, profile_name=profile_name)
|
||||
except ControlPlaneError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_ROLE_INELIGIBLE,
|
||||
"reasons": [str(exc)],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
|
||||
session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}"
|
||||
try:
|
||||
db.upsert_session(
|
||||
session_id=session_id,
|
||||
role=role_norm,
|
||||
profile=profile_name,
|
||||
pid=os.getpid(),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — surface structured
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"reasons": [
|
||||
f"failed to register session in control-plane DB: {exc} "
|
||||
"(fail closed, #613)"
|
||||
],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
|
||||
# Expire stale leases globally before selection.
|
||||
try:
|
||||
db.expire_stale_leases()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"reasons": [f"lease expiry failed: {exc} (fail closed)"],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
|
||||
terminal = None
|
||||
try:
|
||||
terminal = db.get_active_terminal_lock(remote=remote, org=org, repo=repo)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"reasons": [f"terminal lock lookup failed: {exc} (fail closed)"],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
terminal_pr = int(terminal["terminal_pr"]) if terminal else None
|
||||
|
||||
skipped: list[SkipRecord] = []
|
||||
ordered = sort_candidates(list(candidates))
|
||||
selected: WorkCandidate | None = None
|
||||
for c in ordered:
|
||||
reason = classify_skip(c, role=role_norm, terminal_pr=terminal_pr)
|
||||
if reason:
|
||||
skipped.append(SkipRecord(c.kind, c.number, reason))
|
||||
continue
|
||||
selected = c
|
||||
break
|
||||
|
||||
if selected is None:
|
||||
# If terminal lock blocks all review work, surface that explicitly.
|
||||
if terminal_pr is not None and role_norm in (ROLE_REVIEWER, ROLE_MERGER):
|
||||
outcome = OUTCOME_BLOCKED_TERMINAL
|
||||
reasons = [
|
||||
f"no safe work for role '{role_norm}': active terminal-review "
|
||||
f"lock on PR #{terminal_pr} (resolve terminal path first, #332/#600)"
|
||||
]
|
||||
else:
|
||||
outcome = OUTCOME_NO_SAFE
|
||||
reasons = [
|
||||
f"no safe assignable work for role '{role_norm}' "
|
||||
f"among {len(ordered)} candidates"
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": outcome,
|
||||
"apply": bool(apply),
|
||||
"role": role_norm,
|
||||
"profile_name": profile_name,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": None,
|
||||
"expected_role_next": None,
|
||||
"reasons": reasons,
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
),
|
||||
}
|
||||
|
||||
expected_role = expected_role_for_candidate(selected)
|
||||
allowed, forbidden = role_actions(role_norm)
|
||||
selection = {
|
||||
"kind": selected.kind,
|
||||
"number": selected.number,
|
||||
"title": selected.title,
|
||||
"labels": list(selected.labels),
|
||||
"head_sha": selected.head_sha,
|
||||
"priority": selected.priority,
|
||||
"expected_role_next": expected_role,
|
||||
"reason_selected": (
|
||||
f"highest-priority candidate for role '{role_norm}' "
|
||||
f"(expected_role={expected_role})"
|
||||
),
|
||||
}
|
||||
|
||||
if not apply:
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_PREVIEW,
|
||||
"apply": False,
|
||||
"role": role_norm,
|
||||
"profile_name": profile_name,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": selection,
|
||||
"expected_role_next": expected_role,
|
||||
"reasons": [
|
||||
"dry-run only (apply=false); no assignment/lease created — "
|
||||
"call again with apply=true to reserve via control-plane DB"
|
||||
],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
),
|
||||
}
|
||||
|
||||
# Atomic reserve via #613 substrate.
|
||||
ttl = lease_ttl_seconds if lease_ttl_seconds is not None else None
|
||||
try:
|
||||
kwargs: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"role": role_norm,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"kind": selected.kind,
|
||||
"number": selected.number,
|
||||
"expected_head_sha": selected.head_sha,
|
||||
"allowed_actions": allowed,
|
||||
"forbidden_actions": forbidden,
|
||||
"phase": "allocated",
|
||||
}
|
||||
if ttl is not None:
|
||||
kwargs["lease_ttl_seconds"] = int(ttl)
|
||||
result = db.assign_and_lease(**kwargs)
|
||||
except (InvalidWorkKindError, LeaseRequiredError, ControlPlaneError) as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"apply": True,
|
||||
"role": role_norm,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": selection,
|
||||
"expected_role_next": expected_role,
|
||||
"reasons": [f"atomic assign+lease failed: {exc} (fail closed, #613)"],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
if result.outcome == "wait":
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_WAIT,
|
||||
"apply": True,
|
||||
"role": role_norm,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": selection,
|
||||
"expected_role_next": expected_role,
|
||||
"reasons": [result.reason or "foreign active lease"],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": result.as_dict(),
|
||||
"owner_session_id": result.owner_session_id,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
if result.outcome == "no_safe_work":
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"apply": True,
|
||||
"role": role_norm,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": selection,
|
||||
"expected_role_next": expected_role,
|
||||
"reasons": [result.reason or "no_safe_work"],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": result.as_dict(),
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
# assigned
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_ASSIGNED,
|
||||
"apply": True,
|
||||
"role": role_norm,
|
||||
"profile_name": profile_name,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": selection,
|
||||
"expected_role_next": expected_role,
|
||||
"reasons": [
|
||||
selection["reason_selected"],
|
||||
result.reason or "atomic assign+lease created",
|
||||
],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": result.as_dict(),
|
||||
"lease_proof": {
|
||||
"assignment_id": result.assignment_id,
|
||||
"lease_id": result.lease_id,
|
||||
"expires_at": result.expires_at,
|
||||
"expected_head_sha": result.expected_head_sha,
|
||||
"allowed_actions": list(result.allowed_actions),
|
||||
"forbidden_actions": list(result.forbidden_actions),
|
||||
"source": "control_plane_db.assign_and_lease",
|
||||
},
|
||||
"next_valid_command": _next_command(role_norm, selected),
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _next_command(role: str, c: WorkCandidate) -> str:
|
||||
if role == ROLE_AUTHOR and c.kind == "issue":
|
||||
return f"implement issue #{c.number} under a branches/ worktree; open PR when ready"
|
||||
if role == ROLE_AUTHOR and c.kind == "pr":
|
||||
return (
|
||||
f"address REQUEST_CHANGES on PR #{c.number} at head "
|
||||
f"{(c.head_sha or '')[:12]} and push fixes"
|
||||
)
|
||||
if role == ROLE_REVIEWER:
|
||||
return (
|
||||
f"review PR #{c.number} pinned at head {(c.head_sha or '')[:12]} "
|
||||
"via full reviewer workflow"
|
||||
)
|
||||
if role == ROLE_MERGER:
|
||||
return (
|
||||
f"merge PR #{c.number} only with explicit operator MERGE "
|
||||
f"authorization at head {(c.head_sha or '')[:12]}"
|
||||
)
|
||||
if role == ROLE_RECONCILER:
|
||||
return f"diagnose contested state for {c.kind}#{c.number}"
|
||||
return f"proceed on {c.kind}#{c.number} under role {role}"
|
||||
|
||||
|
||||
def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
||||
"""Build a WorkCandidate from a plain dict (tests / MCP inventory)."""
|
||||
return WorkCandidate(
|
||||
kind=str(data.get("kind") or "issue"),
|
||||
number=int(data["number"]),
|
||||
state=str(data.get("state") or "open"),
|
||||
labels=tuple(data.get("labels") or ()),
|
||||
title=str(data.get("title") or ""),
|
||||
priority=int(data.get("priority") or 0),
|
||||
head_sha=data.get("head_sha"),
|
||||
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||
approval_on_current_head=bool(data.get("approval_on_current_head")),
|
||||
approval_stale=bool(data.get("approval_stale")),
|
||||
approval_contaminated=bool(data.get("approval_contaminated")),
|
||||
mergeable=bool(data.get("mergeable")),
|
||||
blocked=bool(data.get("blocked")),
|
||||
dependency_unmet=bool(data.get("dependency_unmet")),
|
||||
dependency_reason=data.get("dependency_reason"),
|
||||
already_claimed_elsewhere=bool(data.get("already_claimed_elsewhere")),
|
||||
)
|
||||
Reference in New Issue
Block a user