mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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}",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user