feat(forge): Phase 1 — GitProvider seam, GitHub transport extracted (#571)

* feat(forge): Phase 0 — git_provider column + registration-time forge validation

Pointing a project at a GitLab/Gitea git_url used to fail silently, several
steps deep, at first PR. New pure policy module (foundation/policy/forge.py)
detects the provider from the git_url host and validates at the
ProjectService create/update chokepoint: github auto-detects and
auto-stamps, explicit git_provider=github is the GitHub Enterprise escape
hatch, gitlab/gitea are recognized-but-not-yet-supported, unknown hosts get
a loud rejection with guidance. An update changing git_url does NOT inherit
a stored auto-stamped provider (restating the override is required), so a
host swap can't smuggle the escape hatch past validation. Migration 075
adds the nullable projects.git_provider column; the panel project dialogs
show the detected forge. Phase 0 of the forge-providers spec.

* feat(forge): Phase 1 — GitProvider seam, GitHub transport extracted

roboco/services/forge/: base.py holds the pure contracts (RepoRef +
GitProvider ABC, stdlib-only — a later GitLabProvider is implemented by
reading this file alone), github.py the httpx transport (20 endpoint
methods behind one shared request-plumbing helper set), registry.py the
wiring (git_provider column -> provider, failing loud on gitlab/gitea).
GitService keeps its exact public surface and all response
classification; its 26 inline REST call sites route through a lazy
_forge property (several suites build GitService via __new__, so an
__init__-set attribute breaks them). github_provisioning and
release_executor ride the same provider. Zero behavior change — the
pre-existing suites pass unmodified; per-project provider resolution
lands with the second provider.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 19:11:56 +02:00
committed by GitHub
co-authored by Renn F
parent 7e01c0cecf
commit 461a6e1ae7
9 changed files with 886 additions and 384 deletions
+3 -1
View File
@@ -147,7 +147,9 @@ export const projectsApi = {
slug: project.slug,
git_url: project.git_url,
// Mock mode: mirror the backend's auto-detect (github.com -> github).
git_provider: project.git_provider ?? detectGithubProvider(project.git_url),
git_provider:
project.git_provider ??
(project.git_url.includes("github.com") ? "github" : null),
default_branch: project.default_branch ?? "main",
environments: project.environments ?? null,
protected_branches: project.protected_branches ?? ["main", "master"],
+5
View File
@@ -169,6 +169,11 @@ select = [
# tool layer; we accept the >5 kwarg signatures here for the same reason
# they're accepted in `roboco/mcp/**`.
"roboco/services/gateway/**/*.py" = ["PLC0415", "PLR0913"]
# The forge provider ABC/transport mirror REST endpoint parameter contracts
# (e.g. list_pulls' head/base/state/per_page, create_release's tag/name/body/
# target_commitish) — a bundle dataclass would just relocate the same named
# fields behind one hop, same rationale as roboco/mcp/** and gateway/** above.
"roboco/services/forge/*.py" = ["PLR0913"]
# Route signatures ARE the HTTP contract — each FastAPI query/path/body
# param must be a discrete typed argument for OpenAPI + validation, so the
# >5-arg rule doesn't fit them (same rationale as roboco/mcp/**). TC003:
+21
View File
@@ -0,0 +1,21 @@
"""Git-forge provider seam (Phase 1 of the forge-providers spec).
``roboco/services/git.py`` used to interleave local-git subprocess work with
inline ``httpx`` calls against GitHub's REST API. This package pulls the REST
transport out from under it behind a provider-agnostic contract
(:mod:`roboco.services.forge.base`) so a future GitLab/Gitea adapter is a new
module here, not a rewrite of ``GitService``.
``base`` is pure contracts, ``github`` is the GitHub REST transport, and
``registry`` resolves a project onto its provider. Nothing here imports
``roboco.services.*`` — ``GitService`` depends on this package, never the
reverse.
"""
from __future__ import annotations
from roboco.services.forge.base import GitProvider, RepoRef
from roboco.services.forge.github import GitHubProvider
from roboco.services.forge.registry import provider_for
__all__ = ["GitHubProvider", "GitProvider", "RepoRef", "provider_for"]
+203
View File
@@ -0,0 +1,203 @@
"""Provider-agnostic git-forge contracts.
Pure module: no ``httpx``, no ``settings``, no ``roboco.services`` imports —
just the shapes a caller needs to talk to *some* forge (GitHub today; GitLab /
Gitea in later phases) without knowing which one. A future ``GitLabProvider``
is implemented by reading this file alone: every method below is the full
surface ``GitService`` calls, with a docstring naming the REST operation it
stands in for.
Methods return ``Any`` deliberately — Phase 1's ``GitHubProvider`` returns raw
``httpx.Response`` objects (so ``GitService`` keeps its existing
status-code/body classification unchanged), and a provider is free to return
its own natural shape as long as callers can read `.status_code` /
`.is_success` / `.text` / `.json()` off it (or, for git.py's existing call
sites, an ``httpx.Response``-compatible object). The contract intentionally
does not force every provider through one wire format.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True, slots=True)
class RepoRef:
"""Provider-opaque repository identity.
GitHub and Gitea both address a repo as ``owner/repo`` — the two fields
below. A future GitLab provider addresses a repo by a URL-encoded full
namespace path (subgroups included) that doesn't decompose into a single
owner segment; ``GitLabProvider.parse_repo_ref`` is free to pack that
whole path into ``owner`` and leave ``repo`` empty, or however it needs —
every other method only ever receives a ``RepoRef`` back from
``parse_repo_ref``/construction, never assumes its internal shape beyond
what THIS provider put there.
"""
owner: str
repo: str
class GitProvider(ABC):
"""The forge REST operations ``GitService`` performs, minus the
classification of what a response means — that stays in ``GitService``.
Every method is transport only: build the request, send it, hand back the
response (or let a transport-level error propagate). Deciding whether a
404 means "doesn't exist" vs. "no CI configured", retrying a specific
status code as part of a *business* policy, and translating a failure
into a domain exception are all ``GitService``'s job, not the provider's.
"""
@abstractmethod
def parse_repo_ref(self, git_url: str) -> RepoRef:
"""Parse a remote URL (https/ssh/tokened) into a :class:`RepoRef`."""
@abstractmethod
async def list_pulls(
self,
repo: RepoRef,
token: str,
*,
head: str | None = None,
base: str | None = None,
state: str = "open",
per_page: int | None = None,
include_api_version: bool = True,
timeout: float | None = None,
) -> Any:
"""List/filter pull requests — ``GET .../pulls``."""
@abstractmethod
async def get_pr(
self,
repo: RepoRef,
token: str,
pr_number: int,
*,
include_api_version: bool = True,
timeout: float | None = None,
) -> Any:
"""Fetch one pull request — ``GET .../pulls/{n}``."""
@abstractmethod
async def get_pr_diff(self, repo: RepoRef, token: str, pr_number: int) -> Any:
"""Fetch a pull request's raw unified diff."""
@abstractmethod
async def create_pr(
self, repo: RepoRef, token: str, *, head: str, base: str, title: str, body: str
) -> Any:
"""Open a pull request — ``POST .../pulls``."""
@abstractmethod
async def update_pr(
self, repo: RepoRef, token: str, pr_number: int, *, payload: dict[str, Any]
) -> Any:
"""Patch a pull request's title/body/state — ``PATCH .../pulls/{n}``."""
@abstractmethod
async def merge_pr(
self, repo: RepoRef, token: str, pr_number: int, *, merge_method: str
) -> Any:
"""Merge a pull request — ``PUT .../pulls/{n}/merge``."""
@abstractmethod
async def request_reviewers(
self, repo: RepoRef, token: str, pr_number: int, reviewers: list[str]
) -> Any:
"""Request reviewers on a pull request."""
@abstractmethod
async def post_review(
self, repo: RepoRef, token: str, pr_number: int, *, body: str, event: str
) -> Any:
"""Post a review (approve / request-changes / comment) on a pull request."""
@abstractmethod
async def merge_branch(
self, repo: RepoRef, token: str, *, base: str, head: str, commit_message: str
) -> Any:
"""Server-side merge one branch into another (the env-sync cascade)."""
@abstractmethod
async def list_ci_runs(
self,
repo: RepoRef,
token: str,
*,
workflow: str | None,
branch: str,
head_sha: str | None,
per_page: int,
) -> Any:
"""List completed CI runs for a branch, optionally scoped to one workflow."""
@abstractmethod
async def list_check_runs(
self, repo: RepoRef, token: str, head_sha: str, *, per_page: int
) -> Any:
"""List check-runs for a commit SHA."""
@abstractmethod
async def list_workflows(self, repo: RepoRef, token: str, *, per_page: int) -> Any:
"""List a repo's configured CI workflows (used to detect "no CI at all")."""
@abstractmethod
async def get_repo(self, repo: RepoRef, token: str) -> Any:
"""Fetch repo metadata (used for merge-method settings and to confirm a
just-created repo)."""
@abstractmethod
async def ensure_label(
self, repo: RepoRef, token: str, name: str, color: str
) -> Any:
"""Create a repo label if missing."""
@abstractmethod
async def add_labels(
self, repo: RepoRef, token: str, pr_number: int, labels: list[str]
) -> Any:
"""Attach labels to an already-open PR/issue."""
@abstractmethod
async def delete_branch_ref(
self, repo: RepoRef, token: str, branch: str, *, timeout: float | None = None
) -> Any:
"""Delete a branch ref on the remote."""
@abstractmethod
async def create_issue_comment(
self, repo: RepoRef, token: str, issue_number: int, body: str
) -> Any:
"""Post a comment on an issue/PR."""
@abstractmethod
async def create_release(
self,
repo: RepoRef,
token: str,
*,
tag_name: str,
name: str,
body: str,
target_commitish: str,
timeout: float | None = None,
) -> Any:
"""Publish a release."""
@abstractmethod
async def create_org_repo(
self,
token: str,
org: str,
*,
name: str,
description: str,
private: bool,
auto_init: bool,
) -> Any:
"""Create a new repository under an org (provisioning)."""
+432
View File
@@ -0,0 +1,432 @@
"""GitHub REST transport — the concrete :class:`GitProvider` for Phase 1.
Every method here is pure wire mechanics: build the URL/headers/payload for
one GitHub REST endpoint, send it, return the raw ``httpx.Response``. No
status-code classification, no retries-as-business-policy, no logging — that
all stays in ``GitService``, which is the only caller that knows what a 404 or
an "already exists" 422 actually MEANS for the operation it's doing.
The one exception is ``list_ci_runs``: GitHub's Actions API is genuinely
flaky under load, so a bounded retry-with-backoff on transient failures
(timeouts, 429/5xx) is transport resilience, not business policy — it moves
here wholesale, returning whatever the last attempt produced (success or not)
for ``GitService`` to classify exactly as before.
Two client lifecycles are served by the same shared ``_send`` helper:
``GitService`` wants a fresh, auto-closed ``httpx.AsyncClient`` per call (its
existing pattern — and what the test suite patches via
``roboco.services.git.httpx.AsyncClient``, which works here too since
``httpx`` is a single shared module object regardless of which file imports
it); ``GitHubProvisioningService`` and the release publisher inject/reuse
their own client. Passing ``client=`` selects the second mode.
"""
from __future__ import annotations
import asyncio
import re
from typing import TYPE_CHECKING, Any, cast
import httpx
from roboco.config import settings
from roboco.exceptions import GitError
from roboco.services.forge.base import GitProvider, RepoRef
if TYPE_CHECKING:
from collections.abc import Mapping
# GitHub REST API version pinned via header on every versioned call — mirrors
# git.py's prior inline headers exactly.
_API_VERSION = "2022-11-28"
_DEFAULT_ACCEPT = "application/vnd.github+json"
# Transient-failure retry policy for the CI-runs list — GitHub Actions is
# flaky enough under load that a single blip must not silently drop a
# self-heal/release-gate poll.
_CI_FETCH_ATTEMPTS = 3
_CI_FETCH_BACKOFF_SECONDS = 0.5
_CI_RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
# owner/repo out of any accepted GitHub remote form: tokened/plain https, ssh.
_REPO_URL_RE = re.compile(
r"github\.com[:/]+(?P<owner>[^/]+)/(?P<repo>[^/\s]+?)(?:\.git)?$"
)
def _default_timeout() -> int:
"""Read fresh on every call (not cached) so a test-time settings patch
of ``git_command_timeout_seconds`` is honored — mirrors git.py's own
``_default_git_timeout``."""
return settings.git_command_timeout_seconds
def _settings_api_base() -> str:
"""Read fresh on every call so a test-time patch of
``settings.github_api_base_url`` is honored (e.g. the e2e harness's fake
GitHub server)."""
return settings.github_api_base_url.rstrip("/")
class GitHubProvider(GitProvider):
"""GitHub.com / GitHub Enterprise REST transport."""
def __init__(self, *, base_url: str | None = None) -> None:
# An explicit override (only ``GitHubProvisioningService`` supplies
# one, already resolved once at its own construction) wins over the
# live setting; otherwise every call re-reads the setting fresh.
self._base_url_override = base_url.rstrip("/") if base_url else None
def _api_base(self) -> str:
return self._base_url_override or _settings_api_base()
def _repo_url(self, repo: RepoRef, *segments: str) -> str:
base = f"{self._api_base()}/repos/{repo.owner}/{repo.repo}"
return "/".join([base, *segments]) if segments else base
@staticmethod
def _headers(
token: str, *, accept: str = _DEFAULT_ACCEPT, include_api_version: bool = True
) -> dict[str, str]:
headers = {"Authorization": f"Bearer {token}", "Accept": accept}
if include_api_version:
headers["X-GitHub-Api-Version"] = _API_VERSION
return headers
async def _send(
self,
method: str,
url: str,
*,
headers: Mapping[str, str],
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
client: httpx.AsyncClient | None = None,
timeout: float | None = None,
) -> httpx.Response:
"""Issue one REST call — a fresh auto-closed client, or a caller-
injected one reused across calls (the provisioning/release lifecycle).
"""
kwargs: dict[str, Any] = {"headers": headers}
if json_body is not None:
kwargs["json"] = json_body
if params is not None:
kwargs["params"] = params
if client is not None:
if timeout is not None:
kwargs["timeout"] = timeout
return cast("httpx.Response", await getattr(client, method)(url, **kwargs))
owned_timeout = timeout if timeout is not None else _default_timeout()
async with httpx.AsyncClient(timeout=owned_timeout) as owned_client:
return cast(
"httpx.Response", await getattr(owned_client, method)(url, **kwargs)
)
# -- identity ----------------------------------------------------------
def parse_repo_ref(self, git_url: str) -> RepoRef:
match = _REPO_URL_RE.search(git_url)
if not match:
raise GitError(
"Could not parse GitHub owner/repo from remote URL",
{
"url_host": git_url.rsplit("@", maxsplit=1)[-1].split(
"/", maxsplit=1
)[0]
},
)
return RepoRef(match.group("owner"), match.group("repo"))
# -- pull requests -------------------------------------------------------
async def list_pulls(
self,
repo: RepoRef,
token: str,
*,
head: str | None = None,
base: str | None = None,
state: str = "open",
per_page: int | None = None,
include_api_version: bool = True,
timeout: float | None = None,
) -> httpx.Response:
params: dict[str, Any] = {"state": state}
if head is not None:
params["head"] = f"{repo.owner}:{head}"
if base is not None:
params["base"] = base
if per_page is not None:
params["per_page"] = per_page
headers = self._headers(token, include_api_version=include_api_version)
return await self._send(
"get",
self._repo_url(repo, "pulls"),
headers=headers,
params=params,
timeout=timeout,
)
async def get_pr(
self,
repo: RepoRef,
token: str,
pr_number: int,
*,
include_api_version: bool = True,
timeout: float | None = None,
) -> httpx.Response:
headers = self._headers(token, include_api_version=include_api_version)
return await self._send(
"get",
self._repo_url(repo, "pulls", str(pr_number)),
headers=headers,
timeout=timeout,
)
async def get_pr_diff(
self, repo: RepoRef, token: str, pr_number: int
) -> httpx.Response:
headers = self._headers(token, accept="application/vnd.github.v3.diff")
return await self._send(
"get", self._repo_url(repo, "pulls", str(pr_number)), headers=headers
)
async def create_pr(
self, repo: RepoRef, token: str, *, head: str, base: str, title: str, body: str
) -> httpx.Response:
headers = self._headers(token)
payload = {"title": title, "body": body, "head": head, "base": base}
return await self._send(
"post", self._repo_url(repo, "pulls"), headers=headers, json_body=payload
)
async def update_pr(
self, repo: RepoRef, token: str, pr_number: int, *, payload: dict[str, Any]
) -> httpx.Response:
headers = self._headers(token)
return await self._send(
"patch",
self._repo_url(repo, "pulls", str(pr_number)),
headers=headers,
json_body=payload,
)
async def merge_pr(
self, repo: RepoRef, token: str, pr_number: int, *, merge_method: str
) -> httpx.Response:
headers = self._headers(token)
return await self._send(
"put",
self._repo_url(repo, "pulls", str(pr_number), "merge"),
headers=headers,
json_body={"merge_method": merge_method},
)
async def request_reviewers(
self, repo: RepoRef, token: str, pr_number: int, reviewers: list[str]
) -> httpx.Response:
headers = self._headers(token)
return await self._send(
"post",
self._repo_url(repo, "pulls", str(pr_number), "requested_reviewers"),
headers=headers,
json_body={"reviewers": reviewers},
)
async def post_review(
self, repo: RepoRef, token: str, pr_number: int, *, body: str, event: str
) -> httpx.Response:
headers = self._headers(token)
return await self._send(
"post",
self._repo_url(repo, "pulls", str(pr_number), "reviews"),
headers=headers,
json_body={"body": body, "event": event},
)
async def merge_branch(
self, repo: RepoRef, token: str, *, base: str, head: str, commit_message: str
) -> httpx.Response:
headers = self._headers(token)
payload = {"base": base, "head": head, "commit_message": commit_message}
return await self._send(
"post", self._repo_url(repo, "merges"), headers=headers, json_body=payload
)
# -- CI ------------------------------------------------------------------
async def list_ci_runs(
self,
repo: RepoRef,
token: str,
*,
workflow: str | None,
branch: str,
head_sha: str | None,
per_page: int,
) -> httpx.Response:
actions_base = self._repo_url(repo, "actions")
url = (
f"{actions_base}/workflows/{workflow}/runs"
if workflow
else f"{actions_base}/runs"
)
headers = self._headers(token)
params: dict[str, Any] = {
"branch": branch,
"status": "completed",
"per_page": per_page,
}
if head_sha:
params["head_sha"] = head_sha
resp: httpx.Response | None = None
for attempt in range(_CI_FETCH_ATTEMPTS):
last = attempt + 1 == _CI_FETCH_ATTEMPTS
try:
resp = await self._send("get", url, headers=headers, params=params)
except httpx.HTTPError:
if last:
raise
await asyncio.sleep(_CI_FETCH_BACKOFF_SECONDS * (attempt + 1))
continue
if resp.is_success or resp.status_code not in _CI_RETRYABLE_STATUS or last:
return resp
await asyncio.sleep(_CI_FETCH_BACKOFF_SECONDS * (attempt + 1))
# _CI_FETCH_ATTEMPTS >= 1, so the loop above always returns or raises.
raise AssertionError("list_ci_runs: retry loop exited without a result")
async def list_check_runs(
self, repo: RepoRef, token: str, head_sha: str, *, per_page: int
) -> httpx.Response:
headers = self._headers(token)
return await self._send(
"get",
self._repo_url(repo, "commits", head_sha, "check-runs"),
headers=headers,
params={"per_page": per_page},
)
async def list_workflows(
self, repo: RepoRef, token: str, *, per_page: int
) -> httpx.Response:
headers = self._headers(token)
return await self._send(
"get",
self._repo_url(repo, "actions", "workflows"),
headers=headers,
params={"per_page": per_page},
)
# -- repo / labels / branches / releases ----------------------------------
async def get_repo(
self,
repo: RepoRef,
token: str,
*,
client: httpx.AsyncClient | None = None,
timeout: float | None = None,
) -> httpx.Response:
headers = self._headers(token)
return await self._send(
"get", self._repo_url(repo), headers=headers, client=client, timeout=timeout
)
async def ensure_label(
self, repo: RepoRef, token: str, name: str, color: str
) -> httpx.Response:
headers = self._headers(token)
return await self._send(
"post",
self._repo_url(repo, "labels"),
headers=headers,
json_body={"name": name, "color": color},
)
async def add_labels(
self, repo: RepoRef, token: str, pr_number: int, labels: list[str]
) -> httpx.Response:
headers = self._headers(token)
return await self._send(
"post",
self._repo_url(repo, "issues", str(pr_number), "labels"),
headers=headers,
json_body={"labels": labels},
)
async def delete_branch_ref(
self, repo: RepoRef, token: str, branch: str, *, timeout: float | None = None
) -> httpx.Response:
headers = self._headers(token)
return await self._send(
"delete",
self._repo_url(repo, "git", "refs", "heads", branch),
headers=headers,
timeout=timeout,
)
async def create_issue_comment(
self, repo: RepoRef, token: str, issue_number: int, body: str
) -> httpx.Response:
headers = self._headers(token)
return await self._send(
"post",
self._repo_url(repo, "issues", str(issue_number), "comments"),
headers=headers,
json_body={"body": body},
)
async def create_release(
self,
repo: RepoRef,
token: str,
*,
tag_name: str,
name: str,
body: str,
target_commitish: str,
timeout: float | None = None,
) -> httpx.Response:
headers = self._headers(token)
payload = {
"tag_name": tag_name,
"name": name,
"body": body,
"target_commitish": target_commitish,
}
return await self._send(
"post",
self._repo_url(repo, "releases"),
headers=headers,
json_body=payload,
timeout=timeout,
)
async def create_org_repo(
self,
token: str,
org: str,
*,
name: str,
description: str,
private: bool,
auto_init: bool,
client: httpx.AsyncClient | None = None,
timeout: float | None = None,
) -> httpx.Response:
headers = self._headers(token)
payload = {
"name": name,
"description": description,
"private": private,
"auto_init": auto_init,
}
return await self._send(
"post",
f"{self._api_base()}/orgs/{org}/repos",
headers=headers,
json_body=payload,
client=client,
timeout=timeout,
)
+39
View File
@@ -0,0 +1,39 @@
"""Provider resolution — wiring only, no transport logic of its own.
Maps a project's ``git_provider`` (Phase 0's ``projects.git_provider`` column;
``roboco/foundation/policy/forge.py`` already rejects ``gitlab``/``gitea`` at
registration time) onto a concrete :class:`GitProvider`. Phase 1 only ever
resolves to :class:`GitHubProvider` in practice — the other branches exist so
resolution fails loud instead of silently misbehaving if that invariant is
ever bypassed (a row written directly, a future migration path, ...).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from roboco.exceptions import GitError
from roboco.services.forge.github import GitHubProvider
if TYPE_CHECKING:
from roboco.services.forge.base import GitProvider
def provider_for(project: Any | None = None) -> GitProvider:
"""Resolve the :class:`GitProvider` for a project (or the system default).
``project`` is duck-typed (only ``.git_provider`` is read) so this module
never needs to import a concrete Project model. ``None`` — no project in
scope, or a project whose ``git_provider`` is unset — resolves to GitHub,
matching ``roboco.foundation.policy.forge.detect_provider``'s default.
"""
provider_name = (
getattr(project, "git_provider", None) if project is not None else None
)
if provider_name in (None, "github"):
return GitHubProvider()
raise GitError(
f"Unsupported git_provider {provider_name!r} — GitLab/Gitea support "
"is not implemented yet.",
{"git_provider": provider_name},
)
+157 -346
View File
@@ -61,6 +61,7 @@ from roboco.services.base import (
UnauthorizedError,
ValidationError,
)
from roboco.services.forge import GitProvider, RepoRef, provider_for
from roboco.services.gateway.quality_gate import GateResult, run_quality_commands
from roboco.services.project import get_project_service
from roboco.services.task import TaskService, get_task_service
@@ -325,11 +326,6 @@ _HTTP_METHOD_NOT_ALLOWED = 405
# commit, or (on the unscoped all-workflows endpoint) an unrelated green
# workflow, masks the HEAD commit's failing run and the signal flickers.
_CI_RUN_WINDOW = 20
# Transient GitHub failures (network, 429, 5xx) are retried within the cycle so
# a single blip does not silently skip a whole self-heal pass.
_CI_FETCH_ATTEMPTS = 3
_CI_FETCH_BACKOFF_SECONDS = 0.5
_CI_RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
# Cap a conventions-validator run so a hung subprocess (tree-sitter deadlock,
# huge repo) can't hang the i_am_done/pr_pass gate forever.
_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS = 120
@@ -374,16 +370,6 @@ def _select_ci_head_run(runs: list[dict[str, Any]]) -> dict[str, Any]:
return max(same_head, key=lambda r: int(r.get("run_attempt") or 0))
def _api_base() -> str:
"""GitHub REST base URL — honors ``settings.github_api_base_url``.
Five call sites already read the setting (CI runs, open-PR list); the
PR create/merge/branch sites hardcoded the public host, which broke any
GitHub Enterprise or test override. One helper keeps them uniform.
"""
return settings.github_api_base_url.rstrip("/")
@dataclass(frozen=True)
class _CiRunQuery:
"""Bundle of per-project inputs to a CI-run fetch (owner/repo, branch, token,
@@ -409,6 +395,21 @@ class GitService(BaseService):
service_name: ClassVar[str] = "git"
@property
def _forge(self) -> GitProvider:
"""The GitHub REST transport (Phase 1 of the forge-providers spec).
A property, not an ``__init__``-set attribute: several unit tests
build a ``GitService`` via ``GitService.__new__(GitService)`` to skip
the DB-session constructor, and a plain instance attribute would be
unset on those. Every project resolves to ``GitHubProvider`` today
(registration-time validation in ``roboco/foundation/policy/forge.py``
rejects anything else) — no per-call project resolution is needed
until a second provider actually exists. Construction is cheap
(no I/O), so resolving fresh per access costs nothing.
"""
return provider_for()
async def _run_git(
self,
workspace: Path,
@@ -778,17 +779,12 @@ class GitService(BaseService):
https://x-access-token:TOKEN@github.com/owner/repo.git
https://github.com/owner/repo.git
git@github.com:owner/repo.git
Delegates to the provider's own URL parsing (``GitHubProvider`` today
— a future provider's shape may differ, e.g. GitLab subgroups).
"""
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")
ref = provider_for().parse_repo_ref(url)
return ref.owner, ref.repo
def _parse_github_remote(self, workspace: Path) -> tuple[str, str]:
"""Read the origin remote URL from a workspace and parse owner/repo."""
@@ -2108,19 +2104,13 @@ class GitService(BaseService):
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"{_api_base()}/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",
},
)
existing = await self._forge.list_pulls(
RepoRef(owner, repo),
git_token,
head=source_branch,
base=target_branch,
include_api_version=False,
)
if existing.is_success and existing.json():
return cast("dict[str, Any]", existing.json()[0])
return None
@@ -2157,18 +2147,10 @@ class GitService(BaseService):
self, project_slug: str, owner: str, repo: str, git_token: str
) -> list[dict[str, Any]] | None:
"""GET a repo's open PRs; return the raw list, or None on any error."""
api_base = settings.github_api_base_url.rstrip("/")
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{api_base}/repos/{owner}/{repo}/pulls",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
params={"state": "open", "per_page": 100},
)
resp = await self._forge.list_pulls(
RepoRef(owner, repo), git_token, per_page=100
)
except httpx.HTTPError as e:
self.log.warning(
"list_open_prs request failed", project=project_slug, error=str(e)
@@ -2266,43 +2248,6 @@ class GitService(BaseService):
"completed_at": run.get("updated_at"),
}
async def _get_ci_runs_response(
self,
project_slug: str,
url: str,
headers: dict[str, str],
params: dict[str, str | int],
) -> httpx.Response | None:
"""GET *url* with retry/back-off; return the successful response or None."""
resp: httpx.Response | None = None
for attempt in range(_CI_FETCH_ATTEMPTS):
last = attempt + 1 == _CI_FETCH_ATTEMPTS
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(url, headers=headers, params=params)
except httpx.HTTPError as e:
if last:
self.log.warning(
"get_latest_ci_conclusion request failed",
project=project_slug,
error=str(e),
)
return None
await asyncio.sleep(_CI_FETCH_BACKOFF_SECONDS * (attempt + 1))
continue
if resp.is_success:
return resp
if resp.status_code in _CI_RETRYABLE_STATUS and not last:
await asyncio.sleep(_CI_FETCH_BACKOFF_SECONDS * (attempt + 1))
continue
self.log.warning(
"get_latest_ci_conclusion non-2xx",
project=project_slug,
status=resp.status_code,
)
return None
return resp
async def _fetch_latest_ci_run(
self,
query: _CiRunQuery,
@@ -2324,29 +2269,32 @@ class GitService(BaseService):
branch, so only pushes to the default branch (not pull-request runs,
whose head is a feature branch) count — exactly the "is the default
branch red" signal self-heal needs. Transient network / 429 / 5xx errors
are retried a few times before giving up so a single blip doesn't
silently skip the cycle.
are retried a few times (inside the provider) before giving up so a
single blip doesn't silently skip the cycle.
"""
owner, repo = query.owner_repo
api_base = settings.github_api_base_url.rstrip("/")
base = f"{api_base}/repos/{owner}/{repo}/actions"
url = f"{base}/workflows/{workflow}/runs" if workflow else f"{base}/runs"
headers = {
"Authorization": f"Bearer {query.git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
params: dict[str, str | int] = {
"branch": query.branch,
"status": "completed",
"per_page": _CI_RUN_WINDOW,
}
if head_sha:
params["head_sha"] = head_sha
resp = await self._get_ci_runs_response(
query.project_slug, url, headers, params
)
if resp is None or not resp.is_success:
try:
resp = await self._forge.list_ci_runs(
RepoRef(owner, repo),
query.git_token,
workflow=workflow,
branch=query.branch,
head_sha=head_sha,
per_page=_CI_RUN_WINDOW,
)
except httpx.HTTPError as e:
self.log.warning(
"get_latest_ci_conclusion request failed",
project=query.project_slug,
error=str(e),
)
return None
if not resp.is_success:
self.log.warning(
"get_latest_ci_conclusion non-2xx",
project=query.project_slug,
status=resp.status_code,
)
return None
data = resp.json()
runs = data.get("workflow_runs") if isinstance(data, dict) else None
@@ -2363,16 +2311,17 @@ class GitService(BaseService):
) -> 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"{_api_base()}/repos/{owner}/{repo}/pulls",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json=payload,
)
return cast(
"httpx.Response",
await self._forge.create_pr(
RepoRef(owner, repo),
git_token,
head=str(payload.get("head", "")),
base=str(payload.get("base", "")),
title=str(payload.get("title", "")),
body=str(payload.get("body", "")),
),
)
except httpx.HTTPError as e:
raise GitError(
f"GitHub API error while creating PR: {e}",
@@ -2391,16 +2340,9 @@ class GitService(BaseService):
(422/409). Best-effort: logs and never raises — a missing label must not
block PR creation."""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.post(
f"{_api_base()}/repos/{owner}/{repo}/labels",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={"name": name, "color": self._PR_LABEL_COLOR},
)
resp = await self._forge.ensure_label(
RepoRef(owner, repo), git_token, name, self._PR_LABEL_COLOR
)
except Exception as e:
self.log.warning("PR label ensure HTTP error", label=name, error=str(e))
return
@@ -2431,16 +2373,9 @@ class GitService(BaseService):
for name in labels:
await self._ensure_label_exists(owner, repo, git_token, name)
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.post(
f"{_api_base()}/repos/{owner}/{repo}/issues/{pr_number}/labels",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={"labels": labels},
)
resp = await self._forge.add_labels(
RepoRef(owner, repo), git_token, pr_number, labels
)
except Exception as e:
self.log.warning("add PR labels HTTP error", pr=pr_number, error=str(e))
return
@@ -2607,20 +2542,13 @@ class GitService(BaseService):
except GitError:
return {"status": "missing_ref"}
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.post(
f"{_api_base()}/repos/{owner}/{repo}/merges",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={
"base": target_branch,
"head": source_branch,
"commit_message": f"sync: {source_branch}{target_branch}",
},
)
resp = await self._forge.merge_branch(
RepoRef(owner, repo),
git_token,
base=target_branch,
head=source_branch,
commit_message=f"sync: {source_branch}{target_branch}",
)
except httpx.HTTPError as exc:
self.log.warning(
"env-sync merges API error", project=project_slug, error=str(exc)
@@ -2828,16 +2756,9 @@ class GitService(BaseService):
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"{_api_base()}/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,
)
resp = await self._forge.update_pr(
RepoRef(owner, repo), git_token, pr_number, payload=payload
)
except httpx.HTTPError as e:
raise GitError(
f"GitHub API error while updating PR #{pr_number}: {e}",
@@ -2869,17 +2790,9 @@ class GitService(BaseService):
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"{_api_base()}/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},
)
resp = await self._forge.request_reviewers(
RepoRef(owner, repo), git_token, pr_number, reviewers
)
except httpx.HTTPError as e:
raise GitError(
f"GitHub API error while adding reviewers to PR #{pr_number}: {e}",
@@ -2929,18 +2842,10 @@ class GitService(BaseService):
git_token = await self._token_for_project(project_slug)
if not git_token:
raise GitError(f"no git token for project {project_slug!r}", details)
api_base = settings.github_api_base_url.rstrip("/")
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.post(
f"{api_base}/repos/{owner}/{repo}/pulls/{pr_number}/reviews",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={"body": body, "event": event},
)
resp = await self._forge.post_review(
RepoRef(owner, repo), git_token, pr_number, body=body, event=event
)
except httpx.HTTPError as e:
raise GitError(
f"GitHub API error while posting review to PR #{pr_number}: {e}",
@@ -2990,17 +2895,10 @@ class GitService(BaseService):
git_token = await self._token_for_project(project_slug)
if not git_token:
return ""
api_base = settings.github_api_base_url.rstrip("/")
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{api_base}/repos/{owner}/{repo}/pulls/{pr_number}",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github.v3.diff",
"X-GitHub-Api-Version": "2022-11-28",
},
)
resp = await self._forge.get_pr_diff(
RepoRef(owner, repo), git_token, pr_number
)
except httpx.HTTPError as e:
self.log.warning(
"get_pr_diff request failed",
@@ -3017,7 +2915,7 @@ class GitService(BaseService):
status=resp.status_code,
)
return ""
return resp.text
return cast("str", resp.text)
async def get_pr_head_sha(self, project_slug: str, pr_number: int) -> str | None:
"""Fetch a PR's current head commit SHA READ-ONLY via the GitHub API.
@@ -3045,17 +2943,8 @@ class GitService(BaseService):
git_token = await self._token_for_project(project_slug)
if not git_token:
return None
api_base = settings.github_api_base_url.rstrip("/")
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{api_base}/repos/{owner}/{repo}/pulls/{pr_number}",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
resp = await self._forge.get_pr(RepoRef(owner, repo), git_token, pr_number)
if not resp.is_success:
self.log.warning(
"get_pr_head_sha non-2xx",
@@ -3114,29 +3003,29 @@ class GitService(BaseService):
config = await self._ci_status_config(project_slug)
if isinstance(config, dict):
return config
owner, repo, headers = config
owner, repo, git_token = config
head_sha_or_gap = await self._resolve_ci_head_sha(
project_slug, pr_number, owner, repo, headers
project_slug, pr_number, owner, repo, git_token
)
if isinstance(head_sha_or_gap, dict):
return head_sha_or_gap
head_sha = head_sha_or_gap
check_runs = await self._fetch_check_runs(
project_slug, owner, repo, head_sha, headers
project_slug, owner, repo, head_sha, git_token
)
if isinstance(check_runs, dict):
return check_runs
if check_runs:
return self._classify_check_runs(check_runs, head_sha)
return await self._classify_zero_check_runs(
project_slug, owner, repo, head_sha, headers
project_slug, owner, repo, head_sha, git_token
)
async def _ci_status_config(
self, project_slug: str
) -> tuple[str, str, dict[str, str]] | dict[str, Any]:
"""Resolve ``(owner, repo, auth headers)`` for a CI-status lookup, or
a terminal ``no_ci_configured`` gap dict when the project, its
) -> tuple[str, str, str] | dict[str, Any]:
"""Resolve ``(owner, repo, git_token)`` for a CI-status lookup, or a
terminal ``no_ci_configured`` gap dict when the project, its
git_url, or a git token is missing, or the git_url doesn't parse."""
project = await get_project_service(self.session).get_by_slug(project_slug)
if project is None or not project.git_url:
@@ -3148,12 +3037,7 @@ class GitService(BaseService):
git_token = await self._token_for_project(project_slug)
if not git_token:
return {"state": "no_ci_configured", "head_sha": None}
headers = {
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
return owner, repo, headers
return owner, repo, git_token
async def _resolve_ci_head_sha(
self,
@@ -3161,7 +3045,7 @@ class GitService(BaseService):
pr_number: int,
owner: str,
repo: str,
headers: dict[str, str],
git_token: str,
) -> str | dict[str, Any]:
"""Resolve the PR's head SHA for ``get_pr_ci_status`` specifically.
@@ -3175,11 +3059,7 @@ class GitService(BaseService):
treat as green.
"""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}",
headers=headers,
)
resp = await self._forge.get_pr(RepoRef(owner, repo), git_token, pr_number)
except httpx.HTTPError as e:
self.log.warning(
"get_pr_ci_status pr lookup unreachable",
@@ -3212,7 +3092,7 @@ class GitService(BaseService):
owner: str,
repo: str,
head_sha: str,
headers: dict[str, str],
git_token: str,
) -> list[dict[str, Any]] | dict[str, Any]:
"""GET the check-runs for ``head_sha``.
@@ -3223,12 +3103,9 @@ class GitService(BaseService):
unparseable body) is ``error``.
"""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{_api_base()}/repos/{owner}/{repo}/commits/{head_sha}/check-runs",
headers=headers,
params={"per_page": 100},
)
resp = await self._forge.list_check_runs(
RepoRef(owner, repo), git_token, head_sha, per_page=100
)
except httpx.HTTPError as e:
self.log.warning(
"get_pr_ci_status check-runs request failed",
@@ -3286,7 +3163,7 @@ class GitService(BaseService):
owner: str,
repo: str,
head_sha: str,
headers: dict[str, str],
git_token: str,
) -> dict[str, Any]:
"""No check-runs exist yet for ``head_sha`` — tell "not scheduled" apart
from "no CI configured" by asking whether the repo has any workflows.
@@ -3295,12 +3172,9 @@ class GitService(BaseService):
``no_ci_configured``; any other failure is ``error``.
"""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{_api_base()}/repos/{owner}/{repo}/actions/workflows",
headers=headers,
params={"per_page": 1},
)
resp = await self._forge.list_workflows(
RepoRef(owner, repo), git_token, per_page=1
)
except httpx.HTTPError as e:
self.log.warning(
"get_pr_ci_status workflows request failed",
@@ -3513,16 +3387,15 @@ class GitService(BaseService):
) -> 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"{_api_base()}/repos/{owner}/{repo}/pulls/{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},
)
return cast(
"httpx.Response",
await self._forge.merge_pr(
RepoRef(owner, repo),
git_token,
pr_number,
merge_method=merge_method,
),
)
except httpx.HTTPError as e:
raise GitError(
f"GitHub API error while merging PR #{pr_number}: {e}",
@@ -3610,16 +3483,9 @@ class GitService(BaseService):
so the branch is preserved (cleanup is best-effort; stranding is not).
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{_api_base()}/repos/{owner}/{repo}/pulls",
params={"base": branch, "state": "open", "per_page": 1},
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
resp = await self._forge.list_pulls(
RepoRef(owner, repo), git_token, base=branch, per_page=1, timeout=10.0
)
if not resp.is_success:
return True
return bool(resp.json())
@@ -3650,16 +3516,9 @@ class GitService(BaseService):
)
return False
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await client.delete(
f"{_api_base()}/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",
},
)
return True
await self._forge.delete_branch_ref(
RepoRef(owner, repo), git_token, branch, timeout=10.0
)
except httpx.HTTPError:
return False
@@ -3671,20 +3530,14 @@ class GitService(BaseService):
Silently swallows errors — branch cleanup is not critical.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
pr_resp = await client.get(
f"{_api_base()}/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
pr_resp = await self._forge.get_pr(
RepoRef(owner, repo), git_token, pr_number, timeout=10.0
)
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
@@ -3872,15 +3725,7 @@ class GitService(BaseService):
merge method in its settings.
"""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{_api_base()}/repos/{owner}/{repo}",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
resp = await self._forge.get_repo(RepoRef(owner, repo), git_token)
if not resp.is_success:
return None
data = resp.json()
@@ -4653,15 +4498,7 @@ class GitService(BaseService):
non-success response is still False (GitHub answered, just not merged).
"""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
resp = await self._forge.get_pr(RepoRef(owner, repo), git_token, pr_number)
except httpx.HTTPError:
return None
if not resp.is_success:
@@ -4799,15 +4636,7 @@ class GitService(BaseService):
) -> tuple[str, str] | None:
"""Return ``(head_ref, base_ref)`` for a PR, or ``None`` if unavailable."""
try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
resp = await client.get(
f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
resp = await self._forge.get_pr(RepoRef(owner, repo), git_token, pr_number)
except httpx.HTTPError:
return None
if not resp.is_success:
@@ -5239,38 +5068,25 @@ class GitService(BaseService):
git_token = await self._get_project_token_or_raise(project.slug)
owner, repo = self._parse_github_remote(workspace)
headers = {
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
existing = await client.get(
f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}",
headers=headers,
repo_ref = RepoRef(owner, repo)
existing = await self._forge.get_pr(repo_ref, git_token, pr_number)
already_closed = (
existing.is_success and existing.json().get("state") == "closed"
)
if not already_closed:
if comment:
await self._forge.create_issue_comment(
repo_ref, git_token, pr_number, comment
)
resp = await self._forge.update_pr(
repo_ref, git_token, pr_number, payload={"state": "closed"}
)
already_closed = (
existing.is_success and existing.json().get("state") == "closed"
)
if not already_closed:
if comment:
await client.post(
f"{_api_base()}/repos/{owner}/{repo}/issues/"
f"{pr_number}/comments",
headers=headers,
json={"body": comment},
)
resp = await client.patch(
f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}",
headers=headers,
json={"state": "closed"},
if not resp.is_success:
raise GitError(
f"GitHub API refused PR close ({resp.status_code}): "
f"{resp.text[:200]}",
{"owner": owner, "repo": repo, "pr": pr_number},
)
if not resp.is_success:
raise GitError(
f"GitHub API refused PR close ({resp.status_code}): "
f"{resp.text[:200]}",
{"owner": owner, "repo": repo, "pr": pr_number},
)
if delete_branch:
await self._delete_pr_branch_best_effort(owner, repo, pr_number, git_token)
@@ -5318,14 +5134,9 @@ class GitService(BaseService):
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"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
},
)
resp = await self._forge.get_pr(
RepoRef(owner, repo), git_token, pr_number, include_api_version=False
)
except httpx.HTTPError as e:
raise GitError(
f"GitHub API error fetching PR #{pr_number}: {e}",
+15 -20
View File
@@ -18,6 +18,8 @@ from dataclasses import dataclass
import httpx
from roboco.config import settings
from roboco.services.forge import RepoRef
from roboco.services.forge.github import GitHubProvider
class ProvisioningError(Exception):
@@ -62,6 +64,7 @@ class GitHubProvisioningService:
)
self._client = client
self._owns_client = client is None
self._provider = GitHubProvider(base_url=self._base_url)
@property
def enabled(self) -> bool:
@@ -98,19 +101,14 @@ class GitHubProvisioningService:
raise ProvisioningDisabledError(msg)
client = await self._http()
try:
resp = await client.post(
f"{self._base_url}/orgs/{self._org}/repos",
headers={
"Authorization": f"Bearer {self._token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={
"name": name,
"description": description[:350],
"private": private,
"auto_init": True,
},
resp = await self._provider.create_org_repo(
self._token,
self._org,
name=name,
description=description[:350],
private=private,
auto_init=True,
client=client,
timeout=self._timeout,
)
except httpx.HTTPError as exc:
@@ -141,13 +139,10 @@ class GitHubProvisioningService:
"""GET ``org/name`` and rebuild a ProvisionedRepo (idempotent re-create)."""
client = await self._http()
try:
resp = await client.get(
f"{self._base_url}/repos/{self._org}/{name}",
headers={
"Authorization": f"Bearer {self._token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
resp = await self._provider.get_repo(
RepoRef(self._org, name),
self._token,
client=client,
timeout=self._timeout,
)
except httpx.HTTPError as exc:
+11 -17
View File
@@ -471,7 +471,8 @@ class _GitReleaseOps:
# binary, so the CLI path fails at publish time with a missing binary.
import httpx
from roboco.config import settings
from roboco.services.forge import RepoRef
from roboco.services.forge.github import GitHubProvider
from roboco.services.git import GitService
from roboco.services.project import ProjectService
@@ -482,23 +483,16 @@ class _GitReleaseOps:
if not token:
raise RuntimeError(f"release publish failed: no git token for {self._slug}")
owner, repo = GitService._parse_git_url(self._git_url)
api_base = settings.github_api_base_url.rstrip("/")
try:
async with httpx.AsyncClient(timeout=_PUBLISH_TIMEOUT_SECONDS) as client:
resp = await client.post(
f"{api_base}/repos/{owner}/{repo}/releases",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={
"tag_name": tag,
"name": tag,
"body": notes,
"target_commitish": self._default_branch,
},
)
resp = await GitHubProvider().create_release(
RepoRef(owner, repo),
token,
tag_name=tag,
name=tag,
body=notes,
target_commitish=self._default_branch,
timeout=_PUBLISH_TIMEOUT_SECONDS,
)
except httpx.HTTPError as e:
raise RuntimeError(f"release publish failed: {e}") from e
if resp.status_code != _HTTP_CREATED: