Files
roboco/roboco/services/git.py
T
06682f33c6 Fix: agent workflow hardening (#70)
* fix(gateway): push the branch before QA handoff so reviewers see the latest commits

The commit content tool commits locally without pushing; only open_pr pushed
the branch. On the first submission that was fine, but a fix committed while
addressing needs_revision never reached origin (open_pr is skipped once the PR
exists), so QA — which reviews the remote PR branch — re-reviewed the stale
remote and re-failed the task on every cycle, a loop that never converged.

i_am_done now pushes the task branch (idempotent; a no-op when nothing is
unpushed) as part of the shared submit gate, covering both the normal and
resume-from-verifying paths. A push failure blocks the handoff with a clear
remediation rather than parking the task in awaiting_qa with commits that exist
only in the developer's local workspace.

* fix(orchestrator): don't reap a stale claim while the agent's container is alive

The stale-claim reaper released any claimed/in_progress task whose
last_heartbeat_at exceeded the TTL. The heartbeat only updates on certain
gateway calls, so a developer deep in a long edit/test cycle outran the TTL and
had its claim reaped mid-work — churning the task and risking a double spawn
against the still-running container.

The reaper now skips a task whose assignee still holds a live (ACTIVE) agent
instance, trusting container liveness — the ground truth — over the heartbeat
proxy. The check is defensive on missing fields so a heartbeat-only caller (and
the reaper's existing unit tests) behave exactly as before.

* fix(gateway): refuse to unblock a task while a dependency is unfinished

A PM unblock on a dependency-gated task moved it straight to in_progress,
overriding the dependency — letting a dependent proceed without its upstream's
work (e.g. a frontend task built before its UX design lands). A dependency
block is meant to clear on its own via _unblock_dependents the moment the
upstream reaches a terminal state.

unblock now refuses while any dependency is still non-terminal, returning a
clear remediation that the block resolves automatically. Manual unblock remains
available for genuine, non-dependency blockers.

* fix(gateway): release a dependency-blocked claim to pending instead of looping

A task that reached claimed/in_progress with an unfinished dependency was left
in that state when the claim guard rejected, so the orchestrator's respawn loop
kept reviving its assignee — which could make no progress — burning work for
nothing.

The claim guard now releases such a task back to pending. claimed -> blocked is
not a legal transition, so pending — held by the dispatch dependency filter — is
the lifecycle-correct resting state: the respawn loop ignores pending tasks, and
_unblock_dependents re-dispatches it once the upstream reaches a terminal state.
release_dependency_blocked_claim shares a _force_unclaim_to_pending core with
unclaim_for_reaper so both record a truthful work-session abandon reason.

* feat(security): warn at startup in header-trust mode + document the auth posture

When ROBOCO_AGENT_AUTH_REQUIRED is not enabled the API accepts the X-Agent-Id /
X-Agent-Role headers without a signed token, so any client that can reach it may
act as any role (including 'ceo'). The API now logs a clear warning at startup
in this mode, and the README gains a Security section documenting the auth
posture and how to harden it. Acceptable only on a trusted private network — do
not expose the API to untrusted networks.

* fix(workspace): scope the refresh fetch to current + default branch

ensure_workspace's healthy short-circuit ran an all-refs 'git fetch origin' to
keep every origin/<branch> ref current. On a monorepo with many accumulated
feature/* branches that exceeds the refresh timeout, the fetch silently fails,
and the workspace keeps a stale base — so an agent builds on an out-of-date
branch.

The refresh now fetches only the workspace's current branch and the repo's
default branch (resolved via origin/HEAD), with --no-tags --prune: it transfers
near-nothing and can't time out. Readers need their own branch and the default;
the integration branch is refreshed at branch-creation time.

* fix(git): refresh a dependency-blocked task's branch off the current integration tip

A cross-cell dependent (e.g. a frontend task waiting on the UX design) was
branched off a base captured before its upstream merged into the integration
branch, and the branch was never re-synced — so the agent built on a stale
snapshot with none of the upstream's work.

Two changes close the gap:
- release_dependency_blocked_claim now clears branch_name, so the re-claim
  (after the dependency clears) re-runs branch creation.
- create_branch, when the branch is already on disk with no commits of its own,
  resets it onto the freshly-pulled base — the dependent now builds on the
  current integration tip. A branch carrying real commits is left untouched, so
  no work is discarded; the cell->leaf cascade carries the upstream down to the
  dev branch automatically.

* refactor(gateway): drop the sibling-sequence claim guard

Sibling sequence no longer gates a claim. Cross-cell ordering is
enforced by task dependencies — a cell task that depends on another is
held until its upstream reaches a terminal state, a stronger,
status-aware gate than the sequence-number check. That check was
dormant in practice anyway: every fan-out child carries sequence 0, on
which the guard short-circuited. `sequence` stays a sibling-ordering /
dispatch-priority field (list_pending ordering and the panel).

Removes sibling_sequence_guard and its _earlier_blocking_sibling
helper, the now-unused skip_sequence parameter threaded through the
claim verbs, and the sibling fetch that fed it.

* feat(gateway): sort a cross-cell dependent after its upstream

When the frontend cell task is wired to depend on its UX/UI sibling, set
its sequence to the upstream's sequence + 1 so it sorts after the design
it waits on — list_pending ordering and the panel now show UX ahead of
the implementation it gates, in either delegation order.

Adds TaskService.set_sequence (the sibling-ordering field is a service
write; it carries no claim-gating semantics — dependencies gate claims).

* feat(gateway): make the backend cell depend on UX too

UX/UI design defines the screens and API contracts both implementation
cells build against, so the backend cell — not just the frontend — waits
on the UX/UI cell task in a product fan-out and sorts after it. Wires in
either delegation order: a backend task delegated after UX gets the
dependency directly; a UX task delegated after a still-pending backend
sibling retro-wires it.

Mirrors the existing frontend wiring (_depend_backend_on_ux and
_depend_pending_backends_on_ux). Backend is held by the same dependency
gate, so it costs no extra dispatch churn.

* fix(websocket): forward notification acks instead of logging them incomplete

The bridge handler serves both notification.sent and notification.acked,
but acked events carry `agent_id` (the acking agent) rather than
`recipient_id`, so every acknowledgement tripped the missing-field guard
and logged "Incomplete notification event" instead of reaching the panel.
Accept either field as the recipient.

* feat(api): hint the full UUID when a truncated task id fails validation

Agents copy the 8-character task prefix the system shows them (the commit
prefix, task summaries) and send it as task_id, which fails UUID
validation with an opaque "invalid length" 422 and wastes a call. The
request-validation handler now detects a task_id UUID error and attaches
a `remediate` hint telling the agent to retry with the full 36-character
UUID from its task envelope.

* fix(audit): record the blocked transition when a task is escalated

Escalation sets a task to blocked by writing task.status directly, which
bypassed the validated transition helper and so never emitted a
task.blocked audit row — the lifecycle moved but the Auditor saw nothing.
Extract the audit emit from the central transition helper into
_emit_status_transition_audit and call it from the escalate path,
capturing the prior status and outgoing owner before reassignment so the
row is attributed correctly.

* fix(docs): stop doubling the docs path so design specs index into RAG

The documenter sometimes hands a doc path already rooted at docs/, and
joining it onto DOCS_BASE_PATH (/app/docs) produced /app/docs/docs/...,
so the file was never found and the spec never indexed — the frontend
cell could not retrieve the UX design over RAG. Normalize the path
before joining: trust an absolute path, otherwise strip a single
redundant leading docs/ segment.

* feat(security): let the control panel authenticate in secure mode

With ROBOCO_AGENT_AUTH_REQUIRED=true every request must carry a valid
HMAC token, which locked the human control panel out — it sends role
headers but no token. nginx, the only trusted hop between the browser
and the API, now injects the CEO token on /api and /ws, so the browser
never holds the signing secret. The injected value is just the existing
per-agent token issued for the CEO identity (issue_panel_token), so the
token-verification path is unchanged. An empty value (dev/header-trust
mode) renders to no header.

`make panel-token` prints the value; set it as ROBOCO_PANEL_AGENT_TOKEN
in .env before enabling secure mode. .env.example and the README
Security section document the flow.

* chore(compose): consolidate the two compose files into one

docker-compose.yml and docker-compose.yaml had diverged: .yml — the file
Docker actually uses — carried ROBOCO_PUBLIC_BASE_URL but was missing the
/app/manifests bind-mount, while .yaml had the manifests mount but not
the base URL. Merge the union into docker-compose.yml and delete the
duplicate so there is one source of truth and no "multiple config files"
warning.

This activates the manifests mount in the deployed file: without it the
orchestrator writes per-agent tool manifests to its ephemeral container
fs, they never reach the host for the daemon to bind-mount, and agents
fall back to all-verbs registration. Drop the stale .yaml reference from
the config.py docstring, the labeler, and the CI path filters.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-05 16:35:22 +02:00

2603 lines
103 KiB
Python

"""
Git Service
Handles git operations for agents working on code tasks.
All business logic for git commands, commit templates, PR generation.
"""
from __future__ import annotations
import asyncio
import base64
import os
import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, cast
from uuid import UUID
import httpx
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
# `api.schemas.git` would trigger `api/__init__.py` (which historically
# loaded the FastAPI app + every route module). The package init no
# longer re-exports `app`, but keeping these imports type-only respects
# the layer rule: services don't depend on the api layer at runtime.
# Routes already pass the Pydantic models, so the methods are duck-typed
# at call time.
from roboco.api.schemas.git import (
GitCheckoutRequest,
GitCommitRequest,
GitCreateBranchRequest,
GitCreatePRRequest,
GitMergePRRequest,
)
from roboco.db.tables import TaskTable
from roboco.config import settings
from roboco.exceptions import GitCommandError, GitError, GitTimeoutError
from roboco.models.base import AgentRole, TaskStatus
from roboco.services.base import (
BaseService,
NotFoundError,
ServiceError,
UnauthorizedError,
ValidationError,
)
from roboco.services.project import get_project_service
from roboco.services.task import TaskService, get_task_service
from roboco.services.work_session import get_work_session_service
from roboco.services.workspace import WorkspaceError, get_workspace_service
from roboco.templates.git import (
BranchNameError,
CommitContext,
InternalPRContext,
RootPRContext,
SubtaskInfo,
build_branch_name,
build_commit_message,
build_pr_body_internal,
build_pr_body_root,
build_pr_title_internal,
build_pr_title_root,
get_root_task_id,
)
from roboco.templates.git.pr_internal import InternalCommitInfo
from roboco.templates.git.pr_root import CommitInfo as PRCommitInfo
from roboco.utils.converters import require_uuid
# Git command timeout in seconds. Sourced from settings so operators can
# raise it without a code change; `_run_git` accepts a per-call override for
# the rare long-running op (staging/committing a large changeset). Read via a
# helper rather than at import time so a test-time settings patch is honored.
def _default_git_timeout() -> int:
return settings.git_command_timeout_seconds
def _commit_git_timeout() -> int:
return settings.git_commit_timeout_seconds
def _network_git_timeout() -> int:
"""Budget for git ops that talk to origin (fetch / pull / push).
A push/fetch on a large private monorepo from a self-hosted runner can
take far longer than the sub-second local-op default — short-budgeting it
is what made open_pr time out before the branch ever reached the remote.
"""
return settings.git_network_timeout_seconds
# Dedicated thread pool for git subprocesses + the post-op ownership repair.
# These run in threads (subprocess + an os.walk chown); under concurrent agent
# load they must not queue behind — or starve — the event loop's default
# executor that every other to_thread call shares. A bounded dedicated pool
# isolates git work and caps host parallelism so a burst of commits/pushes
# can't thrash the box. Never shut down: lives for the process.
_GIT_EXECUTOR = ThreadPoolExecutor(
max_workers=int(os.environ.get("ROBOCO_GIT_EXECUTOR_WORKERS", "16")),
thread_name_prefix="git",
)
# A git subprocess or ownership repair slower than this is logged so a run can
# pinpoint where time goes (e.g. a push to origin). Below it, the fast path
# stays quiet. Set above the normal cost of a push/fetch to a private monorepo
# from a self-hosted runner (~1-2s) so routine ops don't spam "slow git op";
# only a genuinely slow op (>5s) is worth a warning.
_SLOW_GIT_OP_MS = 5000.0
# `_get_gh_env` and the gh-CLI code paths were removed in favor of direct
# GitHub REST API calls — no CLI dependency, and the PAT no longer touches
# subprocess argv / environ.
# Expected number of parts in various git outputs
_REV_LIST_PARTS = 2
# GitHub REST API status codes
_GH_UNPROCESSABLE = 422
# 404 means the PR (or repo) does not exist; surfaced as a typed GitError
# by `update_pr_for_task` so the gateway can convert it into a specific
# invalid_state envelope rather than the generic refusal message.
_HTTP_NOT_FOUND = 404
# 409 means the PR can't be merged in its current state — typically because
# a concurrent sibling-subtask merge updated the target branch and our local
# refs are stale. `pr_merge` re-syncs and retries exactly once on this code.
_HTTP_CONFLICT = 409
class GitService(BaseService):
"""
Service for git operations on agent workspaces.
Handles:
- Git command execution
- Commit creation with templates
- PR creation with templates
- Branch management
"""
service_name: ClassVar[str] = "git"
async def _run_git(
self,
workspace: Path,
args: list[str],
check: bool = True,
token: str | None = None,
timeout: int | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run a git command in the workspace (non-blocking).
If `token` is given, injects an Authorization header via `-c
http.extraheader=...` for this one invocation. The token never
lands in argv (it goes in a config flag that git consumes and
then forgets) and never touches `.git/config` on disk. Use this
for push / fetch / ls-remote — any op that talks to origin.
`timeout` overrides the default per-command budget
(``settings.git_command_timeout_seconds``). Staging and committing
a large changeset (e.g. the Next.js panel) can exceed the short
default while git hashes every object and we re-chown the tree, so
the commit choreography passes the longer
``settings.git_commit_timeout_seconds``.
After every orchestrator-side git op, hand ownership back to the
agent user. Git commands here run as root and create root-owned
files under .git/ (refs, logs/refs, packed-refs, index, objects).
If we don't re-chown, the agent container (uid 1000) can't append
to those files on its next commit and fails with
"unable to append to .git/logs/refs/heads/...".
"""
from roboco.services.workspace import _ensure_agent_owned
effective_timeout = timeout if timeout is not None else _default_git_timeout()
prefix: list[str] = []
if token:
# GitHub's git-over-HTTPS (smart HTTP) authenticates with HTTP
# Basic, NOT Bearer. Using Bearer here causes git to fall
# through to the credential prompt and fail with
# "could not read Username for 'https://github.com'".
# Bearer is only correct for the REST API (PR create/merge,
# via httpx) — keep them separate.
basic = base64.b64encode(f"x-access-token:{token}".encode()).decode()
prefix = [
"-c",
f"http.extraheader=Authorization: Basic {basic}",
]
def _run() -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *prefix, *args],
cwd=workspace,
capture_output=True,
text=True,
timeout=effective_timeout,
check=check,
)
loop = asyncio.get_running_loop()
op = " ".join(args[:2])
t0 = time.monotonic()
try:
result = await loop.run_in_executor(_GIT_EXECUTOR, _run)
except subprocess.TimeoutExpired as e:
raise GitTimeoutError(" ".join(args), effective_timeout) from e
except subprocess.CalledProcessError as e:
raise GitCommandError(
" ".join(args), e.stderr or e.stdout or "Unknown error"
) from e
git_ms = (time.monotonic() - t0) * 1000.0
# Hand .git (and tracked files) back to the agent: this root-run op
# created root-owned files under .git/. Runs in the dedicated git pool
# so it doesn't compete with the event loop's default executor.
t1 = time.monotonic()
await loop.run_in_executor(_GIT_EXECUTOR, _ensure_agent_owned, workspace)
chown_ms = (time.monotonic() - t1) * 1000.0
# Surface slow git/chown ops (instrumentation): a single line that
# pinpoints where an op's time went — the subprocess (e.g. a push to
# origin) vs the ownership repair — without flooding the fast path.
if git_ms > _SLOW_GIT_OP_MS or chown_ms > _SLOW_GIT_OP_MS:
self.log.warning(
"slow git op",
op=op,
git_ms=round(git_ms),
chown_ms=round(chown_ms),
timeout_s=effective_timeout,
workspace=str(workspace),
)
return result
async def _token_for_project(self, project_slug: str) -> str | None:
"""Decrypted project token for orchestrator-side remote git ops."""
from roboco.utils.crypto import EncryptionError
project_service = get_project_service(self.session)
try:
return await project_service.get_decrypted_token_by_slug(project_slug)
except EncryptionError:
return None
async def _token_for_workspace(self, workspace: Path) -> str | None:
"""Derive project_slug from workspace path, then load its token.
Workspace layout is `/data/workspaces/{project}/{team}/{agent}/`, so
the project slug is the first component after `/data/workspaces/`.
Returns None if it can't be derived or the project has no token.
"""
try:
parts = (
workspace.resolve().relative_to(Path(settings.workspaces_root)).parts
)
except (ValueError, OSError):
return None
if not parts:
return None
return await self._token_for_project(parts[0])
async def get_workspace(
self, project_slug: str, agent_id: UUID | None = None
) -> Path:
"""Get the workspace path for an agent on a project."""
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
if not project:
raise NotFoundError("Project", project_slug)
if agent_id is None:
if not project.workspace_path:
raise ValidationError(
f"Project '{project_slug}' has no workspace configured "
"and no agent_id provided for dynamic workspace resolution"
)
workspace = Path(project.workspace_path)
if not workspace.exists():
raise ValidationError(f"Workspace path does not exist: {workspace}")
return workspace
workspace_service = get_workspace_service(self.session)
try:
if settings.workspace_auto_clone:
workspace = await workspace_service.ensure_workspace(
project_slug=project_slug,
agent_id=agent_id,
git_url=project.git_url,
default_branch=project.default_branch or "master",
)
else:
workspace = await workspace_service.resolve_workspace(
project_slug=project_slug,
agent_id=agent_id,
)
if not workspace.exists():
raise ValidationError(
f"Workspace does not exist: {workspace}. "
"Clone the repository first or enable auto_clone."
)
except WorkspaceError as e:
raise ValidationError(str(e)) from e
return workspace
# =========================================================================
# STATUS / INFO METHODS
# =========================================================================
_PORCELAIN_STATUS_WIDTH: ClassVar[int] = 2
@staticmethod
def _classify_porcelain(
lines: list[str],
) -> tuple[list[str], list[str], list[str]]:
"""Split `git status --porcelain` lines into staged/unstaged/untracked.
Canonical porcelain format is `XY PATH` — 2 status chars, 1 space
separator, filename. Slicing `line[3:]` ASSUMES both X and Y are
always present as chars. Git sometimes emits a single-column
status (e.g., `M README.md` from very old git or non-canonical
callers), which trips `line[3:]` into producing "EADME.md".
Split on the first whitespace run to survive either layout.
"""
staged: list[str] = []
unstaged: list[str] = []
untracked: list[str] = []
min_len = GitService._PORCELAIN_STATUS_WIDTH + 1 # status + separator
expected_parts = 2 # split gives [status, path]
for line in lines:
if not line or len(line) < min_len:
continue
# Canonical: XY is positions [0:2], separator at [2], path [3:].
# Defensive: if the char at position 2 isn't whitespace (broken
# status line), fall back to splitting on whitespace.
if line[2] in (" ", "\t"):
status_code = line[:2]
file_path = line[3:]
else:
parts = line.split(maxsplit=1)
if len(parts) < expected_parts:
continue
status_code = parts[0].ljust(GitService._PORCELAIN_STATUS_WIDTH)[
: GitService._PORCELAIN_STATUS_WIDTH
]
file_path = parts[1]
if not file_path:
continue
if status_code[0] in "MADRC":
staged.append(file_path)
if status_code[1] in "MADRC":
unstaged.append(file_path)
if status_code == "??":
untracked.append(file_path)
return staged, unstaged, untracked
async def _ahead_behind(self, workspace: Path, branch: str) -> tuple[int, int]:
"""Return (ahead, behind) vs origin/<branch>; 0,0 on any error."""
try:
rev_cmd = f"{branch}...origin/{branch}"
rev_result = await self._run_git(
workspace, ["rev-list", "--left-right", "--count", rev_cmd], check=False
)
if rev_result.returncode != 0:
return 0, 0
parts = rev_result.stdout.strip().split()
if len(parts) != _REV_LIST_PARTS:
return 0, 0
return int(parts[0]), int(parts[1])
except GitError:
return 0, 0
async def get_status(
self, workspace: Path
) -> tuple[str, bool, list[str], list[str], list[str], int, int]:
"""Get git status for a workspace.
Returns tuple of:
(current_branch, has_changes, staged, unstaged, untracked, ahead, behind)
"""
branch_result = await self._run_git(workspace, ["branch", "--show-current"])
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 []
staged_files, unstaged_files, untracked_files = self._classify_porcelain(lines)
ahead, behind = await self._ahead_behind(workspace, current_branch)
has_changes = bool(staged_files or unstaged_files or untracked_files)
return (
current_branch,
has_changes,
staged_files,
unstaged_files,
untracked_files,
ahead,
behind,
)
async def get_current_branch(self, workspace: Path) -> str:
"""Get the current branch name.
`git branch --show-current` returns empty on detached HEAD. Raise
a clear error in that case — previously we returned the empty
string, and callers forwarded that to downstream git commands or
fell back to parsing `git branch` (plain), which produces the
literal "(HEAD detached at ...)" that then leaked into
`checkout -b` as a would-be branch name. Fail loud instead.
"""
result = await self._run_git(workspace, ["branch", "--show-current"])
branch = result.stdout.strip()
if not branch:
raise GitError(
"Workspace is on a detached HEAD (no current branch). "
"Checkout an actual branch before running this operation.",
{"workspace": str(workspace)},
)
return branch
# =========================================================================
# COMMIT METHODS
# =========================================================================
@staticmethod
def _parse_git_url(url: str) -> tuple[str, str]:
"""Extract (owner, repo) from any accepted GitHub URL form.
Handles tokened, plain-https, and SSH forms:
https://x-access-token:TOKEN@github.com/owner/repo.git
https://github.com/owner/repo.git
git@github.com:owner/repo.git
"""
path_match = re.search(
r"github\.com[:/]+(?P<owner>[^/]+)/(?P<repo>[^/\s]+?)(?:\.git)?$",
url,
)
if not path_match:
raise GitError(
"Could not parse GitHub owner/repo from remote URL",
{"url_host": url.rsplit("@", maxsplit=1)[-1].split("/", maxsplit=1)[0]},
)
return path_match.group("owner"), path_match.group("repo")
def _parse_github_remote(self, workspace: Path) -> tuple[str, str]:
"""Read the origin remote URL from a workspace and parse owner/repo."""
cfg = workspace / ".git" / "config"
try:
text = cfg.read_text()
except OSError as e:
raise GitError(
f"Could not read git config: {e}",
{"workspace": str(workspace)},
) from e
match = re.search(
r"^\s*url\s*=\s*(?P<url>\S+)",
text,
flags=re.MULTILINE,
)
if not match:
raise GitError(
"No remote URL in git config",
{"workspace": str(workspace)},
)
return self._parse_git_url(match.group("url"))
def _get_primary_session_id(self, task: TaskTable | None) -> str | None:
"""Get primary session ID from task's session links.
Guarded against MissingGreenlet: `task.session_links` is a lazy
relationship. If it hasn't been eager-loaded, touching it from this
sync helper inside an async request triggers an async IO call with
no greenlet context → `MissingGreenlet`, which breaks
POST /api/git/commit. Inspect loaded-state first and return
None when not loaded (callers treat None as "no primary session").
"""
if not task:
return None
from sqlalchemy import inspect as sa_inspect
if "session_links" in sa_inspect(task).unloaded:
return None
if not task.session_links:
return None
for link in task.session_links:
if link.is_primary:
return str(link.session_id)
return None
def _parse_commit_stats(self, stat_output: str) -> tuple[int, int, int]:
"""Parse git diff --stat output for insertions, deletions, files_changed."""
insertions, deletions, files_changed = 0, 0, 0
for line in stat_output.split("\n"):
if "insertion" not in line and "deletion" not in line:
continue
parts = line.split(",")
for part in parts:
if "insertion" in part:
insertions = int(part.strip().split()[0])
elif "deletion" in part:
deletions = int(part.strip().split()[0])
elif "file" in part:
files_changed = int(part.strip().split()[0])
return insertions, deletions, files_changed
async def create_commit(
self,
workspace: Path,
agent_id: UUID,
request: GitCommitRequest,
) -> tuple[str, str, int, int, int]:
"""Create a git commit with template-based message.
Returns: (commit_hash, full_message, files_changed, insertions, deletions)
"""
task_id = request.task_id
# Stage files. Large changesets get the longer commit-timeout budget
# (see `git_commit_timeout_seconds`) — the same reason the gateway
# `commit()` adapter uses it.
commit_timeout = _commit_git_timeout()
if request.files:
for file in request.files:
await self._run_git(workspace, ["add", file], timeout=commit_timeout)
else:
await self._run_git(workspace, ["add", "-A"], timeout=commit_timeout)
# Get task info for commit template
task_service = get_task_service(self.session)
task = await task_service.get(task_id)
# Get root task ID (walk up hierarchy)
root_task_id = await get_root_task_id(task_id, task_service)
# Get session ID
session_id = self._get_primary_session_id(task)
# Build commit message using template
commit_ctx = CommitContext(
task_id=str(task_id),
root_task_id=str(root_task_id),
agent_slug=str(agent_id),
session_id=session_id,
commit_type=request.commit_type,
scope=request.scope,
description=request.message,
body=request.body,
)
full_message = build_commit_message(
commit_ctx, settings.public_base_url.rstrip("/") + "/api"
)
# Create commit with agent attribution
author = f"{agent_id} <{agent_id}@roboco.ai>"
await self._run_git(
workspace,
["commit", "-m", full_message, "--author", author],
timeout=commit_timeout,
)
# Get commit info
log_result = await self._run_git(workspace, ["log", "-1", "--format=%H|%s"])
parts = log_result.stdout.strip().split("|")
commit_hash = parts[0] if parts else "unknown"
# Get stats
stat_result = await self._run_git(workspace, ["diff", "--stat", "HEAD~1..HEAD"])
insertions, deletions, files_changed = self._parse_commit_stats(
stat_result.stdout
)
return commit_hash, full_message, files_changed, insertions, deletions
async def _assert_task_owned_with_branch(
self, task_id: UUID, agent_id: UUID
) -> TaskTable:
"""Load task + check assignee + branch. Raises typed service errors."""
task_service = get_task_service(self.session)
task = await task_service.get(task_id)
if not task:
raise NotFoundError(resource_type="Task", resource_id=str(task_id))
if task.assigned_to != agent_id:
raise UnauthorizedError(
action="commit",
reason=(
"NOT_ASSIGNED: Only the assigned agent can operate on "
"this task's branch."
),
)
if not task.branch_name:
raise ValidationError(
"NO_BRANCH: Task has no branch set. Claim the task to "
"generate one before committing."
)
return task
async def _assert_on_task_branch(
self, workspace: Path, task_branch: str | None
) -> None:
"""Reject ops when the workspace is on a branch other than the task's."""
if not task_branch:
return
current_branch = await self.get_current_branch(workspace)
if current_branch and current_branch != task_branch:
raise ValidationError(
f"BRANCH_MISMATCH: Workspace is on '{current_branch}' but "
f"task requires '{task_branch}'. The branch is checked out "
f"into your clone by your role's claim verb: "
f"`i_will_work_on(task_id)` (devs), "
f"`i_will_plan(task_id, plan)` (PMs), "
f"`claim_doc_task(task_id)` (documenters), "
f"`claim_review(task_id)` (QA). Re-call your role's claim "
f"verb on this task instead of switching branches by hand."
)
async def _link_commit_to_task(
self,
task_uuid: UUID,
commit_hash: str,
message: str,
agent_id: UUID,
) -> None:
"""Attach a new commit to the task + work session (best effort).
Linking failures are logged but do not fail the commit: the commit
itself has already landed on the branch. Silent-swallow would hide
regressions in either add_commit path, so we log warnings instead.
"""
task_service = get_task_service(self.session)
try:
task = await task_service.get(task_uuid)
await task_service.add_commit(
task_id=task_uuid,
hash=commit_hash,
message=message,
agent_id=agent_id,
)
if task and task.work_session_id:
work_session_service = get_work_session_service(self.session)
await work_session_service.add_commit(
require_uuid(task.work_session_id), commit_hash
)
await self.session.commit()
except Exception as e:
self.log.warning(
"Commit linking failed; commit present on branch but "
"task rows not updated",
task_id=str(task_uuid),
commit_hash=commit_hash,
error=str(e),
)
async def commit_for_task(
self,
agent_id: UUID,
data: GitCommitRequest,
) -> tuple[str, str, int, int, int]:
"""Create a commit for the caller's assigned task.
Orchestrates precondition checks, workspace resolution, the git
commit itself, and commit-to-task linking. Raises typed service
errors; the API layer translates them to HTTP status codes.
Returns: (commit_hash, full_message, files_changed, insertions, deletions)
"""
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
workspace = await self.get_workspace(data.project_slug, agent_id)
await self._assert_on_task_branch(workspace, task.branch_name)
(
commit_hash,
full_message,
files_changed,
insertions,
deletions,
) = await self.create_commit(workspace, agent_id, data)
await self._link_commit_to_task(
data.task_id, commit_hash, data.message, agent_id
)
return commit_hash, full_message, files_changed, insertions, deletions
# =========================================================================
# BRANCH METHODS
# =========================================================================
async def _resolve_base_branch(
self,
task_id: UUID,
parent_branch_override: str | None,
project_slug: str,
task_service: Any,
) -> str:
"""Work out which branch the new task branch should be cut from.
Priority: explicit override → parent task's branch → project default
branch → "main".
"""
if parent_branch_override:
return parent_branch_override
task = await task_service.get(task_id)
if task and task.parent_task_id:
parent = await task_service.get(UUID(str(task.parent_task_id)))
if parent and parent.branch_name:
return str(parent.branch_name)
return await self._project_default_branch(project_slug)
async def _project_default_branch(self, project_slug: str) -> str:
"""Return the project's configured default branch, or 'master'."""
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
return (
str(project.default_branch)
if project and project.default_branch
else "master"
)
async def _checkout_base_with_fallback(
self,
workspace: Path,
base_branch: str,
default_branch: str,
task_id: UUID,
) -> str:
"""Checkout `base_branch`, falling back to default if it's missing.
Returns the branch actually checked out.
"""
result = await self._run_git(workspace, ["checkout", base_branch], check=False)
if result.returncode == 0:
return base_branch
# Local branch missing — try a tracking branch from remote
tracking = await self._run_git(
workspace,
["checkout", "-b", base_branch, f"origin/{base_branch}"],
check=False,
)
if tracking.returncode == 0:
return base_branch
# Neither local nor remote has this branch — fall back to default
self.log.warning(
"Parent branch unavailable locally and on origin; "
"falling back to default branch",
base_branch=base_branch,
default_branch=default_branch,
task_id=str(task_id),
)
await self._run_git(workspace, ["checkout", default_branch])
return default_branch
async def create_branch(
self,
workspace: Path,
team: str,
request: GitCreateBranchRequest,
) -> tuple[str, str]:
"""Create a task branch with hierarchical naming.
Returns: (branch_name, created_from)
"""
task_id = request.task_id
task_service = get_task_service(self.session)
try:
branch_name = await build_branch_name(
task_id=task_id,
branch_type=request.branch_type,
team=team,
task_service=task_service,
)
except BranchNameError as e:
raise ValidationError(str(e)) from e
base_branch = await self._resolve_base_branch(
task_id, request.parent_branch, request.project_slug, task_service
)
default_branch = await self._project_default_branch(request.project_slug)
# Token for any remote-touching git command below (fetch, ls-remote,
# pull, push). Injected into a single `http.extraheader` config for
# the subprocess — never stored in .git/config on disk.
project_token = await self._token_for_project(request.project_slug)
if base_branch != default_branch:
# Parent is not the default branch - check it exists on remote.
# If it doesn't (ancestor task was claimed but never pushed —
# e.g. PM paused before any commit), we fall back to the default
# branch below rather than hard-failing. The checkout logic has
# a matching fallback and logs a warning so this doesn't go
# unnoticed.
result = await self._run_git(
workspace,
["ls-remote", "--heads", "origin", base_branch],
check=False,
token=project_token,
)
if not result.stdout.strip():
self.log.warning(
"Parent branch not on remote; will fall back to default "
"branch when creating child branch",
base_branch=base_branch,
default_branch=default_branch,
task_id=str(task_id),
)
base_branch = default_branch
# Fetch ONLY the refs this path needs (base + default), not every
# remote branch — an all-refs `fetch origin` on a monorepo with dozens
# of stale branches (dependabot, feature, abandoned) is pure network
# cost on every branch creation. check=False so a base that isn't on
# the remote yet doesn't abort (the fallback below handles it).
fetch_refs = list(dict.fromkeys([base_branch, default_branch]))
await self._run_git(
workspace,
["fetch", "origin", *fetch_refs],
token=project_token,
check=False,
timeout=_network_git_timeout(),
)
base_branch = await self._checkout_base_with_fallback(
workspace, base_branch, default_branch, task_id
)
await self._run_git(
workspace,
["pull", "origin", base_branch],
token=project_token,
timeout=_network_git_timeout(),
)
# Idempotent branch creation: a prior attempt may have created the
# branch on disk but failed before the DB recorded branch_name (the
# claim rolls back its fields, but the on-disk branch persists). A
# plain `checkout -b` then fails "already exists" (exit 128), and the
# resulting error-handling cascade is how a retry spirals. Switch to
# the existing branch instead.
created = await self._run_git(
workspace, ["checkout", "-b", branch_name], check=False
)
if created.returncode != 0:
await self._run_git(workspace, ["checkout", branch_name])
# The branch already existed on disk. If it carries no commits of
# its own — a dependency-blocked task branched before its upstream
# merged into the integration branch, then released and re-claimed —
# re-point it at the freshly-pulled base so the agent builds on the
# current integration tip, not a stale snapshot. Guarded on "no
# commits unique to the branch": a branch with real work is left
# exactly as-is.
unique = await self._run_git(
workspace,
["rev-list", "--count", f"{base_branch}..{branch_name}"],
check=False,
)
if unique.returncode == 0 and unique.stdout.strip() == "0":
await self._run_git(
workspace, ["reset", "--hard", base_branch], check=False
)
await self._run_git(
workspace,
["push", "-u", "origin", branch_name],
token=project_token,
timeout=_network_git_timeout(),
)
# Ownership re-chown happens automatically inside `_run_git` now.
# Store branch name on task
await task_service.update(task_id, branch_name=branch_name)
return branch_name, base_branch
@staticmethod
def _enum_str(value: Any) -> str | None:
"""Return .value when present; otherwise str(), preserving None."""
if value is None:
return None
return value.value if hasattr(value, "value") else str(value)
def _resolve_branch_team(self, project_slug: str, project: Any, task: Any) -> str:
"""Pick the `team` segment for a create_branch call.
Uses the TASK's team first (who is doing the work), then falls
back to the project's assigned cell. This ensures a main-pm
planning task gets a main_pm branch even if the project cell
is backend.
"""
project_cell = (
self._enum_str(project.assigned_cell)
if project and project.assigned_cell
else None
)
task_team = self._enum_str(task.team) if task and task.team else None
if project_cell == "fullstack":
return f"{project_slug}/{task_team or 'cross'}"
if task_team:
return task_team
if project_cell:
return project_cell
return "cross"
async def create_branch_for_task(
self,
agent_id: UUID,
data: GitCreateBranchRequest,
) -> tuple[str, str]:
"""Resolve workspace + team segment, create the branch, commit.
Returns: (branch_name, created_from)
"""
task_service = get_task_service(self.session)
project_service = get_project_service(self.session)
workspace = await self.get_workspace(data.project_slug, agent_id)
task = await task_service.get(data.task_id)
project = await project_service.get_by_slug(data.project_slug)
team_for_branch = self._resolve_branch_team(data.project_slug, project, task)
branch_name, created_from = await self.create_branch(
workspace, team_for_branch, data
)
await self.session.commit()
return branch_name, created_from
async def checkout(self, workspace: Path, branch: str) -> None:
"""Checkout a branch.
Fetches from origin first to ensure remote branches are available.
If the branch doesn't exist locally, creates a tracking branch from remote.
"""
token = await self._token_for_workspace(workspace)
# Fetch just the branch we're about to check out, not every remote ref.
# check=False: a not-yet-pushed branch makes this a no-op, and the
# local/tracking checkout below still works.
await self._run_git(
workspace,
["fetch", "origin", branch],
token=token,
check=False,
timeout=_network_git_timeout(),
)
# Try direct checkout first (works if local branch exists)
result = await self._run_git(workspace, ["checkout", branch], check=False)
if result.returncode != 0:
# Branch doesn't exist locally - create tracking branch from remote
await self._run_git(
workspace, ["checkout", "-b", branch, f"origin/{branch}"]
)
async def _allowed_checkout_branches(
self, project_slug: str, agent_id: UUID
) -> set[str]:
"""Collect branches this agent is allowed to checkout."""
from sqlalchemy import or_, select
from roboco.db.tables import TaskTable
project_service = get_project_service(self.session)
task_service = get_task_service(self.session)
project = await project_service.get_by_slug(project_slug)
allowed: set[str] = set()
if project and project.default_branch:
allowed.add(project.default_branch)
# Include tasks where agent is either assignee OR claimer
result = await self.session.execute(
select(TaskTable)
.where(
or_(
TaskTable.assigned_to == agent_id,
TaskTable.claimed_by == agent_id,
)
)
.where(TaskTable.branch_name.is_not(None))
)
for t in result.scalars().all():
allowed.add(str(t.branch_name))
# Also include ancestor branches (parent task branches) so devs
# can checkout parent branches when working on subtasks.
my_tasks = await task_service.list_by_assignee(agent_id)
for t in my_tasks:
if t.branch_name:
allowed.add(str(t.branch_name))
return allowed
async def checkout_branch_for_agent(
self,
agent_id: UUID,
data: GitCheckoutRequest,
) -> None:
"""Enforce the checkout allowlist + run the checkout.
Agents can only check out branches they have reason to be on: any
of their own assigned tasks' branches, or the project default
branch (read-only inspection). Hierarchical prefixes are allowed
so a PM can checkout the parent branch of a subtask they own.
"""
allowed = await self._allowed_checkout_branches(data.project_slug, agent_id)
if data.branch not in allowed and not any(
owned.startswith(f"{data.branch}--") for owned in allowed
):
raise UnauthorizedError(
action="checkout",
reason=(
f"CHECKOUT_RESTRICTED: Cannot checkout '{data.branch}'. "
f"Allowed: {sorted(allowed)} (and their ancestors). "
"Claim the task whose branch you want to work on."
),
)
workspace = await self.get_workspace(data.project_slug, agent_id)
await self.checkout(workspace, data.branch)
async def push(self, workspace: Path, force: bool = False) -> tuple[str, int]:
"""Push commits to remote.
Returns: (branch, commits_pushed)
"""
branch = await self.get_current_branch(workspace)
token = await self._token_for_workspace(workspace)
count_result = await self._run_git(
workspace,
["rev-list", "--count", f"origin/{branch}..{branch}"],
check=False,
)
commits_to_push = (
int(count_result.stdout.strip()) if count_result.returncode == 0 else 0
)
args = ["push", "-u", "origin", branch]
if force:
args.insert(1, "--force")
await self._run_git(
workspace, args, token=token, timeout=_network_git_timeout()
)
return branch, commits_to_push
async def push_for_task(
self,
agent_id: UUID,
agent_role: AgentRole | str,
data: Any,
) -> tuple[str, int]:
"""Push commits for the caller's assigned task.
Force-push is CEO-only. Otherwise standard assignee + branch
preconditions. Raises typed service errors; the API layer
translates them to HTTP status codes.
Returns: (branch, commits_pushed)
"""
if getattr(data, "force", False) and agent_role != AgentRole.CEO:
raise UnauthorizedError(
action="force_push",
reason=(
"FORCE_PUSH_FORBIDDEN: Force-push is CEO-only. If your "
"branch diverged, `unclaim` the task and re-`claim` it — "
"the choreographer will rebuild the branch and you can "
"replay your commits via `commit(...)`."
),
)
task = await self._assert_task_owned_with_branch(data.task_id, agent_id)
workspace = await self.get_workspace(data.project_slug, agent_id)
await self._assert_on_task_branch(workspace, task.branch_name)
return await self.push(workspace, getattr(data, "force", False))
async def push_task_branch(self, agent_id: UUID, task_id: UUID) -> int:
"""Idempotently push a task's branch to origin; return commits pushed.
Reviewers see the remote PR branch, not the developer's workspace. A
fix committed during a revision cycle lives only in that local clone
until it is pushed — so without an explicit push at the QA-submission
boundary, QA re-reviews the stale remote and fails the same task on
every cycle. Self-resolves the project/workspace from the task so the
choreographer can call it with just (agent, task). A no-op when there
is nothing unpushed; raises typed service errors on a real failure.
"""
task = await self._assert_task_owned_with_branch(task_id, agent_id)
project = await self._project_for_task(task)
if project is None:
return 0
workspace = await self.get_workspace(project.slug, agent_id)
await self._assert_on_task_branch(workspace, task.branch_name)
_branch, pushed = await self.push(workspace)
return pushed
# =========================================================================
# PR METHODS
# =========================================================================
@staticmethod
def _collect_root_commits(
root: TaskTable, descendants: list[TaskTable]
) -> list[PRCommitInfo]:
"""Flatten commits across root + every descendant."""
out: list[PRCommitInfo] = []
for d in [root, *descendants]:
for c in d.commits or []:
out.append(
PRCommitInfo(
hash=str(c.get("hash", "")),
message=str(c.get("message", "")),
agent_slug=str(c.get("agent_id", "unknown")),
)
)
return out
@staticmethod
def _collect_agent_slugs(
root: TaskTable, descendants: list[TaskTable]
) -> list[str]:
"""Unique agent slugs involved in root + descendants."""
slugs = [str(d.assigned_to) for d in descendants if d.assigned_to]
if root.assigned_to:
slugs.append(str(root.assigned_to))
return list(set(slugs))
@staticmethod
def _primary_session_id(task: TaskTable) -> str | None:
"""Session flagged is_primary on the root task's session_links."""
for link in task.session_links or []:
if link.is_primary:
return str(link.session_id)
return None
async def _build_root_pr_context(
self,
task: TaskTable,
task_service: TaskService,
task_uuid: UUID,
source_branch: str,
) -> RootPRContext:
"""Build context for root task PR template."""
descendants = await task_service.get_all_descendants(task_uuid)
subtask_infos = [
SubtaskInfo(
id=str(d.id),
title=str(d.title),
status=str(d.status.value) if d.status else "unknown",
assigned_to=str(d.assigned_to) if d.assigned_to else None,
branch_name=str(d.branch_name) if d.branch_name else None,
commit_count=len(d.commits) if d.commits else 0,
)
for d in descendants
]
task_type = (
source_branch.split("/", maxsplit=1)[0]
if "/" in source_branch
else "feature"
)
return RootPRContext(
root_task_id=str(task.id),
root_task_title=str(task.title),
root_task_description=str(task.description) if task.description else "",
root_task_assigned_to=str(task.assigned_to) if task.assigned_to else None,
root_task_type=task_type,
subtasks=subtask_infos,
commits=self._collect_root_commits(task, descendants),
primary_session_id=self._primary_session_id(task),
agent_slugs=self._collect_agent_slugs(task, descendants),
acceptance_criteria=list(task.acceptance_criteria)
if task.acceptance_criteria
else [],
)
@staticmethod
def _str_or(value: Any, default: str = "") -> str:
"""Helper: str(value) when truthy, else default."""
return str(value) if value else default
@staticmethod
def _str_or_none(value: Any) -> str | None:
"""Helper: str(value) when truthy, else None."""
return str(value) if value else None
async def _build_internal_pr_context(
self,
task: TaskTable,
task_service: TaskService,
source_branch: str,
target_branch: str,
) -> InternalPRContext:
"""Build context for internal PR template."""
parent_task = None
if task.parent_task_id:
parent_task = await task_service.get(UUID(str(task.parent_task_id)))
task_commits = [
InternalCommitInfo(
hash=str(c.get("hash", "")),
message=str(c.get("message", "")),
)
for c in (task.commits or [])
]
status_value = self._str_or(
task.status.value if task.status else None, "unknown"
)
qa_passed = status_value in ("awaiting_documentation", "awaiting_pm_review")
return InternalPRContext(
task_id=str(task.id),
task_title=str(task.title),
task_description=self._str_or(task.description),
task_status=status_value,
task_assigned_to=self._str_or_none(task.assigned_to),
parent_task_id=self._str_or_none(parent_task.id if parent_task else None),
parent_task_title=self._str_or_none(
parent_task.title if parent_task else None
),
source_branch=source_branch,
target_branch=target_branch,
commits=task_commits,
session_id=None,
qa_notes=self._str_or_none(task.qa_notes),
qa_passed=qa_passed,
)
async def _get_project_token_or_raise(self, project_slug: str) -> str:
"""Fetch + decrypt the project's GitHub PAT, raising GitError on problem."""
from roboco.utils.crypto import EncryptionError
project_service = get_project_service(self.session)
try:
git_token = await project_service.get_decrypted_token_by_slug(project_slug)
except EncryptionError as e:
raise GitError(
f"Failed to decrypt git token for project '{project_slug}'. "
"The encryption key may have been rotated; re-set the project token."
) from e
if not git_token:
raise GitError(
f"Project '{project_slug}' has no git token configured. "
"Configure a GitHub PAT in the project settings to create PRs."
)
return git_token
async def _resolve_pr_target_branch(
self, request: GitCreatePRRequest, task: Any, default_branch: str
) -> str:
"""Pick the PR target branch: parent's branch (non-root) or default."""
if request.is_root_pr:
return default_branch
if task.parent_task_id:
task_service = get_task_service(self.session)
parent = await task_service.get(UUID(str(task.parent_task_id)))
branch = parent.branch_name if parent else None
return str(branch) if branch else default_branch
return default_branch
async def _generate_pr_title_body(
self,
request: GitCreatePRRequest,
task: Any,
source_branch: str,
target_branch: str,
task_id: UUID,
) -> tuple[str | None, str | None]:
"""Auto-generate title/body from templates when either is missing."""
pr_title = request.title
pr_body = request.body
if pr_title and pr_body:
return pr_title, pr_body
task_service = get_task_service(self.session)
api_base = settings.internal_api_url
if request.is_root_pr:
root_ctx = await self._build_root_pr_context(
task, task_service, task_id, source_branch
)
pr_title = pr_title or build_pr_title_root(root_ctx)
pr_body = pr_body or build_pr_body_root(root_ctx, api_base)
else:
internal_ctx = await self._build_internal_pr_context(
task, task_service, source_branch, target_branch
)
pr_title = pr_title or build_pr_title_internal(internal_ctx)
pr_body = pr_body or build_pr_body_internal(internal_ctx, api_base)
return pr_title, pr_body
async def _find_existing_pr(
self,
owner: str,
repo: str,
source_branch: str,
target_branch: str,
git_token: str,
) -> dict[str, Any] | None:
"""Return the first open PR for head→base, or None."""
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
existing = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/pulls",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
},
params={
"head": f"{owner}:{source_branch}",
"base": target_branch,
"state": "open",
},
)
if existing.is_success and existing.json():
return cast("dict[str, Any]", existing.json()[0])
return None
async def _post_pr(
self,
owner: str,
repo: str,
git_token: str,
payload: dict[str, Any],
) -> httpx.Response:
"""POST the PR payload to GitHub; translate HTTP errors to GitError."""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
return await client.post(
f"https://api.github.com/repos/{owner}/{repo}/pulls",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json=payload,
)
except httpx.HTTPError as e:
raise GitError(
f"GitHub API error while creating PR: {e}",
{"owner": owner, "repo": repo, "head": payload.get("head")},
) from e
async def create_pull_request(
self, workspace: Path, request: GitCreatePRRequest
) -> tuple[int, str, str, str, str]:
"""Create a pull request via the GitHub REST API.
Returns: (pr_number, pr_url, title, source_branch, target_branch)
"""
task_service = get_task_service(self.session)
task = await task_service.get(request.task_id)
if not task:
raise NotFoundError("Task", str(request.task_id))
source_branch = await self.get_current_branch(workspace)
default_branch = await self._project_default_branch(request.project_slug)
git_token = await self._get_project_token_or_raise(request.project_slug)
target_branch = await self._resolve_pr_target_branch(
request, task, default_branch
)
pr_title, pr_body = await self._generate_pr_title_body(
request, task, source_branch, target_branch, request.task_id
)
owner, repo = self._parse_github_remote(workspace)
resp = await self._post_pr(
owner,
repo,
git_token,
{
"title": pr_title or "",
"body": pr_body or "",
"head": source_branch,
"base": target_branch,
},
)
# Idempotency: PR already exists for this head→base.
if resp.status_code == _GH_UNPROCESSABLE and "already exists" in resp.text:
found = await self._find_existing_pr(
owner, repo, source_branch, target_branch, git_token
)
if found:
return (
int(found["number"]),
found["html_url"],
found.get("title", pr_title or ""),
source_branch,
target_branch,
)
if not resp.is_success:
raise GitError(
f"GitHub API refused PR creation ({resp.status_code}): "
f"{resp.text[:200]}",
{"owner": owner, "repo": repo, "head": source_branch},
)
pr_data = resp.json()
return (
int(pr_data["number"]),
str(pr_data["html_url"]),
pr_title or "",
source_branch,
target_branch,
)
async def _patch_pr_title_body(
self,
owner: str,
repo: str,
pr_number: int,
git_token: str,
payload: dict[str, str],
) -> None:
"""PATCH /repos/{owner}/{repo}/pulls/{pr_number} with title/body.
Translates HTTP failures into GitError so the verb layer can map
them onto invalid_state envelopes. 404 → "PR not found"; any
other non-2xx surfaces the GitHub validation text inline.
"""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.patch(
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",
},
json=payload,
)
except httpx.HTTPError as e:
raise GitError(
f"GitHub API error while updating PR #{pr_number}: {e}",
{"owner": owner, "repo": repo, "pr": pr_number},
) from e
if resp.status_code == _HTTP_NOT_FOUND:
raise GitError(
f"PR not found: #{pr_number} on {owner}/{repo}",
{"owner": owner, "repo": repo, "pr": pr_number},
)
if not resp.is_success:
raise GitError(
f"GitHub API refused PR update ({resp.status_code}): {resp.text[:200]}",
{"owner": owner, "repo": repo, "pr": pr_number},
)
async def _post_pr_reviewers(
self,
owner: str,
repo: str,
pr_number: int,
git_token: str,
reviewers: list[str],
) -> None:
"""POST /repos/{owner}/{repo}/pulls/{pr_number}/requested_reviewers.
Mirrors `_patch_pr_title_body` error handling. The reviewers list
is passed through verbatim — caller is responsible for mapping
agent slugs onto GitHub usernames where the project records that.
"""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.post(
f"https://api.github.com/repos/{owner}/{repo}/pulls/"
f"{pr_number}/requested_reviewers",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={"reviewers": reviewers},
)
except httpx.HTTPError as e:
raise GitError(
f"GitHub API error while adding reviewers to PR #{pr_number}: {e}",
{"owner": owner, "repo": repo, "pr": pr_number},
) from e
if resp.status_code == _HTTP_NOT_FOUND:
raise GitError(
f"PR not found: #{pr_number} on {owner}/{repo}",
{"owner": owner, "repo": repo, "pr": pr_number},
)
if not resp.is_success:
raise GitError(
f"GitHub API refused reviewer request ({resp.status_code}): "
f"{resp.text[:200]}",
{"owner": owner, "repo": repo, "pr": pr_number},
)
async def update_pr_for_task(
self,
task_id: UUID,
*,
title: str | None = None,
body: str | None = None,
reviewers: list[str] | None = None,
) -> dict[str, Any]:
"""Update an open PR's title/body and/or request reviewers.
Looks up the task, resolves the project's GitHub PAT, and routes
through `_patch_pr_title_body` (when title or body is set) and
`_post_pr_reviewers` (when reviewers is set). Either or both run;
the verb layer guarantees at least one is provided.
Returns a dict with `pr_number`, `pr_url`, and a `updated_fields`
list naming which of title/body/reviewers actually went out.
"""
task_service = get_task_service(self.session)
task = await task_service.get(task_id)
if task is None:
raise NotFoundError("Task", str(task_id))
if task.pr_number is None:
raise GitError(
f"Task {task_id} has no PR open; cannot update.",
{"task_id": str(task_id)},
)
project_service = get_project_service(self.session)
project = await project_service.get(UUID(str(task.project_id)))
if project is None:
raise NotFoundError("Project", str(task.project_id))
workspace_agent_id = self._resolve_workspace_agent_id(task, None)
workspace = await self.get_workspace(project.slug, agent_id=workspace_agent_id)
owner, repo = self._parse_github_remote(workspace)
git_token = await self._get_project_token_or_raise(project.slug)
pr_number = int(task.pr_number)
updated: list[str] = []
patch_payload: dict[str, str] = {}
if title is not None:
patch_payload["title"] = title
updated.append("title")
if body is not None:
patch_payload["body"] = body
updated.append("body")
if patch_payload:
await self._patch_pr_title_body(
owner, repo, pr_number, git_token, patch_payload
)
if reviewers is not None:
await self._post_pr_reviewers(owner, repo, pr_number, git_token, reviewers)
updated.append("reviewers")
return {
"pr_number": pr_number,
"pr_url": str(task.pr_url) if task.pr_url else "",
"updated_fields": updated,
}
_PR_OPEN_STATES: ClassVar[frozenset[str]] = frozenset(
{
TaskStatus.IN_PROGRESS.value,
TaskStatus.VERIFYING.value,
TaskStatus.AWAITING_QA.value,
TaskStatus.AWAITING_DOCUMENTATION.value,
TaskStatus.NEEDS_REVISION.value,
}
)
@staticmethod
def _status_value(task: Any) -> str:
"""Task.status may be Enum or str across code paths — normalize."""
status_attr = task.status
return status_attr.value if hasattr(status_attr, "value") else str(status_attr)
async def _assert_pr_create_allowed(
self, task_id: UUID, agent_id: UUID
) -> TaskTable:
"""Enforce assignee + state gate for opening a PR."""
task_service = get_task_service(self.session)
task = await task_service.get(task_id)
if not task:
raise NotFoundError(resource_type="Task", resource_id=str(task_id))
if task.assigned_to != agent_id:
raise UnauthorizedError(
action="create_pr",
reason="NOT_ASSIGNED: Only the assignee can open the PR.",
)
current_status = self._status_value(task)
if current_status not in self._PR_OPEN_STATES:
raise ValidationError(
f"INVALID_STATE_FOR_PR: Task is '{current_status}'; PR "
f"can only be opened during active dev states "
f"({sorted(self._PR_OPEN_STATES)})."
)
return task
async def _record_pr_atomically(
self,
task_uuid: UUID,
pr_number: int,
pr_url: str,
) -> None:
"""Task flags + work_session PR fields must commit together.
If either write fails, roll back and raise a typed error — the
GitHub PR exists on remote but local bookkeeping is inconsistent,
and the caller must know.
"""
task_service = get_task_service(self.session)
work_session_service = get_work_session_service(self.session)
try:
task = await task_service.mark_pr_created(
task_id=task_uuid, pr_number=pr_number, pr_url=pr_url
)
if task is None:
raise ServiceError(
"PR_MARK_FAILED: mark_pr_created returned None. "
"The task may be in an invalid state for PR recording. "
"Check task status and retry."
)
if task.work_session_id:
await work_session_service.create_pr(
require_uuid(task.work_session_id), pr_number, pr_url
)
await self.session.commit()
except Exception as exc:
await self.session.rollback()
raise ServiceError(
"PR_STATE_SYNC_FAILED: GitHub PR was created but the local "
f"state sync failed ({type(exc).__name__}). The PR exists "
"on GitHub but task/work_session fields are unchanged. "
"Retry the PR creation to reconcile, or update manually."
) from exc
async def create_pr_for_task(
self,
agent_id: UUID,
data: GitCreatePRRequest,
) -> tuple[int, str, str, str, str]:
"""Preconditions + PR creation + state sync.
Returns: (pr_number, pr_url, title, source_branch, target_branch)
"""
await self._assert_pr_create_allowed(data.task_id, agent_id)
workspace = await self.get_workspace(data.project_slug, agent_id)
(
pr_number,
pr_url,
title,
source_branch,
target_branch,
) = await self.create_pull_request(workspace, data)
await self._record_pr_atomically(data.task_id, pr_number, pr_url)
return pr_number, pr_url, title, source_branch, target_branch
async def _call_merge_api(
self,
owner: str,
repo: str,
pr_number: int,
git_token: str,
merge_method: str,
) -> httpx.Response:
"""PUT the merge request to GitHub; HTTP errors → GitError."""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
return await client.put(
f"https://api.github.com/repos/{owner}/{repo}/pulls/"
f"{pr_number}/merge",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={"merge_method": merge_method},
)
except httpx.HTTPError as e:
raise GitError(
f"GitHub API error while merging PR #{pr_number}: {e}",
{"owner": owner, "repo": repo, "pr": pr_number},
) from e
async def _sync_target_branch(
self, workspace: Path, target_branch: str, git_token: str
) -> str:
"""Checkout + pull the target branch, return the tip commit hash."""
await self._run_git(workspace, ["checkout", target_branch])
await self._run_git(workspace, ["pull"], token=git_token)
log_result = await self._run_git(workspace, ["log", "-1", "--format=%H"])
return log_result.stdout.strip()
async def _delete_remote_branch_best_effort(
self, owner: str, repo: str, branch: str, git_token: str
) -> None:
"""Best-effort: delete a remote branch by name.
Silently swallows errors — cleanup is not critical. Skips
branches that look like project defaults (main / master /
develop) as a last-chance safety net against bad input.
"""
if branch in ("main", "master", "develop", ""):
return
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await client.delete(
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
except httpx.HTTPError:
return
async def _delete_pr_branch_best_effort(
self, owner: str, repo: str, pr_number: int, git_token: str
) -> None:
"""Best-effort: delete the PR's source branch on the remote after merge.
Silently swallows errors — branch cleanup is not critical.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
pr_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",
},
)
if not pr_resp.is_success:
return
branch = (pr_resp.json().get("head") or {}).get("ref")
if not branch:
return
await self._delete_remote_branch_best_effort(owner, repo, branch, git_token)
except httpx.HTTPError:
return
async def delete_task_branch(self, project_slug: str, branch_name: str) -> None:
"""Delete a remote task branch after cancel/discard. Best-effort.
Called by `TaskService` on cancellation so abandoned task
branches don't accumulate on the remote.
"""
git_token = await self._token_for_project(project_slug)
if not git_token:
return
# Resolve remote from any workspace — branch deletion only needs
# the owner/repo, not a checkout. Use a service-root probe path
# if no agent workspace is available.
try:
project_service = get_project_service(self.session)
project = await project_service.get_by_slug(project_slug)
if not project or not project.git_url:
return
owner, repo = self._parse_git_url(project.git_url)
except Exception:
return
await self._delete_remote_branch_best_effort(
owner, repo, branch_name, git_token
)
async def merge_pull_request(
self, workspace: Path, pr_number: int, merge_method: str, project_slug: str
) -> tuple[str, str]:
"""Merge a PR via the GitHub REST API.
Returns: (target_branch, merge_commit)
"""
git_token = await self._get_project_token_or_raise(project_slug)
owner, repo = self._parse_github_remote(workspace)
if merge_method not in {"merge", "squash", "rebase"}:
merge_method = "squash"
resp = await self._call_merge_api(
owner, repo, pr_number, git_token, merge_method
)
if not resp.is_success:
raise GitError(
f"GitHub API refused PR merge ({resp.status_code}): {resp.text[:200]}",
{"owner": owner, "repo": repo, "pr": pr_number},
)
await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token)
target_branch = await self._project_default_branch(project_slug)
merge_commit = await self._sync_target_branch(
workspace, target_branch, git_token
)
return target_branch, merge_commit
_PM_MERGE_ROLES: ClassVar[frozenset[AgentRole]] = frozenset(
{AgentRole.CELL_PM, AgentRole.MAIN_PM}
)
def _assert_merge_role(self, current_status: str, agent_role: AgentRole) -> None:
"""PR approval chain — PM for PM-review, CEO for CEO-approval."""
if current_status == TaskStatus.AWAITING_CEO_APPROVAL.value:
if agent_role != AgentRole.CEO:
raise UnauthorizedError(
action="merge_pr",
reason=(
"CEO_ONLY: Merging from awaiting_ceo_approval "
"requires the CEO role. PMs escalate; CEO merges "
"to master."
),
)
return
if current_status == TaskStatus.AWAITING_PM_REVIEW.value:
if agent_role not in self._PM_MERGE_ROLES:
raise UnauthorizedError(
action="merge_pr",
reason=(
"PM_ONLY: Merging from awaiting_pm_review requires "
"a PM role (cell_pm or main_pm)."
),
)
return
raise ValidationError(
f"INVALID_STATE_FOR_MERGE: Task is '{current_status}'. Only "
"awaiting_pm_review (PM merge) or awaiting_ceo_approval "
"(CEO merge) can be merged."
)
async def _auto_complete_on_merge(
self,
task_uuid: UUID,
agent_id: UUID,
agent_role: AgentRole,
) -> None:
"""Auto-transition task after PR merge based on status + merger role.
- awaiting_ceo_approval + CEO merger → ceo_approve (→ completed)
- awaiting_pm_review + PM merger → complete
Otherwise leaves the task alone.
"""
task_service = get_task_service(self.session)
task = await task_service.get(task_uuid)
if not task:
return
current_status = self._status_value(task)
if (
current_status == TaskStatus.AWAITING_CEO_APPROVAL.value
and agent_role == AgentRole.CEO
):
await task_service.ceo_approve(task_uuid)
elif (
current_status == TaskStatus.AWAITING_PM_REVIEW.value
and agent_role in self._PM_MERGE_ROLES
):
await task_service.complete(task_uuid, agent_id)
async def merge_pr_for_task(
self,
agent_id: UUID,
agent_role: AgentRole,
data: GitMergePRRequest,
) -> tuple[str, str]:
"""Role-gated merge + work-session record + auto-complete.
Returns: (target_branch, merge_commit)
"""
task_service = get_task_service(self.session)
work_session_service = get_work_session_service(self.session)
task = await task_service.get(data.task_id)
if not task:
raise NotFoundError(resource_type="Task", resource_id=str(data.task_id))
self._assert_merge_role(self._status_value(task), agent_role)
# A coordination root has no project of its own, so the CEO's merge
# request can't carry a project_slug. Resolve the root's repo from its
# product server-side; non-root tasks keep the client-provided slug.
project_slug = data.project_slug
if task.project_id is None:
root_project = await self._project_for_task(task)
if root_project is not None:
project_slug = root_project.slug
workspace = await self.get_workspace(project_slug, agent_id)
target_branch, merge_commit = await self.merge_pull_request(
workspace=workspace,
pr_number=data.pr_number,
merge_method=data.merge_method,
project_slug=project_slug,
)
if task.work_session_id:
await work_session_service.merge_pr(
require_uuid(task.work_session_id), agent_id
)
await self._auto_complete_on_merge(data.task_id, agent_id, agent_role)
await self.session.commit()
return target_branch, merge_commit
# =========================================================================
# GATEWAY (CHOREOGRAPHER) BACKFILL
#
# Branch-keyed entry points. The gateway holds branch_name + pr_number
# but not project_slug or workspace path; these methods derive both
# from the task that owns the branch.
# =========================================================================
async def _task_for_branch(self, branch_name: str) -> TaskTable | None:
"""Find the task whose branch_name matches `branch_name`."""
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
result = await self.session.execute(
select(_TaskTable).where(_TaskTable.branch_name == branch_name).limit(1)
)
return result.scalar_one_or_none()
async def _project_for_task(self, task: Any) -> Any | None:
"""Resolve the Project whose repo a task's branch lives in.
A normal task carries ``project_id``. A coordination root carries a
product (cell->repo map) but no project of its own — its
``feature/main_pm/{root}`` integration branch lives in the product's
repo(s). Fall through to the product's first distinct repo (monorepo =>
the single repo) so root-level git ops (create_pr root->master, the CEO
merge) resolve a workspace. Purely additive: a task WITH project_id
resolves exactly as before.
"""
project_service = get_project_service(self.session)
if task.project_id is not None:
return await project_service.get(UUID(str(task.project_id)))
product_id = getattr(task, "product_id", None)
if product_id is None:
return None
from roboco.services.product import get_product_service
product_service = get_product_service(self.session)
project_ids = await product_service.distinct_project_ids(UUID(str(product_id)))
if not project_ids:
return None
return await project_service.get(project_ids[0])
async def _project_slug_for_branch(self, branch_name: str) -> str | None:
"""Resolve project slug via the task that owns the branch."""
task = await self._task_for_branch(branch_name)
if task is None:
return None
project = await self._project_for_task(task)
return project.slug if project else None
@staticmethod
def _resolve_workspace_agent_id(
task: Any, actor_agent_id: UUID | None
) -> UUID | None:
"""Workspace-agent resolution priority (audit D-40).
actor_agent_id → task.assigned_to → task.created_by → None.
Centralised so push_branch/create_pr/commit/diff/pr_target/pr_merge
share one chain — and individual methods stay below the
cyclomatic-complexity gate (xenon B).
"""
candidate = actor_agent_id or (
UUID(str(task.assigned_to)) if task.assigned_to is not None else None
)
if candidate is None and task.created_by:
candidate = UUID(str(task.created_by))
return candidate
async def _workspace_for_branch(
self,
branch_name: str,
*,
actor_agent_id: UUID | None = None,
) -> Path:
"""Get a workspace where this branch can be operated on.
Resolves the workspace via ``_resolve_workspace_agent_id`` (the
actor → assignee → creator fallback chain). Without it, post-
handoff calls (e.g. pr_target on a task whose assigned_to was
cleared by submit_qa) raise ValidationError when
project.workspace_path is unset.
"""
task = await self._task_for_branch(branch_name)
if task is None:
raise NotFoundError("Branch", branch_name)
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project", str(task.project_id))
workspace_agent_id = self._resolve_workspace_agent_id(task, actor_agent_id)
return await self.get_workspace(project.slug, agent_id=workspace_agent_id)
async def _token_for_branch(self, branch_name: str) -> str | None:
"""Best-effort project PAT for authenticated fetch in the diff path.
Task #168: an unauthenticated ``git fetch`` fails on a private
repo ("could not read Username for github.com"), so the diff base
stays the stale clone-time ``origin/<default>`` and the three-dot
diff spans the whole repo delta instead of the branch's change.
Returns None on ANY resolution failure so the fetch degrades to
unauthenticated (prior behaviour) rather than raising inside an
evidence-assembly path — authentication is an optimisation here,
never a hard dependency of producing a diff.
"""
try:
task = await self._task_for_branch(branch_name)
if task is None:
return None
project_service = get_project_service(self.session)
project = await project_service.get(UUID(str(task.project_id)))
if project is None:
return None
return await self._get_project_token_or_raise(project.slug)
except Exception:
return None
async def checkout_branch_in_agent_workspace(
self,
branch_name: str,
*,
actor_agent_id: UUID,
) -> None:
"""Check out `branch_name` into the actor's own clone.
Task #162: dev/PM workspaces land on the right branch because
``_auto_create_branch`` runs ``git checkout -b`` in the dev's
clone at claim time. The documenter's clone is a *separate*
workspace; when it claims an awaiting_documentation task the
branch already exists (created by the dev) so no checkout ever
ran in the doc's clone — it stayed on the default branch and
``roboco_docs_write`` / ``commit`` failed with BRANCH_MISMATCH.
This puts the doc's workspace on the task branch (fetch +
tracking-branch create). Best-effort: a checkout failure must
not break the claim itself — the caller surfaces a remediation.
"""
workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
await self.checkout(workspace, branch_name)
async def push_branch(
self,
branch_name: str,
*,
actor_agent_id: UUID | None = None,
) -> tuple[str, int]:
"""Push `branch_name` to origin from the assignee's workspace.
Gateway-only entry point — the legacy `push(workspace, force)`
signature stays intact for non-gateway callers. Resolves the
workspace from the task that owns the branch (with caller-actor
fallback per audit D-40), then delegates. Returns
(branch, commits_pushed).
"""
workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
return await self.push(workspace)
async def create_pr(
self,
branch_name: str,
*,
parent: str,
is_root_pr: bool,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]:
"""Open a PR for `branch_name` targeting `parent`.
Returns: ``{"pr_number": int, "pr_url": str}``. Resolves the
underlying project + task from the branch name; the gateway never
passes a project_slug. The `parent` arg supersedes the task's
natural parent so root PRs can target master.
``actor_agent_id`` lets PMs opening the master PR (where
``task.assigned_to`` may be None at completion time) resolve a
workspace via the actor's clone (audit D-40).
"""
task = await self._task_for_branch(branch_name)
if task is None:
raise NotFoundError("Branch", branch_name)
# Resolve via _project_for_task so a coordination root (project_id null)
# opening its root->master PR resolves the repo from its product.
project = await self._project_for_task(task)
if project is None:
raise NotFoundError("Project", str(task.project_id))
workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
git_token = await self._get_project_token_or_raise(project.slug)
owner, repo = self._parse_github_remote(workspace)
pr_title = f"[{str(task.id)[:8]}] {task.title}"
pr_body = task.description or ""
resp = await self._post_pr(
owner,
repo,
git_token,
{
"title": pr_title,
"body": pr_body,
"head": branch_name,
"base": parent,
},
)
if resp.status_code == _GH_UNPROCESSABLE and "already exists" in resp.text:
found = await self._find_existing_pr(
owner, repo, branch_name, parent, git_token
)
if found:
pr_number = int(found["number"])
pr_url = str(found["html_url"])
await self._record_pr_atomically(UUID(str(task.id)), pr_number, pr_url)
return {
"pr_number": pr_number,
"pr_url": pr_url,
"is_root_pr": is_root_pr,
}
if not resp.is_success:
raise GitError(
f"GitHub API refused PR creation ({resp.status_code}): "
f"{resp.text[:200]}",
{"owner": owner, "repo": repo, "head": branch_name},
)
pr_data = resp.json()
pr_number = int(pr_data["number"])
pr_url = str(pr_data["html_url"])
await self._record_pr_atomically(UUID(str(task.id)), pr_number, pr_url)
return {"pr_number": pr_number, "pr_url": pr_url, "is_root_pr": is_root_pr}
async def _lock_parent_task_for_merge(self, parent_task_id: UUID | None) -> None:
"""SELECT FOR UPDATE on the parent task row, if any.
Two PMs completing different subtasks of the same parent could
race on the gh API merge call. Holding a row-level lock on the
parent task serializes those merges at the DB layer — the second
PM's transaction blocks until the first commits, by which time
the first PR is already merged. The lock is auto-released when
the surrounding transaction commits or rolls back.
Root tasks (no parent) skip the lock — there's nothing to
contend on at parent level, and master-bound merges are
serialized by GitHub's PR-state machine alone.
"""
if parent_task_id is None:
return
from sqlalchemy import select
from roboco.db.tables import TaskTable as _TaskTable
await self.session.execute(
select(_TaskTable)
.where(_TaskTable.id == parent_task_id)
.with_for_update(of=_TaskTable)
)
@staticmethod
def _resolve_merger_id(task: Any, actor_agent_id: UUID | None) -> UUID:
"""merged_by attribution priority for pr_merge (audit D-43).
actor → assigned_to → created_by → UUID(int=0) sentinel.
``UUID(0)`` is the explicit "nothing was recoverable" marker
instead of the silent NULL we used to write.
"""
merger = (
actor_agent_id
or (UUID(str(task.assigned_to)) if task.assigned_to else None)
or (UUID(str(task.created_by)) if task.created_by else None)
)
return merger or UUID(int=0)
@dataclass(frozen=True)
class _MergeContext:
"""Bundle of params for `_merge_with_retry` (keeps arg count under 5)."""
owner: str
repo: str
pr_number: int
git_token: str
workspace: Path
target: str
async def _merge_with_retry(self, ctx: GitService._MergeContext) -> Any:
"""Single-retry merge: on 409 (race), sync target then retry once."""
resp = await self._call_merge_api(
ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, "squash"
)
if resp.status_code == _HTTP_CONFLICT:
# Another PM merged a sibling subtask first and our local target
# ref is stale. Refresh and retry once; a second 409 is a real
# conflict the PM resolves manually.
await self._sync_target_branch(ctx.workspace, ctx.target, ctx.git_token)
resp = await self._call_merge_api(
ctx.owner, ctx.repo, ctx.pr_number, ctx.git_token, "squash"
)
if not resp.is_success:
raise GitError(
f"GitHub API refused PR merge ({resp.status_code}): {resp.text[:200]}",
{"owner": ctx.owner, "repo": ctx.repo, "pr": ctx.pr_number},
)
return resp
async def pr_merge(
self,
pr_number: int,
*,
target: str,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]:
"""Merge PR `pr_number` into `target`.
Returns: ``{"merge_commit_sha": str | None}``. Looks up the
task/project that owns the PR to resolve workspace + token.
Concurrency: takes a row-level lock on the parent task before
invoking the GitHub merge API so that two PMs completing
sibling subtasks of the same parent are serialized. On a 409
merge conflict the local target branch is re-pulled and the
merge is retried exactly once before giving up with `GitError`.
"""
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_service = get_project_service(self.session)
project = await project_service.get(UUID(str(task.project_id)))
if project is None:
raise NotFoundError("Project", str(task.project_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)
# CEO is the only one who merges to master. This agent-facing merge path
# (a cell PM merging a leaf/cell PR up the chain) may NEVER target a
# repo's default branch — a root→master PR is merged solely by the CEO
# via approve-&-merge (merge_pr_for_task, CEO-gated from
# awaiting_ceo_approval). Agents open the master PR and escalate.
default_branch = await self._project_default_branch(project.slug)
if target == default_branch:
raise UnauthorizedError(
action="pr_merge",
reason=(
"CEO_ONLY: merging into the default branch "
f"('{default_branch}') is reserved for the CEO via "
"approve-&-merge from awaiting_ceo_approval. Open the PR "
"and escalate; agents never merge to master."
),
)
parent_id = UUID(str(task.parent_task_id)) if task.parent_task_id else None
await self._lock_parent_task_for_merge(parent_id)
await self._merge_with_retry(
self._MergeContext(
owner=owner,
repo=repo,
pr_number=pr_number,
git_token=git_token,
workspace=workspace,
target=target,
)
)
await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token)
merge_commit = await self._sync_target_branch(workspace, target, git_token)
if task.work_session_id:
ws_service = get_work_session_service(self.session)
await ws_service.merge_pr(
require_uuid(task.work_session_id),
self._resolve_merger_id(task, actor_agent_id),
)
return {"merge_commit_sha": merge_commit or None}
async def pr_target(
self,
pr_number: int,
*,
actor_agent_id: UUID | None = None,
) -> str:
"""Return the current target (base) branch of an open PR.
Workspace resolution mirrors pr_merge: actor → assigned_to →
created_by (audit D-40). Lets the Main PM call pr_target after
``submit_qa`` has cleared ``assigned_to`` without ValidationError.
"""
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_service = get_project_service(self.session)
project = await project_service.get(UUID(str(task.project_id)))
if project is None:
raise NotFoundError("Project", str(task.project_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)
owner, repo = self._parse_github_remote(workspace)
git_token = await self._get_project_token_or_raise(project.slug)
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",
},
)
except httpx.HTTPError as e:
raise GitError(
f"GitHub API error fetching PR #{pr_number}: {e}",
{"owner": owner, "repo": repo, "pr": pr_number},
) from e
if not resp.is_success:
raise GitError(
f"GitHub API refused PR fetch ({resp.status_code}): {resp.text[:200]}",
{"owner": owner, "repo": repo, "pr": pr_number},
)
base_ref = (resp.json().get("base") or {}).get("ref")
if not base_ref:
raise GitError(
f"PR #{pr_number} has no base ref",
{"owner": owner, "repo": repo, "pr": pr_number},
)
return str(base_ref)
async def _ref_exists(self, workspace: Any, ref: str) -> bool:
"""True iff `ref` resolves in `workspace` (e.g. 'origin/<branch>')."""
result = await self._run_git(
workspace,
["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
check=False,
)
return result.returncode == 0
async def _default_branch_ref(
self, workspace: Any, token: str | None = None
) -> str:
"""Resolve the repo's default remote branch ref.
Tries ``origin/HEAD`` (the canonical pointer), then common
defaults. Always returns a usable ref string; falls back to
``origin/master`` so the diff command is still well-formed even
on a misconfigured remote (an empty/garbage diff is recoverable;
a malformed git invocation is not). This only resolves the ref
NAME — the caller is responsible for fetching it fresh (#168).
"""
head = await self._run_git(
workspace,
["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
check=False,
)
target = head.stdout.strip()
if head.returncode == 0 and target:
# refs/remotes/origin/HEAD -> refs/remotes/origin/<name>
return target.replace("refs/remotes/", "", 1)
for candidate in ("origin/master", "origin/main"):
await self._run_git(
workspace,
["fetch", "origin", candidate.split("/", 1)[1]],
check=False,
token=token,
)
if await self._ref_exists(workspace, candidate):
return candidate
return "origin/master"
async def _resolve_diff_base(
self, workspace: Any, branch_name: str, token: str | None = None
) -> str:
"""Best diff base for `branch_name` when no explicit base is given.
Task #161: a leaf dev branch's ``parent_branch_for`` is the
cell-PM branch (``feature/{team}/{root}--{cellpm}``) which is
NEVER pushed — only devs push their own leaf branch. Diffing
against a non-existent ``origin/<parent>`` returns an empty
diff, so QA / docs see nothing. Fall back to the repo default
branch when the computed parent ref is absent on origin.
Task #168: the default-branch ref in an inspecting clone is the
stale clone-time tip (origin/HEAD is set, so _default_branch_ref
early-returns its NAME without fetching). A three-dot diff against
a stale base spans the whole repo delta, not the branch's change.
Re-fetch the resolved base authenticated (unauth fails on private
repos) so the base is current.
"""
from roboco.services.gateway.merge_chain import parent_branch_for
parent = parent_branch_for(branch_name)
await self._run_git(
workspace, ["fetch", "origin", parent], check=False, token=token
)
if await self._ref_exists(workspace, f"origin/{parent}"):
return f"origin/{parent}"
default = await self._default_branch_ref(workspace, token=token)
short = default.split("/", 1)[1] if "/" in default else default
await self._run_git(
workspace, ["fetch", "origin", short], check=False, token=token
)
return default
async def _resolve_head_ref(
self, workspace: Any, branch_name: str, token: str | None = None
) -> str:
"""Ref for the branch tip that actually resolves in `workspace`.
Task #161 (facet): the local ``<branch_name>`` ref only exists in
the clone where the dev ran ``git checkout -b`` at claim. QA /
documenter / PM inspect from their OWN clones, which never had
that local branch — a bare ``<branch_name>`` resolves
``refs/heads`` then ``refs/remotes/<name>`` but NEVER
``refs/remotes/origin/<name>``, so ``git diff base...<branch>``
had an unresolvable head and returned an empty diff (QA saw no
changes on a real PR). ``open_pr`` pushes the leaf branch, so
``origin/<branch>`` is the workspace-independent source of truth.
Fetch it, then prefer the local branch (dev's own clone) and fall
back to ``origin/<branch>``; last resort the bare name so the
diff command stays well-formed.
"""
await self._run_git(
workspace, ["fetch", "origin", branch_name], check=False, token=token
)
if await self._ref_exists(workspace, branch_name):
return branch_name
if await self._ref_exists(workspace, f"origin/{branch_name}"):
return f"origin/{branch_name}"
return branch_name
async def diff(
self,
*,
branch_name: str,
base: str | None = None,
actor_agent_id: UUID | None = None,
) -> str:
"""Return the git diff for `branch_name` against `base`.
When `base` is omitted, diffs against the branch's parent (per
`parent_branch_for`), falling back to the repo default branch
when that parent was never pushed (Task #161). Content_actions
evidence path can pass `HEAD~1` for an incremental diff.
``actor_agent_id`` resolves the workspace via the caller's clone
when ``task.assigned_to`` is None (audit D-40) — important for
QA reviewing post-submit_qa.
"""
workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
token = await self._token_for_branch(branch_name)
head_ref = await self._resolve_head_ref(workspace, branch_name, token=token)
base_ref = (
base
if base is not None
else await self._resolve_diff_base(workspace, branch_name, token=token)
)
diff_result = await self._run_git(
workspace, ["diff", f"{base_ref}...{head_ref}"], check=False
)
return diff_result.stdout
async def list_changed_files(
self,
*,
branch_name: str,
base: str | None = None,
actor_agent_id: UUID | None = None,
) -> list[str]:
"""Return the file paths changed on `branch_name` relative to `base`.
Mirrors ``diff`` but invokes ``git diff --name-only`` so the
gateway evidence path can populate ``files_changed`` from the
authoritative git state — independent of whether the agent
ever called the legacy ``add_files_modified`` HTTP endpoint
(which the gateway commit() does not call). Empty paths are
skipped; output preserves git's order. Same Task #161 default-
branch fallback as ``diff``.
"""
workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
token = await self._token_for_branch(branch_name)
head_ref = await self._resolve_head_ref(workspace, branch_name, token=token)
base_ref = (
base
if base is not None
else await self._resolve_diff_base(workspace, branch_name, token=token)
)
result = await self._run_git(
workspace,
["diff", "--name-only", f"{base_ref}...{head_ref}"],
check=False,
)
return [line for line in result.stdout.splitlines() if line.strip()]
async def commit(
self,
*,
branch_name: str,
message: str,
task_id: UUID,
files: list[str] | None = None,
actor_agent_id: UUID | None = None,
) -> dict[str, Any]:
"""Gateway adapter — commit on `branch_name` with a free-form message.
Resolves the workspace + project from the branch (same approach as
`push_branch`/`create_pr`), stages the requested files (or all),
and runs `git commit -m {message}`. Bypasses the conventional-commit
template — content_actions has its own validator that asserts the
message is descriptive, and the orchestrator-side git template only
applies to the structured `commit_for_task` API path.
``actor_agent_id`` falls back through the same chain as pr_merge
when ``task.assigned_to`` was cleared by an earlier transition
(audit D-40).
Returns a dict shaped for the gateway: ``{"sha": str, "message": str,
"files_changed": int, "insertions": int, "deletions": int}``. Tests
and downstream gateway code only consume `sha`; the rest is included
so we don't have to invent a new shape later.
"""
workspace = await self._workspace_for_branch(
branch_name, actor_agent_id=actor_agent_id
)
await self._assert_on_task_branch(workspace, branch_name)
# Stage files explicitly when provided; otherwise stage everything
# the agent has touched. Mirrors create_commit's staging logic.
# Staging + committing a large changeset (the Next.js panel runs into
# the hundreds of files) can exceed the short default git timeout, so
# these ops get the longer `git_commit_timeout_seconds` budget.
commit_timeout = _commit_git_timeout()
if files:
for file in files:
await self._run_git(workspace, ["add", file], timeout=commit_timeout)
else:
await self._run_git(workspace, ["add", "-A"], timeout=commit_timeout)
# Free-form gateway commit — message is passed verbatim. The
# gateway's commit_validator already rejected garbage messages
# before we got here; we don't double-validate.
await self._run_git(
workspace, ["commit", "-m", message], timeout=commit_timeout
)
log_result = await self._run_git(workspace, ["log", "-1", "--format=%H|%s"])
parts = log_result.stdout.strip().split("|", 1)
commit_hash = parts[0] if parts else "unknown"
full_message = parts[1] if len(parts) > 1 else message
stat_result = await self._run_git(
workspace, ["diff", "--stat", "HEAD~1..HEAD"], check=False
)
insertions, deletions, files_changed = self._parse_commit_stats(
stat_result.stdout
)
# Best-effort link to the task; mirrors commit_for_task. A linking
# failure must not lose the commit.
task = await self._task_for_branch(branch_name)
if task is not None and task.assigned_to is not None:
await self._link_commit_to_task(
task_id, commit_hash, message, UUID(str(task.assigned_to))
)
return {
"sha": commit_hash,
"message": full_message,
"files_changed": files_changed,
"insertions": insertions,
"deletions": deletions,
}
def get_git_service(session: AsyncSession) -> GitService:
"""Factory function to get git service."""
return GitService(session)