Fix the PR-divergence respawn loop: loop gate, CEO god-mode, PR conflict resolver, sequence-ordered merge (#164)

* fix(orchestrator,panel): bound the respawn loop gate and give the CEO a status override

The PM respawn loop gate could never fire on a recurring tracing_gap: every
same-status respawn that emitted a tracing_gap reset the strike counter, so a
task whose unblock can never satisfy its decision gate respawned forever. Cap
the number of tracing_gap resets (pm_respawn_max_tracing_resets) so strikes
accrue once a gap is clearly recurring rather than progressing, and route the
pm-review and blocker dispatch respawn paths through the gate so it actually
applies to those loops.

Panel: the task status dropdown was driven solely by the lifecycle graph, so a
task wedged in a terminal/blocked state offered no actionable transitions. Add
an audited admin status override (PATCH status -> admin_set_status) for every
non-in-band target, letting the human operator force any state.

* feat(git): add rebase_onto_base and close_pull_request PR-divergence primitives

Agents had no way to resolve a PR that could not merge because a sibling merged
overlapping work first: their only moves were complete (which 405s) or block
(which loops). Add the two missing operations:

- rebase_onto_base rebases a head branch onto the latest base and classifies
  the outcome: superseded (no unique commits -> safe to close), rebased (unique
  work -> force-pushed, ready to merge), or conflicts (aborted, needs a human).
- close_pull_request retires a superseded PR with an explanatory comment.

These back both the sequence-ordered merge and the conflict resolver.

* feat(gateway): auto-resolve a leaf PR that can't merge instead of looping

When a sibling lands overlapping work first, the cell PM's complete() merge
hits a GitHub 405 and the task re-blocks, respawning the PM forever (the
production wedge: one task burned 6000+ tool calls over 3 hours). The merge
now raises MergeConflictError, and cell_pm_complete resolves it:

- rebase the branch onto the current base;
- superseded (no unique commits) -> close the dead PR + complete the task
  without a redundant merge (the manual action operators kept requesting);
- rebased (unique work) -> retry the merge, then complete;
- genuine conflicts -> admin-override the task to awaiting_ceo_approval and
  alert the CEO, so it leaves agent dispatch instead of looping.

MergeConflictError subclasses GitError, so existing handlers are unaffected.

* test(git): silence unused-arg lint in close_pull_request stub

* feat(orchestrator): sequence-ordered merge for leaf siblings

Leaf siblings share one cell branch, but within-cell siblings were all left at
the default sequence 0, so two leaf PRs raced into the same branch and the
second wedged. Now:

- decomposition assigns each new sibling the next ordinal within its parent, so
  the merge order is well-defined;
- the pm-review dispatcher holds a higher-sequence leaf until its earlier
  same-team siblings are terminal, so they merge into the shared branch in order
  instead of racing.

Loop-free by construction: a gated task is simply not dispatched this tick (no
reject, no respawn). Terminal siblings never block, so a cancelled sibling can't
deadlock the rest; any sibling lookup failure degrades to dispatch.

* test: use monkeypatch.setattr instead of type:ignore in new tests

CI type-checks tests/ (the type-gated suite) which my local 'mypy roboco/' skipped.
The method-mock assignments tripped mypy method-assign/assignment; replace the
silencing comments with monkeypatch.setattr and local mock refs for assertions,
matching the project's no-type:ignore rule.

* fix(git): stop get_status misreporting an unstaged deletion as staged

git_status used stdout.strip().split() before parsing porcelain. strip() eats
the leading space on the first line, so an unstaged deletion (' D file') became
'D file' and parsed as a STAGED deletion — the false 'staged' that caused 6
wasted QA cycles when a dev deleted a file without staging it. Use splitlines(),
which preserves the index/worktree status columns.

* feat(panel): mobile sidebar hamburger + Sheet drawer (AC1)

The umbrella's AC1 was never built: on mobile the sidebar had no entry point.
Extract the nav/footer into shared SidebarNav/SidebarFooter, hide the static
sidebar below md, and add a hamburger in the header that opens the same nav in a
left Sheet drawer (closing on navigation). Desktop is unchanged.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-14 23:18:58 +02:00
committed by GitHub
co-authored by Renn F
parent bb9d4ff12a
commit 2817ca1ceb
14 changed files with 1297 additions and 64 deletions
+11
View File
@@ -453,6 +453,17 @@ def _scrub_git_secrets(text: str) -> str:
return text
class MergeConflictError(GitError):
"""A PR could not be merged because its branch conflicts with the base.
Raised when the GitHub merge API refuses the merge (e.g. HTTP 405 "not
mergeable") after the in-band retry. Distinct from a generic ``GitError``
so the completion path can route to conflict resolution (rebase / close
superseded / escalate) instead of failing and looping. A subclass of
``GitError`` so existing ``except GitError`` handlers stay correct.
"""
class GitCommandError(GitError):
"""Git command execution failed."""
+7
View File
@@ -35,6 +35,13 @@ class BudgetPolicy:
loop_window: int = 10 # rolling-window size
loop_action: Literal["warn", "halt"] = "halt" # NEW: was effectively "warn"
pm_respawn_max_unproductive: int = 3
# A same-status respawn that emitted a tracing_gap is normally treated as a
# rule-following retry and resets the unproductive counter. That reset is
# bounded: a task whose EVERY respawn trips the SAME tracing_gap is a stuck
# loop, not progress (e.g. a cold-respawned PM that can never satisfy the
# unblock journal-decision gate). After this many consecutive resets the
# gap stops counting as progress and strikes accrue, so the loop gate fires.
pm_respawn_max_tracing_resets: int = 3
verb_retry_max_per_minute: int = 3 # default cap for verbs not in VERB_RETRY_LIMITS
+94 -5
View File
@@ -5585,6 +5585,7 @@ Start now: evidence(task_id="{task_id}")
# Use foundation's default; keep the local name for back-compat.
_PM_RESPAWN_MAX_UNPRODUCTIVE = _AGENT_LOOP_BUDGET.pm_respawn_max_unproductive
_PM_RESPAWN_MAX_TRACING_RESETS = _AGENT_LOOP_BUDGET.pm_respawn_max_tracing_resets
async def _pm_respawn_should_gate(
self, agent_slug: str, task: dict[str, Any]
@@ -5629,12 +5630,29 @@ Start now: evidence(task_id="{task_id}")
}
return False
# Same status as last spawn — could be a stuck loop OR a
# rule-following retry. Consult audit before counting.
# rule-following retry. A tracing_gap normally means the agent is
# advancing through a verb chain, so reset the strike counter — but
# only up to a bound. A task whose EVERY respawn trips the same gap is
# wedged, not progressing (e.g. the unblock journal-decision gate a
# cold-respawned PM can never satisfy), so cap the resets and let
# strikes accrue once the budget is exhausted. Without this cap the
# gate never fires for a tracing_gap loop and respawns run forever.
if await self._pm_made_rule_following_retry(agent_slug, task_id, record):
record["count"] = 1
record["last_check"] = now
record["notified"] = False
return False
resets = record.get("tracing_resets", 0)
if resets < self._PM_RESPAWN_MAX_TRACING_RESETS:
record["tracing_resets"] = resets + 1
record["count"] = 1
record["last_check"] = now
record["notified"] = False
return False
logger.warning(
"PM respawn tracing_gap reset budget exhausted — "
"treating recurring gap as a stuck loop",
agent_id=agent_slug,
task_id=task_id,
task_status=current_status,
tracing_resets=resets,
)
record["count"] += 1
record["last_check"] = now
if record["count"] > self._PM_RESPAWN_MAX_UNPRODUCTIVE:
@@ -6690,6 +6708,57 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
)
return True
async def _blocked_by_earlier_sibling(self, task: dict[str, Any]) -> bool:
"""True if a lower-sequence, same-team sibling is not yet terminal.
Sequence-ordered merge: leaf siblings share one cell branch, so merging
a later sibling before an earlier one diverges the branch and wedges the
loser. Hold a higher-sequence sibling's review/merge dispatch until the
earlier ones land (or are cancelled). Loop-free: the task simply isn't
dispatched this tick no reject, no respawn churn.
Only same-team siblings block (they target the same branch). Terminal
siblings (completed/cancelled) never block, so a cancelled sibling can't
deadlock the rest. Best-effort: any lookup failure falls through to
dispatch the ordering check must never wedge the dispatcher.
"""
parent_id = task.get("parent_task_id")
seq = task.get("sequence")
team = task.get("team")
if not parent_id or seq is None:
return False
from uuid import UUID
from roboco.db.base import get_session_factory
from roboco.models.base import TaskStatus
from roboco.services.task import get_task_service
terminal = {TaskStatus.COMPLETED, TaskStatus.CANCELLED}
try:
session_factory = get_session_factory()
async with session_factory() as db:
task_svc = get_task_service(db)
siblings = await task_svc.get_subtasks(UUID(str(parent_id)))
except Exception as exc:
logger.debug(
"sibling-order check failed; dispatching anyway",
task_id=task.get("id"),
error=str(exc),
)
return False
for sib in siblings:
sib_seq = getattr(sib, "sequence", 0) or 0
sib_team = getattr(sib, "team", None)
sib_status = getattr(sib, "status", None)
sib_team_val = getattr(sib_team, "value", sib_team)
if (
str(sib_team_val) == str(team)
and sib_seq < seq
and sib_status not in terminal
):
return True
return False
async def _dispatch_pm_review_work(self, client: httpx.AsyncClient) -> None:
"""
Dispatch PM review work to cell PMs or Main PM.
@@ -6703,11 +6772,23 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
team = task.get("team")
assigned_to = task.get("assigned_to")
# Sequence-ordered merge: don't review/merge a leaf until its
# earlier same-team siblings have landed, so they merge into the
# shared cell branch in order instead of racing and wedging.
if await self._blocked_by_earlier_sibling(task):
continue
# If already assigned, check if that agent is running
if assigned_to:
assigned_slug = self._resolve_agent_slug(assigned_to)
if self._is_agent_active(assigned_slug):
continue
# Loop guard: a review task that keeps re-surfacing without
# advancing (e.g. an unmergeable PR that re-blocks every cycle)
# must stop respawning the reviewer, else it burns tokens
# forever. The gate notifies the CEO once it trips.
if await self._pm_respawn_should_gate(assigned_slug, task):
continue
# Agent not running - spawn them to continue
await self.spawn_agent(
agent_id=assigned_slug,
@@ -6819,6 +6900,14 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
if self._is_agent_active(agent_id):
continue
# Loop guard: a blocked task whose unblock can never succeed (e.g.
# a cold-respawned PM that can't satisfy the unblock decision gate,
# or an unresolvable merge conflict) must stop respawning the
# resolver. The gate notifies the CEO once it trips so the wedged
# task surfaces instead of silently burning tokens.
if await self._pm_respawn_should_gate(agent_id, task):
continue
await self.spawn_agent(
agent_id=agent_id,
task_id=task["id"],
+143 -3
View File
@@ -19,6 +19,7 @@ from uuid import UUID
import structlog
from roboco.exceptions import MergeConflictError
from roboco.foundation.policy import lifecycle as spec_module
from roboco.services.gateway.choreographer._verb_runner import VerbRunner
from roboco.services.gateway.claim_guards import (
@@ -4102,6 +4103,14 @@ class Choreographer:
estimated_complexity=complexity_enum,
)
new_task = await self.task.create_subtask(req)
# Assign a distinct ordinal within the parent's siblings so the merge
# order is deterministic. Within-cell siblings were all left at the
# default sequence 0 — which is why two leaf PRs raced into the same
# cell branch and the second wedged. Each new sibling takes the next
# ordinal (the count of pre-existing siblings).
siblings = await self.task.get_subtasks(parent_task_id)
next_seq = len([s for s in siblings if s.id != new_task.id])
await self.task.set_sequence(new_task.id, next_seq)
# Thread the parent's existing session links onto the
# new subtask so the assigned agent (dev/qa/doc) lands in the
# group chat the PM has already been talking in. Pre-gateway
@@ -4631,16 +4640,37 @@ class Choreographer:
# change); for a cell task it is the root branch (feature/main_pm/…),
# which parent_branch_for would have mis-derived as feature/<cellteam>/…
target = await resolve_parent_branch(t, self.task)
merge_result = await self.git.pr_merge(
t.pr_number, target=target, actor_agent_id=pm_agent_id
try:
merge_result = await self.git.pr_merge(
t.pr_number, target=target, actor_agent_id=pm_agent_id
)
except MergeConflictError as exc:
# A sibling landed overlapping work first, so this PR can't merge.
# Resolve it (rebase / close-superseded / escalate) instead of
# letting the failure re-block the task and respawn the PM forever.
return await self._resolve_merge_conflict_on_complete(
pm_agent_id, task_id, t, target, notes, exc
)
return await self._finalize_cell_complete(
pm_agent_id, task_id, t, notes, merge_result.get("merge_commit_sha")
)
async def _finalize_cell_complete(
self,
pm_agent_id: UUID,
task_id: UUID,
t: Any,
notes: str,
merge_commit: str | None,
) -> Envelope:
"""Mark the leaf completed and propagate the completion to its parent."""
leaf_parent_id = t.parent_task_id
leaf_team = t.team
t = await self.task.cell_pm_complete(
pm_agent_id,
task_id,
notes,
merge_commit=merge_result.get("merge_commit_sha"),
merge_commit=merge_commit,
)
# Now that the leaf is completed, propagate the completion up to the
# parent task: if the parent's subtasks are all terminal, hand the
@@ -4655,6 +4685,116 @@ class Choreographer:
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role="cell_pm")
async def _resolve_merge_conflict_on_complete(
self,
pm_agent_id: UUID,
task_id: UUID,
t: Any,
target: str,
notes: str,
exc: MergeConflictError,
) -> Envelope:
"""Resolve a leaf PR that couldn't merge because a sibling landed first.
Rebase the branch onto the current base and act on the outcome rather
than failing (which re-blocks the task and respawns the PM forever):
- ``rebased`` — the branch now integrates cleanly; retry the merge.
- ``superseded`` — every change is already in the base via the sibling;
close the dead PR and complete the task without a redundant merge.
- ``conflicts`` / ``unknown`` — a human must resolve; escalate to the
CEO (``awaiting_ceo_approval``) so the task leaves the agent loop.
"""
rebase = await self.git.rebase_pr_for_task(
t.pr_number, actor_agent_id=pm_agent_id
)
status = rebase.get("status")
if status == "rebased":
merge_result = await self.git.pr_merge(
t.pr_number, target=target, actor_agent_id=pm_agent_id
)
return await self._finalize_cell_complete(
pm_agent_id, task_id, t, notes, merge_result.get("merge_commit_sha")
)
if status == "superseded":
await self.git.close_pull_request(
t.pr_number,
comment=(
"Closed as superseded: every change on this branch is "
"already present in the base via a sibling PR that merged "
"first. Completing the task without a redundant merge."
),
actor_agent_id=pm_agent_id,
)
return await self._finalize_cell_complete(
pm_agent_id, task_id, t, notes, None
)
return await self._escalate_merge_conflict_to_ceo(
pm_agent_id, task_id, t, rebase, exc
)
async def _escalate_merge_conflict_to_ceo(
self,
pm_agent_id: UUID,
task_id: UUID,
t: Any,
rebase: dict[str, Any],
exc: MergeConflictError,
) -> Envelope:
"""Route an unresolvable PR conflict to the CEO; never loop on it.
Moves the task to ``awaiting_ceo_approval`` (admin override — the leaf
has no in-band edge there) so it leaves agent dispatch, and best-effort
alerts the CEO with the conflicting files.
"""
from roboco.models.base import TaskStatus
files = rebase.get("files") or []
logger.info(
"merge conflict escalated to CEO",
task_id=str(task_id),
conflicting_files=len(files),
rebase_status=rebase.get("status"),
merge_error=str(exc),
)
await self.task.admin_set_status(
task_id,
TaskStatus.AWAITING_CEO_APPROVAL,
actor_id=pm_agent_id,
actor_role="cell_pm",
)
await self._notify_ceo_merge_conflict(task_id, files)
t = await self.task.get(task_id)
detail = f" ({len(files)} conflicting file(s))" if files else ""
return Envelope.ok(
status=str(t.status),
task_id=str(task_id),
next=(
"the PR has merge conflicts that could not be resolved "
f"automatically{detail}; escalated to the CEO. A developer can "
"rebase the branch, or the CEO can close the PR if superseded."
),
context_briefing=await self._briefing_for(pm_agent_id, task_id),
).with_introspection(task=t, role="cell_pm")
async def _notify_ceo_merge_conflict(self, task_id: UUID, files: list[str]) -> None:
"""Best-effort CEO alert for a wedged merge conflict; never raises."""
from roboco.services.notification import NotificationService
try:
await NotificationService().send_stuck_agent_notification(
task_id=str(task_id),
agent_slug="cell_pm",
task_status="awaiting_ceo_approval",
to_agent="ceo",
)
except Exception:
logger.warning(
"failed to send CEO merge-conflict notification",
task_id=str(task_id),
conflicting_files=len(files),
)
async def _maybe_advance_parent_to_pm_review(
self, parent_task_id: UUID, leaf_team: Any
) -> None:
+204 -3
View File
@@ -39,7 +39,12 @@ if TYPE_CHECKING:
)
from roboco.db.tables import TaskTable
from roboco.config import settings
from roboco.exceptions import GitCommandError, GitError, GitTimeoutError
from roboco.exceptions import (
GitCommandError,
GitError,
GitTimeoutError,
MergeConflictError,
)
from roboco.models.base import AgentRole, TaskStatus
from roboco.services.base import (
BaseService,
@@ -388,7 +393,13 @@ class GitService(BaseService):
current_branch = branch_result.stdout.strip()
status_result = await self._run_git(workspace, ["status", "--porcelain"])
lines = status_result.stdout.strip().split("\n") if status_result.stdout else []
# Use splitlines(), NOT stdout.strip().split("\n"): porcelain encodes the
# index column in position 0, which is a SPACE for a worktree-only change
# (e.g. " D file" = unstaged deletion). strip() eats that leading space on
# the first line, turning " D file" into "D file" — which then parses as a
# STAGED deletion. That false "staged" caused 6 wasted QA cycles when a
# dev deleted a file but hadn't staged it. splitlines() preserves columns.
lines = status_result.stdout.splitlines() if status_result.stdout else []
staged_files, unstaged_files, untracked_files = self._classify_porcelain(lines)
ahead, behind = await self._ahead_behind(workspace, current_branch)
@@ -2277,7 +2288,11 @@ class GitService(BaseService):
ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, "squash"
)
if not resp.is_success:
raise GitError(
# A merge refusal (typically 405 "not mergeable") means the branch
# conflicts with the base — a sibling landed overlapping work first.
# Raise the specific subclass so the completion path can rebase /
# close-superseded / escalate instead of failing into a respawn loop.
raise MergeConflictError(
f"GitHub API refused PR merge ({resp.status_code}): {resp.text[:200]}",
{"owner": ctx.owner, "repo": ctx.repo, "pr": ctx.pr_number},
)
@@ -2361,6 +2376,192 @@ class GitService(BaseService):
)
return {"merge_commit_sha": merge_commit or None}
async def _get_pr_refs(
self, owner: str, repo: str, pr_number: int, git_token: str
) -> tuple[str, str] | None:
"""Return ``(head_ref, base_ref)`` for a PR, or ``None`` if unavailable."""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
except httpx.HTTPError:
return None
if not resp.is_success:
return None
data = resp.json()
head = (data.get("head") or {}).get("ref")
base = (data.get("base") or {}).get("ref")
if not head or not base:
return None
return str(head), str(base)
async def rebase_onto_base(
self,
workspace: Path,
*,
head_branch: str,
base_branch: str,
git_token: str,
) -> dict[str, Any]:
"""Rebase ``head_branch`` onto the latest ``base_branch`` from origin.
This is the primitive both the sequence-ordered merge (rebase a later
sibling onto the prior one's merged result) and the conflict resolver
(rebase a wedged PR before re-merging) build on.
Returns one of:
- ``{"status": "superseded"}`` — the rebase was clean and the head
has NO commits the base lacks; all its work is already in the base
(e.g. a sibling that merged a superset landed first). Safe to close
the PR + complete the task without a merge.
- ``{"status": "rebased", "unique_commits": int}`` — clean rebase
with unique work; the rebased branch was force-pushed to origin and
can now merge cleanly.
- ``{"status": "conflicts", "files": [...]}`` — the rebase hit
conflicts and was aborted; a developer must resolve by hand.
Never touches the base branch and only ever force-pushes
``head_branch`` (with ``--force-with-lease``). The caller must ensure
``base_branch`` is not a protected/default branch — agents never
rebase-merge into master.
"""
await self._run_git(workspace, ["fetch", "origin"], token=git_token)
await self._run_git(workspace, ["checkout", head_branch])
await self._run_git(workspace, ["reset", "--hard", f"origin/{head_branch}"])
rebase = await self._run_git(
workspace, ["rebase", f"origin/{base_branch}"], check=False
)
if rebase.returncode != 0:
conflict = await self._run_git(
workspace,
["diff", "--name-only", "--diff-filter=U"],
check=False,
)
files = [f for f in conflict.stdout.splitlines() if f.strip()]
await self._run_git(workspace, ["rebase", "--abort"], check=False)
return {"status": "conflicts", "files": files}
count = await self._run_git(
workspace,
["rev-list", "--count", f"origin/{base_branch}..HEAD"],
)
unique = int(count.stdout.strip() or "0")
if unique == 0:
return {"status": "superseded"}
await self._run_git(
workspace,
["push", "--force-with-lease", "origin", f"HEAD:{head_branch}"],
token=git_token,
)
return {"status": "rebased", "unique_commits": unique}
async def rebase_pr_for_task(
self,
pr_number: int,
*,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]:
"""Resolve workspace/refs for a PR and rebase its branch onto the base.
Thin wrapper over :meth:`rebase_onto_base` that loads the task/project
that owns the PR (mirrors :meth:`pr_merge`) and reads the PR's head/base
refs from GitHub. Returns the same classification dict, or
``{"status": "unknown"}`` when refs can't be resolved.
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
result = await self.session.execute(
select(_TaskTable).where(_TaskTable.pr_number == pr_number).limit(1)
)
task = result.scalar_one_or_none()
if task is None:
raise NotFoundError("PR", str(pr_number))
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project for task", str(task.id))
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
git_token = await self._get_project_token_or_raise(project.slug)
owner, repo = self._parse_github_remote(workspace)
refs = await self._get_pr_refs(owner, repo, pr_number, git_token)
if refs is None:
return {"status": "unknown"}
head_branch, base_branch = refs
return await self.rebase_onto_base(
workspace,
head_branch=head_branch,
base_branch=base_branch,
git_token=git_token,
)
async def close_pull_request(
self,
pr_number: int,
*,
comment: str | None = None,
delete_branch: bool = True,
actor_agent_id: UUID | None = None,
) -> None:
"""Close PR ``pr_number`` on GitHub, optionally with an explanatory comment.
Used to retire a PR whose work is already in the base (superseded) so a
wedged task can complete without a merge — the "close the dead PR"
action agents had no verb for. Best-effort branch cleanup on close.
"""
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
result = await self.session.execute(
select(_TaskTable).where(_TaskTable.pr_number == pr_number).limit(1)
)
task = result.scalar_one_or_none()
if task is None:
raise NotFoundError("PR", str(pr_number))
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project for task", str(task.id))
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
git_token = await self._get_project_token_or_raise(project.slug)
owner, repo = self._parse_github_remote(workspace)
headers = {
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
if comment:
await client.post(
f"https://api.github.com/repos/{owner}/{repo}/issues/"
f"{pr_number}/comments",
headers=headers,
json={"body": comment},
)
resp = await client.patch(
f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}",
headers=headers,
json={"state": "closed"},
)
if not resp.is_success:
raise GitError(
f"GitHub API refused PR close ({resp.status_code}): {resp.text[:200]}",
{"owner": owner, "repo": repo, "pr": pr_number},
)
if delete_branch:
await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token)
async def pr_target(
self,
pr_number: int,