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, slug: project.slug,
git_url: project.git_url, git_url: project.git_url,
// Mock mode: mirror the backend's auto-detect (github.com -> github). // 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", default_branch: project.default_branch ?? "main",
environments: project.environments ?? null, environments: project.environments ?? null,
protected_branches: project.protected_branches ?? ["main", "master"], 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 # tool layer; we accept the >5 kwarg signatures here for the same reason
# they're accepted in `roboco/mcp/**`. # they're accepted in `roboco/mcp/**`.
"roboco/services/gateway/**/*.py" = ["PLC0415", "PLR0913"] "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 # Route signatures ARE the HTTP contract — each FastAPI query/path/body
# param must be a discrete typed argument for OpenAPI + validation, so the # 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: # >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},
)
+123 -312
View File
@@ -61,6 +61,7 @@ from roboco.services.base import (
UnauthorizedError, UnauthorizedError,
ValidationError, ValidationError,
) )
from roboco.services.forge import GitProvider, RepoRef, provider_for
from roboco.services.gateway.quality_gate import GateResult, run_quality_commands from roboco.services.gateway.quality_gate import GateResult, run_quality_commands
from roboco.services.project import get_project_service from roboco.services.project import get_project_service
from roboco.services.task import TaskService, get_task_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 # commit, or (on the unscoped all-workflows endpoint) an unrelated green
# workflow, masks the HEAD commit's failing run and the signal flickers. # workflow, masks the HEAD commit's failing run and the signal flickers.
_CI_RUN_WINDOW = 20 _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, # 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. # huge repo) can't hang the i_am_done/pr_pass gate forever.
_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS = 120 _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)) 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) @dataclass(frozen=True)
class _CiRunQuery: class _CiRunQuery:
"""Bundle of per-project inputs to a CI-run fetch (owner/repo, branch, token, """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" 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( async def _run_git(
self, self,
workspace: Path, workspace: Path,
@@ -778,17 +779,12 @@ class GitService(BaseService):
https://x-access-token:TOKEN@github.com/owner/repo.git https://x-access-token:TOKEN@github.com/owner/repo.git
https://github.com/owner/repo.git https://github.com/owner/repo.git
git@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( ref = provider_for().parse_repo_ref(url)
r"github\.com[:/]+(?P<owner>[^/]+)/(?P<repo>[^/\s]+?)(?:\.git)?$", return ref.owner, ref.repo
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]: def _parse_github_remote(self, workspace: Path) -> tuple[str, str]:
"""Read the origin remote URL from a workspace and parse owner/repo.""" """Read the origin remote URL from a workspace and parse owner/repo."""
@@ -2108,18 +2104,12 @@ class GitService(BaseService):
git_token: str, git_token: str,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Return the first open PR for head→base, or None.""" """Return the first open PR for head→base, or None."""
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: existing = await self._forge.list_pulls(
existing = await client.get( RepoRef(owner, repo),
f"{_api_base()}/repos/{owner}/{repo}/pulls", git_token,
headers={ head=source_branch,
"Authorization": f"Bearer {git_token}", base=target_branch,
"Accept": "application/vnd.github+json", include_api_version=False,
},
params={
"head": f"{owner}:{source_branch}",
"base": target_branch,
"state": "open",
},
) )
if existing.is_success and existing.json(): if existing.is_success and existing.json():
return cast("dict[str, Any]", existing.json()[0]) return cast("dict[str, Any]", existing.json()[0])
@@ -2157,17 +2147,9 @@ class GitService(BaseService):
self, project_slug: str, owner: str, repo: str, git_token: str self, project_slug: str, owner: str, repo: str, git_token: str
) -> list[dict[str, Any]] | None: ) -> list[dict[str, Any]] | None:
"""GET a repo's open PRs; return the raw list, or None on any error.""" """GET a repo's open PRs; return the raw list, or None on any error."""
api_base = settings.github_api_base_url.rstrip("/")
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.list_pulls(
resp = await client.get( RepoRef(owner, repo), git_token, per_page=100
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},
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
self.log.warning( self.log.warning(
@@ -2266,43 +2248,6 @@ class GitService(BaseService):
"completed_at": run.get("updated_at"), "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( async def _fetch_latest_ci_run(
self, self,
query: _CiRunQuery, query: _CiRunQuery,
@@ -2324,29 +2269,32 @@ class GitService(BaseService):
branch, so only pushes to the default branch (not pull-request runs, branch, so only pushes to the default branch (not pull-request runs,
whose head is a feature branch) count exactly the "is the default whose head is a feature branch) count exactly the "is the default
branch red" signal self-heal needs. Transient network / 429 / 5xx errors 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 are retried a few times (inside the provider) before giving up so a
silently skip the cycle. single blip doesn't silently skip the cycle.
""" """
owner, repo = query.owner_repo owner, repo = query.owner_repo
api_base = settings.github_api_base_url.rstrip("/") try:
base = f"{api_base}/repos/{owner}/{repo}/actions" resp = await self._forge.list_ci_runs(
url = f"{base}/workflows/{workflow}/runs" if workflow else f"{base}/runs" RepoRef(owner, repo),
headers = { query.git_token,
"Authorization": f"Bearer {query.git_token}", workflow=workflow,
"Accept": "application/vnd.github+json", branch=query.branch,
"X-GitHub-Api-Version": "2022-11-28", head_sha=head_sha,
} per_page=_CI_RUN_WINDOW,
params: dict[str, str | int] = { )
"branch": query.branch, except httpx.HTTPError as e:
"status": "completed", self.log.warning(
"per_page": _CI_RUN_WINDOW, "get_latest_ci_conclusion request failed",
} project=query.project_slug,
if head_sha: error=str(e),
params["head_sha"] = head_sha )
resp = await self._get_ci_runs_response( return None
query.project_slug, url, headers, params if not resp.is_success:
self.log.warning(
"get_latest_ci_conclusion non-2xx",
project=query.project_slug,
status=resp.status_code,
) )
if resp is None or not resp.is_success:
return None return None
data = resp.json() data = resp.json()
runs = data.get("workflow_runs") if isinstance(data, dict) else None runs = data.get("workflow_runs") if isinstance(data, dict) else None
@@ -2363,15 +2311,16 @@ class GitService(BaseService):
) -> httpx.Response: ) -> httpx.Response:
"""POST the PR payload to GitHub; translate HTTP errors to GitError.""" """POST the PR payload to GitHub; translate HTTP errors to GitError."""
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: return cast(
return await client.post( "httpx.Response",
f"{_api_base()}/repos/{owner}/{repo}/pulls", await self._forge.create_pr(
headers={ RepoRef(owner, repo),
"Authorization": f"Bearer {git_token}", git_token,
"Accept": "application/vnd.github+json", head=str(payload.get("head", "")),
"X-GitHub-Api-Version": "2022-11-28", base=str(payload.get("base", "")),
}, title=str(payload.get("title", "")),
json=payload, body=str(payload.get("body", "")),
),
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise GitError( raise GitError(
@@ -2391,15 +2340,8 @@ class GitService(BaseService):
(422/409). Best-effort: logs and never raises a missing label must not (422/409). Best-effort: logs and never raises a missing label must not
block PR creation.""" block PR creation."""
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.ensure_label(
resp = await client.post( RepoRef(owner, repo), git_token, name, self._PR_LABEL_COLOR
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},
) )
except Exception as e: except Exception as e:
self.log.warning("PR label ensure HTTP error", label=name, error=str(e)) self.log.warning("PR label ensure HTTP error", label=name, error=str(e))
@@ -2431,15 +2373,8 @@ class GitService(BaseService):
for name in labels: for name in labels:
await self._ensure_label_exists(owner, repo, git_token, name) await self._ensure_label_exists(owner, repo, git_token, name)
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.add_labels(
resp = await client.post( RepoRef(owner, repo), git_token, pr_number, labels
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},
) )
except Exception as e: except Exception as e:
self.log.warning("add PR labels HTTP error", pr=pr_number, error=str(e)) self.log.warning("add PR labels HTTP error", pr=pr_number, error=str(e))
@@ -2607,19 +2542,12 @@ class GitService(BaseService):
except GitError: except GitError:
return {"status": "missing_ref"} return {"status": "missing_ref"}
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.merge_branch(
resp = await client.post( RepoRef(owner, repo),
f"{_api_base()}/repos/{owner}/{repo}/merges", git_token,
headers={ base=target_branch,
"Authorization": f"Bearer {git_token}", head=source_branch,
"Accept": "application/vnd.github+json", commit_message=f"sync: {source_branch}{target_branch}",
"X-GitHub-Api-Version": "2022-11-28",
},
json={
"base": target_branch,
"head": source_branch,
"commit_message": f"sync: {source_branch}{target_branch}",
},
) )
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
self.log.warning( self.log.warning(
@@ -2828,15 +2756,8 @@ class GitService(BaseService):
other non-2xx surfaces the GitHub validation text inline. other non-2xx surfaces the GitHub validation text inline.
""" """
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.update_pr(
resp = await client.patch( RepoRef(owner, repo), git_token, pr_number, payload=payload
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,
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise GitError( raise GitError(
@@ -2869,16 +2790,8 @@ class GitService(BaseService):
agent slugs onto GitHub usernames where the project records that. agent slugs onto GitHub usernames where the project records that.
""" """
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.request_reviewers(
resp = await client.post( RepoRef(owner, repo), git_token, pr_number, reviewers
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},
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise GitError( raise GitError(
@@ -2929,17 +2842,9 @@ class GitService(BaseService):
git_token = await self._token_for_project(project_slug) git_token = await self._token_for_project(project_slug)
if not git_token: if not git_token:
raise GitError(f"no git token for project {project_slug!r}", details) raise GitError(f"no git token for project {project_slug!r}", details)
api_base = settings.github_api_base_url.rstrip("/")
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.post_review(
resp = await client.post( RepoRef(owner, repo), git_token, pr_number, body=body, event=event
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},
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise GitError( raise GitError(
@@ -2990,16 +2895,9 @@ class GitService(BaseService):
git_token = await self._token_for_project(project_slug) git_token = await self._token_for_project(project_slug)
if not git_token: if not git_token:
return "" return ""
api_base = settings.github_api_base_url.rstrip("/")
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.get_pr_diff(
resp = await client.get( RepoRef(owner, repo), git_token, pr_number
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",
},
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
self.log.warning( self.log.warning(
@@ -3017,7 +2915,7 @@ class GitService(BaseService):
status=resp.status_code, status=resp.status_code,
) )
return "" return ""
return resp.text return cast("str", resp.text)
async def get_pr_head_sha(self, project_slug: str, pr_number: int) -> str | None: 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. """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) git_token = await self._token_for_project(project_slug)
if not git_token: if not git_token:
return None return None
api_base = settings.github_api_base_url.rstrip("/")
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.get_pr(RepoRef(owner, repo), git_token, pr_number)
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 resp.is_success: if not resp.is_success:
self.log.warning( self.log.warning(
"get_pr_head_sha non-2xx", "get_pr_head_sha non-2xx",
@@ -3114,29 +3003,29 @@ class GitService(BaseService):
config = await self._ci_status_config(project_slug) config = await self._ci_status_config(project_slug)
if isinstance(config, dict): if isinstance(config, dict):
return config return config
owner, repo, headers = config owner, repo, git_token = config
head_sha_or_gap = await self._resolve_ci_head_sha( 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): if isinstance(head_sha_or_gap, dict):
return head_sha_or_gap return head_sha_or_gap
head_sha = head_sha_or_gap head_sha = head_sha_or_gap
check_runs = await self._fetch_check_runs( 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): if isinstance(check_runs, dict):
return check_runs return check_runs
if check_runs: if check_runs:
return self._classify_check_runs(check_runs, head_sha) return self._classify_check_runs(check_runs, head_sha)
return await self._classify_zero_check_runs( 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( async def _ci_status_config(
self, project_slug: str self, project_slug: str
) -> tuple[str, str, dict[str, str]] | dict[str, Any]: ) -> tuple[str, str, str] | dict[str, Any]:
"""Resolve ``(owner, repo, auth headers)`` for a CI-status lookup, or """Resolve ``(owner, repo, git_token)`` for a CI-status lookup, or a
a terminal ``no_ci_configured`` gap dict when the project, its 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.""" 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) project = await get_project_service(self.session).get_by_slug(project_slug)
if project is None or not project.git_url: 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) git_token = await self._token_for_project(project_slug)
if not git_token: if not git_token:
return {"state": "no_ci_configured", "head_sha": None} return {"state": "no_ci_configured", "head_sha": None}
headers = { return owner, repo, git_token
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
return owner, repo, headers
async def _resolve_ci_head_sha( async def _resolve_ci_head_sha(
self, self,
@@ -3161,7 +3045,7 @@ class GitService(BaseService):
pr_number: int, pr_number: int,
owner: str, owner: str,
repo: str, repo: str,
headers: dict[str, str], git_token: str,
) -> str | dict[str, Any]: ) -> str | dict[str, Any]:
"""Resolve the PR's head SHA for ``get_pr_ci_status`` specifically. """Resolve the PR's head SHA for ``get_pr_ci_status`` specifically.
@@ -3175,11 +3059,7 @@ class GitService(BaseService):
treat as green. treat as green.
""" """
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.get_pr(RepoRef(owner, repo), git_token, pr_number)
resp = await client.get(
f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}",
headers=headers,
)
except httpx.HTTPError as e: except httpx.HTTPError as e:
self.log.warning( self.log.warning(
"get_pr_ci_status pr lookup unreachable", "get_pr_ci_status pr lookup unreachable",
@@ -3212,7 +3092,7 @@ class GitService(BaseService):
owner: str, owner: str,
repo: str, repo: str,
head_sha: str, head_sha: str,
headers: dict[str, str], git_token: str,
) -> list[dict[str, Any]] | dict[str, Any]: ) -> list[dict[str, Any]] | dict[str, Any]:
"""GET the check-runs for ``head_sha``. """GET the check-runs for ``head_sha``.
@@ -3223,11 +3103,8 @@ class GitService(BaseService):
unparseable body) is ``error``. unparseable body) is ``error``.
""" """
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.list_check_runs(
resp = await client.get( RepoRef(owner, repo), git_token, head_sha, per_page=100
f"{_api_base()}/repos/{owner}/{repo}/commits/{head_sha}/check-runs",
headers=headers,
params={"per_page": 100},
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
self.log.warning( self.log.warning(
@@ -3286,7 +3163,7 @@ class GitService(BaseService):
owner: str, owner: str,
repo: str, repo: str,
head_sha: str, head_sha: str,
headers: dict[str, str], git_token: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""No check-runs exist yet for ``head_sha`` — tell "not scheduled" apart """No check-runs exist yet for ``head_sha`` — tell "not scheduled" apart
from "no CI configured" by asking whether the repo has any workflows. from "no CI configured" by asking whether the repo has any workflows.
@@ -3295,11 +3172,8 @@ class GitService(BaseService):
``no_ci_configured``; any other failure is ``error``. ``no_ci_configured``; any other failure is ``error``.
""" """
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.list_workflows(
resp = await client.get( RepoRef(owner, repo), git_token, per_page=1
f"{_api_base()}/repos/{owner}/{repo}/actions/workflows",
headers=headers,
params={"per_page": 1},
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
self.log.warning( self.log.warning(
@@ -3513,15 +3387,14 @@ class GitService(BaseService):
) -> httpx.Response: ) -> httpx.Response:
"""PUT the merge request to GitHub; HTTP errors → GitError.""" """PUT the merge request to GitHub; HTTP errors → GitError."""
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: return cast(
return await client.put( "httpx.Response",
f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}/merge", await self._forge.merge_pr(
headers={ RepoRef(owner, repo),
"Authorization": f"Bearer {git_token}", git_token,
"Accept": "application/vnd.github+json", pr_number,
"X-GitHub-Api-Version": "2022-11-28", merge_method=merge_method,
}, ),
json={"merge_method": merge_method},
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise GitError( raise GitError(
@@ -3610,15 +3483,8 @@ class GitService(BaseService):
so the branch is preserved (cleanup is best-effort; stranding is not). so the branch is preserved (cleanup is best-effort; stranding is not).
""" """
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: resp = await self._forge.list_pulls(
resp = await client.get( RepoRef(owner, repo), git_token, base=branch, per_page=1, timeout=10.0
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",
},
) )
if not resp.is_success: if not resp.is_success:
return True return True
@@ -3650,16 +3516,9 @@ class GitService(BaseService):
) )
return False return False
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: await self._forge.delete_branch_ref(
await client.delete( RepoRef(owner, repo), git_token, branch, timeout=10.0
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
except httpx.HTTPError: except httpx.HTTPError:
return False return False
@@ -3671,14 +3530,8 @@ class GitService(BaseService):
Silently swallows errors branch cleanup is not critical. Silently swallows errors branch cleanup is not critical.
""" """
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: pr_resp = await self._forge.get_pr(
pr_resp = await client.get( RepoRef(owner, repo), git_token, pr_number, timeout=10.0
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: if not pr_resp.is_success:
return return
@@ -3872,15 +3725,7 @@ class GitService(BaseService):
merge method in its settings. merge method in its settings.
""" """
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.get_repo(RepoRef(owner, repo), git_token)
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",
},
)
if not resp.is_success: if not resp.is_success:
return None return None
data = resp.json() data = resp.json()
@@ -4653,15 +4498,7 @@ class GitService(BaseService):
non-success response is still False (GitHub answered, just not merged). non-success response is still False (GitHub answered, just not merged).
""" """
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.get_pr(RepoRef(owner, repo), git_token, pr_number)
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",
},
)
except httpx.HTTPError: except httpx.HTTPError:
return None return None
if not resp.is_success: if not resp.is_success:
@@ -4799,15 +4636,7 @@ class GitService(BaseService):
) -> tuple[str, str] | None: ) -> tuple[str, str] | None:
"""Return ``(head_ref, base_ref)`` for a PR, or ``None`` if unavailable.""" """Return ``(head_ref, base_ref)`` for a PR, or ``None`` if unavailable."""
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.get_pr(RepoRef(owner, repo), git_token, pr_number)
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",
},
)
except httpx.HTTPError: except httpx.HTTPError:
return None return None
if not resp.is_success: if not resp.is_success:
@@ -5239,31 +5068,18 @@ class GitService(BaseService):
git_token = await self._get_project_token_or_raise(project.slug) git_token = await self._get_project_token_or_raise(project.slug)
owner, repo = self._parse_github_remote(workspace) owner, repo = self._parse_github_remote(workspace)
headers = { repo_ref = RepoRef(owner, repo)
"Authorization": f"Bearer {git_token}", existing = await self._forge.get_pr(repo_ref, git_token, pr_number)
"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,
)
already_closed = ( already_closed = (
existing.is_success and existing.json().get("state") == "closed" existing.is_success and existing.json().get("state") == "closed"
) )
if not already_closed: if not already_closed:
if comment: if comment:
await client.post( await self._forge.create_issue_comment(
f"{_api_base()}/repos/{owner}/{repo}/issues/" repo_ref, git_token, pr_number, comment
f"{pr_number}/comments",
headers=headers,
json={"body": comment},
) )
resp = await client.patch( resp = await self._forge.update_pr(
f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}", repo_ref, git_token, pr_number, payload={"state": "closed"}
headers=headers,
json={"state": "closed"},
) )
if not resp.is_success: if not resp.is_success:
raise GitError( raise GitError(
@@ -5318,13 +5134,8 @@ class GitService(BaseService):
git_token = await self._get_project_token_or_raise(project.slug) git_token = await self._get_project_token_or_raise(project.slug)
try: try:
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client: resp = await self._forge.get_pr(
resp = await client.get( RepoRef(owner, repo), git_token, pr_number, include_api_version=False
f"{_api_base()}/repos/{owner}/{repo}/pulls/{pr_number}",
headers={
"Authorization": f"Bearer {git_token}",
"Accept": "application/vnd.github+json",
},
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise GitError( raise GitError(
+15 -20
View File
@@ -18,6 +18,8 @@ from dataclasses import dataclass
import httpx import httpx
from roboco.config import settings from roboco.config import settings
from roboco.services.forge import RepoRef
from roboco.services.forge.github import GitHubProvider
class ProvisioningError(Exception): class ProvisioningError(Exception):
@@ -62,6 +64,7 @@ class GitHubProvisioningService:
) )
self._client = client self._client = client
self._owns_client = client is None self._owns_client = client is None
self._provider = GitHubProvider(base_url=self._base_url)
@property @property
def enabled(self) -> bool: def enabled(self) -> bool:
@@ -98,19 +101,14 @@ class GitHubProvisioningService:
raise ProvisioningDisabledError(msg) raise ProvisioningDisabledError(msg)
client = await self._http() client = await self._http()
try: try:
resp = await client.post( resp = await self._provider.create_org_repo(
f"{self._base_url}/orgs/{self._org}/repos", self._token,
headers={ self._org,
"Authorization": f"Bearer {self._token}", name=name,
"Accept": "application/vnd.github+json", description=description[:350],
"X-GitHub-Api-Version": "2022-11-28", private=private,
}, auto_init=True,
json={ client=client,
"name": name,
"description": description[:350],
"private": private,
"auto_init": True,
},
timeout=self._timeout, timeout=self._timeout,
) )
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
@@ -141,13 +139,10 @@ class GitHubProvisioningService:
"""GET ``org/name`` and rebuild a ProvisionedRepo (idempotent re-create).""" """GET ``org/name`` and rebuild a ProvisionedRepo (idempotent re-create)."""
client = await self._http() client = await self._http()
try: try:
resp = await client.get( resp = await self._provider.get_repo(
f"{self._base_url}/repos/{self._org}/{name}", RepoRef(self._org, name),
headers={ self._token,
"Authorization": f"Bearer {self._token}", client=client,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=self._timeout, timeout=self._timeout,
) )
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
+10 -16
View File
@@ -471,7 +471,8 @@ class _GitReleaseOps:
# binary, so the CLI path fails at publish time with a missing binary. # binary, so the CLI path fails at publish time with a missing binary.
import httpx 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.git import GitService
from roboco.services.project import ProjectService from roboco.services.project import ProjectService
@@ -482,22 +483,15 @@ class _GitReleaseOps:
if not token: if not token:
raise RuntimeError(f"release publish failed: no git token for {self._slug}") raise RuntimeError(f"release publish failed: no git token for {self._slug}")
owner, repo = GitService._parse_git_url(self._git_url) owner, repo = GitService._parse_git_url(self._git_url)
api_base = settings.github_api_base_url.rstrip("/")
try: try:
async with httpx.AsyncClient(timeout=_PUBLISH_TIMEOUT_SECONDS) as client: resp = await GitHubProvider().create_release(
resp = await client.post( RepoRef(owner, repo),
f"{api_base}/repos/{owner}/{repo}/releases", token,
headers={ tag_name=tag,
"Authorization": f"Bearer {token}", name=tag,
"Accept": "application/vnd.github+json", body=notes,
"X-GitHub-Api-Version": "2022-11-28", target_commitish=self._default_branch,
}, timeout=_PUBLISH_TIMEOUT_SECONDS,
json={
"tag_name": tag,
"name": tag,
"body": notes,
"target_commitish": self._default_branch,
},
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise RuntimeError(f"release publish failed: {e}") from e raise RuntimeError(f"release publish failed: {e}") from e