mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(forge): Phases 2+2.1+3 — Gitea + GitLab providers, per-call routing, local-merge fallback (#575)
* feat(forge): Phase 2 — Gitea provider, per-call routing, host registry Gitea support lands behind the Phase-1 seam: - GiteaProvider (services/forge/gitea.py): Gitea v1 transport addressed by instance host (api base from the project's git_url). Where Gitea's wire contract diverges from GitHub's, the provider adapts responses back into the shapes GitService already classifies (ShapedResponse): `token` auth scheme, duplicate-PR 409→422 with the "already exists" text GitService keys on, commit statuses reshaped into check_runs / workflow_runs envelopes, APPROVE→APPROVED review mapping, Do-keyed POST merge, merge-method repo keys, label-color '#' prefix, client-side head/base PR filtering. Deliberate postures per the spec: zero-workflows fail-open (statuses-free repo → no_ci_configured) and merge_branch as a shaped 501 (env-sync cascade lands on missing_ref; the shared local-git fallback is Phase-2.1). - ForgeRouter (services/forge/router.py): GitService._forge now routes per call from RepoRef.host — every existing call site unchanged in shape. RepoRef gains an optional host; _parse_git_url returns the host-stamped ref and it is threaded through GitService/release executor instead of being rebuilt from strings (helpers re-signatured to take RepoRef). - Host registry (services/forge/registry.py): in-memory host→provider map, self-healing — ProjectService.get/get_by_slug re-register on every read; provider_for resolves gitea projects by git_url host. - Registration validation now accepts git_provider="gitea"; GitLab remains recognized-but-rejected. Panel: the read-only Forge badge becomes a real picker (Auto-detect / GitHub-GHE / Gitea / GitLab disabled). Plain git (clone/fetch/push) needs no changes — the Basic-auth extraheader works on Gitea unchanged. Gates: mypy 392 files, xenon A, full unit suite 6356 green, integration suite 2257 green. * feat(forge): live-Gitea contract suite + scheme support + slash-safe refs Hardening from running the provider against a real dockerized Gitea 1.22.6 (the spec's Phase-2 contract suite, now committed as the env-gated tests/e2e_smoke/test_gitea_live.py — self-seeding: creates its own repo, pushes real commits, and drives PR open → duplicate reshape → list/filter → diff → review → labels → commit-status CI reshapes → squash merge → branch delete → release, plus a live verification of the x-access-token Basic-auth git-CLI claim). Two real findings fixed: - Branch refs weren't URL-encoded — every RoboCo branch carries slashes (feature/backend/...), and Gitea's router 404s on the extra path segments. list_ci_runs + delete_branch_ref now quote the ref (regression-pinned in the unit suite). - The API base hardcoded https; a LAN instance serving plain http is a real deployment shape. GiteaProvider gains a scheme (recorded per host by the registry from the project's git_url). ShapedResponse moves to forge/shaping.py (shared by the upcoming GitLab transport, which needs its text override for diff reassembly). * feat(forge): Phase 3 GitLab provider + Phase 2.1 local-merge fallback GitLabProvider (services/forge/gitlab.py): GitLab v4 transport addressed by host+scheme, subgroup-safe (the MR project path packs into RepoRef.owner, URL-encoded per call). Adapters translate MR semantics into the GitHub shapes GitService classifies: iid→number, source/target_branch→head/base with a merged bool, per-file diffs reassembled into unified-diff text (ShapedResponse text override, 3-page cap), approve-vs-note review routing (GitLab has no request-changes verb), pipelines/statuses reshaped into workflow_runs/check_runs, merge-method repo-key mapping, duplicate-MR 409→422. Reviewer mirroring is skipped (needs numeric ids RoboCo doesn't store); provisioning stays Phase 4. gitlab.com now auto-detects at registration like github.com; self-hosted GitLab sets the provider explicitly (panel picker enabled). Phase 2.1: neither Gitea nor GitLab has GitHub's server-side merges API — their merge_branch returns a shaped 501 and GitService.sync_env_branch now runs the shared local-git fallback (_local_merge_branch: throwaway clone → ancestor check → merge → push; a conflict aborts with the remote untouched; same status vocabulary as the merges-API path). Also aligns the whole tree with the full gate's tests/-scoped mypy (provider-test responder typing, e2e_smoke's stale owner/repo shapes). Gates: mypy 1229 files clean, xenon A, unit suite 6393 green, forge suites 85 green, panel typecheck/lint clean. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -186,6 +186,10 @@ Git authentication is managed **per-project** through encrypted GitHub PATs:
|
||||
|
||||
**HTTPS URLs require tokens** - attempting to clone without a token will raise `WorkspaceError`.
|
||||
|
||||
### Forge providers (GitHub + Gitea + GitLab)
|
||||
|
||||
The REST surface (PRs, CI status, reviews, labels, releases) is provider-routed (`roboco/services/forge/`): `GitProvider` is the ~20-method transport contract, `GitHubProvider`, `GiteaProvider`, and `GitLabProvider` implement it, and `GitService._forge` returns a `ForgeRouter` that picks the transport per call from `RepoRef.host` — `None` (github.com/GHE) rides GitHub, a registered Gitea/GitLab host rides that instance's provider, so `GitService`'s call sites never know which forge they're on. A project opts in via `projects.git_provider` (gitlab.com auto-detects like github.com; self-hosted instances set it explicitly; `"github"` doubles as the GHE escape hatch with `ROBOCO_GITHUB_API_BASE_URL`) — panel: the Forge select in the edit-project dialog. The host→provider(+scheme — plain-http LAN instances are supported) map is in-memory per process, self-healing: `ProjectService.get`/`get_by_slug` re-register on every read. Both non-GitHub providers adapt their wire contracts back into the GitHub shapes `GitService` classifies (`forge/shaping.py` `ShapedResponse`): Gitea — `token` auth scheme, duplicate-PR 409→422, commit statuses reshaped into `check_runs`/`workflow_runs`, `Do`-keyed POST merge, slash-encoded refs; GitLab — MR iid→`number`, source/target_branch→`head`/`base`, per-file diffs reassembled into unified-diff text, approve-vs-note review routing (no request-changes verb exists), pipelines/statuses CI reshapes, reviewer-request skipped (needs numeric ids). Neither has GitHub's server-side merges API: their `merge_branch` returns a shaped 501 and `GitService.sync_env_branch` runs the shared local-git fallback (`_local_merge_branch`: throwaway clone → merge → push; conflict aborts with the remote untouched, same status vocabulary). Plain git (clone/fetch/push) is forge-agnostic — the Basic-auth `x-access-token:<token>` extraheader works on Gitea/GitLab unchanged (verified live on Gitea). The env-gated `tests/e2e_smoke/test_gitea_live.py` is the live contract suite (self-seeding against a dockerized `gitea/gitea`; it caught the slash-encoding and http-scheme gaps).
|
||||
|
||||
## Task Lifecycle
|
||||
|
||||
### Task States
|
||||
|
||||
@@ -30,15 +30,6 @@ import { Team, type ProjectUpdate, type Project } from "@/types";
|
||||
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
|
||||
import { validateLadder } from "@/components/projects/ladder-validation";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// A null git_provider means "not yet stamped" (pre-Phase-0 project or a
|
||||
// non-github.com host awaiting an explicit choice) — RoboCo is GitHub-only
|
||||
// today either way, so the badge falls back to "GitHub" rather than "Unknown".
|
||||
function forgeLabel(gitProvider: string | null): string {
|
||||
if (!gitProvider) return "GitHub";
|
||||
return gitProvider.charAt(0).toUpperCase() + gitProvider.slice(1);
|
||||
}
|
||||
|
||||
const cells: { value: Team; label: string }[] = [
|
||||
{ value: Team.BACKEND, label: "Backend" },
|
||||
@@ -136,6 +127,7 @@ function EditProjectForm({
|
||||
// Initialize form state from project
|
||||
const [name, setName] = useState(project.name);
|
||||
const [gitUrl, setGitUrl] = useState(project.git_url);
|
||||
const [gitProvider, setGitProvider] = useState(project.git_provider ?? "auto");
|
||||
const [assignedCell, setAssignedCell] = useState(project.assigned_cell);
|
||||
const [defaultBranch, setDefaultBranch] = useState(project.default_branch);
|
||||
const [environments, setEnvironments] = useState(project.environments ?? null);
|
||||
@@ -219,6 +211,7 @@ function EditProjectForm({
|
||||
const updates: ProjectUpdate = {
|
||||
name,
|
||||
git_url: gitUrl,
|
||||
git_provider: gitProvider === "auto" ? null : gitProvider,
|
||||
assigned_cell: assignedCell,
|
||||
default_branch: defaultBranch || "main",
|
||||
environments,
|
||||
@@ -317,17 +310,22 @@ function EditProjectForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Forge (read-only — GitHub-only today) */}
|
||||
{/* Forge provider */}
|
||||
<div className="grid gap-2">
|
||||
<HelpTip label="Auto-detected from the Git URL's host; RoboCo's PR/CI/review surface is GitHub-only today. GitLab & Gitea support planned.">
|
||||
<HelpTip label="Which forge API serves PR/CI/review operations. Auto-detect covers github.com; a self-hosted Gitea instance (or GitHub Enterprise) must be set explicitly — the host comes from the Git URL. GitLab support is planned.">
|
||||
<Label>Forge</Label>
|
||||
</HelpTip>
|
||||
<div>
|
||||
<Badge variant="secondary">{forgeLabel(project.git_provider)}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GitLab & Gitea support planned.
|
||||
</p>
|
||||
<Select value={gitProvider} onValueChange={setGitProvider}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Auto-detect" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto-detect (github.com)</SelectItem>
|
||||
<SelectItem value="github">GitHub / GitHub Enterprise</SelectItem>
|
||||
<SelectItem value="gitea">Gitea (self-hosted)</SelectItem>
|
||||
<SelectItem value="gitlab">GitLab (gitlab.com / self-hosted)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Git Token Section */}
|
||||
|
||||
@@ -24,10 +24,11 @@ KNOWN_PROVIDERS: tuple[str, ...] = ("github", "gitlab", "gitea")
|
||||
_SCP_HOST_RE = re.compile(r"^(?:[^@/]+@)?(?P<host>[^/:]+):")
|
||||
|
||||
|
||||
def _extract_host(git_url: str) -> str | None:
|
||||
def extract_host(git_url: str) -> str | None:
|
||||
"""Pull the host out of an https, ssh://, or scp-like git URL.
|
||||
|
||||
Returns None when no host can be found (unparseable input).
|
||||
Returns None when no host can be found (unparseable input). Public —
|
||||
the forge registry keys its host→provider routing map on this.
|
||||
"""
|
||||
url = git_url.strip()
|
||||
if not url:
|
||||
@@ -50,7 +51,7 @@ def detect_provider(git_url: str) -> str | None:
|
||||
those apart. A project on a non-SaaS host must set ``git_provider``
|
||||
explicitly (a one-click choice in the panel's project dialog).
|
||||
"""
|
||||
host = _extract_host(git_url)
|
||||
host = extract_host(git_url)
|
||||
if host == "github.com":
|
||||
return "github"
|
||||
if host == "gitlab.com":
|
||||
@@ -67,13 +68,14 @@ def validate_project_forge(git_url: str | None, git_provider: str | None) -> str
|
||||
- an unknown ``git_provider`` string -> error naming ``KNOWN_PROVIDERS``.
|
||||
- explicit ``git_provider="github"`` -> OK regardless of host (the GitHub
|
||||
Enterprise escape hatch — current behavior preserved).
|
||||
- explicit ``git_provider`` of "gitlab"/"gitea" -> error: recognized but
|
||||
not yet supported.
|
||||
- explicit ``git_provider="gitea"`` -> OK (Phase 2: the Gitea transport
|
||||
is live; the host comes from the git_url).
|
||||
- explicit ``git_provider="gitlab"`` -> error: recognized but not yet
|
||||
supported.
|
||||
- no explicit ``git_provider``, host detects to "github" -> OK.
|
||||
- no explicit ``git_provider``, anything else (unknown host, or a detected
|
||||
but unsupported host like gitlab.com) -> error steering the operator to
|
||||
either a GitHub host or the explicit ``git_provider="github"`` escape
|
||||
hatch for GHE.
|
||||
- no explicit ``git_provider``, anything else (unknown host, or a
|
||||
detected but unsupported host like gitlab.com) -> error steering the
|
||||
operator to an explicit provider choice.
|
||||
"""
|
||||
if not git_url:
|
||||
return None
|
||||
@@ -84,17 +86,14 @@ def validate_project_forge(git_url: str | None, git_provider: str | None) -> str
|
||||
f"Unknown git_provider {git_provider!r}; must be one of "
|
||||
f"{', '.join(KNOWN_PROVIDERS)}."
|
||||
)
|
||||
if git_provider == "github":
|
||||
if git_provider in ("github", "gitea", "gitlab"):
|
||||
return None
|
||||
return (
|
||||
f"git_provider={git_provider!r} is recognized but not yet "
|
||||
"supported — RoboCo is GitHub-only today; GitLab/Gitea support "
|
||||
"is planned."
|
||||
)
|
||||
return f"git_provider={git_provider!r} is recognized but not yet supported."
|
||||
|
||||
if detect_provider(git_url) == "github":
|
||||
if detect_provider(git_url) in ("github", "gitlab"):
|
||||
return None
|
||||
return (
|
||||
"RoboCo currently supports GitHub-hosted repos only. If this is a "
|
||||
'GitHub Enterprise host, set git_provider="github" explicitly.'
|
||||
"RoboCo supports GitHub- and GitLab-hosted repos by default. For a "
|
||||
'self-hosted forge set git_provider explicitly ("gitea", "gitlab", '
|
||||
'or "github" for GitHub Enterprise).'
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Git-forge provider seam (Phase 1 of the forge-providers spec).
|
||||
"""Git-forge provider seam (Phases 1-2 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
|
||||
@@ -6,8 +6,9 @@ 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
|
||||
``base`` is pure contracts, ``github``/``gitea`` are REST transports,
|
||||
``router`` routes per call from ``RepoRef.host``, and ``registry`` resolves
|
||||
a project onto its provider and keeps the host→provider map. Nothing here imports
|
||||
``roboco.services.*`` — ``GitService`` depends on this package, never the
|
||||
reverse.
|
||||
"""
|
||||
@@ -15,7 +16,17 @@ reverse.
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.services.forge.base import GitProvider, RepoRef
|
||||
from roboco.services.forge.gitea import GiteaProvider
|
||||
from roboco.services.forge.github import GitHubProvider
|
||||
from roboco.services.forge.registry import provider_for
|
||||
from roboco.services.forge.registry import provider_for, register_project_forge
|
||||
from roboco.services.forge.router import ForgeRouter
|
||||
|
||||
__all__ = ["GitHubProvider", "GitProvider", "RepoRef", "provider_for"]
|
||||
__all__ = [
|
||||
"ForgeRouter",
|
||||
"GitHubProvider",
|
||||
"GitProvider",
|
||||
"GiteaProvider",
|
||||
"RepoRef",
|
||||
"provider_for",
|
||||
"register_project_forge",
|
||||
]
|
||||
|
||||
@@ -35,10 +35,15 @@ class RepoRef:
|
||||
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.
|
||||
|
||||
``host`` carries the forge host for self-hosted providers (a Gitea
|
||||
instance's API base derives from it); ``None`` means "the default GitHub
|
||||
host" so every existing two-arg construction site keeps its meaning.
|
||||
"""
|
||||
|
||||
owner: str
|
||||
repo: str
|
||||
host: str | None = None
|
||||
|
||||
|
||||
class GitProvider(ABC):
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Gitea REST transport — Phase 2 of the forge-providers spec.
|
||||
|
||||
Gitea's API is deliberately GitHub-shaped, so most methods are the same
|
||||
paths against ``https://{host}/api/v1`` with ``Authorization: token`` (the
|
||||
scheme Gitea's classic PATs require; Bearer is rejected). Where Gitea's wire
|
||||
contract diverges, this provider ADAPTS the response back into the GitHub
|
||||
shape ``GitService`` classifies (see the per-method notes) rather than
|
||||
teaching ``GitService`` a second dialect — the seam's contract is that
|
||||
callers keep reading ``.status_code`` / ``.is_success`` / ``.text`` /
|
||||
``.json()`` exactly as they do for GitHub. Adapted responses are wrapped in
|
||||
:class:`ShapedResponse`.
|
||||
|
||||
Deliberate Phase-2 postures (per the spec):
|
||||
|
||||
- CI is classified from Gitea's commit-status API reshaped into GitHub's
|
||||
``check_runs`` / ``workflow_runs`` envelopes; ``list_workflows`` always
|
||||
reports zero so a statuses-free repo classifies as ``no_ci_configured``
|
||||
(fail-open, the posture the GitHub path takes for unreachable repos).
|
||||
- ``merge_branch`` (the env-sync cascade's server-side merge) has no Gitea
|
||||
equivalent; it returns a shaped 501 so ``_env_merge_status`` lands on its
|
||||
existing ``missing_ref`` branch. The shared local-git fallback is a
|
||||
follow-up, not silently faked here.
|
||||
- Plain git (clone/fetch/push) needs no provider work: Gitea, like GitHub
|
||||
and GitLab, accepts a PAT as the Basic-auth password with the username
|
||||
ignored, so the existing ``x-access-token:<token>`` extraheader works.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.exceptions import GitError
|
||||
from roboco.services.forge.base import GitProvider, RepoRef
|
||||
from roboco.services.forge.shaping import ShapedResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
# Gitea review-event vocabulary differs from GitHub's by one word.
|
||||
_REVIEW_EVENT_MAP = {"APPROVE": "APPROVED"}
|
||||
|
||||
# Gitea commit-status states → GitHub check-run/workflow-run vocabulary.
|
||||
_STATUS_CONCLUSION = {"success": "success", "failure": "failure", "error": "failure"}
|
||||
|
||||
|
||||
def _default_timeout() -> int:
|
||||
return settings.git_command_timeout_seconds
|
||||
|
||||
|
||||
class GiteaProvider(GitProvider):
|
||||
"""Self-hosted Gitea transport, addressed by instance host.
|
||||
|
||||
``scheme`` comes from the project's git_url via the registry — a LAN
|
||||
instance serving plain http (no TLS terminator) is a real deployment
|
||||
shape, not just a test convenience.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, scheme: str = "https") -> None:
|
||||
self._host = host.strip().rstrip("/")
|
||||
self._scheme = scheme
|
||||
self._repo_url_re = re.compile(
|
||||
re.escape(self._host)
|
||||
+ r"[:/]+(?P<owner>[^/]+)/(?P<repo>[^/\s]+?)(?:\.git)?$"
|
||||
)
|
||||
|
||||
def _api_base(self) -> str:
|
||||
return f"{self._scheme}://{self._host}/api/v1"
|
||||
|
||||
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) -> dict[str, str]:
|
||||
# Gitea classic PATs require the `token` scheme; Bearer is rejected.
|
||||
return {"Authorization": f"token {token}", "Accept": "application/json"}
|
||||
|
||||
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:
|
||||
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 = self._repo_url_re.search(git_url)
|
||||
if not match:
|
||||
raise GitError(
|
||||
"Could not parse Gitea owner/repo from remote URL",
|
||||
{"host": self._host},
|
||||
)
|
||||
return RepoRef(match.group("owner"), match.group("repo"), host=self._host)
|
||||
|
||||
# -- 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,
|
||||
) -> Any:
|
||||
"""Gitea's list endpoint has no head/base filters — fetch and filter
|
||||
client-side, reshaping each element with the one field GitHub has
|
||||
and Gitea lacks (``author_association``)."""
|
||||
_ = include_api_version
|
||||
params: dict[str, Any] = {"state": state, "limit": per_page or 50}
|
||||
resp = await self._send(
|
||||
"get",
|
||||
self._repo_url(repo, "pulls"),
|
||||
headers=self._headers(token),
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
pulls = resp.json()
|
||||
if not isinstance(pulls, list):
|
||||
return resp
|
||||
selected = [
|
||||
self._shape_pull(pr)
|
||||
for pr in pulls
|
||||
if self._pull_matches(pr, head=head, base=base)
|
||||
]
|
||||
return ShapedResponse(resp, json_payload=selected)
|
||||
|
||||
@staticmethod
|
||||
def _pull_matches(
|
||||
pr: dict[str, Any], *, head: str | None, base: str | None
|
||||
) -> bool:
|
||||
if head is not None and ((pr.get("head") or {}).get("ref")) != head:
|
||||
return False
|
||||
return not (base is not None and ((pr.get("base") or {}).get("ref")) != base)
|
||||
|
||||
@staticmethod
|
||||
def _shape_pull(pr: dict[str, Any]) -> dict[str, Any]:
|
||||
shaped = dict(pr)
|
||||
shaped.setdefault("author_association", "NONE")
|
||||
return shaped
|
||||
|
||||
async def get_pr(
|
||||
self,
|
||||
repo: RepoRef,
|
||||
token: str,
|
||||
pr_number: int,
|
||||
*,
|
||||
include_api_version: bool = True,
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
_ = include_api_version
|
||||
return await self._send(
|
||||
"get",
|
||||
self._repo_url(repo, "pulls", str(pr_number)),
|
||||
headers=self._headers(token),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def get_pr_diff(self, repo: RepoRef, token: str, pr_number: int) -> Any:
|
||||
return await self._send(
|
||||
"get",
|
||||
self._repo_url(repo, "pulls", f"{pr_number}.diff"),
|
||||
headers=self._headers(token),
|
||||
)
|
||||
|
||||
async def create_pr(
|
||||
self, repo: RepoRef, token: str, *, head: str, base: str, title: str, body: str
|
||||
) -> Any:
|
||||
"""Gitea signals the duplicate-PR case as 409 where GitHub uses 422;
|
||||
reshape that one case so GitService's idempotency branch
|
||||
(`422 and "already exists" in text`) keeps working."""
|
||||
resp = await self._send(
|
||||
"post",
|
||||
self._repo_url(repo, "pulls"),
|
||||
headers=self._headers(token),
|
||||
json_body={"title": title, "body": body, "head": head, "base": base},
|
||||
)
|
||||
if resp.status_code == httpx.codes.CONFLICT and "already exists" in resp.text:
|
||||
return ShapedResponse(resp, status_code=httpx.codes.UNPROCESSABLE_ENTITY)
|
||||
return resp
|
||||
|
||||
async def update_pr(
|
||||
self, repo: RepoRef, token: str, pr_number: int, *, payload: dict[str, Any]
|
||||
) -> Any:
|
||||
return await self._send(
|
||||
"patch",
|
||||
self._repo_url(repo, "pulls", str(pr_number)),
|
||||
headers=self._headers(token),
|
||||
json_body=payload,
|
||||
)
|
||||
|
||||
async def merge_pr(
|
||||
self, repo: RepoRef, token: str, pr_number: int, *, merge_method: str
|
||||
) -> Any:
|
||||
# Gitea: POST (not PUT) with the method under "Do".
|
||||
return await self._send(
|
||||
"post",
|
||||
self._repo_url(repo, "pulls", str(pr_number), "merge"),
|
||||
headers=self._headers(token),
|
||||
json_body={"Do": merge_method},
|
||||
)
|
||||
|
||||
async def request_reviewers(
|
||||
self, repo: RepoRef, token: str, pr_number: int, reviewers: list[str]
|
||||
) -> Any:
|
||||
return await self._send(
|
||||
"post",
|
||||
self._repo_url(repo, "pulls", str(pr_number), "requested_reviewers"),
|
||||
headers=self._headers(token),
|
||||
json_body={"reviewers": reviewers},
|
||||
)
|
||||
|
||||
async def post_review(
|
||||
self, repo: RepoRef, token: str, pr_number: int, *, body: str, event: str
|
||||
) -> Any:
|
||||
return await self._send(
|
||||
"post",
|
||||
self._repo_url(repo, "pulls", str(pr_number), "reviews"),
|
||||
headers=self._headers(token),
|
||||
json_body={"body": body, "event": _REVIEW_EVENT_MAP.get(event, event)},
|
||||
)
|
||||
|
||||
async def merge_branch(
|
||||
self, repo: RepoRef, token: str, *, base: str, head: str, commit_message: str
|
||||
) -> Any:
|
||||
"""No server-side merges API on Gitea — a shaped 501 lands
|
||||
``_env_merge_status`` on its existing ``missing_ref`` branch."""
|
||||
_ = (repo, token, base, head, commit_message)
|
||||
request = httpx.Request("post", f"https://{self._host}/unsupported")
|
||||
real = httpx.Response(
|
||||
httpx.codes.NOT_IMPLEMENTED,
|
||||
request=request,
|
||||
json={"message": "Gitea has no server-side branch-merge API"},
|
||||
)
|
||||
return ShapedResponse(real)
|
||||
|
||||
# -- CI ----------------------------------------------------------------
|
||||
|
||||
async def list_ci_runs(
|
||||
self,
|
||||
repo: RepoRef,
|
||||
token: str,
|
||||
*,
|
||||
workflow: str | None,
|
||||
branch: str,
|
||||
head_sha: str | None,
|
||||
per_page: int,
|
||||
) -> Any:
|
||||
"""Gitea has no workflow-runs listing with GitHub's shape; the
|
||||
combined commit status for the branch head is the signal. A settled
|
||||
combined state becomes one synthetic ``workflow_runs`` entry (the
|
||||
consumer picks conclusion/head_sha off it); pending or empty yields
|
||||
no completed runs, exactly like GitHub's ``status=completed``
|
||||
filter."""
|
||||
_ = (workflow, head_sha, per_page)
|
||||
resp = await self._send(
|
||||
"get",
|
||||
# Branch names carry slashes (feature/backend/...) — encode or
|
||||
# Gitea's router 404s on the extra path segments.
|
||||
self._repo_url(repo, "commits", quote(branch, safe=""), "status"),
|
||||
headers=self._headers(token),
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
data = resp.json() if isinstance(resp.json(), dict) else {}
|
||||
conclusion = _STATUS_CONCLUSION.get(str(data.get("state") or "").lower())
|
||||
runs: list[dict[str, Any]] = []
|
||||
if conclusion is not None:
|
||||
runs.append(
|
||||
{
|
||||
"head_sha": data.get("sha") or "",
|
||||
"run_attempt": 1,
|
||||
"conclusion": conclusion,
|
||||
"name": "combined-status",
|
||||
"html_url": data.get("url") or "",
|
||||
"updated_at": "",
|
||||
}
|
||||
)
|
||||
return ShapedResponse(resp, json_payload={"workflow_runs": runs})
|
||||
|
||||
async def list_check_runs(
|
||||
self, repo: RepoRef, token: str, head_sha: str, *, per_page: int
|
||||
) -> Any:
|
||||
"""Commit statuses reshaped into GitHub's ``check_runs`` envelope —
|
||||
the per-name latest-wins dedup upstream keys on ``id``, which Gitea
|
||||
statuses already increment."""
|
||||
resp = await self._send(
|
||||
"get",
|
||||
self._repo_url(repo, "commits", head_sha, "statuses"),
|
||||
headers=self._headers(token),
|
||||
params={"limit": per_page},
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
statuses = resp.json()
|
||||
if not isinstance(statuses, list):
|
||||
return resp
|
||||
check_runs = [self._shape_status(status) for status in statuses]
|
||||
return ShapedResponse(resp, json_payload={"check_runs": check_runs})
|
||||
|
||||
@staticmethod
|
||||
def _shape_status(status: dict[str, Any]) -> dict[str, Any]:
|
||||
state = str(status.get("status") or "").lower()
|
||||
settled = state in _STATUS_CONCLUSION
|
||||
return {
|
||||
"id": status.get("id") or 0,
|
||||
"name": status.get("context") or "status",
|
||||
"status": "completed" if settled else "in_progress",
|
||||
"conclusion": _STATUS_CONCLUSION.get(state),
|
||||
}
|
||||
|
||||
async def list_workflows(self, repo: RepoRef, token: str, *, per_page: int) -> Any:
|
||||
"""Fail-open: no cheap "is CI configured at all" probe exists on
|
||||
Gitea, so zero-check-runs classifies as ``no_ci_configured`` (the
|
||||
spec's chosen posture) rather than ``pending_not_scheduled``."""
|
||||
_ = (repo, token, per_page)
|
||||
request = httpx.Request("get", f"https://{self._host}/synthetic")
|
||||
real = httpx.Response(httpx.codes.OK, request=request, json={"total_count": 0})
|
||||
return ShapedResponse(real)
|
||||
|
||||
# -- repo / labels / branches / releases -------------------------------
|
||||
|
||||
async def get_repo(
|
||||
self,
|
||||
repo: RepoRef,
|
||||
token: str,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
"""Gitea names the merge-method toggles differently — reshape onto
|
||||
GitHub's ``allow_*`` keys the merge-method fallback reads."""
|
||||
resp = await self._send(
|
||||
"get",
|
||||
self._repo_url(repo),
|
||||
headers=self._headers(token),
|
||||
client=client,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
return resp
|
||||
shaped = dict(data)
|
||||
shaped["allow_merge_commit"] = data.get("allow_merge_commits", True)
|
||||
shaped["allow_rebase_merge"] = data.get("allow_rebase", True)
|
||||
shaped.setdefault("allow_squash_merge", True)
|
||||
return ShapedResponse(resp, json_payload=shaped)
|
||||
|
||||
async def ensure_label(
|
||||
self, repo: RepoRef, token: str, name: str, color: str
|
||||
) -> Any:
|
||||
# Gitea wants the leading '#' on label colors; GitHub omits it.
|
||||
hex_color = color if color.startswith("#") else f"#{color}"
|
||||
return await self._send(
|
||||
"post",
|
||||
self._repo_url(repo, "labels"),
|
||||
headers=self._headers(token),
|
||||
json_body={"name": name, "color": hex_color},
|
||||
)
|
||||
|
||||
async def add_labels(
|
||||
self, repo: RepoRef, token: str, pr_number: int, labels: list[str]
|
||||
) -> Any:
|
||||
return await self._send(
|
||||
"post",
|
||||
self._repo_url(repo, "issues", str(pr_number), "labels"),
|
||||
headers=self._headers(token),
|
||||
json_body={"labels": labels},
|
||||
)
|
||||
|
||||
async def delete_branch_ref(
|
||||
self, repo: RepoRef, token: str, branch: str, *, timeout: float | None = None
|
||||
) -> Any:
|
||||
return await self._send(
|
||||
"delete",
|
||||
self._repo_url(repo, "branches", quote(branch, safe="")),
|
||||
headers=self._headers(token),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def create_issue_comment(
|
||||
self, repo: RepoRef, token: str, issue_number: int, body: str
|
||||
) -> Any:
|
||||
return await self._send(
|
||||
"post",
|
||||
self._repo_url(repo, "issues", str(issue_number), "comments"),
|
||||
headers=self._headers(token),
|
||||
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,
|
||||
) -> Any:
|
||||
return await self._send(
|
||||
"post",
|
||||
self._repo_url(repo, "releases"),
|
||||
headers=self._headers(token),
|
||||
json_body={
|
||||
"tag_name": tag_name,
|
||||
"name": name,
|
||||
"body": body,
|
||||
"target_commitish": target_commitish,
|
||||
},
|
||||
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,
|
||||
) -> Any:
|
||||
return await self._send(
|
||||
"post",
|
||||
f"{self._api_base()}/orgs/{org}/repos",
|
||||
headers=self._headers(token),
|
||||
json_body={
|
||||
"name": name,
|
||||
"description": description,
|
||||
"private": private,
|
||||
"auto_init": auto_init,
|
||||
},
|
||||
client=client,
|
||||
timeout=timeout,
|
||||
)
|
||||
@@ -0,0 +1,595 @@
|
||||
"""GitLab REST v4 transport — Phase 3 of the forge-providers spec.
|
||||
|
||||
GitLab's API is the most semantically divergent of the three forges: pull
|
||||
requests are "merge requests" addressed by a per-project ``iid``, review has
|
||||
no request-changes verb, CI is pipelines/statuses rather than
|
||||
workflows/check-runs, and a repo is addressed by its full (URL-encoded)
|
||||
namespace path rather than an ``owner/repo`` pair — subgroups make that path
|
||||
arbitrarily deep, which is exactly what :class:`~roboco.services.forge.base.RepoRef`
|
||||
was built to shrug off (the whole path packs into ``owner``; ``repo`` is
|
||||
unused). Every method below ADAPTS the GitLab wire shape back into the GitHub
|
||||
shape ``GitService`` classifies (see the per-method notes), wrapped in
|
||||
:class:`ShapedResponse` — callers keep reading ``.status_code`` /
|
||||
``.is_success`` / ``.text`` / ``.json()`` exactly as they do for GitHub.
|
||||
|
||||
Deliberate Phase-3 postures (per the spec):
|
||||
|
||||
- ``merge_branch`` (the env-sync cascade's server-side merge) has no GitLab
|
||||
equivalent; it returns a shaped 501 so ``_env_merge_status`` lands on its
|
||||
existing ``missing_ref`` branch, same as Gitea.
|
||||
- ``request_reviewers`` needs numeric GitLab user ids RoboCo does not store
|
||||
(only usernames/slugs) — mirroring is skipped with a synthetic 200 rather
|
||||
than failing the PR-open flow over a best-effort reviewer nudge.
|
||||
- ``create_org_repo`` (provisioning) is explicitly out of Phase 3 scope
|
||||
(Phase 4) — a synthetic 501 keeps the interface implementable without
|
||||
faking GitLab's namespace-model POST here.
|
||||
- Plain git (clone/fetch/push) needs no provider work: GitLab, like GitHub
|
||||
and Gitea, accepts a PAT as the Basic-auth password with the username
|
||||
ignored, so the existing ``x-access-token:<token>`` extraheader works.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.exceptions import GitError
|
||||
from roboco.services.forge.base import GitProvider, RepoRef
|
||||
from roboco.services.forge.shaping import ShapedResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
# GitLab MR list `state` param: "open" is GitHub/RoboCo's vocabulary, GitLab
|
||||
# calls it "opened". Every other value (closed/merged/locked/all) passes
|
||||
# through unchanged.
|
||||
_STATE_PARAM_MAP = {"open": "opened"}
|
||||
|
||||
# GitLab pipeline/commit-status states → GitHub check-run/workflow-run
|
||||
# conclusion vocabulary. Unsettled states (running/pending/created) are
|
||||
# absent on purpose — they classify as "not yet concluded" by omission.
|
||||
_CONCLUSION_MAP = {"success": "success", "failed": "failure", "canceled": "failure"}
|
||||
_IN_PROGRESS_STATUSES = frozenset({"running", "pending", "created"})
|
||||
|
||||
# get_pr_diff pagination cap — GitLab's /diffs endpoint is JSON-per-file
|
||||
# with no raw-unified-diff media type, so a large MR needs multiple pages
|
||||
# reassembled; bounded so one pathological MR can't loop forever.
|
||||
_DIFF_MAX_PAGES = 3
|
||||
_DIFF_PAGE_SIZE = 100
|
||||
|
||||
# A GitLab project path is at least "namespace/project" — one bare segment
|
||||
# can't be a valid remote (no owner-less repos on GitLab).
|
||||
_MIN_PATH_SEGMENTS = 2
|
||||
|
||||
|
||||
def _default_timeout() -> int:
|
||||
return settings.git_command_timeout_seconds
|
||||
|
||||
|
||||
class GitLabProvider(GitProvider):
|
||||
"""Self-hosted or gitlab.com REST v4 transport, addressed by instance host."""
|
||||
|
||||
def __init__(self, host: str, scheme: str = "https") -> None:
|
||||
self._host = host.strip().rstrip("/")
|
||||
self._scheme = scheme
|
||||
self._repo_url_re = re.compile(
|
||||
re.escape(self._host) + r"[:/]+(?P<path>[^\s]+?)(?:\.git)?$"
|
||||
)
|
||||
|
||||
def _project_base(self, repo: RepoRef) -> str:
|
||||
# RepoRef.owner carries the FULL namespace path (subgroups included);
|
||||
# GitLab addresses a project by that path (or numeric id) URL-encoded
|
||||
# as a single path segment.
|
||||
encoded = quote(repo.owner, safe="")
|
||||
return f"{self._scheme}://{self._host}/api/v4/projects/{encoded}"
|
||||
|
||||
def _url(self, repo: RepoRef, *segments: str) -> str:
|
||||
base = self._project_base(repo)
|
||||
return "/".join([base, *segments]) if segments else base
|
||||
|
||||
@staticmethod
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
|
||||
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:
|
||||
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:
|
||||
"""Pack the FULL namespace path (subgroups allowed, 2+ segments)
|
||||
into ``RepoRef.owner``; ``repo`` stays empty since GitLab addresses
|
||||
a project by that whole path, not an owner/repo pair."""
|
||||
match = self._repo_url_re.search(git_url)
|
||||
if not match:
|
||||
raise GitError(
|
||||
"Could not parse GitLab project path from remote URL",
|
||||
{"host": self._host},
|
||||
)
|
||||
segments = [s for s in match.group("path").strip("/").split("/") if s]
|
||||
if len(segments) < _MIN_PATH_SEGMENTS:
|
||||
raise GitError(
|
||||
"GitLab remote URL is missing a namespace/project path",
|
||||
{"host": self._host, "path": match.group("path")},
|
||||
)
|
||||
return RepoRef("/".join(segments), "", host=self._host)
|
||||
|
||||
# -- pull requests (merge requests) --------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _shape_pull(mr: dict[str, Any], repo: RepoRef) -> dict[str, Any]:
|
||||
gitlab_state = mr.get("state")
|
||||
author = mr.get("author") or {}
|
||||
return {
|
||||
**mr,
|
||||
"number": mr.get("iid"),
|
||||
"html_url": mr.get("web_url") or "",
|
||||
"title": mr.get("title") or "",
|
||||
"state": "open" if gitlab_state == "opened" else "closed",
|
||||
"merged": gitlab_state == "merged",
|
||||
"head": {
|
||||
"ref": mr.get("source_branch") or "",
|
||||
"sha": mr.get("sha") or "",
|
||||
"repo": {"full_name": repo.owner},
|
||||
},
|
||||
"base": {"ref": mr.get("target_branch") or ""},
|
||||
"user": {"login": author.get("username") or ""},
|
||||
"author_association": "NONE",
|
||||
}
|
||||
|
||||
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:
|
||||
"""GitLab natively filters by source_branch/target_branch — no
|
||||
client-side filtering needed (unlike Gitea)."""
|
||||
_ = include_api_version
|
||||
params: dict[str, Any] = {"state": _STATE_PARAM_MAP.get(state, state)}
|
||||
if head is not None:
|
||||
params["source_branch"] = head
|
||||
if base is not None:
|
||||
params["target_branch"] = base
|
||||
if per_page is not None:
|
||||
params["per_page"] = per_page
|
||||
resp = await self._send(
|
||||
"get",
|
||||
self._url(repo, "merge_requests"),
|
||||
headers=self._headers(token),
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
mrs = resp.json()
|
||||
if not isinstance(mrs, list):
|
||||
return resp
|
||||
shaped = [self._shape_pull(mr, repo) for mr in mrs]
|
||||
return ShapedResponse(resp, json_payload=shaped)
|
||||
|
||||
async def get_pr(
|
||||
self,
|
||||
repo: RepoRef,
|
||||
token: str,
|
||||
pr_number: int,
|
||||
*,
|
||||
include_api_version: bool = True,
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
_ = include_api_version
|
||||
resp = await self._send(
|
||||
"get",
|
||||
self._url(repo, "merge_requests", str(pr_number)),
|
||||
headers=self._headers(token),
|
||||
timeout=timeout,
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
return resp
|
||||
return ShapedResponse(resp, json_payload=self._shape_pull(data, repo))
|
||||
|
||||
async def get_pr_diff(self, repo: RepoRef, token: str, pr_number: int) -> Any:
|
||||
"""GitLab has no raw-unified-diff media type — ``/diffs`` returns one
|
||||
JSON object per changed file; reassemble a unified diff from up to
|
||||
3 pages of 100."""
|
||||
url = self._url(repo, "merge_requests", str(pr_number), "diffs")
|
||||
headers = self._headers(token)
|
||||
diffs: list[dict[str, Any]] = []
|
||||
resp = await self._send(
|
||||
"get", url, headers=headers, params={"page": 1, "per_page": _DIFF_PAGE_SIZE}
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
page = resp.json()
|
||||
if isinstance(page, list):
|
||||
diffs.extend(page)
|
||||
for page_number in range(2, _DIFF_MAX_PAGES + 1):
|
||||
if not isinstance(page, list) or len(page) < _DIFF_PAGE_SIZE:
|
||||
break
|
||||
resp = await self._send(
|
||||
"get",
|
||||
url,
|
||||
headers=headers,
|
||||
params={"page": page_number, "per_page": _DIFF_PAGE_SIZE},
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
page = resp.json()
|
||||
if isinstance(page, list):
|
||||
diffs.extend(page)
|
||||
text = "".join(
|
||||
f"diff --git a/{item.get('old_path', '')} b/{item.get('new_path', '')}\n"
|
||||
f"{item.get('diff', '')}"
|
||||
for item in diffs
|
||||
)
|
||||
return ShapedResponse(resp, text=text)
|
||||
|
||||
async def create_pr(
|
||||
self, repo: RepoRef, token: str, *, head: str, base: str, title: str, body: str
|
||||
) -> Any:
|
||||
"""GitLab signals the duplicate-MR case as 409 where GitHub uses
|
||||
422; reshape that one case so GitService's idempotency branch
|
||||
(`422 and "already exists" in text`) keeps working."""
|
||||
resp = await self._send(
|
||||
"post",
|
||||
self._url(repo, "merge_requests"),
|
||||
headers=self._headers(token),
|
||||
json_body={
|
||||
"source_branch": head,
|
||||
"target_branch": base,
|
||||
"title": title,
|
||||
"description": body,
|
||||
},
|
||||
)
|
||||
if resp.status_code == httpx.codes.CONFLICT and "already exists" in resp.text:
|
||||
return ShapedResponse(resp, status_code=httpx.codes.UNPROCESSABLE_ENTITY)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
return resp
|
||||
return ShapedResponse(resp, json_payload=self._shape_pull(data, repo))
|
||||
|
||||
async def update_pr(
|
||||
self, repo: RepoRef, token: str, pr_number: int, *, payload: dict[str, Any]
|
||||
) -> httpx.Response:
|
||||
"""Translate GitHub's PATCH vocabulary onto GitLab's PUT one:
|
||||
``body``→``description``, ``state=closed``→``state_event=close``."""
|
||||
translated: dict[str, Any] = {}
|
||||
if "title" in payload:
|
||||
translated["title"] = payload["title"]
|
||||
if "body" in payload:
|
||||
translated["description"] = payload["body"]
|
||||
if payload.get("state") == "closed":
|
||||
translated["state_event"] = "close"
|
||||
return await self._send(
|
||||
"put",
|
||||
self._url(repo, "merge_requests", str(pr_number)),
|
||||
headers=self._headers(token),
|
||||
json_body=translated,
|
||||
)
|
||||
|
||||
async def merge_pr(
|
||||
self, repo: RepoRef, token: str, pr_number: int, *, merge_method: str
|
||||
) -> httpx.Response:
|
||||
return await self._send(
|
||||
"put",
|
||||
self._url(repo, "merge_requests", str(pr_number), "merge"),
|
||||
headers=self._headers(token),
|
||||
json_body={"squash": merge_method == "squash"},
|
||||
)
|
||||
|
||||
async def request_reviewers(
|
||||
self, repo: RepoRef, token: str, pr_number: int, reviewers: list[str]
|
||||
) -> Any:
|
||||
"""GitLab reviewer assignment needs numeric user ids
|
||||
(``reviewer_ids``) — RoboCo only ever stores usernames/slugs, and
|
||||
resolving those is out of scope for Phase 3 (spec's open items).
|
||||
Skip with a synthetic success rather than failing the PR-open flow
|
||||
over a best-effort reviewer mirror."""
|
||||
_ = (repo, token, pr_number, reviewers)
|
||||
request = httpx.Request("put", f"{self._scheme}://{self._host}/synthetic")
|
||||
real = httpx.Response(
|
||||
httpx.codes.OK,
|
||||
request=request,
|
||||
json={"skipped": "gitlab reviewer mirroring needs numeric ids"},
|
||||
)
|
||||
return ShapedResponse(real)
|
||||
|
||||
async def post_review(
|
||||
self, repo: RepoRef, token: str, pr_number: int, *, body: str, event: str
|
||||
) -> httpx.Response:
|
||||
"""GitLab has no request-changes verb: APPROVE hits the dedicated
|
||||
approve endpoint, anything else (REQUEST_CHANGES/COMMENT) becomes a
|
||||
plain note carrying the verdict in its body."""
|
||||
if event == "APPROVE":
|
||||
return await self._send(
|
||||
"post",
|
||||
self._url(repo, "merge_requests", str(pr_number), "approve"),
|
||||
headers=self._headers(token),
|
||||
)
|
||||
return await self._send(
|
||||
"post",
|
||||
self._url(repo, "merge_requests", str(pr_number), "notes"),
|
||||
headers=self._headers(token),
|
||||
json_body={"body": body},
|
||||
)
|
||||
|
||||
async def merge_branch(
|
||||
self, repo: RepoRef, token: str, *, base: str, head: str, commit_message: str
|
||||
) -> Any:
|
||||
"""No server-side merges API on GitLab — a shaped 501 lands
|
||||
``_env_merge_status`` on its existing ``missing_ref`` branch (the
|
||||
shared local-git fallback lives in GitService)."""
|
||||
_ = (repo, token, base, head, commit_message)
|
||||
request = httpx.Request("post", f"{self._scheme}://{self._host}/unsupported")
|
||||
real = httpx.Response(
|
||||
httpx.codes.NOT_IMPLEMENTED,
|
||||
request=request,
|
||||
json={"message": "GitLab has no server-side branch-merge API"},
|
||||
)
|
||||
return ShapedResponse(real)
|
||||
|
||||
# -- CI --------------------------------------------------------------------
|
||||
|
||||
async def list_ci_runs(
|
||||
self,
|
||||
repo: RepoRef,
|
||||
token: str,
|
||||
*,
|
||||
workflow: str | None,
|
||||
branch: str,
|
||||
head_sha: str | None,
|
||||
per_page: int,
|
||||
) -> Any:
|
||||
"""GitLab pipelines are the CI signal (no per-workflow-run listing
|
||||
like GitHub Actions); pipelines sort newest-first by default, so the
|
||||
first SETTLED entry becomes the one ``workflow_runs`` entry."""
|
||||
_ = (workflow, head_sha)
|
||||
resp = await self._send(
|
||||
"get",
|
||||
self._url(repo, "pipelines"),
|
||||
headers=self._headers(token),
|
||||
params={"ref": branch, "per_page": per_page},
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
pipelines = resp.json()
|
||||
if not isinstance(pipelines, list):
|
||||
return resp
|
||||
runs: list[dict[str, Any]] = []
|
||||
for pipeline in pipelines:
|
||||
conclusion = _CONCLUSION_MAP.get(str(pipeline.get("status") or "").lower())
|
||||
if conclusion is None:
|
||||
continue
|
||||
runs.append(
|
||||
{
|
||||
"head_sha": pipeline.get("sha") or "",
|
||||
"run_attempt": 1,
|
||||
"conclusion": conclusion,
|
||||
"name": "pipeline",
|
||||
"html_url": pipeline.get("web_url") or "",
|
||||
"updated_at": pipeline.get("updated_at") or "",
|
||||
}
|
||||
)
|
||||
break
|
||||
return ShapedResponse(resp, json_payload={"workflow_runs": runs})
|
||||
|
||||
async def list_check_runs(
|
||||
self, repo: RepoRef, token: str, head_sha: str, *, per_page: int
|
||||
) -> Any:
|
||||
"""Commit statuses reshaped into GitHub's ``check_runs`` envelope."""
|
||||
resp = await self._send(
|
||||
"get",
|
||||
self._url(repo, "repository", "commits", head_sha, "statuses"),
|
||||
headers=self._headers(token),
|
||||
params={"per_page": per_page},
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
statuses = resp.json()
|
||||
if not isinstance(statuses, list):
|
||||
return resp
|
||||
check_runs = [self._shape_status(status) for status in statuses]
|
||||
return ShapedResponse(resp, json_payload={"check_runs": check_runs})
|
||||
|
||||
@staticmethod
|
||||
def _shape_status(status: dict[str, Any]) -> dict[str, Any]:
|
||||
state = str(status.get("status") or "").lower()
|
||||
return {
|
||||
"id": status.get("id") or 0,
|
||||
"name": status.get("name") or "status",
|
||||
"status": "in_progress" if state in _IN_PROGRESS_STATUSES else "completed",
|
||||
"conclusion": _CONCLUSION_MAP.get(state),
|
||||
}
|
||||
|
||||
async def list_workflows(self, repo: RepoRef, token: str, *, per_page: int) -> Any:
|
||||
"""Any pipeline at all — even zero check-runs — means CI IS
|
||||
configured, so a pipelines-configured repo classifies
|
||||
``pending_not_scheduled`` rather than ``no_ci_configured``."""
|
||||
_ = per_page
|
||||
resp = await self._send(
|
||||
"get",
|
||||
self._url(repo, "pipelines"),
|
||||
headers=self._headers(token),
|
||||
params={"per_page": 1},
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
pipelines = resp.json()
|
||||
total = 1 if isinstance(pipelines, list) and pipelines else 0
|
||||
return ShapedResponse(resp, json_payload={"total_count": total})
|
||||
|
||||
# -- repo / labels / branches / releases -----------------------------------
|
||||
|
||||
async def get_repo(
|
||||
self,
|
||||
repo: RepoRef,
|
||||
token: str,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> Any:
|
||||
"""GitLab names merge-method settings differently — reshape onto
|
||||
GitHub's ``allow_*`` keys the merge-method fallback reads."""
|
||||
resp = await self._send(
|
||||
"get",
|
||||
self._url(repo),
|
||||
headers=self._headers(token),
|
||||
client=client,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
return resp
|
||||
squash_option = data.get("squash_option")
|
||||
merge_method = data.get("merge_method")
|
||||
shaped = dict(data)
|
||||
shaped["full_name"] = data.get("path_with_namespace") or ""
|
||||
shaped["html_url"] = data.get("web_url") or ""
|
||||
shaped["clone_url"] = data.get("http_url_to_repo") or ""
|
||||
shaped["allow_squash_merge"] = (
|
||||
squash_option != "never" if squash_option else True
|
||||
)
|
||||
shaped["allow_merge_commit"] = merge_method in (None, "merge")
|
||||
shaped["allow_rebase_merge"] = merge_method in ("rebase_merge", "ff")
|
||||
return ShapedResponse(resp, json_payload=shaped)
|
||||
|
||||
async def ensure_label(
|
||||
self, repo: RepoRef, token: str, name: str, color: str
|
||||
) -> httpx.Response:
|
||||
# GitLab wants the leading '#' on label colors; GitHub omits it.
|
||||
hex_color = color if color.startswith("#") else f"#{color}"
|
||||
return await self._send(
|
||||
"post",
|
||||
self._url(repo, "labels"),
|
||||
headers=self._headers(token),
|
||||
json_body={"name": name, "color": hex_color},
|
||||
)
|
||||
|
||||
async def add_labels(
|
||||
self, repo: RepoRef, token: str, pr_number: int, labels: list[str]
|
||||
) -> httpx.Response:
|
||||
return await self._send(
|
||||
"put",
|
||||
self._url(repo, "merge_requests", str(pr_number)),
|
||||
headers=self._headers(token),
|
||||
json_body={"add_labels": ",".join(labels)},
|
||||
)
|
||||
|
||||
async def delete_branch_ref(
|
||||
self, repo: RepoRef, token: str, branch: str, *, timeout: float | None = None
|
||||
) -> httpx.Response:
|
||||
return await self._send(
|
||||
"delete",
|
||||
self._url(repo, "repository", "branches", quote(branch, safe="")),
|
||||
headers=self._headers(token),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
async def create_issue_comment(
|
||||
self, repo: RepoRef, token: str, issue_number: int, body: str
|
||||
) -> httpx.Response:
|
||||
# RoboCo only ever comments on PRs — GitLab's PR comments live under
|
||||
# the merge-request "notes" endpoint, not a separate issues API call.
|
||||
return await self._send(
|
||||
"post",
|
||||
self._url(repo, "merge_requests", str(issue_number), "notes"),
|
||||
headers=self._headers(token),
|
||||
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,
|
||||
) -> Any:
|
||||
resp = await self._send(
|
||||
"post",
|
||||
self._url(repo, "releases"),
|
||||
headers=self._headers(token),
|
||||
json_body={
|
||||
"tag_name": tag_name,
|
||||
"name": name,
|
||||
"description": body,
|
||||
"ref": target_commitish,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
if not resp.is_success:
|
||||
return resp
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
return resp
|
||||
links = data.get("_links") or {}
|
||||
shaped = dict(data)
|
||||
shaped["html_url"] = links.get("self") or ""
|
||||
return ShapedResponse(resp, json_payload=shaped)
|
||||
|
||||
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,
|
||||
) -> Any:
|
||||
"""GitLab project provisioning (the namespace-model POST) is Phase 4
|
||||
— a synthetic 501 keeps the interface implementable without faking
|
||||
it here."""
|
||||
_ = (token, org, name, description, private, auto_init, client, timeout)
|
||||
request = httpx.Request("post", f"{self._scheme}://{self._host}/synthetic")
|
||||
real = httpx.Response(
|
||||
httpx.codes.NOT_IMPLEMENTED,
|
||||
request=request,
|
||||
json={"message": "GitLab provisioning is Phase 4"},
|
||||
)
|
||||
return ShapedResponse(real)
|
||||
@@ -1,11 +1,18 @@
|
||||
"""Provider resolution — wiring only, no transport logic of its own.
|
||||
"""Provider resolution + the host↔provider map behind per-call routing.
|
||||
|
||||
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, ...).
|
||||
``provider_for(project)`` maps a project's ``git_provider`` (Phase 0's
|
||||
``projects.git_provider`` column) onto a concrete :class:`GitProvider`.
|
||||
Phase 2 adds gitea: the provider is addressed by the instance host, derived
|
||||
from the project's ``git_url``.
|
||||
|
||||
The module also keeps the process-wide **host map** the
|
||||
:class:`~roboco.services.forge.router.ForgeRouter` consults per call:
|
||||
``register_project_forge`` is invoked at the project chokepoints every git
|
||||
flow already crosses (``ProjectService.create``/``update`` and the decrypted
|
||||
token reads), so by the time any REST call happens for a gitea project its
|
||||
host is registered. In-memory and per-process by design — a restart forgets
|
||||
it and the very next project/token read re-registers (the same self-healing
|
||||
posture as the read-clone sync throttle).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,27 +20,85 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from roboco.exceptions import GitError
|
||||
from roboco.foundation.policy.forge import extract_host
|
||||
from roboco.services.forge.gitea import GiteaProvider
|
||||
from roboco.services.forge.github import GitHubProvider
|
||||
from roboco.services.forge.gitlab import GitLabProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from roboco.services.forge.base import GitProvider
|
||||
|
||||
# host (lowercase) → provider name ("github" | "gitea"). github.com is
|
||||
# implicit and never needs registering. _HOST_SCHEMES remembers a plain-http
|
||||
# host (LAN instance with no TLS terminator) so the API base matches the
|
||||
# git_url's own scheme; absent = https.
|
||||
_HOST_PROVIDERS: dict[str, str] = {}
|
||||
_HOST_SCHEMES: dict[str, str] = {}
|
||||
|
||||
|
||||
def host_of(git_url: str | None) -> str | None:
|
||||
"""The forge host of a git URL (https/ssh/scp forms), or None."""
|
||||
if not git_url:
|
||||
return None
|
||||
return extract_host(git_url)
|
||||
|
||||
|
||||
def _scheme_of(git_url: str) -> str:
|
||||
return "http" if git_url.strip().lower().startswith("http://") else "https"
|
||||
|
||||
|
||||
def register_project_forge(git_url: str | None, git_provider: str | None) -> None:
|
||||
"""Record a project's host→provider mapping for per-call routing.
|
||||
|
||||
Called from the ProjectService chokepoints; a github.com host or a
|
||||
missing provider records nothing (GitHub is the router's default).
|
||||
"""
|
||||
host = host_of(git_url)
|
||||
if host is None or host == "github.com" or git_url is None:
|
||||
return
|
||||
if git_provider in ("github", "gitea", "gitlab"):
|
||||
_HOST_PROVIDERS[host] = git_provider
|
||||
_HOST_SCHEMES[host] = _scheme_of(git_url)
|
||||
|
||||
|
||||
def provider_name_for_host(host: str) -> str | None:
|
||||
"""The registered provider name for a host — None when unregistered."""
|
||||
if host == "github.com":
|
||||
return "github"
|
||||
return _HOST_PROVIDERS.get(host.lower())
|
||||
|
||||
|
||||
def scheme_for_host(host: str) -> str:
|
||||
"""The registered scheme for a host — https unless the project's git_url
|
||||
said otherwise."""
|
||||
return _HOST_SCHEMES.get(host.lower(), "https")
|
||||
|
||||
|
||||
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.
|
||||
``project`` is duck-typed (only ``.git_provider`` / ``.git_url`` are
|
||||
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()
|
||||
host = host_of(getattr(project, "git_url", None))
|
||||
if host is None:
|
||||
raise GitError(
|
||||
f"{provider_name} project has no parseable git_url host",
|
||||
{"git_provider": provider_name},
|
||||
)
|
||||
if provider_name == "gitea":
|
||||
return GiteaProvider(host, scheme=scheme_for_host(host))
|
||||
if provider_name == "gitlab":
|
||||
return GitLabProvider(host, scheme=scheme_for_host(host))
|
||||
raise GitError(
|
||||
f"Unsupported git_provider {provider_name!r} — GitLab/Gitea support "
|
||||
"is not implemented yet.",
|
||||
f"Unsupported git_provider {provider_name!r}.",
|
||||
{"git_provider": provider_name},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Per-call forge routing — the seam that lets ``GitService`` stay
|
||||
provider-blind.
|
||||
|
||||
``GitService._forge`` returns a :class:`ForgeRouter`; every call site keeps
|
||||
its exact Phase-1 shape (``self._forge.method(RepoRef(...), token, ...)``)
|
||||
and the router picks the concrete transport per call from ``RepoRef.host``:
|
||||
``None`` (or a github-registered host) → :class:`GitHubProvider`; a host
|
||||
registered as gitea → :class:`GiteaProvider` for that host. The
|
||||
host↔provider map is populated by ``registry.register_project_forge`` at the
|
||||
project/token chokepoints — in-memory, per-process, self-healing on the next
|
||||
project read after a restart.
|
||||
|
||||
``parse_repo_ref`` is the entry that stamps ``host`` onto the ref: GitHub
|
||||
URLs parse exactly as before (host None), a registered gitea host parses
|
||||
through its own provider, and an unregistered non-GitHub host fails loud
|
||||
naming the fix instead of several steps deep.
|
||||
|
||||
Every transport method is an explicit one-line delegate (no ``__getattr__``
|
||||
magic) so the ABC contract and mypy keep checking call sites.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from roboco.exceptions import GitError
|
||||
from roboco.services.forge import registry
|
||||
from roboco.services.forge.base import GitProvider, RepoRef
|
||||
from roboco.services.forge.gitea import GiteaProvider
|
||||
from roboco.services.forge.github import _REPO_URL_RE, GitHubProvider
|
||||
from roboco.services.forge.gitlab import GitLabProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
|
||||
class ForgeRouter(GitProvider):
|
||||
"""Implements the :class:`GitProvider` surface by per-call delegation."""
|
||||
|
||||
@staticmethod
|
||||
def _provider_for_ref(ref: RepoRef) -> GitProvider:
|
||||
if ref.host is None:
|
||||
return GitHubProvider()
|
||||
provider_name = registry.provider_name_for_host(ref.host)
|
||||
if provider_name == "gitea":
|
||||
return GiteaProvider(ref.host, scheme=registry.scheme_for_host(ref.host))
|
||||
if provider_name == "gitlab":
|
||||
return GitLabProvider(ref.host, scheme=registry.scheme_for_host(ref.host))
|
||||
if provider_name in (None, "github"):
|
||||
# A GHE host registered as github (or parsed before registration)
|
||||
# rides the GitHub transport with its configured base URL.
|
||||
return GitHubProvider()
|
||||
raise GitError(
|
||||
f"Unsupported git_provider {provider_name!r} for host {ref.host!r}.",
|
||||
{"git_provider": provider_name, "host": ref.host},
|
||||
)
|
||||
|
||||
def parse_repo_ref(self, git_url: str) -> RepoRef:
|
||||
if _REPO_URL_RE.search(git_url):
|
||||
return GitHubProvider().parse_repo_ref(git_url)
|
||||
host = registry.host_of(git_url)
|
||||
provider_name = registry.provider_name_for_host(host) if host else None
|
||||
if host is not None and provider_name == "gitea":
|
||||
return GiteaProvider(
|
||||
host, scheme=registry.scheme_for_host(host)
|
||||
).parse_repo_ref(git_url)
|
||||
if host is not None and provider_name == "gitlab":
|
||||
return GitLabProvider(
|
||||
host, scheme=registry.scheme_for_host(host)
|
||||
).parse_repo_ref(git_url)
|
||||
raise GitError(
|
||||
"Could not resolve a forge for this remote URL — a non-GitHub "
|
||||
"host must belong to a registered project with git_provider set "
|
||||
"(gitea/gitlab today; GHE uses git_provider='github' with "
|
||||
"ROBOCO_GITHUB_API_BASE_URL).",
|
||||
{"host": host or "unknown"},
|
||||
)
|
||||
|
||||
async def list_pulls(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
|
||||
return await self._provider_for_ref(repo).list_pulls(repo, token, **kwargs)
|
||||
|
||||
async def get_pr(
|
||||
self, repo: RepoRef, token: str, pr_number: int, **kwargs: Any
|
||||
) -> Any:
|
||||
return await self._provider_for_ref(repo).get_pr(
|
||||
repo, token, pr_number, **kwargs
|
||||
)
|
||||
|
||||
async def get_pr_diff(self, repo: RepoRef, token: str, pr_number: int) -> Any:
|
||||
return await self._provider_for_ref(repo).get_pr_diff(repo, token, pr_number)
|
||||
|
||||
async def create_pr(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
|
||||
return await self._provider_for_ref(repo).create_pr(repo, token, **kwargs)
|
||||
|
||||
async def update_pr(
|
||||
self, repo: RepoRef, token: str, pr_number: int, **kwargs: Any
|
||||
) -> Any:
|
||||
return await self._provider_for_ref(repo).update_pr(
|
||||
repo, token, pr_number, **kwargs
|
||||
)
|
||||
|
||||
async def merge_pr(
|
||||
self, repo: RepoRef, token: str, pr_number: int, **kwargs: Any
|
||||
) -> Any:
|
||||
return await self._provider_for_ref(repo).merge_pr(
|
||||
repo, token, pr_number, **kwargs
|
||||
)
|
||||
|
||||
async def request_reviewers(
|
||||
self, repo: RepoRef, token: str, pr_number: int, reviewers: list[str]
|
||||
) -> Any:
|
||||
return await self._provider_for_ref(repo).request_reviewers(
|
||||
repo, token, pr_number, reviewers
|
||||
)
|
||||
|
||||
async def post_review(
|
||||
self, repo: RepoRef, token: str, pr_number: int, **kwargs: Any
|
||||
) -> Any:
|
||||
return await self._provider_for_ref(repo).post_review(
|
||||
repo, token, pr_number, **kwargs
|
||||
)
|
||||
|
||||
async def merge_branch(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
|
||||
return await self._provider_for_ref(repo).merge_branch(repo, token, **kwargs)
|
||||
|
||||
async def list_ci_runs(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
|
||||
return await self._provider_for_ref(repo).list_ci_runs(repo, token, **kwargs)
|
||||
|
||||
async def list_check_runs(
|
||||
self, repo: RepoRef, token: str, head_sha: str, **kwargs: Any
|
||||
) -> Any:
|
||||
return await self._provider_for_ref(repo).list_check_runs(
|
||||
repo, token, head_sha, **kwargs
|
||||
)
|
||||
|
||||
async def list_workflows(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
|
||||
return await self._provider_for_ref(repo).list_workflows(repo, token, **kwargs)
|
||||
|
||||
async def get_repo(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
|
||||
return await self._provider_for_ref(repo).get_repo(repo, token, **kwargs)
|
||||
|
||||
async def ensure_label(
|
||||
self, repo: RepoRef, token: str, name: str, color: str
|
||||
) -> Any:
|
||||
return await self._provider_for_ref(repo).ensure_label(repo, token, name, color)
|
||||
|
||||
async def add_labels(
|
||||
self, repo: RepoRef, token: str, pr_number: int, labels: list[str]
|
||||
) -> Any:
|
||||
return await self._provider_for_ref(repo).add_labels(
|
||||
repo, token, pr_number, labels
|
||||
)
|
||||
|
||||
async def delete_branch_ref(
|
||||
self, repo: RepoRef, token: str, branch: str, **kwargs: Any
|
||||
) -> Any:
|
||||
return await self._provider_for_ref(repo).delete_branch_ref(
|
||||
repo, token, branch, **kwargs
|
||||
)
|
||||
|
||||
async def create_issue_comment(
|
||||
self, repo: RepoRef, token: str, issue_number: int, body: str
|
||||
) -> Any:
|
||||
return await self._provider_for_ref(repo).create_issue_comment(
|
||||
repo, token, issue_number, body
|
||||
)
|
||||
|
||||
async def create_release(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
|
||||
return await self._provider_for_ref(repo).create_release(repo, token, **kwargs)
|
||||
|
||||
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,
|
||||
) -> Any:
|
||||
"""The one transport method with no RepoRef — provisioning is
|
||||
GitHub-only today, matching its direct GitHubProvider construction
|
||||
elsewhere."""
|
||||
return await GitHubProvider().create_org_repo(
|
||||
token,
|
||||
org,
|
||||
name=name,
|
||||
description=description,
|
||||
private=private,
|
||||
auto_init=auto_init,
|
||||
client=client,
|
||||
timeout=timeout,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Response shaping shared by non-GitHub transports.
|
||||
|
||||
Adapters translate a forge's native wire contract back into the GitHub
|
||||
shapes ``GitService`` classifies; :class:`ShapedResponse` is the stand-in
|
||||
they return. Callers only ever read ``status_code`` / ``is_success`` /
|
||||
``text`` / ``json()`` (the seam's documented contract), so that is the
|
||||
whole surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class ShapedResponse:
|
||||
"""An httpx.Response stand-in with translated status/body/text.
|
||||
|
||||
Wraps the real response for fidelity while letting the adapter override
|
||||
the JSON payload, the status code, and/or the text (GitLab's diff
|
||||
reassembly returns synthesized unified-diff text).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
real: httpx.Response,
|
||||
*,
|
||||
json_payload: Any | None = None,
|
||||
status_code: int | None = None,
|
||||
text: str | None = None,
|
||||
) -> None:
|
||||
self._real = real
|
||||
self._json = json_payload
|
||||
self._text = text
|
||||
self.status_code = status_code if status_code is not None else real.status_code
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return httpx.codes.is_success(self.status_code)
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
if self._text is not None:
|
||||
return self._text
|
||||
return self._real.text
|
||||
|
||||
def json(self) -> Any:
|
||||
if self._json is not None:
|
||||
return self._json
|
||||
return self._real.json()
|
||||
+243
-183
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ from roboco.foundation.policy.forge import detect_provider, validate_project_for
|
||||
from roboco.models.base import TaskStatus, Team
|
||||
from roboco.models.project import ProjectCreate, ProjectUpdate
|
||||
from roboco.services.base import BaseService, ConflictError, NotFoundError
|
||||
from roboco.services.forge import register_project_forge
|
||||
from roboco.utils.crypto import EncryptionError, decrypt_token, encrypt_token
|
||||
|
||||
# Statuses that are NOT active progress: completed (done), cancelled
|
||||
@@ -104,11 +105,12 @@ class ProjectService(BaseService):
|
||||
self._assert_git_url_allowed(data.git_url)
|
||||
self._assert_forge_supported(data.git_url, data.git_provider)
|
||||
|
||||
# Null + a github.com git_url auto-stamps "github" so the column
|
||||
# reflects reality without forcing every caller to set it explicitly.
|
||||
# Null + a SaaS-detectable host auto-stamps the provider so the
|
||||
# column reflects reality without forcing every caller to set it
|
||||
# explicitly (github.com → github, gitlab.com → gitlab).
|
||||
git_provider = data.git_provider
|
||||
if git_provider is None and detect_provider(data.git_url) == "github":
|
||||
git_provider = "github"
|
||||
if git_provider is None:
|
||||
git_provider = detect_provider(data.git_url)
|
||||
|
||||
# Encrypt git token if provided
|
||||
encrypted_token = None
|
||||
@@ -173,19 +175,29 @@ class ProjectService(BaseService):
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _register_forge(project: ProjectTable | None) -> ProjectTable | None:
|
||||
"""Record a loaded project's host→provider mapping for the forge
|
||||
router (in-memory, per-process). Riding the getters makes the map
|
||||
self-healing: any flow that touches a project re-registers it, so a
|
||||
restart never leaves a gitea host unroutable past the first read."""
|
||||
if project is not None:
|
||||
register_project_forge(project.git_url, project.git_provider)
|
||||
return project
|
||||
|
||||
async def get(self, project_id: UUID) -> ProjectTable | None:
|
||||
"""Get a project by ID."""
|
||||
result = await self.session.execute(
|
||||
select(ProjectTable).where(ProjectTable.id == project_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
return self._register_forge(result.scalar_one_or_none())
|
||||
|
||||
async def get_by_slug(self, slug: str) -> ProjectTable | None:
|
||||
"""Get a project by its URL-safe slug."""
|
||||
result = await self.session.execute(
|
||||
select(ProjectTable).where(ProjectTable.slug == slug)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
return self._register_forge(result.scalar_one_or_none())
|
||||
|
||||
async def get_or_raise(self, project_id: UUID) -> ProjectTable:
|
||||
"""Get a project by ID or raise NotFoundError."""
|
||||
|
||||
@@ -471,7 +471,6 @@ class _GitReleaseOps:
|
||||
# binary, so the CLI path fails at publish time with a missing binary.
|
||||
import httpx
|
||||
|
||||
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,10 +481,10 @@ 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)
|
||||
repo_ref = GitService._parse_git_url(self._git_url)
|
||||
try:
|
||||
resp = await GitHubProvider().create_release(
|
||||
RepoRef(owner, repo),
|
||||
repo_ref,
|
||||
token,
|
||||
tag_name=tag,
|
||||
name=tag,
|
||||
|
||||
@@ -63,6 +63,7 @@ from roboco.db.tables import (
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
|
||||
from roboco.models.work_session import WorkSessionStatus
|
||||
from roboco.services.forge import RepoRef
|
||||
from roboco.services.git import GitService
|
||||
from roboco.services.work_session import get_work_session_service
|
||||
from roboco.services.workspace import WorkspaceService
|
||||
@@ -290,7 +291,7 @@ async def test_m38_pr_is_merged_returns_none_on_httperror() -> None:
|
||||
"roboco.services.git.httpx.AsyncClient",
|
||||
return_value=_httpx_raising_client(),
|
||||
):
|
||||
out = await svc._pr_is_merged("acme", "repo", 11, "tok")
|
||||
out = await svc._pr_is_merged(RepoRef("acme", "repo"), 11, "tok")
|
||||
assert out is None
|
||||
|
||||
|
||||
@@ -310,8 +311,7 @@ async def test_m38_merge_with_retry_none_does_not_raise_conflict() -> None:
|
||||
_bind(svc, "_sync_target_branch", AsyncMock())
|
||||
|
||||
ctx = GitService._MergeContext(
|
||||
owner="acme",
|
||||
repo="repo",
|
||||
repo_ref=RepoRef("acme", "repo"),
|
||||
pr_number=11,
|
||||
git_token="tok",
|
||||
workspace=Path("/tmp/ws"),
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Live-Gitea contract test for GiteaProvider (forge Phase 2).
|
||||
|
||||
Runs the real wire contract against a live Gitea instance — the spec's
|
||||
"contract suite recorded against a dockerized gitea/gitea" — and is fully
|
||||
self-seeding: it creates its own uniquely-named repo, pushes real commits,
|
||||
and exercises the provider end to end (PR open → duplicate reshape →
|
||||
list/filter → diff → comment review → commit-status CI reshape → squash
|
||||
merge → branch delete → release), plus the git-CLI Basic-auth extraheader
|
||||
claim the provider docstring makes.
|
||||
|
||||
Skipped unless both env vars are set:
|
||||
|
||||
ROBOCO_GITEA_E2E_URL e.g. http://localhost:3310
|
||||
ROBOCO_GITEA_E2E_TOKEN an admin PAT (scopes: all)
|
||||
|
||||
Local run: `docker run -d -p 3310:3000 -e GITEA__security__INSTALL_LOCK=true
|
||||
gitea/gitea:1.22`, create an admin + token (`gitea admin user create` /
|
||||
`generate-access-token`), export the two vars, run this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from roboco.services.forge.base import RepoRef
|
||||
from roboco.services.forge.gitea import GiteaProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_URL = os.environ.get("ROBOCO_GITEA_E2E_URL", "")
|
||||
_TOKEN = os.environ.get("ROBOCO_GITEA_E2E_TOKEN", "")
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.asyncio,
|
||||
pytest.mark.skipif(
|
||||
not (_URL and _TOKEN),
|
||||
reason="ROBOCO_GITEA_E2E_URL / ROBOCO_GITEA_E2E_TOKEN not set",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _split_url() -> tuple[str, str]:
|
||||
parts = urlsplit(_URL)
|
||||
host = parts.netloc or parts.path
|
||||
return (parts.scheme or "http"), host
|
||||
|
||||
|
||||
def _api(method: str, path: str, **kwargs: object) -> httpx.Response:
|
||||
scheme, host = _split_url()
|
||||
return httpx.request(
|
||||
method,
|
||||
f"{scheme}://{host}/api/v1{path}",
|
||||
headers={"Authorization": f"token {_TOKEN}"},
|
||||
timeout=15.0,
|
||||
**kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=cwd, check=True, capture_output=True, text=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _seed_repo(tmp_path: Path, repo_name: str) -> str:
|
||||
"""Create the repo via API, push a feature commit; return its sha."""
|
||||
resp = _api(
|
||||
"post",
|
||||
"/user/repos",
|
||||
json={"name": repo_name, "auto_init": True, "default_branch": "main"},
|
||||
)
|
||||
assert resp.status_code == httpx.codes.CREATED, resp.text
|
||||
login = _api("get", "/user").json()["username"]
|
||||
scheme, host = _split_url()
|
||||
clone_url = f"{scheme}://{login}:{_TOKEN}@{host}/{login}/{repo_name}.git"
|
||||
clone = tmp_path / "clone"
|
||||
subprocess.run(
|
||||
["git", "clone", clone_url, str(clone)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
_git(clone, "config", "user.email", "e2e@example.com")
|
||||
_git(clone, "config", "user.name", "E2E")
|
||||
_git(clone, "config", "commit.gpgsign", "false")
|
||||
_git(clone, "checkout", "-b", "feat/e2e-change")
|
||||
(clone / "widget.txt").write_text("widget v2\n")
|
||||
_git(clone, "add", "widget.txt")
|
||||
_git(clone, "commit", "-m", "add widget")
|
||||
_git(clone, "push", "-u", "origin", "feat/e2e-change")
|
||||
return _git(clone, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
async def _verify_pr_flow(provider: GiteaProvider, ref: RepoRef, head_sha: str) -> int:
|
||||
"""create → duplicate reshape → list/filter → get → diff → review →
|
||||
labels; returns the PR number."""
|
||||
created = await provider.create_pr(
|
||||
ref,
|
||||
_TOKEN,
|
||||
head="feat/e2e-change",
|
||||
base="main",
|
||||
title="E2E change",
|
||||
body="live contract test",
|
||||
)
|
||||
assert created.is_success, created.text
|
||||
pr = created.json()
|
||||
pr_number: int = pr["number"]
|
||||
assert pr["html_url"]
|
||||
|
||||
duplicate = await provider.create_pr(
|
||||
ref,
|
||||
_TOKEN,
|
||||
head="feat/e2e-change",
|
||||
base="main",
|
||||
title="E2E change",
|
||||
body="dup",
|
||||
)
|
||||
assert duplicate.status_code == httpx.codes.UNPROCESSABLE_ENTITY
|
||||
assert "already exists" in duplicate.text.lower()
|
||||
|
||||
listed = await provider.list_pulls(ref, _TOKEN, head="feat/e2e-change", base="main")
|
||||
pulls = listed.json()
|
||||
assert [p["number"] for p in pulls] == [pr_number]
|
||||
assert pulls[0]["author_association"] == "NONE"
|
||||
|
||||
fetched = (await provider.get_pr(ref, _TOKEN, pr_number)).json()
|
||||
assert fetched["head"]["sha"] == head_sha
|
||||
assert fetched["base"]["ref"] == "main"
|
||||
assert not fetched.get("merged")
|
||||
|
||||
diff = await provider.get_pr_diff(ref, _TOKEN, pr_number)
|
||||
assert "widget.txt" in diff.text
|
||||
|
||||
# COMMENT — self-review approve/request-changes is refused by Gitea.
|
||||
review = await provider.post_review(
|
||||
ref, _TOKEN, pr_number, body="looks fine", event="COMMENT"
|
||||
)
|
||||
assert review.is_success, review.text
|
||||
|
||||
label = await provider.ensure_label(ref, _TOKEN, "cell/backend", "8250df")
|
||||
assert label.is_success or label.status_code in (409, 422), label.text
|
||||
attach = await provider.add_labels(ref, _TOKEN, pr_number, ["cell/backend"])
|
||||
assert attach.is_success, attach.text
|
||||
return pr_number
|
||||
|
||||
|
||||
async def _verify_ci_reshapes(
|
||||
provider: GiteaProvider, ref: RepoRef, head_sha: str
|
||||
) -> None:
|
||||
"""A real commit status classifies through both GitHub-shaped views."""
|
||||
status = _api(
|
||||
"post",
|
||||
f"/repos/{ref.owner}/{ref.repo}/statuses/{head_sha}",
|
||||
json={"state": "success", "context": "ci/e2e", "description": "ok"},
|
||||
)
|
||||
assert status.status_code == httpx.codes.CREATED, status.text
|
||||
check_runs = (
|
||||
await provider.list_check_runs(ref, _TOKEN, head_sha, per_page=50)
|
||||
).json()["check_runs"]
|
||||
assert check_runs and check_runs[0]["conclusion"] == "success"
|
||||
assert check_runs[0]["status"] == "completed"
|
||||
runs = (
|
||||
await provider.list_ci_runs(
|
||||
ref,
|
||||
_TOKEN,
|
||||
workflow=None,
|
||||
branch="feat/e2e-change",
|
||||
head_sha=None,
|
||||
per_page=5,
|
||||
)
|
||||
).json()["workflow_runs"]
|
||||
assert runs and runs[0]["conclusion"] == "success"
|
||||
assert runs[0]["head_sha"] == head_sha
|
||||
|
||||
|
||||
async def _verify_merge_publish_cli(
|
||||
provider: GiteaProvider, ref: RepoRef, pr_number: int, scheme: str
|
||||
) -> None:
|
||||
"""Squash merge → merged flag → branch delete → release → CLI auth."""
|
||||
merged = await provider.merge_pr(ref, _TOKEN, pr_number, merge_method="squash")
|
||||
assert merged.is_success, merged.text
|
||||
for _ in range(10):
|
||||
if (await provider.get_pr(ref, _TOKEN, pr_number)).json().get("merged"):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
assert (await provider.get_pr(ref, _TOKEN, pr_number)).json()["merged"] is True
|
||||
|
||||
deleted = await provider.delete_branch_ref(ref, _TOKEN, "feat/e2e-change")
|
||||
assert deleted.status_code in (204, 200), deleted.text
|
||||
|
||||
release = await provider.create_release(
|
||||
ref,
|
||||
_TOKEN,
|
||||
tag_name="v0.0.1-e2e",
|
||||
name="v0.0.1-e2e",
|
||||
body="live",
|
||||
target_commitish="main",
|
||||
)
|
||||
assert release.status_code == httpx.codes.CREATED, release.text
|
||||
assert release.json().get("html_url")
|
||||
|
||||
# The provider docstring's git-CLI claim: Basic auth with the
|
||||
# x-access-token username + PAT password works against Gitea.
|
||||
basic = base64.b64encode(f"x-access-token:{_TOKEN}".encode()).decode()
|
||||
ls = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
f"http.extraheader=Authorization: Basic {basic}",
|
||||
"ls-remote",
|
||||
f"{scheme}://{ref.host}/{ref.owner}/{ref.repo}.git",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert ls.returncode == 0, ls.stderr
|
||||
assert "refs/heads/main" in ls.stdout
|
||||
|
||||
|
||||
async def test_gitea_live_contract(tmp_path: Path) -> None:
|
||||
scheme, host = _split_url()
|
||||
repo_name = f"e2e-{uuid4().hex[:8]}"
|
||||
head_sha = _seed_repo(tmp_path, repo_name)
|
||||
login = _api("get", "/user").json()["username"]
|
||||
ref = RepoRef(login, repo_name, host=host)
|
||||
provider = GiteaProvider(host, scheme=scheme)
|
||||
|
||||
repo_resp = await provider.get_repo(ref, _TOKEN)
|
||||
assert repo_resp.is_success
|
||||
repo_json = repo_resp.json()
|
||||
assert repo_json["full_name"] == f"{login}/{repo_name}"
|
||||
assert "allow_merge_commit" in repo_json
|
||||
assert "allow_squash_merge" in repo_json
|
||||
|
||||
pr_number = await _verify_pr_flow(provider, ref, head_sha)
|
||||
await _verify_ci_reshapes(provider, ref, head_sha)
|
||||
await _verify_merge_publish_cli(provider, ref, pr_number, scheme)
|
||||
|
||||
_api("delete", f"/repos/{login}/{repo_name}")
|
||||
@@ -10,6 +10,7 @@ from uuid import uuid4
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, ProjectTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.services.forge import RepoRef
|
||||
from roboco.services.git import _ConventionsPr, get_git_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -144,7 +145,9 @@ async def test_open_conventions_pr_force_pushes_scaffold_branch(
|
||||
|
||||
monkeypatch.setattr(git, "_token_for_project", _fake_token)
|
||||
monkeypatch.setattr(git, "push", _fake_push)
|
||||
monkeypatch.setattr(git, "_parse_github_remote", lambda _ws: ("owner", "repo"))
|
||||
monkeypatch.setattr(
|
||||
git, "_parse_github_remote", lambda _ws: RepoRef("owner", "repo")
|
||||
)
|
||||
|
||||
_pr_number = 42
|
||||
_pr_url = "https://github.com/owner/repo/pull/42"
|
||||
@@ -155,7 +158,7 @@ async def test_open_conventions_pr_force_pushes_scaffold_branch(
|
||||
def json(self) -> dict[str, object]:
|
||||
return {"number": _pr_number, "html_url": _pr_url}
|
||||
|
||||
async def _fake_post_pr(_owner: str, _repo: str, _token: str, _body: Any) -> _Resp:
|
||||
async def _fake_post_pr(_repo_ref: RepoRef, _token: str, _body: Any) -> _Resp:
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(git, "_post_pr", _fake_post_pr)
|
||||
|
||||
@@ -95,18 +95,19 @@ def test_explicit_github_provider_is_ok_regardless_of_host() -> None:
|
||||
assert validate_project_forge(url, "github") is None
|
||||
|
||||
|
||||
def test_explicit_gitlab_provider_rejected_as_not_yet_supported() -> None:
|
||||
error = validate_project_forge("https://gitlab.com/group/project.git", "gitlab")
|
||||
assert error is not None
|
||||
assert "not yet" in error.lower()
|
||||
assert "gitlab" in error.lower()
|
||||
def test_explicit_gitlab_provider_accepted() -> None:
|
||||
"""Phase 3: the GitLab transport is live — explicit gitlab validates."""
|
||||
assert (
|
||||
validate_project_forge("https://gitlab.com/group/project.git", "gitlab") is None
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_gitea_provider_rejected_as_not_yet_supported() -> None:
|
||||
error = validate_project_forge("https://gitea.example.com/owner/repo.git", "gitea")
|
||||
assert error is not None
|
||||
assert "not yet" in error.lower()
|
||||
assert "gitea" in error.lower()
|
||||
def test_explicit_gitea_provider_accepted() -> None:
|
||||
"""Phase 2: the Gitea transport is live — explicit gitea validates."""
|
||||
assert (
|
||||
validate_project_forge("https://gitea.example.com/owner/repo.git", "gitea")
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_host_no_explicit_provider_rejected() -> None:
|
||||
@@ -115,10 +116,9 @@ def test_unknown_host_no_explicit_provider_rejected() -> None:
|
||||
assert "github" in error.lower()
|
||||
|
||||
|
||||
def test_detected_gitlab_no_explicit_provider_rejected() -> None:
|
||||
error = validate_project_forge("https://gitlab.com/group/project.git", None)
|
||||
assert error is not None
|
||||
assert "github" in error.lower()
|
||||
def test_detected_gitlab_no_explicit_provider_accepted() -> None:
|
||||
"""gitlab.com detection is unambiguous — auto-accepted like github.com."""
|
||||
assert validate_project_forge("https://gitlab.com/group/project.git", None) is None
|
||||
|
||||
|
||||
def test_unknown_provider_string_rejected_naming_known_providers() -> None:
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""GiteaProvider wire contract: token auth scheme, GitHub-shape adapters
|
||||
(duplicate-PR 409→422, statuses→check_runs, combined-status→workflow_runs,
|
||||
merge-method key mapping), and the deliberate Phase-2 postures (synthetic
|
||||
zero workflows, unsupported server-side branch merge).
|
||||
|
||||
Uses httpx.MockTransport through the provider's own ``_send`` (the
|
||||
``client=``-less path is exercised by monkeypatching ``httpx.AsyncClient``
|
||||
to inject the transport — the same seam the git-service suite patches).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from roboco.services.forge.base import RepoRef
|
||||
from roboco.services.forge.gitea import GiteaProvider, ShapedResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
REF = RepoRef("acme", "widgets", host="gitea.example.com")
|
||||
|
||||
|
||||
class _Recorder:
|
||||
def __init__(self, responder: Callable[[httpx.Request], httpx.Response]) -> None:
|
||||
self.requests: list[httpx.Request] = []
|
||||
self._responder = responder
|
||||
|
||||
def handler(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
return self._responder(request)
|
||||
|
||||
|
||||
def _patch_client(monkeypatch: pytest.MonkeyPatch, recorder: _Recorder) -> None:
|
||||
real_client = httpx.AsyncClient
|
||||
|
||||
def _factory(**kwargs: Any) -> httpx.AsyncClient:
|
||||
kwargs.pop("timeout", None)
|
||||
return real_client(transport=httpx.MockTransport(recorder.handler))
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _factory)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_header_uses_token_scheme_and_api_v1_base(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GiteaProvider("gitea.example.com").get_pr(REF, "SECRET", 7)
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert request.headers["Authorization"] == "token SECRET"
|
||||
assert (
|
||||
str(request.url)
|
||||
== "https://gitea.example.com/api/v1/repos/acme/widgets/pulls/7"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_duplicate_409_reshapes_to_github_422(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
lambda _r: httpx.Response(
|
||||
409, text="pull request already exists for these targets"
|
||||
)
|
||||
)
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GiteaProvider("gitea.example.com").create_pr(
|
||||
REF, "t", head="feat", base="main", title="T", body="B"
|
||||
)
|
||||
|
||||
assert resp.status_code == httpx.codes.UNPROCESSABLE_ENTITY
|
||||
assert "already exists" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_pr_posts_do_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GiteaProvider("gitea.example.com").merge_pr(
|
||||
REF, "t", 7, merge_method="squash"
|
||||
)
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert request.method == "POST"
|
||||
assert request.url.path.endswith("/pulls/7/merge")
|
||||
assert b'"Do": "squash"' in request.content or b'"Do":"squash"' in request.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_review_maps_approve_event(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
provider = GiteaProvider("gitea.example.com")
|
||||
await provider.post_review(REF, "t", 7, body="lgtm", event="APPROVE")
|
||||
await provider.post_review(REF, "t", 7, body="fix", event="REQUEST_CHANGES")
|
||||
|
||||
assert b"APPROVED" in recorder.requests[0].content
|
||||
assert b"REQUEST_CHANGES" in recorder.requests[1].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pulls_filters_client_side_and_injects_association(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pulls = [
|
||||
{"number": 1, "head": {"ref": "feat-a"}, "base": {"ref": "main"}},
|
||||
{"number": 2, "head": {"ref": "feat-b"}, "base": {"ref": "main"}},
|
||||
]
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=pulls))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GiteaProvider("gitea.example.com").list_pulls(
|
||||
REF, "t", head="feat-b", base="main"
|
||||
)
|
||||
|
||||
selected = resp.json()
|
||||
assert [pr["number"] for pr in selected] == [2]
|
||||
assert selected[0]["author_association"] == "NONE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_runs_reshaped_from_statuses(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
statuses = [
|
||||
{"id": 11, "status": "success", "context": "ci/build"},
|
||||
{"id": 12, "status": "pending", "context": "ci/test"},
|
||||
{"id": 13, "status": "error", "context": "ci/lint"},
|
||||
]
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=statuses))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GiteaProvider("gitea.example.com").list_check_runs(
|
||||
REF, "t", "abc123", per_page=100
|
||||
)
|
||||
|
||||
runs = resp.json()["check_runs"]
|
||||
assert runs[0] == {
|
||||
"id": 11,
|
||||
"name": "ci/build",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
}
|
||||
assert runs[1]["status"] == "in_progress"
|
||||
assert runs[1]["conclusion"] is None
|
||||
assert runs[2]["conclusion"] == "failure"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ci_runs_reshaped_from_combined_status(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
combined = {"state": "failure", "sha": "abc123", "url": "https://x"}
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=combined))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GiteaProvider("gitea.example.com").list_ci_runs(
|
||||
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
|
||||
)
|
||||
|
||||
runs = resp.json()["workflow_runs"]
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["conclusion"] == "failure"
|
||||
assert runs[0]["head_sha"] == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_combined_status_yields_no_completed_runs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
lambda _r: httpx.Response(200, json={"state": "pending", "sha": "abc"})
|
||||
)
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GiteaProvider("gitea.example.com").list_ci_runs(
|
||||
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
|
||||
)
|
||||
|
||||
assert resp.json()["workflow_runs"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workflows_is_synthetic_zero() -> None:
|
||||
resp = await GiteaProvider("gitea.example.com").list_workflows(REF, "t", per_page=1)
|
||||
assert resp.is_success
|
||||
assert resp.json() == {"total_count": 0}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_branch_is_shaped_not_implemented() -> None:
|
||||
resp = await GiteaProvider("gitea.example.com").merge_branch(
|
||||
REF, "t", base="stag", head="main", commit_message="cascade"
|
||||
)
|
||||
assert isinstance(resp, ShapedResponse)
|
||||
assert resp.status_code == httpx.codes.NOT_IMPLEMENTED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_repo_maps_merge_method_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repo_obj = {
|
||||
"full_name": "acme/widgets",
|
||||
"allow_merge_commits": False,
|
||||
"allow_rebase": False,
|
||||
"allow_squash_merge": True,
|
||||
}
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=repo_obj))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GiteaProvider("gitea.example.com").get_repo(REF, "t")
|
||||
|
||||
shaped = resp.json()
|
||||
assert shaped["allow_merge_commit"] is False
|
||||
assert shaped["allow_rebase_merge"] is False
|
||||
assert shaped["allow_squash_merge"] is True
|
||||
|
||||
|
||||
def test_parse_repo_ref_stamps_host() -> None:
|
||||
provider = GiteaProvider("gitea.example.com")
|
||||
ref = provider.parse_repo_ref("https://gitea.example.com/acme/widgets.git")
|
||||
assert ref == RepoRef("acme", "widgets", host="gitea.example.com")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_label_prefixes_hash_on_color(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(201, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GiteaProvider("gitea.example.com").ensure_label(REF, "t", "root", "8250df")
|
||||
|
||||
assert b"#8250df" in recorder.requests[0].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_branch_segments_are_url_encoded(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Every RoboCo branch carries slashes (feature/backend/...) — an
|
||||
unencoded segment 404s at Gitea's router (caught by the live suite)."""
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={"state": "success"}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
provider = GiteaProvider("gitea.example.com")
|
||||
|
||||
await provider.list_ci_runs(
|
||||
REF, "t", workflow=None, branch="feature/backend/ABC", head_sha=None, per_page=5
|
||||
)
|
||||
await provider.delete_branch_ref(REF, "t", "feature/backend/ABC")
|
||||
|
||||
assert "/commits/feature%2Fbackend%2FABC/status" in str(recorder.requests[0].url)
|
||||
assert "/branches/feature%2Fbackend%2FABC" in str(recorder.requests[1].url)
|
||||
@@ -0,0 +1,606 @@
|
||||
"""GitLabProvider wire contract: Bearer auth + urlencoded project path,
|
||||
MR→PR shape adaptation (iid→number, source/target_branch→head/base, merged
|
||||
bool), payload-key translation (create_pr duplicate 409→422, update_pr
|
||||
close→state_event), squash-flag merge, approve-vs-note review routing, diff
|
||||
reassembly, pipelines→workflow_runs / statuses→check_runs CI classification,
|
||||
merge-method mapping, and the deliberate Phase-3 synthetic postures
|
||||
(request_reviewers, create_org_repo, merge_branch).
|
||||
|
||||
Uses httpx.MockTransport through the provider's own ``_send`` — same seam
|
||||
``test_gitea_provider.py`` exercises.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from roboco.services.forge.base import RepoRef
|
||||
from roboco.services.forge.gitlab import GitLabProvider, ShapedResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
REF = RepoRef("group/sub/proj", "", host="gitlab.example.com")
|
||||
|
||||
_MR_IID = 3
|
||||
_DIFF_PAGE_CAP = 3
|
||||
|
||||
|
||||
class _Recorder:
|
||||
def __init__(self, responder: Callable[[httpx.Request], httpx.Response]) -> None:
|
||||
self.requests: list[httpx.Request] = []
|
||||
self._responder = responder
|
||||
|
||||
def handler(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
return self._responder(request)
|
||||
|
||||
|
||||
def _patch_client(monkeypatch: pytest.MonkeyPatch, recorder: _Recorder) -> None:
|
||||
real_client = httpx.AsyncClient
|
||||
|
||||
def _factory(**kwargs: Any) -> httpx.AsyncClient:
|
||||
kwargs.pop("timeout", None)
|
||||
return real_client(transport=httpx.MockTransport(recorder.handler))
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _factory)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_header_uses_bearer_and_urlencoded_subgroup_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").get_pr(REF, "SECRET", 7)
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert request.headers["Authorization"] == "Bearer SECRET"
|
||||
assert (
|
||||
str(request.url)
|
||||
== "https://gitlab.example.com/api/v4/projects/group%2Fsub%2Fproj/merge_requests/7"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_repo_ref_subgroup_path_stamps_host() -> None:
|
||||
provider = GitLabProvider("gitlab.example.com")
|
||||
ref = provider.parse_repo_ref("https://gitlab.example.com/group/sub/proj.git")
|
||||
assert ref == RepoRef("group/sub/proj", "", host="gitlab.example.com")
|
||||
|
||||
|
||||
def test_parse_repo_ref_rejects_single_segment_path() -> None:
|
||||
provider = GitLabProvider("gitlab.example.com")
|
||||
with pytest.raises(Exception, match="namespace/project"):
|
||||
provider.parse_repo_ref("https://gitlab.example.com/onlyproject.git")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pulls_maps_state_and_filters_natively(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
mrs = [
|
||||
{
|
||||
"iid": _MR_IID,
|
||||
"web_url": "https://gitlab.example.com/group/sub/proj/-/merge_requests/3",
|
||||
"title": "Feature",
|
||||
"state": "opened",
|
||||
"source_branch": "feat-a",
|
||||
"target_branch": "main",
|
||||
"sha": "abc123",
|
||||
"author": {"username": "renzo"},
|
||||
}
|
||||
]
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=mrs))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").list_pulls(
|
||||
REF, "t", head="feat-a", base="main"
|
||||
)
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert request.url.params["state"] == "opened"
|
||||
assert request.url.params["source_branch"] == "feat-a"
|
||||
assert request.url.params["target_branch"] == "main"
|
||||
|
||||
shaped = resp.json()[0]
|
||||
assert shaped["number"] == _MR_IID
|
||||
assert shaped["html_url"].endswith("/merge_requests/3")
|
||||
assert shaped["state"] == "open"
|
||||
assert shaped["merged"] is False
|
||||
assert shaped["head"] == {
|
||||
"ref": "feat-a",
|
||||
"sha": "abc123",
|
||||
"repo": {"full_name": "group/sub/proj"},
|
||||
}
|
||||
assert shaped["base"] == {"ref": "main"}
|
||||
assert shaped["user"] == {"login": "renzo"}
|
||||
assert shaped["author_association"] == "NONE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pr_adapts_merged_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mr = {
|
||||
"iid": 9,
|
||||
"web_url": "https://gitlab.example.com/x",
|
||||
"title": "T",
|
||||
"state": "merged",
|
||||
"source_branch": "feat",
|
||||
"target_branch": "main",
|
||||
"sha": "deadbeef",
|
||||
"author": {},
|
||||
}
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=mr))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").get_pr(REF, "t", 9)
|
||||
|
||||
shaped = resp.json()
|
||||
assert shaped["state"] == "closed"
|
||||
assert shaped["merged"] is True
|
||||
assert shaped["user"] == {"login": ""}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_translates_payload_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
lambda _r: httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"iid": 1,
|
||||
"web_url": "https://x",
|
||||
"title": "T",
|
||||
"state": "opened",
|
||||
"source_branch": "feat",
|
||||
"target_branch": "main",
|
||||
"sha": "s",
|
||||
"author": {"username": "bot"},
|
||||
},
|
||||
)
|
||||
)
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").create_pr(
|
||||
REF, "t", head="feat", base="main", title="T", body="B"
|
||||
)
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert (
|
||||
b'"source_branch": "feat"' in request.content
|
||||
or b'"source_branch":"feat"' in request.content
|
||||
)
|
||||
assert (
|
||||
b'"target_branch": "main"' in request.content
|
||||
or b'"target_branch":"main"' in request.content
|
||||
)
|
||||
assert (
|
||||
b'"description": "B"' in request.content
|
||||
or b'"description":"B"' in request.content
|
||||
)
|
||||
assert resp.json()["number"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_duplicate_409_reshapes_to_github_422(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
lambda _r: httpx.Response(409, text="Another open merge request already exists")
|
||||
)
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").create_pr(
|
||||
REF, "t", head="feat", base="main", title="T", body="B"
|
||||
)
|
||||
|
||||
assert resp.status_code == httpx.codes.UNPROCESSABLE_ENTITY
|
||||
assert "already exists" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pr_close_maps_to_state_event(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").update_pr(
|
||||
REF, "t", 5, payload={"state": "closed", "body": "why"}
|
||||
)
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert request.method == "PUT"
|
||||
assert (
|
||||
b'"state_event": "close"' in request.content
|
||||
or b'"state_event":"close"' in request.content
|
||||
)
|
||||
assert (
|
||||
b'"description": "why"' in request.content
|
||||
or b'"description":"why"' in request.content
|
||||
)
|
||||
assert b"state_event" in request.content
|
||||
assert b'"state":' not in request.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_pr_sends_squash_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").merge_pr(
|
||||
REF, "t", 5, merge_method="squash"
|
||||
)
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert request.method == "PUT"
|
||||
assert request.url.path.endswith("/merge_requests/5/merge")
|
||||
assert b'"squash": true' in request.content or b'"squash":true' in request.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_pr_no_squash_when_method_differs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").merge_pr(
|
||||
REF, "t", 5, merge_method="merge"
|
||||
)
|
||||
|
||||
assert b'"squash": false' in recorder.requests[0].content or (
|
||||
b'"squash":false' in recorder.requests[0].content
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_review_approve_hits_approve_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").post_review(
|
||||
REF, "t", 5, body="lgtm", event="APPROVE"
|
||||
)
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert request.method == "POST"
|
||||
assert request.url.path.endswith("/merge_requests/5/approve")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_review_request_changes_posts_note(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").post_review(
|
||||
REF, "t", 5, body="please fix X", event="REQUEST_CHANGES"
|
||||
)
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert request.method == "POST"
|
||||
assert request.url.path.endswith("/merge_requests/5/notes")
|
||||
assert b"please fix X" in request.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pr_diff_reassembles_unified_text_single_page(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
page = [
|
||||
{"old_path": "a.py", "new_path": "a.py", "diff": "@@ -1 +1 @@\n-x\n+y\n"},
|
||||
{"old_path": "b.py", "new_path": "b.py", "diff": "@@ -1 +1 @@\n-p\n+q\n"},
|
||||
]
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=page))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").get_pr_diff(REF, "t", 9)
|
||||
|
||||
assert len(recorder.requests) == 1
|
||||
assert "diff --git a/a.py b/a.py" in resp.text
|
||||
assert "diff --git a/b.py b/b.py" in resp.text
|
||||
assert "@@ -1 +1 @@\n-x\n+y\n" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pr_diff_paginates_full_pages_and_caps_at_three(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
full_page = [
|
||||
{"old_path": f"f{i}.py", "new_path": f"f{i}.py", "diff": "d\n"}
|
||||
for i in range(100)
|
||||
]
|
||||
|
||||
def _responder(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=full_page)
|
||||
|
||||
recorder = _Recorder(_responder)
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").get_pr_diff(REF, "t", 9)
|
||||
|
||||
# 3 full pages fetched, then the loop stops without a 4th request.
|
||||
assert len(recorder.requests) == _DIFF_PAGE_CAP
|
||||
pages = [r.url.params.get("page") for r in recorder.requests]
|
||||
assert pages == ["1", "2", "3"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_ci_runs_reports_newest_settled_pipeline(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pipelines = [
|
||||
{"status": "success", "sha": "abc", "web_url": "https://x", "updated_at": "t"},
|
||||
{"status": "failed", "sha": "old", "web_url": "https://y", "updated_at": "t2"},
|
||||
]
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=pipelines))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").list_ci_runs(
|
||||
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
|
||||
)
|
||||
|
||||
runs = resp.json()["workflow_runs"]
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["head_sha"] == "abc"
|
||||
assert runs[0]["conclusion"] == "success"
|
||||
assert runs[0]["name"] == "pipeline"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_ci_runs_skips_unsettled_leading_pipelines(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pipelines = [
|
||||
{"status": "running", "sha": "new"},
|
||||
{"status": "success", "sha": "prior"},
|
||||
]
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=pipelines))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").list_ci_runs(
|
||||
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
|
||||
)
|
||||
|
||||
runs = resp.json()["workflow_runs"]
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["head_sha"] == "prior"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_ci_runs_none_settled_yields_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=[{"status": "running"}]))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").list_ci_runs(
|
||||
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
|
||||
)
|
||||
|
||||
assert resp.json()["workflow_runs"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_runs_reshaped_from_statuses(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
statuses = [
|
||||
{"id": 1, "status": "success", "name": "build"},
|
||||
{"id": 2, "status": "running", "name": "test"},
|
||||
{"id": 3, "status": "failed", "name": "lint"},
|
||||
{"id": 4, "status": "canceled", "name": "deploy"},
|
||||
]
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=statuses))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").list_check_runs(
|
||||
REF, "t", "abc123", per_page=100
|
||||
)
|
||||
|
||||
runs = resp.json()["check_runs"]
|
||||
assert runs[0] == {
|
||||
"id": 1,
|
||||
"name": "build",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
}
|
||||
assert runs[1]["status"] == "in_progress"
|
||||
assert runs[1]["conclusion"] is None
|
||||
assert runs[2]["conclusion"] == "failure"
|
||||
assert runs[3]["conclusion"] == "failure"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workflows_reports_total_count_from_pipelines(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=[{"id": 1}]))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").list_workflows(
|
||||
REF, "t", per_page=1
|
||||
)
|
||||
|
||||
assert resp.json() == {"total_count": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workflows_zero_when_no_pipelines(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=[]))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").list_workflows(
|
||||
REF, "t", per_page=1
|
||||
)
|
||||
|
||||
assert resp.json() == {"total_count": 0}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_repo_maps_merge_method_and_squash_option(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repo_obj = {
|
||||
"path_with_namespace": "group/sub/proj",
|
||||
"web_url": "https://gitlab.example.com/group/sub/proj",
|
||||
"http_url_to_repo": "https://gitlab.example.com/group/sub/proj.git",
|
||||
"squash_option": "never",
|
||||
"merge_method": "ff",
|
||||
}
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json=repo_obj))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").get_repo(REF, "t")
|
||||
|
||||
shaped = resp.json()
|
||||
assert shaped["full_name"] == "group/sub/proj"
|
||||
assert shaped["html_url"] == "https://gitlab.example.com/group/sub/proj"
|
||||
assert shaped["clone_url"] == "https://gitlab.example.com/group/sub/proj.git"
|
||||
assert shaped["allow_squash_merge"] is False
|
||||
assert shaped["allow_merge_commit"] is False
|
||||
assert shaped["allow_rebase_merge"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_repo_defaults_when_settings_absent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").get_repo(REF, "t")
|
||||
|
||||
shaped = resp.json()
|
||||
assert shaped["allow_squash_merge"] is True
|
||||
assert shaped["allow_merge_commit"] is True
|
||||
assert shaped["allow_rebase_merge"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_label_prefixes_hash_on_color(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(201, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").ensure_label(REF, "t", "root", "8250df")
|
||||
|
||||
assert b"#8250df" in recorder.requests[0].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_labels_uses_add_labels_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").add_labels(REF, "t", 5, ["a", "b"])
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert request.method == "PUT"
|
||||
assert (
|
||||
b'"add_labels": "a,b"' in request.content
|
||||
or b'"add_labels":"a,b"' in request.content
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_branch_ref_urlencodes_branch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(204))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").delete_branch_ref(
|
||||
REF, "t", "feature/backend/ABC12345"
|
||||
)
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert request.method == "DELETE"
|
||||
assert "feature%2Fbackend%2FABC12345" in str(request.url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_issue_comment_posts_note(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
recorder = _Recorder(lambda _r: httpx.Response(201, json={}))
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").create_issue_comment(REF, "t", 5, "hi")
|
||||
|
||||
request = recorder.requests[0]
|
||||
assert request.url.path.endswith("/merge_requests/5/notes")
|
||||
assert b"hi" in request.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_release_shapes_html_url_from_links_self(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
lambda _r: httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"tag_name": "v1.0.0",
|
||||
"_links": {
|
||||
"self": "https://gitlab.example.com/group/sub/proj/-/releases/v1.0.0"
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").create_release(
|
||||
REF,
|
||||
"t",
|
||||
tag_name="v1.0.0",
|
||||
name="v1.0.0",
|
||||
body="notes",
|
||||
target_commitish="main",
|
||||
)
|
||||
|
||||
assert resp.json()["html_url"] == (
|
||||
"https://gitlab.example.com/group/sub/proj/-/releases/v1.0.0"
|
||||
)
|
||||
request = recorder.requests[0]
|
||||
assert (
|
||||
b'"description": "notes"' in request.content
|
||||
or b'"description":"notes"' in request.content
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_reviewers_is_synthetic_skip() -> None:
|
||||
resp = await GitLabProvider("gitlab.example.com").request_reviewers(
|
||||
REF, "t", 5, ["renzo"]
|
||||
)
|
||||
assert isinstance(resp, ShapedResponse)
|
||||
assert resp.is_success
|
||||
assert "skipped" in resp.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_branch_is_shaped_not_implemented() -> None:
|
||||
resp = await GitLabProvider("gitlab.example.com").merge_branch(
|
||||
REF, "t", base="stag", head="main", commit_message="cascade"
|
||||
)
|
||||
assert isinstance(resp, ShapedResponse)
|
||||
assert resp.status_code == httpx.codes.NOT_IMPLEMENTED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_repo_is_synthetic_501() -> None:
|
||||
resp = await GitLabProvider("gitlab.example.com").create_org_repo(
|
||||
"t", "acme", name="widgets", description="", private=True, auto_init=True
|
||||
)
|
||||
assert isinstance(resp, ShapedResponse)
|
||||
assert resp.status_code == httpx.codes.NOT_IMPLEMENTED
|
||||
assert "Phase 4" in resp.json()["message"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""ForgeRouter + registry: host-map registration, per-call provider
|
||||
resolution off RepoRef.host, and URL parsing that stamps the host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from roboco.exceptions import GitError
|
||||
from roboco.services.forge import registry
|
||||
from roboco.services.forge.base import RepoRef
|
||||
from roboco.services.forge.gitea import GiteaProvider
|
||||
from roboco.services.forge.github import GitHubProvider
|
||||
from roboco.services.forge.gitlab import GitLabProvider
|
||||
from roboco.services.forge.registry import (
|
||||
provider_for,
|
||||
register_project_forge,
|
||||
)
|
||||
from roboco.services.forge.router import ForgeRouter
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_host_map() -> None:
|
||||
registry._HOST_PROVIDERS.clear()
|
||||
|
||||
|
||||
def test_github_host_needs_no_registration() -> None:
|
||||
register_project_forge("https://github.com/acme/widgets.git", "github")
|
||||
assert registry._HOST_PROVIDERS == {}
|
||||
assert registry.provider_name_for_host("github.com") == "github"
|
||||
|
||||
|
||||
def test_gitea_host_registers_and_resolves() -> None:
|
||||
register_project_forge("https://gitea.example.com/acme/widgets.git", "gitea")
|
||||
assert registry.provider_name_for_host("gitea.example.com") == "gitea"
|
||||
|
||||
|
||||
def test_router_resolves_provider_from_ref_host() -> None:
|
||||
register_project_forge("https://gitea.example.com/acme/widgets.git", "gitea")
|
||||
router = ForgeRouter()
|
||||
assert isinstance(router._provider_for_ref(RepoRef("a", "b")), GitHubProvider)
|
||||
assert isinstance(
|
||||
router._provider_for_ref(RepoRef("a", "b", host="gitea.example.com")),
|
||||
GiteaProvider,
|
||||
)
|
||||
|
||||
|
||||
def test_router_parse_github_url_unchanged() -> None:
|
||||
ref = ForgeRouter().parse_repo_ref("git@github.com:acme/widgets.git")
|
||||
assert ref == RepoRef("acme", "widgets")
|
||||
assert ref.host is None
|
||||
|
||||
|
||||
def test_router_parse_registered_gitea_url_stamps_host() -> None:
|
||||
register_project_forge("https://gitea.example.com/acme/widgets.git", "gitea")
|
||||
ref = ForgeRouter().parse_repo_ref("https://gitea.example.com/acme/widgets.git")
|
||||
assert ref.host == "gitea.example.com"
|
||||
|
||||
|
||||
def test_router_parse_unregistered_host_fails_loud() -> None:
|
||||
with pytest.raises(GitError, match="registered project"):
|
||||
ForgeRouter().parse_repo_ref("https://git.internal.example/a/b.git")
|
||||
|
||||
|
||||
def test_provider_for_gitea_project_uses_git_url_host() -> None:
|
||||
class _Project:
|
||||
git_provider = "gitea"
|
||||
git_url = "https://gitea.example.com/acme/widgets.git"
|
||||
|
||||
provider = provider_for(_Project())
|
||||
assert isinstance(provider, GiteaProvider)
|
||||
|
||||
|
||||
def test_provider_for_gitlab_project_uses_git_url_host() -> None:
|
||||
class _Project:
|
||||
git_provider = "gitlab"
|
||||
git_url = "https://gitlab.com/acme/widgets.git"
|
||||
|
||||
assert isinstance(provider_for(_Project()), GitLabProvider)
|
||||
|
||||
|
||||
def test_router_routes_registered_gitlab_host() -> None:
|
||||
register_project_forge("https://gitlab.example.com/g/sub/p.git", "gitlab")
|
||||
router = ForgeRouter()
|
||||
provider = router._provider_for_ref(
|
||||
RepoRef("g/sub/p", "", host="gitlab.example.com")
|
||||
)
|
||||
assert isinstance(provider, GitLabProvider)
|
||||
ref = router.parse_repo_ref("https://gitlab.example.com/g/sub/p.git")
|
||||
assert ref.owner == "g/sub/p"
|
||||
assert ref.host == "gitlab.example.com"
|
||||
@@ -18,6 +18,7 @@ import pytest
|
||||
from roboco.config import settings
|
||||
from roboco.exceptions import GitCommandError, GitError, MergeConflictError
|
||||
from roboco.services.base import NotFoundError, UnauthorizedError, ValidationError
|
||||
from roboco.services.forge import RepoRef
|
||||
from roboco.services.git import GitService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -442,7 +443,7 @@ async def test_pr_target_returns_base_ref() -> None:
|
||||
|
||||
svc = _service(execute_returns=result)
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="token"))
|
||||
|
||||
fake_response = MagicMock()
|
||||
@@ -490,7 +491,7 @@ async def test_create_pr_returns_pr_dict() -> None:
|
||||
_bind(svc, "_task_for_branch", AsyncMock(return_value=fake_task))
|
||||
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_record_pr_atomically", AsyncMock())
|
||||
# parent == default → _ensure_base_on_remote short-circuits (no git call)
|
||||
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
|
||||
@@ -532,7 +533,7 @@ async def test_create_pr_records_pr_despite_cancellation_after_post() -> None:
|
||||
_bind(svc, "_task_for_branch", AsyncMock(return_value=fake_task))
|
||||
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
|
||||
|
||||
recorded = {"done": False}
|
||||
@@ -590,7 +591,7 @@ async def test_create_pr_cancellation_waits_out_record_before_reraising() -> Non
|
||||
_bind(svc, "_task_for_branch", AsyncMock(return_value=fake_task))
|
||||
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
|
||||
|
||||
order: list[str] = []
|
||||
@@ -737,7 +738,7 @@ async def test_ensure_label_exists_swallows_non_httpx_error() -> None:
|
||||
"roboco.services.git.httpx.AsyncClient",
|
||||
return_value=_non_httpx_raising_client(),
|
||||
):
|
||||
await svc._ensure_label_exists("acme", "repo", "tok", "cell/backend")
|
||||
await svc._ensure_label_exists(RepoRef("acme", "repo"), "tok", "cell/backend")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -748,7 +749,7 @@ async def test_apply_pr_labels_swallows_non_httpx_error() -> None:
|
||||
"roboco.services.git.httpx.AsyncClient",
|
||||
return_value=_non_httpx_raising_client(),
|
||||
):
|
||||
await svc._apply_pr_labels("acme", "repo", "tok", 11, ["cell/backend"])
|
||||
await svc._apply_pr_labels(RepoRef("acme", "repo"), "tok", 11, ["cell/backend"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -774,7 +775,7 @@ async def test_pr_merge_returns_merge_commit_dict() -> None:
|
||||
svc = _service(execute_returns=result)
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
|
||||
fake_resp = MagicMock(is_success=True, status_code=200)
|
||||
_bind(svc, "_call_merge_api", AsyncMock(return_value=fake_resp))
|
||||
@@ -805,7 +806,7 @@ async def test_pr_merge_into_default_branch_is_ceo_only() -> None:
|
||||
svc = _service(execute_returns=result)
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
|
||||
merge_api = AsyncMock()
|
||||
_bind(svc, "_call_merge_api", merge_api)
|
||||
@@ -1302,7 +1303,7 @@ async def test_pr_is_merged_returns_none_on_httpx_error() -> None:
|
||||
"roboco.services.git.httpx.AsyncClient",
|
||||
return_value=_httpx_raising_client(),
|
||||
):
|
||||
out = await svc._pr_is_merged("acme", "repo", 11, "tok")
|
||||
out = await svc._pr_is_merged(RepoRef("acme", "repo"), 11, "tok")
|
||||
assert out is None
|
||||
|
||||
|
||||
@@ -1317,8 +1318,7 @@ async def test_merge_with_retry_none_does_not_raise_merge_conflict() -> None:
|
||||
_bind(svc, "_pr_is_merged", AsyncMock(return_value=None))
|
||||
|
||||
ctx = GitService._MergeContext(
|
||||
owner="acme",
|
||||
repo="repo",
|
||||
repo_ref=RepoRef("acme", "repo"),
|
||||
pr_number=11,
|
||||
git_token="tok",
|
||||
workspace=Path("/tmp/ws"),
|
||||
@@ -1340,8 +1340,7 @@ async def test_merge_with_retry_false_raises_merge_conflict() -> None:
|
||||
_bind(svc, "_pr_is_merged", AsyncMock(return_value=False))
|
||||
|
||||
ctx = GitService._MergeContext(
|
||||
owner="acme",
|
||||
repo="repo",
|
||||
repo_ref=RepoRef("acme", "repo"),
|
||||
pr_number=11,
|
||||
git_token="tok",
|
||||
workspace=Path("/tmp/ws"),
|
||||
@@ -1356,7 +1355,7 @@ async def test_merge_pull_request_none_does_not_raise_git_error() -> None:
|
||||
"""CEO merge path: indeterminate (None) falls through to cleanup, not GitError."""
|
||||
svc = _service()
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_first_allowed_merge_method", AsyncMock(return_value=None))
|
||||
_bind(svc, "_delete_pr_branch_best_effort", AsyncMock())
|
||||
_bind(svc, "_sync_target_branch", AsyncMock(return_value="abc123sha"))
|
||||
@@ -1381,7 +1380,7 @@ async def test_is_pr_merged_for_task_none_treated_as_merged() -> None:
|
||||
svc = _service(execute_returns=result)
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_project_for_task", AsyncMock(return_value=fake_project))
|
||||
_bind(svc, "_resolve_workspace_agent_id", MagicMock(return_value=None))
|
||||
_bind(svc, "_pr_is_merged", AsyncMock(return_value=None))
|
||||
@@ -1451,7 +1450,7 @@ async def test_update_pr_for_task_threads_actor_agent_id() -> None:
|
||||
return Path("/tmp/ws")
|
||||
|
||||
_bind(svc, "get_workspace", AsyncMock(side_effect=_capture_workspace))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
|
||||
fake_task_service = MagicMock()
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from roboco.services.forge import RepoRef
|
||||
from roboco.services.git import GitService
|
||||
|
||||
|
||||
@@ -48,7 +49,7 @@ async def test_delete_skips_branch_with_open_dependents() -> None:
|
||||
client = _fake_client()
|
||||
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
|
||||
await svc._delete_remote_branch_best_effort(
|
||||
"acme", "repo", "feature/main_pm/abc123", "tok"
|
||||
RepoRef("acme", "repo"), "feature/main_pm/abc123", "tok"
|
||||
)
|
||||
client.delete.assert_not_awaited()
|
||||
|
||||
@@ -60,7 +61,7 @@ async def test_delete_removes_leaf_branch_with_no_dependents() -> None:
|
||||
client = _fake_client()
|
||||
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
|
||||
await svc._delete_remote_branch_best_effort(
|
||||
"acme", "repo", "feature/backend/abc--cell--leaf", "tok"
|
||||
RepoRef("acme", "repo"), "feature/backend/abc--cell--leaf", "tok"
|
||||
)
|
||||
client.delete.assert_awaited_once()
|
||||
|
||||
@@ -72,7 +73,9 @@ async def test_delete_skips_default_branch_before_checking_dependents() -> None:
|
||||
_bind(svc, "_branch_has_open_dependents", dep)
|
||||
client = _fake_client()
|
||||
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
|
||||
await svc._delete_remote_branch_best_effort("acme", "repo", "master", "tok")
|
||||
await svc._delete_remote_branch_best_effort(
|
||||
RepoRef("acme", "repo"), "master", "tok"
|
||||
)
|
||||
client.delete.assert_not_awaited()
|
||||
dep.assert_not_awaited()
|
||||
|
||||
@@ -89,7 +92,7 @@ async def test_has_open_dependents_true_when_open_pr_targets_base() -> None:
|
||||
client.get = AsyncMock(return_value=resp)
|
||||
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
|
||||
out = await svc._branch_has_open_dependents(
|
||||
"acme", "repo", "feature/main_pm/abc123", "tok"
|
||||
RepoRef("acme", "repo"), "feature/main_pm/abc123", "tok"
|
||||
)
|
||||
assert out is True
|
||||
|
||||
@@ -103,7 +106,7 @@ async def test_has_open_dependents_false_when_none() -> None:
|
||||
client.get = AsyncMock(return_value=resp)
|
||||
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
|
||||
out = await svc._branch_has_open_dependents(
|
||||
"acme", "repo", "feature/x--leaf", "tok"
|
||||
RepoRef("acme", "repo"), "feature/x--leaf", "tok"
|
||||
)
|
||||
assert out is False
|
||||
|
||||
@@ -116,6 +119,6 @@ async def test_has_open_dependents_fails_safe_on_non_success() -> None:
|
||||
client.get = AsyncMock(return_value=resp)
|
||||
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
|
||||
out = await svc._branch_has_open_dependents(
|
||||
"acme", "repo", "feature/main_pm/abc123", "tok"
|
||||
RepoRef("acme", "repo"), "feature/main_pm/abc123", "tok"
|
||||
)
|
||||
assert out is True
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""The env-sync local-git merge fallback (forge Phase 2.1): a provider's
|
||||
shaped 501 routes sync_env_branch through a throwaway clone→merge→push, with
|
||||
the same status vocabulary the merges-API path produces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import roboco.services.git as git_module
|
||||
from roboco.services.git import GitService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _bind(svc: GitService, name: str, value: object) -> None:
|
||||
"""Stub without tripping mypy's method-assign check (test_git.py idiom)."""
|
||||
setattr(svc, name, value)
|
||||
|
||||
|
||||
def _service() -> GitService:
|
||||
svc = GitService.__new__(GitService)
|
||||
_bind(svc, "log", MagicMock())
|
||||
return svc
|
||||
|
||||
|
||||
def _result(returncode: int = 0, stdout: str = "") -> SimpleNamespace:
|
||||
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr="")
|
||||
|
||||
|
||||
def _scripted_run_git(
|
||||
outcomes: dict[str, SimpleNamespace],
|
||||
) -> tuple[AsyncMock, list[str]]:
|
||||
"""Route each _run_git call by its first meaningful arg; record verbs."""
|
||||
verbs: list[str] = []
|
||||
|
||||
async def _run(_workspace: Path, args: list[str], **_kw: Any) -> SimpleNamespace:
|
||||
verb = args[0] if args[0] != "merge-base" else "merge-base"
|
||||
verbs.append(verb)
|
||||
return outcomes.get(verb, _result())
|
||||
|
||||
return AsyncMock(side_effect=_run), verbs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clean_merge_pushes_and_reports_sha() -> None:
|
||||
svc = _service()
|
||||
# merge-base non-zero = not an ancestor → real merge happens.
|
||||
outcomes = {
|
||||
"merge-base": _result(1),
|
||||
"rev-parse": _result(0, "abc123\n"),
|
||||
}
|
||||
run, verbs = _scripted_run_git(outcomes)
|
||||
_bind(svc, "_run_git", run)
|
||||
|
||||
status = await svc._local_merge_branch("https://g/x/y.git", "tok", "stag", "main")
|
||||
|
||||
assert status == {"status": "merged", "sha": "abc123"}
|
||||
assert verbs[0] == "clone"
|
||||
assert "merge" in verbs
|
||||
assert "push" in verbs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_ancestor_short_circuits() -> None:
|
||||
svc = _service()
|
||||
run, verbs = _scripted_run_git({"merge-base": _result(0)})
|
||||
_bind(svc, "_run_git", run)
|
||||
|
||||
status = await svc._local_merge_branch("https://g/x/y.git", "tok", "stag", "main")
|
||||
|
||||
assert status == {"status": "already_ancestor"}
|
||||
assert "merge" not in verbs
|
||||
assert "push" not in verbs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_conflict_never_pushes() -> None:
|
||||
svc = _service()
|
||||
run, verbs = _scripted_run_git(
|
||||
{"merge-base": _result(1), "merge": _result(1, "CONFLICT")}
|
||||
)
|
||||
_bind(svc, "_run_git", run)
|
||||
|
||||
status = await svc._local_merge_branch("https://g/x/y.git", "tok", "stag", "main")
|
||||
|
||||
assert status == {"status": "conflict"}
|
||||
assert "push" not in verbs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_branch_maps_to_missing_ref() -> None:
|
||||
svc = _service()
|
||||
run, _ = _scripted_run_git({"fetch": _result(128)})
|
||||
_bind(svc, "_run_git", run)
|
||||
|
||||
status = await svc._local_merge_branch("https://g/x/y.git", "tok", "stag", "main")
|
||||
|
||||
assert status == {"status": "missing_ref"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_env_branch_routes_shaped_501_to_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
svc = _service()
|
||||
_bind(svc, "session", MagicMock())
|
||||
|
||||
project = SimpleNamespace(git_url="https://gitea.example.com/a/b.git")
|
||||
project_svc = MagicMock(get_by_slug=AsyncMock(return_value=project))
|
||||
monkeypatch.setattr(git_module, "get_project_service", lambda _s: project_svc)
|
||||
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_git_url", MagicMock(return_value=MagicMock()))
|
||||
|
||||
forge = MagicMock(
|
||||
merge_branch=AsyncMock(
|
||||
return_value=SimpleNamespace(status_code=501, text="", json=dict)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(GitService, "_forge", property(lambda _self: forge))
|
||||
fallback = AsyncMock(return_value={"status": "merged", "sha": "abc"})
|
||||
_bind(svc, "_local_merge_branch", fallback)
|
||||
|
||||
status = await svc.sync_env_branch("proj", "stag", "main")
|
||||
|
||||
assert status == {"status": "merged", "sha": "abc"}
|
||||
fallback.assert_awaited_once_with(
|
||||
"https://gitea.example.com/a/b.git", "tok", "stag", "main"
|
||||
)
|
||||
@@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from roboco.exceptions import MergeConflictError
|
||||
from roboco.services.forge import RepoRef
|
||||
from roboco.services.git import GitService
|
||||
|
||||
|
||||
@@ -39,8 +40,7 @@ def _resp(status_code: int, *, is_success: bool) -> Any:
|
||||
|
||||
def _ctx() -> Any:
|
||||
return GitService._MergeContext(
|
||||
owner="acme",
|
||||
repo="repo",
|
||||
repo_ref=RepoRef("acme", "repo"),
|
||||
pr_number=42,
|
||||
git_token="tok",
|
||||
workspace=Path("/ws"),
|
||||
@@ -90,7 +90,7 @@ async def test_pr_is_merged_true_when_github_reports_merged() -> None:
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
client.get = AsyncMock(return_value=resp)
|
||||
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
|
||||
assert await svc._pr_is_merged("acme", "repo", 42, "tok") is True
|
||||
assert await svc._pr_is_merged(RepoRef("acme", "repo"), 42, "tok") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -102,4 +102,4 @@ async def test_pr_is_merged_false_on_non_success() -> None:
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
client.get = AsyncMock(return_value=resp)
|
||||
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
|
||||
assert await svc._pr_is_merged("acme", "repo", 42, "tok") is False
|
||||
assert await svc._pr_is_merged(RepoRef("acme", "repo"), 42, "tok") is False
|
||||
|
||||
@@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
import roboco.services.git as git_module
|
||||
from roboco.exceptions import GitError
|
||||
from roboco.services.forge import RepoRef
|
||||
from roboco.services.git import GitService
|
||||
|
||||
|
||||
@@ -71,7 +72,9 @@ async def test_first_allowed_skips_disabled_method(
|
||||
monkeypatch.setattr(
|
||||
git_module.httpx, "AsyncClient", lambda *_a, **_k: _FakeClient(resp)
|
||||
)
|
||||
method = await svc._first_allowed_merge_method("o", "r", "tok", exclude="squash")
|
||||
method = await svc._first_allowed_merge_method(
|
||||
RepoRef("o", "r"), "tok", exclude="squash"
|
||||
)
|
||||
assert method == "merge" # squash disabled + excluded -> next permitted
|
||||
|
||||
|
||||
@@ -84,7 +87,7 @@ async def test_first_allowed_returns_none_when_lookup_fails(
|
||||
monkeypatch.setattr(
|
||||
git_module.httpx, "AsyncClient", lambda *_a, **_k: _FakeClient(resp)
|
||||
)
|
||||
assert await svc._first_allowed_merge_method("o", "r", "tok") is None
|
||||
assert await svc._first_allowed_merge_method(RepoRef("o", "r"), "tok") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -95,7 +98,9 @@ async def test_merge_retries_with_allowed_method_on_405(
|
||||
monkeypatch.setattr(
|
||||
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
||||
)
|
||||
monkeypatch.setattr(svc, "_parse_github_remote", lambda _ws: ("owner", "repo"))
|
||||
monkeypatch.setattr(
|
||||
svc, "_parse_github_remote", lambda _ws: RepoRef("owner", "repo")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
svc, "_delete_pr_branch_best_effort", AsyncMock(return_value=None)
|
||||
)
|
||||
@@ -109,9 +114,7 @@ async def test_merge_retries_with_allowed_method_on_405(
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_call(
|
||||
_owner: str, _repo: str, _pr: int, _token: str, method: str
|
||||
) -> Any:
|
||||
async def fake_call(_repo_ref: RepoRef, _pr: int, _token: str, method: str) -> Any:
|
||||
calls.append(method)
|
||||
return (
|
||||
_resp(200, is_success=True)
|
||||
@@ -136,7 +139,9 @@ async def test_merge_does_not_retry_when_method_allowed(
|
||||
monkeypatch.setattr(
|
||||
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
||||
)
|
||||
monkeypatch.setattr(svc, "_parse_github_remote", lambda _ws: ("owner", "repo"))
|
||||
monkeypatch.setattr(
|
||||
svc, "_parse_github_remote", lambda _ws: RepoRef("owner", "repo")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
svc, "_delete_pr_branch_best_effort", AsyncMock(return_value=None)
|
||||
)
|
||||
@@ -149,9 +154,7 @@ async def test_merge_does_not_retry_when_method_allowed(
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_call(
|
||||
_owner: str, _repo: str, _pr: int, _token: str, method: str
|
||||
) -> Any:
|
||||
async def fake_call(_repo_ref: RepoRef, _pr: int, _token: str, method: str) -> Any:
|
||||
calls.append(method)
|
||||
return _resp(200, is_success=True)
|
||||
|
||||
@@ -176,7 +179,9 @@ async def test_merge_already_merged_pr_is_idempotent_success(
|
||||
monkeypatch.setattr(
|
||||
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
||||
)
|
||||
monkeypatch.setattr(svc, "_parse_github_remote", lambda _ws: ("owner", "repo"))
|
||||
monkeypatch.setattr(
|
||||
svc, "_parse_github_remote", lambda _ws: RepoRef("owner", "repo")
|
||||
)
|
||||
delete_branch = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
|
||||
monkeypatch.setattr(
|
||||
@@ -219,7 +224,9 @@ async def test_merge_raises_when_not_merged_and_refused(
|
||||
monkeypatch.setattr(
|
||||
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
||||
)
|
||||
monkeypatch.setattr(svc, "_parse_github_remote", lambda _ws: ("owner", "repo"))
|
||||
monkeypatch.setattr(
|
||||
svc, "_parse_github_remote", lambda _ws: RepoRef("owner", "repo")
|
||||
)
|
||||
delete_branch = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -21,6 +21,7 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.base import NotFoundError
|
||||
from roboco.services.forge import RepoRef
|
||||
from roboco.services.git import GitService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -130,7 +131,7 @@ async def test_pr_target_with_project_id_skips_wrong_repo_task() -> None:
|
||||
svc = GitService(session)
|
||||
_bind = object.__setattr__
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="token"))
|
||||
|
||||
with _patch_project_service(MagicMock(slug="roboco")), pytest.raises(NotFoundError):
|
||||
|
||||
@@ -14,6 +14,7 @@ from uuid import UUID, uuid4
|
||||
import pytest
|
||||
from roboco.exceptions import GitError
|
||||
from roboco.services.base import NotFoundError
|
||||
from roboco.services.forge import RepoRef
|
||||
from roboco.services.git import GitService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -93,7 +94,7 @@ async def _stub_task_get(svc: GitService, task: object | None) -> None:
|
||||
def _wire_service(svc: GitService, task: MagicMock) -> MagicMock:
|
||||
"""Apply common bindings: workspace, remote parse, token resolution."""
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
|
||||
# update_pr_for_task fetches the task via get_task_service; we patch it
|
||||
|
||||
@@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.forge import RepoRef
|
||||
from roboco.services.git import GitService
|
||||
|
||||
|
||||
@@ -151,7 +152,7 @@ async def test_close_pull_request_patches_state_closed(
|
||||
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
svc, "_parse_github_remote", MagicMock(return_value=("owner", "repo"))
|
||||
svc, "_parse_github_remote", MagicMock(return_value=RepoRef("owner", "repo"))
|
||||
)
|
||||
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
|
||||
|
||||
@@ -233,7 +234,7 @@ async def test_close_pull_request_does_not_delete_branch_by_default(
|
||||
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
svc, "_parse_github_remote", MagicMock(return_value=("owner", "repo"))
|
||||
svc, "_parse_github_remote", MagicMock(return_value=RepoRef("owner", "repo"))
|
||||
)
|
||||
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
|
||||
|
||||
@@ -299,7 +300,7 @@ async def test_close_pull_request_idempotent_when_already_closed(
|
||||
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
svc, "_parse_github_remote", MagicMock(return_value=("owner", "repo"))
|
||||
svc, "_parse_github_remote", MagicMock(return_value=RepoRef("owner", "repo"))
|
||||
)
|
||||
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", AsyncMock())
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.forge import RepoRef
|
||||
from roboco.services.git import GitService
|
||||
|
||||
|
||||
@@ -111,7 +112,7 @@ async def test_rebase_pr_for_task_rebases_in_worktree_not_clone() -> None:
|
||||
|
||||
state = _stub_rebase_common(svc, clone)
|
||||
object.__setattr__(
|
||||
svc, "_parse_github_remote", MagicMock(return_value=("owner", "repo"))
|
||||
svc, "_parse_github_remote", MagicMock(return_value=RepoRef("owner", "repo"))
|
||||
)
|
||||
object.__setattr__(
|
||||
svc,
|
||||
|
||||
@@ -22,6 +22,7 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.exceptions import GitError, MergeConflictError
|
||||
from roboco.services.forge import RepoRef
|
||||
from roboco.services.git import GitService
|
||||
|
||||
# Module-level constants kept local so the assertions stay readable and
|
||||
@@ -101,7 +102,7 @@ async def test_pr_merge_retries_once_on_409_conflict() -> None:
|
||||
svc = GitService(_make_session(fake_task, fake_parent))
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
|
||||
call_seq = AsyncMock(side_effect=[_fake_response(409), _fake_response(200)])
|
||||
_bind(svc, "_call_merge_api", call_seq)
|
||||
@@ -145,7 +146,7 @@ async def test_pr_merge_raises_after_second_409() -> None:
|
||||
svc = GitService(_make_session(fake_task, fake_parent))
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
|
||||
call_seq = AsyncMock(side_effect=[_fake_response(409), _fake_response(409)])
|
||||
_bind(svc, "_call_merge_api", call_seq)
|
||||
@@ -182,7 +183,7 @@ async def test_pr_merge_does_not_retry_on_non_409_error() -> None:
|
||||
svc = GitService(_make_session(fake_task, fake_parent))
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
|
||||
call_seq = AsyncMock(side_effect=[_fake_response(422)])
|
||||
_bind(svc, "_call_merge_api", call_seq)
|
||||
@@ -220,7 +221,7 @@ async def test_pr_merge_locks_parent_task_with_for_update() -> None:
|
||||
svc = GitService(session)
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_call_merge_api", AsyncMock(return_value=_fake_response(200)))
|
||||
_bind(svc, "_delete_pr_branch_best_effort", AsyncMock())
|
||||
_bind(svc, "_sync_target_branch", AsyncMock(return_value="abc"))
|
||||
@@ -268,7 +269,7 @@ async def test_pr_merge_skips_parent_lock_for_root_task() -> None:
|
||||
svc = GitService(session)
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_call_merge_api", AsyncMock(return_value=_fake_response(200)))
|
||||
_bind(svc, "_delete_pr_branch_best_effort", AsyncMock())
|
||||
_bind(svc, "_sync_target_branch", AsyncMock(return_value="abc"))
|
||||
@@ -316,7 +317,7 @@ async def test_pr_merge_scopes_task_lookup_by_project_id() -> None:
|
||||
svc = GitService(session)
|
||||
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
|
||||
_bind(svc, "_call_merge_api", AsyncMock(return_value=_fake_response(200)))
|
||||
_bind(svc, "_delete_pr_branch_best_effort", AsyncMock())
|
||||
_bind(svc, "_sync_target_branch", AsyncMock(return_value="sha"))
|
||||
@@ -342,8 +343,7 @@ _HTTP_METHOD_NOT_ALLOWED = 405
|
||||
|
||||
def _merge_ctx(pr_number: int = 11) -> GitService._MergeContext:
|
||||
return GitService._MergeContext(
|
||||
owner="acme",
|
||||
repo="repo",
|
||||
repo_ref=RepoRef("acme", "repo"),
|
||||
pr_number=pr_number,
|
||||
git_token="tok",
|
||||
workspace=Path("/tmp/ws"),
|
||||
|
||||
Reference in New Issue
Block a user