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
611 lines
21 KiB
Python
611 lines
21 KiB
Python
"""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")),
|
|
)
|