diff --git a/.env.example b/.env.example index 20d733d1e..1d62b5cb6 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,11 @@ FORGE_REQUIRE_PROJECT_CONFIG=true # GITHUB_KNOWN_REPOS=org/repo1,org/repo2 # GITHUB_DEFAULT_REPO=org/repo1 +# GitLab connections are explicit repos.yaml entries. Keep each instance's +# token and webhook secret in its own environment variables, for example: +# ACME_GITLAB_TOKEN=glpat-your-token +# ACME_GITLAB_WEBHOOK_SECRET=your-webhook-secret + # ============================================================================= # LLM Configuration # Forge passes LangChain chat model instances into Deep Agents. Built-in diff --git a/docs/getting-started.md b/docs/getting-started.md index 4431ae04b..86e6ee1bc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -84,6 +84,14 @@ Point Jira and GitHub webhooks at your server. **Events:** Pull requests, Pull request reviews, Check runs, Issue comments +=== "GitLab" + + **URL:** `https://your-server.com/api/v1/webhooks/gitlab` + + Configure an explicit GitLab connection and repository in `repos.yaml`, then + set its `webhook_secret_env` value as the GitLab webhook secret. Select Merge + request, Note, Pipeline, and Push events. + For local development you have two options: === "forge-poller (recommended)" diff --git a/docs/reference/api.md b/docs/reference/api.md index cc9771f8c..c07450a66 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -73,6 +73,16 @@ Receives GitHub webhook events. Validates the signature and enqueues for async p Returns HTTP 200 immediately. Processing is asynchronous. +### GitLab Webhook + +```http +POST /api/v1/webhooks/gitlab +``` + +Receives GitLab merge-request, note, pipeline, and push events. The repository +must be an explicit GitLab `repos.yaml` entry; `X-Gitlab-Token` must match the +connection's `webhook_secret_env` value. Returns HTTP 202 when queued. + --- ### Prometheus Metrics diff --git a/docs/reference/config.md b/docs/reference/config.md index 9f22968e5..5428bbab1 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -284,6 +284,29 @@ registry, the process caches that registry for its lifetime. Restart the gateway and every worker after changing the file. See [operations](../operations.md) for the safe deployment and recovery model. +GitLab repositories use explicit connections (there is no implicit GitLab +default), which supports both GitLab.com and self-managed instances: + +Set `base_url` to either the GitLab host/root URL (including any self-managed +path prefix) or an explicit REST API v4 URL. Forge normalizes host/root URLs +by appending `/api/v4`; explicit URLs ending in `/api/v4` are accepted as-is. + +```yaml +connections: + engineering-gitlab: + provider: gitlab + base_url: https://gitlab.example.com + credential_env: ENGINEERING_GITLAB_TOKEN + webhook_secret_env: ENGINEERING_GITLAB_WEBHOOK_SECRET +repositories: + payments-api: + provider: gitlab + connection: engineering-gitlab + namespace: platform/payments-api + default_branch: main + change_request_mode: direct +``` + ## Proposal review configuration Projects can opt into GitHub pull-request review for PRDs and specifications. diff --git a/src/forge/api/routes/__init__.py b/src/forge/api/routes/__init__.py index 907a0aaee..0c518ff6b 100644 --- a/src/forge/api/routes/__init__.py +++ b/src/forge/api/routes/__init__.py @@ -2,6 +2,7 @@ from forge.api.routes.executions import router as executions_router from forge.api.routes.github import router as github_router +from forge.api.routes.gitlab import router as gitlab_router from forge.api.routes.health import router as health_router from forge.api.routes.jira import router as jira_router from forge.api.routes.metrics import router as metrics_router @@ -10,6 +11,7 @@ __all__ = [ "executions_router", "github_router", + "gitlab_router", "effects_router", "health_router", "jira_router", diff --git a/src/forge/api/routes/gitlab.py b/src/forge/api/routes/gitlab.py new file mode 100644 index 000000000..982f83cc8 --- /dev/null +++ b/src/forge/api/routes/gitlab.py @@ -0,0 +1,160 @@ +"""GitLab webhook endpoint for receiving repository events.""" + +import json +import logging + +from fastapi import APIRouter, Header, HTTPException, Request, status + +from forge.api.routes.metrics import ( + record_webhook_failed, + record_webhook_processed, + record_webhook_received, +) +from forge.config import get_settings +from forge.integrations.source_control.contracts import NormalizedEvent, Provider +from forge.integrations.source_control.errors import NotFoundError, ProviderConfigError +from forge.integrations.source_control.gitlab.adapter import GitLabAdapter +from forge.integrations.source_control.registry import get_registry, resolve_env_value +from forge.integrations.source_control.ticket_keys import extract_ticket_key +from forge.observability.config import get_tracer +from forge.observability.context import get_correlation_id +from forge.queue.producer import QueueProducer + +logger = logging.getLogger(__name__) +tracer = get_tracer("forge.api.gitlab") + +router = APIRouter(prefix="/api/v1/webhooks", tags=["gitlab"]) + + +def _extract_ticket_key(event: NormalizedEvent) -> str: + """Extract a Jira ticket key from a NormalizedEvent. + + Falls back to the raw payload's `ref` for push events -- GitLab's push + webhook payload carries a top-level `ref` (e.g. "refs/heads/forge/AISOS-123"), + directly analogous to GitHub's, and GitLabAdapter.parse_webhook doesn't + populate change_request for push events -- and to `object_attributes.ref` + for pipeline events on a plain branch with no MR attached, which carry the + branch there instead of at the top level. + """ + return extract_ticket_key( + event, + fallback_branch_sources=( + event.raw.get("ref", ""), + event.raw.get("object_attributes", {}).get("ref", ""), + ), + ) + + +@router.post( + "/gitlab", + status_code=status.HTTP_202_ACCEPTED, + responses={ + 202: {"description": "Event accepted for processing"}, + 401: {"description": "Invalid webhook token"}, + }, +) +async def receive_gitlab_webhook( + request: Request, + x_gitlab_event: str = Header(default=""), + x_gitlab_token: str = Header(default=""), + x_gitlab_event_uuid: str = Header(default=""), +) -> dict[str, str]: + """Receive and queue GitLab webhook events. + + Every GitLab connection must be explicitly configured in repos.yaml with + a webhook_secret_env (GitLab has no implicit default connection, unlike + GitHub) -- there is no unauthenticated "unmanaged repository" ack-and-drop + path here the way the GitHub route has for its implicit default. + """ + settings = get_settings() + span = tracer.start_span( + "gitlab_webhook", + attributes={ + "correlation_id": get_correlation_id(), + "forge.source": "gitlab", + "forge.event_type": x_gitlab_event, + }, + ) + + try: + body = await request.body() + + try: + sniff_payload = json.loads(body) if body else {} + except json.JSONDecodeError: + sniff_payload = {} + repo_namespace = sniff_payload.get("project", {}).get("path_with_namespace", "") + + registry = get_registry() + try: + connection = registry.resolve(repo_namespace, provider_hint=Provider.GITLAB).connection + except (NotFoundError, ProviderConfigError): + span.set_attribute("error", True) + span.set_attribute("error.type", "auth_failure") + logger.warning("GitLab webhook for unconfigured repository %r rejected", repo_namespace) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid webhook signature" + ) + + webhook_secret = ( + resolve_env_value(connection.webhook_secret_env, settings) + if connection.webhook_secret_env + else None + ) + adapter = GitLabAdapter(connection=connection, webhook_secret=webhook_secret) + + if not await adapter.verify_webhook({"X-Gitlab-Token": x_gitlab_token}, body): + span.set_attribute("error", True) + span.set_attribute("error.type", "auth_failure") + logger.warning("Invalid GitLab webhook token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid webhook signature" + ) + + try: + event = await adapter.parse_webhook({"X-Gitlab-Event": x_gitlab_event}, body, registry) + except (NotFoundError, ProviderConfigError): + span.set_attribute("forge.skipped", True) + span.set_attribute("forge.skip_reason", "unmanaged_repository") + span.set_attribute("forge.event_id", x_gitlab_event_uuid) + record_webhook_received(source="gitlab", event_type=x_gitlab_event) + # Return GitLab's delivery UUID (the identifier shown in its webhook + # delivery log) so a discarded event stays correlatable. + return {"status": "ignored", "event_id": x_gitlab_event_uuid} + + ticket_key = _extract_ticket_key(event) + span.set_attribute("forge.ticket_key", ticket_key) + span.set_attribute("forge.event_id", event.id) + + record_webhook_received(source="gitlab", event_type=x_gitlab_event) + + producer = QueueProducer() + message_id = await producer.publish_event(event, ticket_key) + + if message_id is None: + span.set_attribute("forge.skipped", True) + span.set_attribute("forge.skip_reason", "duplicate event") + return {"status": "duplicate", "event_id": event.id, "ticket_key": ticket_key} + + span.set_attribute("forge.queued", True) + logger.info( + f"GitLab webhook queued: event_id={event.id}, kind={event.kind}, repo={event.repo_ref.namespace}" + ) + record_webhook_processed(source="gitlab", event_type=x_gitlab_event) + + return {"status": "queued", "event_id": event.id, "ticket_key": ticket_key} + + except HTTPException: + raise + except Exception as e: + span.set_attribute("error", True) + span.set_attribute("error.type", "internal_error") + logger.error(f"Failed to process GitLab webhook: {e}") + record_webhook_failed( + source="gitlab", event_type=x_gitlab_event, error_type="internal_error" + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to process webhook" + ) + finally: + span.end() diff --git a/src/forge/cli.py b/src/forge/cli.py index 806fd111a..0318d8e83 100644 --- a/src/forge/cli.py +++ b/src/forge/cli.py @@ -7,6 +7,7 @@ from typing import Any import forge.integrations.source_control.github # noqa: F401 (registers GitHub adapter factory) +import forge.integrations.source_control.gitlab # noqa: F401 (registers GitLab adapter factory) from forge.config import get_settings diff --git a/src/forge/integrations/gitlab/__init__.py b/src/forge/integrations/gitlab/__init__.py new file mode 100644 index 000000000..c20f0c1dd --- /dev/null +++ b/src/forge/integrations/gitlab/__init__.py @@ -0,0 +1,5 @@ +"""GitLab integration for MR management and webhook handling.""" + +from forge.integrations.gitlab.client import GitLabClient + +__all__ = ["GitLabClient"] diff --git a/src/forge/integrations/gitlab/client.py b/src/forge/integrations/gitlab/client.py new file mode 100644 index 000000000..d09130e41 --- /dev/null +++ b/src/forge/integrations/gitlab/client.py @@ -0,0 +1,383 @@ +"""GitLab REST API v4 client for merge request and repository operations.""" + +import asyncio +from typing import Any +from urllib.parse import quote + +import httpx + +from forge.integrations.source_control.errors import SourceControlError, TransientProviderError + +_DEFAULT_API_BASE_URL = "https://gitlab.com/api/v4" + + +def encode_project_id(namespace: str) -> str: + """URL-encode a namespace/path project identifier for GitLab's :id param.""" + return quote(namespace, safe="") + + +class GitLabClient: + """Async client for the GitLab REST API v4. + + Unlike GitHubClient, there is no zero-config Settings fallback: GitLab + has no implicit default connection (see registry.py), so every + GitLabClient is always constructed with an explicitly-resolved + credential. + """ + + def __init__( + self, + credential: str, + *, + base_url: str | None = None, + ca_path: str | None = None, + ): + self._credential = credential + if base_url: + self.base_url = base_url.rstrip("/") + if not self.base_url.endswith("/api/v4"): + self.base_url = f"{self.base_url}/api/v4" + else: + self.base_url = _DEFAULT_API_BASE_URL + self._ca_path = ca_path + self._client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + base_url=self.base_url, + headers={"PRIVATE-TOKEN": self._credential}, + timeout=30.0, + verify=self._ca_path or True, + ) + return self._client + + async def close(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + + async def get_project(self, namespace: str) -> dict[str, Any]: + client = await self._get_client() + response = await client.get(f"/projects/{encode_project_id(namespace)}") + response.raise_for_status() + return response.json() + + async def get_authenticated_user(self) -> dict[str, Any]: + client = await self._get_client() + response = await client.get("/user") + response.raise_for_status() + return response.json() + + async def get_fork(self, namespace: str, fork_owner: str) -> dict[str, Any] | None: + """Return ``fork_owner``'s fork of ``namespace``, or None if none exists. + + Looks the fork up through the upstream project's fork list and matches + on the owning namespace rather than assuming the fork kept the upstream + project's path. GitLab appends a numeric suffix (e.g. ``repo1``) when a + same-named project already exists under ``fork_owner`` at fork time, so + a path-based lookup would 404 and make get_or_create_fork create a + duplicate fork. + """ + client = await self._get_client() + per_page = 100 + page = 1 + while True: + response = await client.get( + f"/projects/{encode_project_id(namespace)}/forks", + params={"page": page, "per_page": per_page}, + ) + response.raise_for_status() + forks = response.json() + for fork in forks: + if fork.get("namespace", {}).get("full_path") == fork_owner: + return fork + if len(forks) < per_page: + return None + page += 1 + + async def create_fork(self, namespace: str) -> dict[str, Any]: + client = await self._get_client() + response = await client.post(f"/projects/{encode_project_id(namespace)}/fork") + response.raise_for_status() + return response.json() + + async def get_or_create_fork( + self, + namespace: str, + fork_owner: str, + wait_for_ready: bool = True, + max_wait_seconds: int = 60, + ) -> dict[str, Any]: + existing = await self.get_fork(namespace, fork_owner) + if existing is not None: + if existing.get("import_status") == "failed": + raise SourceControlError( + f"Fork {existing.get('path_with_namespace', fork_owner)} of {namespace} " + "exists but its import failed; remove it in GitLab and retry." + ) + return existing + + fork = await self.create_fork(namespace) + if not wait_for_ready: + return fork + + fork_id = fork["id"] + client = await self._get_client() + elapsed = 0 + while fork.get("import_status", "finished") not in (None, "finished"): + if fork.get("import_status") == "failed": + raise SourceControlError( + f"GitLab reported that fork {fork_id} of {namespace} failed to import." + ) + if elapsed >= max_wait_seconds: + raise TransientProviderError( + f"Fork {fork_id} of {namespace} was not ready after {max_wait_seconds}s " + "(import still in progress)." + ) + await asyncio.sleep(2) + elapsed += 2 + response = await client.get(f"/projects/{fork_id}") + response.raise_for_status() + fork = response.json() + return fork + + async def create_merge_request( + self, + source_namespace: str, + *, + source_branch: str, + target_branch: str, + title: str, + description: str, + target_project_id: int | None = None, + ) -> dict[str, Any]: + client = await self._get_client() + payload: dict[str, Any] = { + "source_branch": source_branch, + "target_branch": target_branch, + "title": title, + "description": description, + } + if target_project_id is not None: + payload["target_project_id"] = target_project_id + response = await client.post( + f"/projects/{encode_project_id(source_namespace)}/merge_requests", json=payload + ) + response.raise_for_status() + return response.json() + + async def get_merge_requests( + self, + namespace: str, + *, + source_branch: str, + state: str = "opened", + source_project_id: int | None = None, + target_branch: str | None = None, + ) -> list[dict[str, Any]]: + """List merge requests for a project, filtered by source branch. + + ``namespace`` is scoped to the project a merge request belongs to, + which in GitLab's data model is always the *target* project of the + MR -- for a fork-mode MR, this is the upstream project, not the fork. + """ + client = await self._get_client() + params: dict[str, str | int] = {"source_branch": source_branch, "state": state} + if source_project_id is not None: + params["source_project_id"] = source_project_id + if target_branch is not None: + params["target_branch"] = target_branch + response = await client.get( + f"/projects/{encode_project_id(namespace)}/merge_requests", + params=params, + ) + response.raise_for_status() + return response.json() + + async def get_merge_request(self, namespace: str, iid: int) -> dict[str, Any]: + client = await self._get_client() + response = await client.get( + f"/projects/{encode_project_id(namespace)}/merge_requests/{iid}" + ) + response.raise_for_status() + return response.json() + + async def update_merge_request( + self, + namespace: str, + iid: int, + *, + title: str | None = None, + description: str | None = None, + state_event: str | None = None, + ) -> dict[str, Any]: + client = await self._get_client() + payload: dict[str, Any] = {} + if title is not None: + payload["title"] = title + if description is not None: + payload["description"] = description + if state_event is not None: + payload["state_event"] = state_event + response = await client.put( + f"/projects/{encode_project_id(namespace)}/merge_requests/{iid}", json=payload + ) + response.raise_for_status() + return response.json() + + async def create_note(self, namespace: str, iid: int, body: str) -> dict[str, Any]: + client = await self._get_client() + response = await client.post( + f"/projects/{encode_project_id(namespace)}/merge_requests/{iid}/notes", + json={"body": body}, + ) + response.raise_for_status() + return response.json() + + async def get_discussions(self, namespace: str, iid: int) -> list[dict[str, Any]]: + client = await self._get_client() + per_page = 100 + path = f"/projects/{encode_project_id(namespace)}/merge_requests/{iid}/discussions" + results: list[dict[str, Any]] = [] + page = 1 + while True: + response = await client.get(path, params={"page": page, "per_page": per_page}) + response.raise_for_status() + page_results = response.json() + results.extend(page_results) + if len(page_results) < per_page: + return results + page += 1 + + async def reply_to_discussion( + self, namespace: str, iid: int, discussion_id: str, body: str + ) -> dict[str, Any]: + client = await self._get_client() + response = await client.post( + f"/projects/{encode_project_id(namespace)}/merge_requests/{iid}/discussions/{discussion_id}/notes", + json={"body": body}, + ) + response.raise_for_status() + return response.json() + + async def get_approvals(self, namespace: str, iid: int) -> dict[str, Any]: + client = await self._get_client() + response = await client.get( + f"/projects/{encode_project_id(namespace)}/merge_requests/{iid}/approvals" + ) + response.raise_for_status() + return response.json() + + async def get_commit_statuses(self, namespace: str, ref: str) -> list[dict[str, Any]]: + client = await self._get_client() + per_page = 100 + path = ( + f"/projects/{encode_project_id(namespace)}/repository/commits/" + f"{encode_project_id(ref)}/statuses" + ) + results: list[dict[str, Any]] = [] + page = 1 + while True: + response = await client.get(path, params={"page": page, "per_page": per_page}) + response.raise_for_status() + page_results = response.json() + results.extend(page_results) + if len(page_results) < per_page: + return results + page += 1 + + async def get_job_trace(self, namespace: str, job_id: int) -> str: + client = await self._get_client() + response = await client.get(f"/projects/{encode_project_id(namespace)}/jobs/{job_id}/trace") + response.raise_for_status() + return response.text + + async def get_job_artifacts(self, namespace: str, job_id: int) -> bytes | None: + client = await self._get_client() + response = await client.get( + f"/projects/{encode_project_id(namespace)}/jobs/{job_id}/artifacts" + ) + if response.status_code == 404: + return None + response.raise_for_status() + return response.content + + async def get_file_raw(self, namespace: str, path: str, ref: str) -> str | None: + client = await self._get_client() + response = await client.get( + f"/projects/{encode_project_id(namespace)}/repository/files/{encode_project_id(path)}/raw", + params={"ref": ref}, + ) + if response.status_code == 404: + return None + response.raise_for_status() + return response.text + + async def get_file_metadata(self, namespace: str, path: str, ref: str) -> dict[str, Any] | None: + """Fetch file metadata via HEAD, which GitLab returns as response + headers -- avoids downloading the (potentially large) base64-encoded + file body that a GET to this same endpoint would include.""" + client = await self._get_client() + response = await client.head( + f"/projects/{encode_project_id(namespace)}/repository/files/{encode_project_id(path)}", + params={"ref": ref}, + ) + if response.status_code == 404: + return None + response.raise_for_status() + headers = response.headers + return { + "file_name": headers.get("X-Gitlab-File-Name"), + "file_path": headers.get("X-Gitlab-File-Path"), + "size": headers.get("X-Gitlab-Size"), + "encoding": headers.get("X-Gitlab-Encoding"), + "content_sha256": headers.get("X-Gitlab-Content-Sha256"), + "ref": headers.get("X-Gitlab-Ref"), + "blob_id": headers.get("X-Gitlab-Blob-Id"), + "commit_id": headers.get("X-Gitlab-Commit-Id"), + "last_commit_id": headers.get("X-Gitlab-Last-Commit-Id"), + "execute_filemode": headers.get("X-Gitlab-Execute-Filemode"), + } + + async def create_file( + self, namespace: str, path: str, *, branch: str, content: str, commit_message: str + ) -> None: + client = await self._get_client() + response = await client.post( + f"/projects/{encode_project_id(namespace)}/repository/files/{encode_project_id(path)}", + json={"branch": branch, "content": content, "commit_message": commit_message}, + ) + response.raise_for_status() + + async def update_file( + self, + namespace: str, + path: str, + *, + branch: str, + content: str, + commit_message: str, + last_commit_id: str | None, + ) -> None: + client = await self._get_client() + payload: dict[str, Any] = { + "branch": branch, + "content": content, + "commit_message": commit_message, + } + if last_commit_id is not None: + payload["last_commit_id"] = last_commit_id + response = await client.put( + f"/projects/{encode_project_id(namespace)}/repository/files/{encode_project_id(path)}", + json=payload, + ) + response.raise_for_status() + + async def create_branch(self, namespace: str, branch: str, ref: str) -> None: + client = await self._get_client() + response = await client.post( + f"/projects/{encode_project_id(namespace)}/repository/branches", + json={"branch": branch, "ref": ref}, + ) + response.raise_for_status() diff --git a/src/forge/integrations/source_control/contracts.py b/src/forge/integrations/source_control/contracts.py index 736213710..dafb8b510 100644 --- a/src/forge/integrations/source_control/contracts.py +++ b/src/forge/integrations/source_control/contracts.py @@ -162,6 +162,9 @@ class GitCredentials: # Enterprise Server). None for the common case (public GitHub or a CA # trusted by the default store). ca_path: str | None = None + # GitHub accepts ``x-access-token`` while GitLab's HTTPS token + # authentication convention requires ``oauth2``. + url_user: str = "x-access-token" @dataclass diff --git a/src/forge/integrations/source_control/gitlab/__init__.py b/src/forge/integrations/source_control/gitlab/__init__.py new file mode 100644 index 000000000..2ab2e6ea4 --- /dev/null +++ b/src/forge/integrations/source_control/gitlab/__init__.py @@ -0,0 +1,33 @@ +"""GitLab source control adapter.""" + +from forge.config import get_settings +from forge.integrations.source_control.contracts import Connection, Provider +from forge.integrations.source_control.gitlab.adapter import GitLabAdapter +from forge.integrations.source_control.registry import ( + register_adapter_factory, + resolve_env_value, +) + +__all__ = ["GitLabAdapter"] + + +def _build_gitlab_adapter(connection: Connection) -> GitLabAdapter: + """Registry factory: resolve the connection's credential/secret and bind them. + + Unlike GitHub, GitLab has no Settings field for its credential -- Settings + doesn't model GITLAB_TOKEN, so resolve_env_value's os.environ fallback is + what actually resolves it here (see resolve_env_value's docstring). + """ + settings = get_settings() + credential = resolve_env_value(connection.credential_env, settings) + webhook_secret = ( + resolve_env_value(connection.webhook_secret_env, settings) + if connection.webhook_secret_env + else None + ) + return GitLabAdapter( + connection=connection, credential=credential, webhook_secret=webhook_secret + ) + + +register_adapter_factory(Provider.GITLAB, _build_gitlab_adapter) diff --git a/src/forge/integrations/source_control/gitlab/adapter.py b/src/forge/integrations/source_control/gitlab/adapter.py new file mode 100644 index 000000000..e76ef04d3 --- /dev/null +++ b/src/forge/integrations/source_control/gitlab/adapter.py @@ -0,0 +1,807 @@ +"""GitLab implementation of the SourceControlProvider protocol.""" + +import hashlib +import hmac +import json +import logging +import re +from collections import OrderedDict +from datetime import UTC, datetime + +import httpx + +from forge.integrations.gitlab.client import GitLabClient +from forge.integrations.source_control.contracts import ( + Actor, + ChangeRequest, + ChangeRequestIdentity, + ChangeRequestState, + CheckConclusion, + CheckRun, + CheckStatus, + Connection, + EventKind, + GitCredentials, + NormalizedEvent, + Provider, + RepositoryRef, + RepositoryResolver, + Review, + ReviewComment, + ReviewState, + WriteTarget, +) +from forge.integrations.source_control.errors import ( + ConflictError, + NotFoundError, + ProviderConfigError, + SourceControlError, +) +from forge.integrations.source_control.http_errors import translate_provider_errors + +logger = logging.getLogger(__name__) + +_translate = translate_provider_errors("GitLab") + +_DEFAULT_API_BASE_URL = "https://gitlab.com/api/v4" +_DEFAULT_WEB_BASE_URL = "https://gitlab.com" + +_MAX_FORK_PROJECT_CACHE_ENTRIES = 1000 + + +_CR_STATE_MAP: dict[str, ChangeRequestState] = { + "opened": ChangeRequestState.OPEN, + "closed": ChangeRequestState.CLOSED, + "merged": ChangeRequestState.MERGED, + "locked": ChangeRequestState.OPEN, +} + + +def _looks_like_stale_commit_error(response: httpx.Response) -> bool: + try: + message = str(response.json().get("message", "")).lower() + except Exception: + return False + return "changed since" in message + + +def _looks_like_branch_exists_error(response: httpx.Response) -> bool: + try: + message = str(response.json().get("message", "")).lower() + except Exception: + return False + return "already exists" in message + + +def _web_base_url(connection: Connection) -> str: + """Derive the git/web host from a connection's API base_url. + + Public GitLab's API root is https://gitlab.com/api/v4; a self-managed + instance's is https://gitlab.example.com/api/v4. Both share the same + host as their web/git root, so this only strips the /api/v4 suffix. + """ + base = (connection.base_url or "").rstrip("/") + if not base or base == _DEFAULT_API_BASE_URL: + return _DEFAULT_WEB_BASE_URL + if base.endswith("/api/v4"): + return base[: -len("/api/v4")] + return base + + +def _require_native_id(identity: ChangeRequestIdentity) -> int: + """Coerce identity.native_id to an int, rejecting a missing/None value. + + A None native_id (e.g. from an identity built off a malformed webhook + payload) must not silently become MR iid 0 and produce a confusing + 404 from GitLab -- fail clearly at the adapter boundary instead. + """ + if identity.native_id is None: + raise ValueError(f"ChangeRequestIdentity has no native_id: {identity}") + return int(identity.native_id) + + +def _merge_request_head_sha(attrs: dict) -> str: + return (attrs.get("last_commit") or {}).get("id") or attrs.get("sha", "") + + +class GitLabAdapter: + """GitLab implementation of SourceControlProvider protocol.""" + + def __init__( + self, + connection: Connection, + credential: str | None = None, + webhook_secret: str | None = None, + client: GitLabClient | None = None, + ): + self._connection = connection + self._credential = credential + self._webhook_secret = webhook_secret + self._client: GitLabClient | None = client + # Three-valued: unknown (None) / known-supported (True) / + # known-unsupported (False). Cached per adapter instance, which + # Registry reuses across every project/MR resolved through this + # connection -- see Registry._adapter_cache. + self._approvals_supported: bool | None = None + # Fork-mode MRs run their pipeline in the source (fork) project, not + # repo_ref.namespace (always the upstream/target project). These + # caches -- populated whenever an MR's attrs are seen (see + # _cache_source_project) -- let get_checks/get_check_logs/ + # get_check_artifacts query the project that actually has the + # commit statuses/job instead of the upstream, which would never + # see them. This adapter instance is shared across every + # repo/MR on the connection (see Registry._adapter_cache), so + # _source_project_by_ref is keyed by (repo_ref.namespace, ref) to + # avoid two repos with a same-named branch clobbering each + # other's entry, and both caches are bounded LRUs (evicted via + # _cache_put) since they are otherwise never evicted for the + # life of a long-running worker process. + self._source_project_by_ref: OrderedDict[tuple[str, str], str] = OrderedDict() + self._project_by_job_id: OrderedDict[int, str] = OrderedDict() + + @staticmethod + def _cache_put( + cache: OrderedDict, key, value, *, max_size: int = _MAX_FORK_PROJECT_CACHE_ENTRIES + ) -> None: + cache[key] = value + cache.move_to_end(key) + while len(cache) > max_size: + cache.popitem(last=False) + + def _get_client(self) -> GitLabClient: + if self._client is None: + if self._credential is None: + raise ProviderConfigError( + f"GitLabAdapter for connection '{self._connection.name}' has no " + "credential configured; GitLab has no implicit default connection." + ) + self._client = GitLabClient( + credential=self._credential, + base_url=self._connection.base_url or None, + ca_path=self._connection.ca_path, + ) + return self._client + + @_translate + async def resolve_default_branch(self, repo_ref: RepositoryRef) -> str: + client = self._get_client() + project = await client.get_project(repo_ref.namespace) + return project.get("default_branch", "main") + + async def get_git_credentials(self, _repo_ref: RepositoryRef) -> GitCredentials: + web_base = _web_base_url(self._connection) + host = web_base.removeprefix("https://").removeprefix("http://") + if self._credential is None: + raise ProviderConfigError( + f"GitLabAdapter for connection '{self._connection.name}' has no " + "credential configured; cannot derive git credentials." + ) + return GitCredentials( + host=host, token=self._credential, ca_path=self._connection.ca_path, url_user="oauth2" + ) + + @_translate + async def get_authenticated_identity(self, _repo_ref: RepositoryRef) -> Actor: + client = self._get_client() + user = await client.get_authenticated_user() + username = user.get("username", "") + return Actor(login=username, is_bot="bot" in username.lower()) + + @_translate + async def ensure_write_target(self, repo_ref: RepositoryRef) -> WriteTarget: + web_base = _web_base_url(self._connection) + + if repo_ref.change_request_mode == "direct": + return WriteTarget( + clone_url=f"{web_base}/{repo_ref.namespace}.git", + push_remote_name="origin", + head_ref=f"forge/{repo_ref.namespace}", + base_branch=repo_ref.default_branch, + ) + + client = self._get_client() + identity = await self.get_authenticated_identity(repo_ref) + fork = await client.get_or_create_fork(repo_ref.namespace, fork_owner=identity.login) + + fork_namespace = fork["path_with_namespace"] + fork_owner, fork_repo = fork_namespace.rsplit("/", 1) + clone_url = fork.get("http_url_to_repo") or f"{web_base}/{fork_namespace}.git" + return WriteTarget( + clone_url=clone_url, + push_remote_name="origin", + head_ref=f"forge/{repo_ref.namespace}", + base_branch=repo_ref.default_branch, + fork_owner=fork_owner, + fork_repo=fork_repo, + ) + + @_translate + async def create_change_request( + self, + repo_ref: RepositoryRef, + target: WriteTarget, + title: str, + body: str, + draft: bool = False, + ) -> ChangeRequest: + """Create a merge request (or reuse an existing one for the same source branch). + + Unlike GitHub (which 422s), GitLab returns 409 Conflict when an open MR + already exists for the source branch. On 409, the existing MR is looked + up (scoped to repo_ref.namespace, which is always the target/upstream + project regardless of fork/direct mode) and returned with created=False, + mirroring GitHub's create_pull_request/_map_change_request(..., + created=False) behavior. If GitLab reports the conflict but no matching + open MR can be found, the 409 is translated into ConflictError rather + than left as a raw httpx type. + """ + client = self._get_client() + + target_project_id = None + source_namespace = repo_ref.namespace + if target.fork_owner: + source_namespace = f"{target.fork_owner}/{target.fork_repo}" + upstream = await client.get_project(repo_ref.namespace) + target_project_id = upstream["id"] + + mr_title = ( + f"Draft: {title}" if draft and not title.startswith(("Draft:", "WIP:")) else title + ) + try: + result = await client.create_merge_request( + source_namespace, + source_branch=target.head_ref, + target_branch=target.base_branch, + title=mr_title, + description=body, + target_project_id=target_project_id, + ) + except httpx.HTTPStatusError as exc: + if exc.response.status_code != 409: + raise + source_project = await client.get_project(source_namespace) + existing = await client.get_merge_requests( + repo_ref.namespace, + source_branch=target.head_ref, + source_project_id=source_project["id"], + target_branch=target.base_branch, + ) + if existing: + logger.info( + f"MR already exists for {target.head_ref} -> {target.base_branch}: " + f"!{existing[0].get('iid')} in {repo_ref.namespace}" + ) + return self._map_change_request(existing[0], repo_ref=repo_ref, created=False) + raise ConflictError( + f"GitLab reports a merge request already exists for source branch " + f"{target.head_ref!r} in {repo_ref.namespace}, but it could not be located " + "via the merge requests list endpoint." + ) from exc + return self._map_change_request(result, repo_ref=repo_ref, created=True) + + @_translate + async def get_change_request( + self, repo_ref: RepositoryRef, identity: ChangeRequestIdentity + ) -> ChangeRequest: + client = self._get_client() + mr = await client.get_merge_request(repo_ref.namespace, _require_native_id(identity)) + return self._map_change_request(mr, repo_ref=repo_ref, identity=identity) + + @_translate + async def update_change_request( + self, + repo_ref: RepositoryRef, + identity: ChangeRequestIdentity, + *, + title: str | None = None, + body: str | None = None, + state: ChangeRequestState | None = None, + ) -> ChangeRequest: + client = self._get_client() + + state_event: str | None = None + if state is not None: + if state == ChangeRequestState.MERGED: + raise ValueError( + "Cannot set change request state to MERGED via update_change_request; " + "GitLab's MR update endpoint only supports 'close' or 'reopen' state_events." + ) + state_event = "close" if state == ChangeRequestState.CLOSED else "reopen" + + mr = await client.update_merge_request( + repo_ref.namespace, + _require_native_id(identity), + title=title, + description=body, + state_event=state_event, + ) + return self._map_change_request(mr, repo_ref=repo_ref, identity=identity) + + async def verify_webhook(self, headers: dict[str, str], _body: bytes) -> bool: + """GitLab sends its configured secret verbatim in X-Gitlab-Token + (no HMAC signing of the body, unlike GitHub).""" + if not self._webhook_secret: + logger.warning( + "Webhook verification failed: no webhook secret configured for this connection" + ) + return False + return hmac.compare_digest(headers.get("X-Gitlab-Token", ""), self._webhook_secret) + + async def parse_webhook( + self, _headers: dict[str, str], body: bytes, resolver: RepositoryResolver + ) -> NormalizedEvent: + event_id = hashlib.sha256(body).hexdigest()[:16] + payload = json.loads(body.decode()) + object_kind = payload.get("object_kind", "") + + repo_namespace = payload.get("project", {}).get("path_with_namespace", "") + repo_ref = resolver.resolve(repo_namespace, provider_hint=Provider.GITLAB).repo_ref + + actor = self._extract_actor(payload, object_kind) + kind = self._map_event_kind(object_kind, payload) + + change_request = None + comment = None + review = None + check_suite_status = None + + if object_kind == "merge_request": + attrs = payload.get("object_attributes", {}) + change_request = self._map_change_request(attrs, repo_ref=repo_ref) + review = self._map_approval_review(attrs, payload) + elif object_kind == "note": + attrs = payload.get("object_attributes", {}) + if attrs.get("noteable_type") == "MergeRequest": + comment = self._map_note(attrs, actor.login) + mr = payload.get("merge_request") + if mr is not None: + change_request = self._map_change_request(mr, repo_ref=repo_ref) + elif object_kind == "pipeline": + attrs = payload.get("object_attributes", {}) + check_suite_status, _ = self._map_check_status(attrs.get("status", "")) + mr_stub = payload.get("merge_request") + if mr_stub is not None: + change_request = self._map_change_request(mr_stub, repo_ref=repo_ref) + + return NormalizedEvent( + id=event_id, + kind=kind, + repo_ref=repo_ref, + actor=actor, + received_at=datetime.now(UTC), + change_request=change_request, + comment=comment, + review=review, + check_suite_status=check_suite_status, + raw=payload, + ) + + _APPROVAL_REVIEW_STATE: dict[str, ReviewState] = { + "approved": ReviewState.APPROVED, + "approval": ReviewState.APPROVED, + # GitLab has no formal "un-approve" review state; the withdrawal of an + # approval is the closest neutral-model equivalent to DISMISSED. + "unapproved": ReviewState.DISMISSED, + "unapproval": ReviewState.DISMISSED, + } + + def _map_approval_review(self, attrs: dict, payload: dict) -> Review | None: + """Map an MR approval/unapproval action to a Review. + + GitLab's approvals are the only submission-level verdict it has (see + get_review_threads), so this is the only case parse_webhook populates + `review` for a merge_request event. + """ + action = attrs.get("action", "") + state = self._APPROVAL_REVIEW_STATE.get(action) + if state is None: + return None + return Review( + id=f"{attrs.get('iid', '')}-{action}", + state=state, + body="", + author=payload.get("user", {}).get("username", ""), + comments=[], + ) + + def _extract_actor(self, payload: dict, object_kind: str) -> Actor: + if object_kind == "push": + username = payload.get("user_username", "") + else: + username = payload.get("user", {}).get("username", "") + return Actor(login=username, is_bot="bot" in username.lower()) + + def _map_event_kind(self, object_kind: str, payload: dict) -> EventKind: + if object_kind == "merge_request": + action = payload.get("object_attributes", {}).get("action", "") + return { + "open": EventKind.CR_OPENED, + "reopen": EventKind.CR_UPDATED, + "update": EventKind.CR_UPDATED, + "close": EventKind.CR_CLOSED, + "merge": EventKind.CR_MERGED, + "approved": EventKind.REVIEW_SUBMITTED, + "unapproved": EventKind.REVIEW_SUBMITTED, + "approval": EventKind.REVIEW_SUBMITTED, + "unapproval": EventKind.REVIEW_SUBMITTED, + }.get(action, EventKind.UNKNOWN) + if object_kind == "note": + attrs = payload.get("object_attributes", {}) + return ( + EventKind.COMMENT_CREATED + if attrs.get("noteable_type") == "MergeRequest" + else EventKind.UNKNOWN + ) + if object_kind == "pipeline": + return EventKind.CHECK_UPDATED + if object_kind == "push": + return EventKind.PUSH + return EventKind.UNKNOWN + + def _map_note(self, attrs: dict, author: str) -> ReviewComment: + position = attrs.get("position") or {} + return ReviewComment( + id=str(attrs.get("id", "")), + body=attrs.get("note", "") or "", + author=author, + path=position.get("new_path"), + line=position.get("new_line"), + ) + + def _map_change_request( + self, + attrs: dict, + *, + repo_ref: RepositoryRef, + identity: ChangeRequestIdentity | None = None, + created: bool = False, + ) -> ChangeRequest: + """Map a GitLab merge request attrs dict into a ChangeRequest. + + ``repo_ref`` is always required, both to namespace the fork-project + cache (see _cache_source_project) and, when ``identity`` is not + given, to construct a fresh identity from the MR iid. + """ + if identity is None: + identity = ChangeRequestIdentity( + connection=repo_ref.connection, + repository_id=repo_ref.id, + native_id=attrs.get("iid"), + ) + self._cache_source_project(repo_ref, attrs) + return ChangeRequest( + identity=identity, + url=attrs.get("url", "") or attrs.get("web_url", ""), + title=attrs.get("title", "") or "", + body=attrs.get("description", "") or "", + state=_CR_STATE_MAP.get(attrs.get("state", ""), ChangeRequestState.OPEN), + source_branch=attrs.get("source_branch", "") or "", + target_branch=attrs.get("target_branch", "") or "", + head_sha=_merge_request_head_sha(attrs), + draft=attrs.get("draft", attrs.get("work_in_progress", False)), + created=created, + ) + + def _cache_source_project(self, repo_ref: RepositoryRef, attrs: dict) -> None: + """Remember the project a fork-mode MR's commits actually live in. + + GitLab scopes commit statuses/jobs to the project that ran the + pipeline -- for a fork-mode MR that's the source (fork) project, + never repo_ref.namespace (always the upstream/target project; see + get_merge_requests). Keyed by (repo_ref.namespace, ref) -- this + adapter instance is shared across every repo on the connection + (see Registry._adapter_cache), so the namespace must be part of + the key or two repos with a same-named branch (or a shared + fallback ref like "main") would clobber each other's entry. + Looked up by whatever ref callers use: the head commit sha, and + the source branch name as a fallback. + """ + source_project_id = attrs.get("source_project_id") + target_project_id = attrs.get("target_project_id") + if source_project_id is None or source_project_id == target_project_id: + return + project_ref = str(source_project_id) + namespace = repo_ref.namespace + head_sha = _merge_request_head_sha(attrs) + if head_sha: + self._cache_put(self._source_project_by_ref, (namespace, head_sha), project_ref) + source_branch = attrs.get("source_branch", "") or "" + if source_branch: + self._cache_put(self._source_project_by_ref, (namespace, source_branch), project_ref) + + def _map_check_status( + self, status: str, *, allow_failure: bool = False + ) -> tuple[CheckStatus, CheckConclusion]: + if allow_failure and status in {"failed", "canceled", "manual"}: + return CheckStatus.COMPLETED, CheckConclusion.NEUTRAL + table = { + "success": (CheckStatus.COMPLETED, CheckConclusion.SUCCESS), + "failed": (CheckStatus.COMPLETED, CheckConclusion.FAILURE), + "canceled": (CheckStatus.COMPLETED, CheckConclusion.CANCELLED), + "skipped": (CheckStatus.COMPLETED, CheckConclusion.SKIPPED), + "manual": (CheckStatus.QUEUED, CheckConclusion.NONE), + "created": (CheckStatus.QUEUED, CheckConclusion.NONE), + "pending": (CheckStatus.QUEUED, CheckConclusion.NONE), + "scheduled": (CheckStatus.QUEUED, CheckConclusion.NONE), + "preparing": (CheckStatus.QUEUED, CheckConclusion.NONE), + "waiting_for_resource": (CheckStatus.QUEUED, CheckConclusion.NONE), + "running": (CheckStatus.IN_PROGRESS, CheckConclusion.NONE), + } + return table.get(status, (CheckStatus.IN_PROGRESS, CheckConclusion.NONE)) + + @_translate + async def create_comment( + self, repo_ref: RepositoryRef, identity: ChangeRequestIdentity, body: str + ) -> ReviewComment: + client = self._get_client() + note = await client.create_note(repo_ref.namespace, _require_native_id(identity), body) + return self._map_note_response(note) + + @translate_provider_errors("GitLab") + async def get_change_request_comments( + self, repo_ref: RepositoryRef, identity: ChangeRequestIdentity + ) -> list[ReviewComment]: + """Return all discussion notes for effect idempotency checks.""" + discussions = await self._get_client().get_discussions( + repo_ref.namespace, _require_native_id(identity) + ) + return [ + self._map_note_response(note) + for discussion in discussions + for note in discussion.get("notes", []) + ] + + @_translate + async def reply_to_comment( + self, + repo_ref: RepositoryRef, + identity: ChangeRequestIdentity, + comment_id: str, + body: str, + ) -> ReviewComment: + client = self._get_client() + iid = _require_native_id(identity) + discussions = await client.get_discussions(repo_ref.namespace, iid) + discussion_id = next( + ( + d["id"] + for d in discussions + if any(str(n.get("id")) == comment_id for n in d.get("notes", [])) + ), + None, + ) + if discussion_id is None: + raise NotFoundError( + f"No discussion on MR !{iid} of {repo_ref.namespace} contains note {comment_id!r}." + ) + note = await client.reply_to_discussion(repo_ref.namespace, iid, discussion_id, body) + return self._map_note_response(note, in_reply_to=comment_id) + + def _map_note_response(self, note: dict, *, in_reply_to: str | None = None) -> ReviewComment: + return ReviewComment( + id=str(note.get("id", "")), + body=note.get("body", "") or "", + author=(note.get("author") or {}).get("username", ""), + path=note.get("position", {}).get("new_path") if note.get("position") else None, + line=note.get("position", {}).get("new_line") if note.get("position") else None, + resolved=note.get("resolved", False), + in_reply_to=in_reply_to, + ) + + @_translate + async def get_review_threads( + self, repo_ref: RepositoryRef, identity: ChangeRequestIdentity + ) -> list[Review]: + """GitLab has no submission-level "review" object: this maps the + Approvals API, so only APPROVED entries are ever returned (or an + empty list) -- there is no GitLab analog to CHANGES_REQUESTED, + COMMENTED, PENDING, or DISMISSED at this level. + + Degrades to [] once the Approvals API is confirmed absent on this + connection (self-managed instances without it, e.g. below + Premium/Ultimate) rather than raising -- see _approvals_supported. + """ + iid = _require_native_id(identity) + if self._approvals_supported is False: + return [] + + client = self._get_client() + try: + approvals = await client.get_approvals(repo_ref.namespace, iid) + except httpx.HTTPStatusError as exc: + if exc.response.status_code != 404: + raise + try: + await client.get_merge_request(repo_ref.namespace, iid) + except httpx.HTTPStatusError as confirm_exc: + if confirm_exc.response.status_code == 404: + # The MR itself doesn't exist -- an ordinary not-found, + # not a capability gap. Leave _approvals_supported + # untouched and re-raise the original 404. + raise exc from confirm_exc + raise + self._approvals_supported = False + logger.info( + "GitLab connection %r has no Approvals API support; " + "get_review_threads will return [] for it going forward.", + self._connection.name, + ) + return [] + + self._approvals_supported = True + return [ + Review( + id=str(entry["user"]["id"]), + state=ReviewState.APPROVED, + body="", + author=entry["user"].get("username", ""), + comments=[], + ) + for entry in approvals.get("approved_by", []) + ] + + @_translate + async def get_review_thread_comments( + self, repo_ref: RepositoryRef, identity: ChangeRequestIdentity + ) -> list[Review]: + client = self._get_client() + discussions = await client.get_discussions(repo_ref.namespace, _require_native_id(identity)) + reviews: list[Review] = [] + for discussion in discussions: + notes = discussion.get("notes", []) + first = notes[0] if notes else {} + if ( + first.get("type") != "DiffNote" + or not first.get("resolvable") + or first.get("resolved") + ): + continue + comments = [self._map_note_response(n) for n in notes] + reviews.append( + Review( + id=str(discussion.get("id", "")), + state=ReviewState.COMMENTED, + body="", + author=comments[0].author if comments else "", + comments=comments, + ) + ) + return reviews + + @_translate + async def get_review_comments_for_submission( + self, repo_ref: RepositoryRef, identity: ChangeRequestIdentity, review_id: str + ) -> list[ReviewComment]: + """GitLab has no grouping of comments by review submission; review_id + is treated as a discussion id (the id space get_review_thread_comments + returns). An id with no matching discussion returns [] rather than + raising, since a caller may legitimately pass an approval id from + get_review_threads, which is a different id space.""" + client = self._get_client() + discussions = await client.get_discussions(repo_ref.namespace, _require_native_id(identity)) + discussion = next((d for d in discussions if str(d.get("id")) == review_id), None) + if discussion is None: + return [] + return [self._map_note_response(n) for n in discussion.get("notes", [])] + + @_translate + async def get_checks(self, repo_ref: RepositoryRef, ref: str) -> list[CheckRun]: + client = self._get_client() + project_ref = self._source_project_by_ref.get((repo_ref.namespace, ref), repo_ref.namespace) + entries = await client.get_commit_statuses(project_ref, ref) + return [self._map_commit_status(entry, project_ref) for entry in entries] + + def _map_commit_status(self, entry: dict, project_ref: str) -> CheckRun: + status, conclusion = self._map_check_status( + entry.get("status", ""), allow_failure=entry.get("allow_failure", False) + ) + target_url = entry.get("target_url") or "" + job_id = self._parse_job_id(target_url) + if job_id is not None: + self._cache_put(self._project_by_job_id, job_id, project_ref) + return CheckRun( + name=entry.get("name", ""), + status=status, + conclusion=conclusion, + url=target_url, + logs_url=str(job_id) if job_id is not None else None, + ) + + @staticmethod + def _parse_job_id(target_url: str) -> int | None: + match = re.search(r"/-/(?:jobs|builds)/(\d+)", target_url) + return int(match.group(1)) if match else None + + @staticmethod + def _require_numeric_logs_url(name: str, logs_url: str) -> int: + try: + return int(logs_url) + except ValueError as exc: + raise SourceControlError( + f"Check {name!r} has a non-numeric logs_url {logs_url!r}; " + "expected a GitLab CI job id." + ) from exc + + @_translate + async def get_check_logs(self, repo_ref: RepositoryRef, check: CheckRun) -> str: + if not check.logs_url: + raise NotFoundError( + f"No logs available for check {check.name!r}: it is not backed by " + "a GitLab CI job (e.g. an external CI integration's commit status)." + ) + job_id = self._require_numeric_logs_url(check.name, check.logs_url) + project_ref = self._project_by_job_id.get(job_id, repo_ref.namespace) + client = self._get_client() + try: + return await client.get_job_trace(project_ref, job_id) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + raise NotFoundError( + f"No trace found for check {check.name!r} (job {job_id}); it may have " + "expired or been deleted under GitLab's job log retention policy." + ) from exc + raise + + @_translate + async def get_check_artifacts( + self, repo_ref: RepositoryRef, check: CheckRun + ) -> list[tuple[str, bytes]]: + if not check.logs_url: + return [] + job_id = self._require_numeric_logs_url(check.name, check.logs_url) + project_ref = self._project_by_job_id.get(job_id, repo_ref.namespace) + client = self._get_client() + artifact_bytes = await client.get_job_artifacts(project_ref, job_id) + if artifact_bytes is None: + return [] + return [("artifacts.zip", artifact_bytes)] + + @_translate + async def get_file(self, repo_ref: RepositoryRef, path: str, ref: str) -> str: + client = self._get_client() + content = await client.get_file_raw(repo_ref.namespace, path, ref) + if content is None: + raise NotFoundError(f"File {path!r} not found at ref {ref!r} in {repo_ref.namespace}.") + return content + + @_translate + async def put_file( + self, repo_ref: RepositoryRef, path: str, content: str, message: str, branch: str + ) -> None: + client = self._get_client() + existing = await client.get_file_metadata(repo_ref.namespace, path, branch) + try: + if existing is not None: + await client.update_file( + repo_ref.namespace, + path, + branch=branch, + content=content, + commit_message=message, + last_commit_id=existing.get("last_commit_id"), + ) + else: + await client.create_file( + repo_ref.namespace, path, branch=branch, content=content, commit_message=message + ) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 400 and _looks_like_stale_commit_error(exc.response): + raise ConflictError( + f"File {path!r} on {branch!r} in {repo_ref.namespace} was concurrently " + "modified since it was last read; retry with a fresh read." + ) from exc + raise + + @_translate + async def create_branch(self, repo_ref: RepositoryRef, name: str, base: str) -> None: + client = self._get_client() + try: + await client.create_branch(repo_ref.namespace, name, base) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 400 and _looks_like_branch_exists_error(exc.response): + return + raise + + async def close(self) -> None: + if self._client is not None: + await self._client.close() diff --git a/src/forge/integrations/source_control/http_errors.py b/src/forge/integrations/source_control/http_errors.py new file mode 100644 index 000000000..2725bd6eb --- /dev/null +++ b/src/forge/integrations/source_control/http_errors.py @@ -0,0 +1,58 @@ +"""Shared HTTP-status -> provider-neutral-exception translation. + +Applied to every adapter method that calls a provider's REST API, so the +boundary contract documented in errors.py -- "nothing above the adapter +layer touches a provider's own exception types directly" -- holds for +every adapter without each one re-implementing this mapping. +""" + +import functools + +import httpx + +from forge.integrations.source_control.errors import ( + AuthenticationError, + RateLimitedError, + TransientProviderError, +) + + +def translate_provider_errors(provider_name: str): + """Build a decorator that translates ``httpx`` exceptions into the neutral + error hierarchy, tagging messages with ``provider_name`` (e.g. "GitHub", + "GitLab"). Statuses with no generic neutral mapping (404, 409, 422, ...) + are left for callers to handle themselves and propagate unchanged. + """ + + def decorator(func): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + if status in (401, 403): + raise AuthenticationError( + f"{provider_name} rejected the request: {exc}" + ) from exc + if status == 429: + retry_after = exc.response.headers.get("Retry-After") + try: + parsed_retry_after = float(retry_after) if retry_after else None + except ValueError: + parsed_retry_after = None + raise RateLimitedError( + f"{provider_name} rate-limited the request: {exc}", + retry_after=parsed_retry_after, + ) from exc + if status >= 500: + raise TransientProviderError( + f"{provider_name} returned {status}: {exc}" + ) from exc + raise + except httpx.TransportError as exc: + raise TransientProviderError(f"{provider_name} request failed: {exc}") from exc + + return wrapper + + return decorator diff --git a/src/forge/integrations/source_control/ticket_keys.py b/src/forge/integrations/source_control/ticket_keys.py new file mode 100644 index 000000000..1aca8300f --- /dev/null +++ b/src/forge/integrations/source_control/ticket_keys.py @@ -0,0 +1,37 @@ +"""Shared Jira ticket-key extraction for provider webhook routes. + +Both the GitHub and GitLab webhook routes derive a ticket key the same way -- +prefer the change request's title/branch, then fall back to branch names carried +in the raw payload -- differing only in which raw fields carry the branch. This +module holds the one regex and the shared traversal so provider behavior cannot +drift between the two routes. +""" + +import re +from collections.abc import Iterable + +from forge.integrations.source_control.contracts import NormalizedEvent + +TICKET_PATTERN = re.compile(r"([A-Z][A-Z0-9]+-\d+)", re.IGNORECASE) + + +def extract_ticket_key( + event: NormalizedEvent, *, fallback_branch_sources: Iterable[str] = () +) -> str: + """Extract a Jira ticket key from a NormalizedEvent. + + Prefers the change request's title/branch when one is present. Otherwise + searches ``fallback_branch_sources`` -- the provider-specific raw payload + fields (a push ref, a pipeline/check branch) that carry the branch when no + change request is attached. Returns "" when nothing matches. + """ + if event.change_request is not None: + for text in (event.change_request.title, event.change_request.source_branch): + match = TICKET_PATTERN.search(text or "") + if match: + return match.group(1).upper() + for text in fallback_branch_sources: + match = TICKET_PATTERN.search(str(text)) + if match: + return match.group(1).upper() + return "" diff --git a/src/forge/main.py b/src/forge/main.py index bfc27c42b..09461e624 100644 --- a/src/forge/main.py +++ b/src/forge/main.py @@ -10,12 +10,14 @@ from fastapi.middleware.cors import CORSMiddleware import forge.integrations.source_control.github # noqa: F401 (registers GitHub adapter factory) +import forge.integrations.source_control.gitlab # noqa: F401 (registers GitLab adapter factory) from forge import __version__ from forge.api.middleware.correlation import CorrelationIdMiddleware from forge.api.routes import ( effects_router, executions_router, github_router, + gitlab_router, health_router, jira_router, metrics_router, @@ -125,6 +127,10 @@ def create_app() -> FastAPI: "name": "github", "description": "GitHub webhook endpoints", }, + { + "name": "gitlab", + "description": "GitLab webhook endpoints", + }, ], docs_url=None if settings.disable_openapi_docs else "/docs", redoc_url=None if settings.disable_openapi_docs else "/redoc", @@ -149,6 +155,7 @@ def create_app() -> FastAPI: app.include_router(effects_router) app.include_router(jira_router) app.include_router(github_router) + app.include_router(gitlab_router) app.include_router(executions_router) app.include_router(org_pulse_router) diff --git a/src/forge/workspace/git_ops.py b/src/forge/workspace/git_ops.py index 4d288b00b..eea769ac3 100644 --- a/src/forge/workspace/git_ops.py +++ b/src/forge/workspace/git_ops.py @@ -45,7 +45,10 @@ def repo_path(self) -> Path: def _remote_url(self, owner: str, repo: str) -> str: """Build an authenticated HTTPS clone/remote URL for owner/repo on this workspace's connection host.""" - return f"https://x-access-token:{self.credentials.token}@{self.credentials.host}/{owner}/{repo}.git" + return ( + f"https://{self.credentials.url_user}:{self.credentials.token}" + f"@{self.credentials.host}/{owner}/{repo}.git" + ) def _git_env(self) -> dict[str, str] | None: """Subprocess environment for git commands, trusting this connection's diff --git a/tests/contracts/source_control/test_gitlab_adapter.py b/tests/contracts/source_control/test_gitlab_adapter.py new file mode 100644 index 000000000..4e6c8d440 --- /dev/null +++ b/tests/contracts/source_control/test_gitlab_adapter.py @@ -0,0 +1,1824 @@ +"""Tests for GitLab source control adapter.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from forge.integrations.gitlab.client import GitLabClient +from forge.integrations.source_control.contracts import ( + ChangeRequestIdentity, + ChangeRequestState, + CheckConclusion, + CheckRun, + CheckStatus, + Connection, + EventKind, + Provider, + RepositoryRef, + ResolvedRepository, + ReviewState, + SourceControlProvider, + WriteTarget, +) +from forge.integrations.source_control.errors import ( + AuthenticationError, + ConflictError, + RateLimitedError, + SourceControlError, + TransientProviderError, +) +from forge.integrations.source_control.errors import ( + NotFoundError as SCNotFoundError, +) +from forge.integrations.source_control.gitlab.adapter import GitLabAdapter +from tests.contracts.source_control.conformance_suite import ( + assert_repository_operations, + assert_webhook_parsing, +) + + +@pytest.fixture +def gitlab_connection() -> Connection: + return Connection( + name="test-gitlab", + provider=Provider.GITLAB, + base_url="https://gitlab.com/api/v4", + credential_env="GITLAB_TOKEN", + webhook_secret_env="GITLAB_WEBHOOK_SECRET", + ) + + +@pytest.fixture +def gitlab_repo_ref() -> RepositoryRef: + return RepositoryRef( + id="test/repo", + provider=Provider.GITLAB, + connection="test-gitlab", + namespace="test/repo", + default_branch="main", + change_request_mode="fork", + ) + + +@pytest.fixture +def mock_gitlab_http_client() -> GitLabClient: + client = GitLabClient(credential="test-token-123") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + return client + + +@pytest.fixture +def gitlab_adapter_with_mock_client( + gitlab_connection: Connection, mock_gitlab_http_client: GitLabClient +) -> GitLabAdapter: + return GitLabAdapter( + gitlab_connection, credential="test-token-123", client=mock_gitlab_http_client + ) + + +class TestResolveDefaultBranchAndIdentity: + @pytest.mark.asyncio + async def test_conformance_repository_operations( + self, + gitlab_adapter_with_mock_client: GitLabAdapter, + gitlab_repo_ref: RepositoryRef, + mock_gitlab_http_client: GitLabClient, + ): + mock_client = mock_gitlab_http_client._client + + def _get(path, **_kwargs): + response = MagicMock() + response.raise_for_status = MagicMock() + if path.endswith("/user"): + response.json.return_value = {"username": "forge-bot"} + else: + response.json.return_value = {"default_branch": "develop"} + return response + + mock_client.get = AsyncMock(side_effect=_get) + + await assert_repository_operations(gitlab_adapter_with_mock_client, gitlab_repo_ref) + + +class TestGetGitCredentials: + @pytest.mark.asyncio + async def test_uses_oauth2_url_user(self, gitlab_adapter_with_mock_client, gitlab_repo_ref): + credentials = await gitlab_adapter_with_mock_client.get_git_credentials(gitlab_repo_ref) + assert credentials.host == "gitlab.com" + assert credentials.token == "test-token-123" + assert credentials.url_user == "oauth2" + + +class MockResolver: + def __init__(self, repo_ref, connection): + self._repo_ref = repo_ref + self._connection = connection + + def resolve( + self, + identifier, # noqa: ARG002 + provider_hint=None, # noqa: ARG002 + ): + return ResolvedRepository( + repo_ref=self._repo_ref, connection=self._connection, adapter=None + ) + + +@pytest.fixture +def webhook_secret() -> str: + return "test-webhook-secret" + + +class TestVerifyWebhook: + @pytest.mark.asyncio + async def test_valid_token_verifies(self, gitlab_connection, webhook_secret): + adapter = GitLabAdapter(gitlab_connection, credential="tok", webhook_secret=webhook_secret) + assert await adapter.verify_webhook({"X-Gitlab-Token": webhook_secret}, b"{}") is True + + @pytest.mark.asyncio + async def test_invalid_token_fails(self, gitlab_connection, webhook_secret): + adapter = GitLabAdapter(gitlab_connection, credential="tok", webhook_secret=webhook_secret) + assert await adapter.verify_webhook({"X-Gitlab-Token": "wrong"}, b"{}") is False + + @pytest.mark.asyncio + async def test_fails_closed_when_no_secret_configured( + self, gitlab_adapter_with_mock_client, webhook_secret + ): + assert gitlab_adapter_with_mock_client._webhook_secret is None + assert ( + await gitlab_adapter_with_mock_client.verify_webhook( + {"X-Gitlab-Token": webhook_secret}, b"{}" + ) + is False + ) + + +def _mr_opened_payload(**overrides) -> dict: + payload = { + "object_kind": "merge_request", + "user": {"username": "alice", "name": "Alice"}, + "project": {"path_with_namespace": "test/repo"}, + "object_attributes": { + "iid": 42, + "title": "Test MR", + "description": "Test body", + "state": "opened", + "action": "open", + "source_branch": "feature", + "target_branch": "main", + "url": "https://gitlab.com/test/repo/-/merge_requests/42", + "draft": False, + "last_commit": {"id": "abc123"}, + "sha": "rest-response-sha", + }, + } + payload.update(overrides) + return payload + + +class TestParseWebhookMergeRequest: + @pytest.mark.asyncio + async def test_parses_mr_opened( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, gitlab_connection + ): + body = json.dumps(_mr_opened_payload()).encode() + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + await assert_webhook_parsing( + gitlab_adapter_with_mock_client, + {"X-Gitlab-Event": "Merge Request Hook"}, + body, + resolver, + expected_kind=EventKind.CR_OPENED, + expected_repo_namespace="test/repo", + ) + + @pytest.mark.asyncio + async def test_maps_change_request_fields( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, gitlab_connection + ): + body = json.dumps(_mr_opened_payload()).encode() + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + event = await gitlab_adapter_with_mock_client.parse_webhook( + {"X-Gitlab-Event": "Merge Request Hook"}, body, resolver + ) + + assert event.change_request.identity.native_id == 42 + assert event.change_request.title == "Test MR" + assert event.change_request.body == "Test body" + assert event.change_request.state == ChangeRequestState.OPEN + assert event.change_request.head_sha == "abc123" + assert event.actor.login == "alice" + assert event.actor.is_bot is False + + @pytest.mark.asyncio + async def test_synthesizes_event_id_from_body( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, gitlab_connection + ): + """GitLab sends no delivery-id header; parse_webhook must synthesize one.""" + body = json.dumps(_mr_opened_payload()).encode() + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + event = await gitlab_adapter_with_mock_client.parse_webhook({}, body, resolver) + + import hashlib + + assert event.id == hashlib.sha256(body).hexdigest()[:16] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("action", "expected_kind"), + [ + ("reopen", EventKind.CR_UPDATED), + ("update", EventKind.CR_UPDATED), + ("close", EventKind.CR_CLOSED), + ("merge", EventKind.CR_MERGED), + ("approved", EventKind.REVIEW_SUBMITTED), + ("unapproved", EventKind.REVIEW_SUBMITTED), + ], + ) + async def test_maps_action_to_event_kind( + self, + gitlab_adapter_with_mock_client, + gitlab_repo_ref, + gitlab_connection, + action, + expected_kind, + ): + payload = _mr_opened_payload() + payload["object_attributes"]["action"] = action + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + event = await gitlab_adapter_with_mock_client.parse_webhook( + {}, json.dumps(payload).encode(), resolver + ) + + assert event.kind == expected_kind + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("action", "expected_state"), + [ + ("approved", ReviewState.APPROVED), + ("approval", ReviewState.APPROVED), + ("unapproved", ReviewState.DISMISSED), + ("unapproval", ReviewState.DISMISSED), + ], + ) + async def test_approval_actions_populate_review( + self, + gitlab_adapter_with_mock_client, + gitlab_repo_ref, + gitlab_connection, + action, + expected_state, + ): + """worker.py's human-review-gate approval path and the PRD/spec + proposal review paths require event.review to be populated to unpause; + approving/unapproving a GitLab MR must not be a no-op for those gates.""" + payload = _mr_opened_payload() + payload["object_attributes"]["action"] = action + payload["user"] = {"username": "reviewer-bob"} + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + event = await gitlab_adapter_with_mock_client.parse_webhook( + {}, json.dumps(payload).encode(), resolver + ) + + assert event.review is not None + assert event.review.state == expected_state + assert event.review.author == "reviewer-bob" + assert event.review.comments == [] + + @pytest.mark.asyncio + async def test_non_approval_actions_leave_review_none( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, gitlab_connection + ): + payload = _mr_opened_payload() + payload["object_attributes"]["action"] = "update" + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + event = await gitlab_adapter_with_mock_client.parse_webhook( + {}, json.dumps(payload).encode(), resolver + ) + + assert event.review is None + + +def test_map_change_request_with_identity_preserves_identity( + gitlab_adapter_with_mock_client: GitLabAdapter, + gitlab_repo_ref: RepositoryRef, +): + """repo_ref is always required (it namespaces the fork-project cache -- + see test_source_project_cache_is_namespaced_per_repo), but when identity + is also given it wins for the returned ChangeRequest's identity rather + than being rebuilt from repo_ref + the MR iid.""" + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=1 + ) + + change_request = gitlab_adapter_with_mock_client._map_change_request( + _mr_opened_payload()["object_attributes"], repo_ref=gitlab_repo_ref, identity=identity + ) + + assert change_request.identity == identity + + +def test_map_change_request_requires_repo_ref( + gitlab_adapter_with_mock_client: GitLabAdapter, +): + """repo_ref is a required keyword-only argument -- omitting it must raise + a clear TypeError rather than blowing up later with an opaque + AttributeError when it's dereferenced for cache namespacing.""" + with pytest.raises(TypeError): + gitlab_adapter_with_mock_client._map_change_request( # type: ignore[call-arg] + _mr_opened_payload()["object_attributes"] + ) + + +def test_source_project_cache_is_namespaced_per_repo( + gitlab_adapter_with_mock_client: GitLabAdapter, +): + """GitLabAdapter instances are cached per-connection and reused across + every repo on that connection (see Registry._adapter_cache). Two repos + on the same connection whose MRs happen to share a source branch name + must not clobber each other's fork-project cache entry.""" + repo_a = RepositoryRef( + id="a/repo", + provider=Provider.GITLAB, + connection="test-gitlab", + namespace="a/repo", + default_branch="main", + change_request_mode="fork", + ) + repo_b = RepositoryRef( + id="b/repo", + provider=Provider.GITLAB, + connection="test-gitlab", + namespace="b/repo", + default_branch="main", + change_request_mode="fork", + ) + attrs = { + "iid": 1, + "source_branch": "shared-branch-name", + "source_project_id": 200, + "target_project_id": 100, + "sha": "shared-sha", + } + + gitlab_adapter_with_mock_client._map_change_request(attrs, repo_ref=repo_a) + gitlab_adapter_with_mock_client._map_change_request( + {**attrs, "source_project_id": 300}, repo_ref=repo_b + ) + + assert ( + gitlab_adapter_with_mock_client._source_project_by_ref[("a/repo", "shared-branch-name")] + == "200" + ) + assert ( + gitlab_adapter_with_mock_client._source_project_by_ref[("b/repo", "shared-branch-name")] + == "300" + ) + + +def test_map_change_request_uses_rest_sha_when_last_commit_is_missing( + gitlab_adapter_with_mock_client: GitLabAdapter, + gitlab_repo_ref: RepositoryRef, +): + """REST MR responses omit webhook-only last_commit but provide sha.""" + attrs = { + "iid": 1, + "source_branch": "feature", + "source_project_id": 200, + "target_project_id": 100, + "sha": "rest-response-sha", + } + + change_request = gitlab_adapter_with_mock_client._map_change_request( + attrs, repo_ref=gitlab_repo_ref + ) + + assert change_request.head_sha == "rest-response-sha" + assert ( + gitlab_adapter_with_mock_client._source_project_by_ref[( + "test/repo", "rest-response-sha" + )] + == "200" + ) + + +class TestParseWebhookNote: + @pytest.mark.asyncio + async def test_note_on_merge_request_is_comment_created( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, gitlab_connection + ): + payload = { + "object_kind": "note", + "user": {"username": "bob"}, + "project": {"path_with_namespace": "test/repo"}, + "object_attributes": { + "id": 555, + "note": "Looks good", + "noteable_type": "MergeRequest", + }, + } + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + event = await gitlab_adapter_with_mock_client.parse_webhook( + {}, json.dumps(payload).encode(), resolver + ) + + assert event.kind == EventKind.COMMENT_CREATED + assert event.comment.id == "555" + assert event.comment.body == "Looks good" + assert event.comment.author == "bob" + assert event.comment.in_reply_to is None + + @pytest.mark.asyncio + async def test_note_on_merge_request_populates_change_request( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, gitlab_connection + ): + """A note-webhook payload carries a top-level `merge_request` object; + without change_request populated from it, `_extract_ticket_key` in the + gitlab route has nothing to fall back to (note events have no + top-level `ref`) and worker.py drops the event before it reaches + any workflow gate.""" + payload = { + "object_kind": "note", + "user": {"username": "bob"}, + "project": {"path_with_namespace": "test/repo"}, + "object_attributes": { + "id": 555, + "note": "!skip-gate flaky-test", + "noteable_type": "MergeRequest", + }, + "merge_request": { + "iid": 42, + "title": "AISOS-1: Test MR", + "description": "Test body", + "state": "opened", + "source_branch": "forge/AISOS-1", + "target_branch": "main", + "url": "https://gitlab.com/test/repo/-/merge_requests/42", + "last_commit": {"id": "def456"}, + "draft": False, + }, + } + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + event = await gitlab_adapter_with_mock_client.parse_webhook( + {}, json.dumps(payload).encode(), resolver + ) + + assert event.change_request is not None + assert event.change_request.identity.native_id == 42 + assert event.change_request.title == "AISOS-1: Test MR" + assert event.change_request.source_branch == "forge/AISOS-1" + assert event.change_request.target_branch == "main" + assert event.change_request.state == ChangeRequestState.OPEN + assert event.change_request.head_sha == "def456" + + @pytest.mark.asyncio + async def test_note_on_merge_request_without_mr_object_leaves_change_request_none( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, gitlab_connection + ): + """Not every note payload is guaranteed to carry `merge_request` (e.g. + a malformed or unusual payload); parse_webhook must degrade gracefully + rather than raising.""" + payload = { + "object_kind": "note", + "user": {"username": "bob"}, + "project": {"path_with_namespace": "test/repo"}, + "object_attributes": { + "id": 555, + "note": "Looks good", + "noteable_type": "MergeRequest", + }, + } + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + event = await gitlab_adapter_with_mock_client.parse_webhook( + {}, json.dumps(payload).encode(), resolver + ) + + assert event.change_request is None + assert event.comment is not None + + @pytest.mark.asyncio + async def test_note_on_issue_is_unknown( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, gitlab_connection + ): + payload = { + "object_kind": "note", + "user": {"username": "bob"}, + "project": {"path_with_namespace": "test/repo"}, + "object_attributes": {"id": 1, "note": "x", "noteable_type": "Issue"}, + } + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + event = await gitlab_adapter_with_mock_client.parse_webhook( + {}, json.dumps(payload).encode(), resolver + ) + + assert event.kind == EventKind.UNKNOWN + + +class TestParseWebhookPipeline: + @pytest.mark.asyncio + async def test_pipeline_sets_check_suite_status_not_check( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, gitlab_connection + ): + from forge.integrations.source_control.contracts import CheckStatus + + payload = { + "object_kind": "pipeline", + "user": {"username": "ci-bot"}, + "project": {"path_with_namespace": "test/repo"}, + "object_attributes": {"id": 999, "status": "running", "ref": "feature"}, + "merge_request": { + "iid": 42, + "source_branch": "feature", + "target_branch": "main", + "state": "opened", + }, + } + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + event = await gitlab_adapter_with_mock_client.parse_webhook( + {}, json.dumps(payload).encode(), resolver + ) + + assert event.kind == EventKind.CHECK_UPDATED + assert event.check is None + assert event.check_suite_status == CheckStatus.IN_PROGRESS + assert event.change_request.identity.native_id == 42 + + +class TestParseWebhookPush: + @pytest.mark.asyncio + async def test_push_reads_top_level_user_fields( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, gitlab_connection + ): + payload = { + "object_kind": "push", + "user_username": "carol", + "project": {"path_with_namespace": "test/repo"}, + } + resolver = MockResolver(gitlab_repo_ref, gitlab_connection) + + event = await gitlab_adapter_with_mock_client.parse_webhook( + {}, json.dumps(payload).encode(), resolver + ) + + assert event.kind == EventKind.PUSH + assert event.actor.login == "carol" + assert event.change_request is None + + +class TestTranslateProviderErrors: + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("status_code", "expected_exception"), + [(401, AuthenticationError), (429, RateLimitedError), (503, TransientProviderError)], + ) + async def test_status_code_maps_to_neutral_exception( + self, + gitlab_adapter_with_mock_client: GitLabAdapter, + gitlab_repo_ref: RepositoryRef, + mock_gitlab_http_client: GitLabClient, + status_code: int, + expected_exception: type[Exception], + ): + response = httpx.Response( + status_code, + headers={"Retry-After": "5"} if status_code == 429 else {}, + request=httpx.Request("GET", "https://gitlab.com/api/v4/projects/test%2Frepo"), + ) + mock_gitlab_http_client.get_project = AsyncMock( + side_effect=httpx.HTTPStatusError("boom", request=response.request, response=response) + ) + with pytest.raises(expected_exception): + await gitlab_adapter_with_mock_client.resolve_default_branch(gitlab_repo_ref) + + +class TestEnsureWriteTarget: + @pytest.mark.asyncio + async def test_direct_mode_makes_no_api_calls( + self, gitlab_adapter_with_mock_client, mock_gitlab_http_client + ): + direct_ref = RepositoryRef( + id="test/repo", + provider=Provider.GITLAB, + connection="test-gitlab", + namespace="test/repo", + default_branch="main", + change_request_mode="direct", + ) + + target = await gitlab_adapter_with_mock_client.ensure_write_target(direct_ref) + + assert target.clone_url == "https://gitlab.com/test/repo.git" + assert target.push_remote_name == "origin" + assert target.base_branch == "main" + mock_gitlab_http_client._client.get.assert_not_called() + mock_gitlab_http_client._client.post.assert_not_called() + + @pytest.mark.asyncio + async def test_fork_mode_creates_and_returns_fork_target( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_authenticated_user = AsyncMock( + return_value={"username": "forge-bot"} + ) + mock_gitlab_http_client.get_or_create_fork = AsyncMock( + return_value={ + "id": 7, + "path_with_namespace": "forge-bot/repo", + "http_url_to_repo": "https://gitlab.com/forge-bot/repo.git", + } + ) + + target = await gitlab_adapter_with_mock_client.ensure_write_target(gitlab_repo_ref) + + mock_gitlab_http_client.get_or_create_fork.assert_awaited_once_with( + "test/repo", fork_owner="forge-bot" + ) + assert target.clone_url == "https://gitlab.com/forge-bot/repo.git" + assert target.fork_owner == "forge-bot" + assert target.fork_repo == "repo" + + +class TestCreateChangeRequest: + @pytest.mark.asyncio + async def test_direct_mode_creates_mr_without_target_project_id( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + write_target = WriteTarget( + clone_url="https://gitlab.com/test/repo.git", + push_remote_name="origin", + head_ref="forge/test/repo", + base_branch="main", + ) + mock_gitlab_http_client.create_merge_request = AsyncMock( + return_value={ + "iid": 5, + "title": "Test MR", + "description": "body", + "state": "opened", + "source_branch": "forge/test/repo", + "target_branch": "main", + "web_url": "https://gitlab.com/test/repo/-/merge_requests/5", + "draft": False, + } + ) + + cr = await gitlab_adapter_with_mock_client.create_change_request( + gitlab_repo_ref, write_target, title="Test MR", body="body" + ) + + mock_gitlab_http_client.create_merge_request.assert_awaited_once_with( + "test/repo", + source_branch="forge/test/repo", + target_branch="main", + title="Test MR", + description="body", + target_project_id=None, + ) + assert cr.identity.native_id == 5 + assert cr.url == "https://gitlab.com/test/repo/-/merge_requests/5" + + @pytest.mark.asyncio + async def test_fork_mode_resolves_upstream_numeric_id_and_uses_fork_as_source( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + write_target = WriteTarget( + clone_url="https://gitlab.com/forge-bot/repo.git", + push_remote_name="origin", + head_ref="forge/test/repo", + base_branch="main", + fork_owner="forge-bot", + fork_repo="repo", + ) + mock_gitlab_http_client.get_project = AsyncMock(return_value={"id": 100}) + mock_gitlab_http_client.create_merge_request = AsyncMock( + return_value={ + "iid": 6, + "title": "T", + "description": "", + "state": "opened", + "source_branch": "forge/test/repo", + "target_branch": "main", + "web_url": "u", + "draft": True, + } + ) + + cr = await gitlab_adapter_with_mock_client.create_change_request( + gitlab_repo_ref, write_target, title="T", body="", draft=True + ) + + mock_gitlab_http_client.get_project.assert_awaited_once_with("test/repo") + mock_gitlab_http_client.create_merge_request.assert_awaited_once_with( + "forge-bot/repo", + source_branch="forge/test/repo", + target_branch="main", + title="Draft: T", + description="", + target_project_id=100, + ) + assert cr.draft is True + + @pytest.mark.asyncio + async def test_409_conflict_returns_existing_mr_uncreated( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + """GitLab returns 409 (not GitHub's 422) when an open MR already + exists for the source branch. This must not leak a raw + httpx.HTTPStatusError past the adapter boundary -- the existing MR is + looked up and returned with created=False, mirroring GitHub's + create_pull_request behavior.""" + write_target = WriteTarget( + clone_url="https://gitlab.com/forge-bot/repo.git", + push_remote_name="origin", + head_ref="forge/test/repo", + base_branch="main", + fork_owner="forge-bot", + fork_repo="repo", + ) + conflict_response = httpx.Response( + 409, + json={ + "message": ["Another open merge request already exists for this source branch: !7"] + }, + request=httpx.Request( + "POST", "https://gitlab.com/api/v4/projects/test%2Frepo/merge_requests" + ), + ) + mock_gitlab_http_client.create_merge_request = AsyncMock( + side_effect=httpx.HTTPStatusError( + "409 conflict", request=conflict_response.request, response=conflict_response + ) + ) + mock_gitlab_http_client.get_project = AsyncMock( + side_effect=[{"id": 100}, {"id": 200}] + ) + mock_gitlab_http_client.get_merge_requests = AsyncMock( + return_value=[ + { + "iid": 7, + "title": "Existing MR", + "description": "already open", + "state": "opened", + "source_branch": "forge/test/repo", + "target_branch": "main", + "web_url": "https://gitlab.com/test/repo/-/merge_requests/7", + "draft": False, + } + ] + ) + + cr = await gitlab_adapter_with_mock_client.create_change_request( + gitlab_repo_ref, write_target, title="Test MR", body="body" + ) + + mock_gitlab_http_client.get_merge_requests.assert_awaited_once_with( + "test/repo", + source_branch="forge/test/repo", + source_project_id=200, + target_branch="main", + ) + assert mock_gitlab_http_client.get_project.await_args_list == [ + (("test/repo",), {}), + (("forge-bot/repo",), {}), + ] + assert cr.identity.native_id == 7 + assert cr.created is False + assert cr.url == "https://gitlab.com/test/repo/-/merge_requests/7" + + @pytest.mark.asyncio + async def test_409_conflict_with_no_matching_mr_raises_conflict_error( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + """If GitLab reports the conflict but the lookup finds nothing (e.g. a + race where the MR was closed between the 409 and the lookup), this + must still not leak a raw httpx type -- fall back to ConflictError.""" + write_target = WriteTarget( + clone_url="https://gitlab.com/test/repo.git", + push_remote_name="origin", + head_ref="forge/test/repo", + base_branch="main", + ) + conflict_response = httpx.Response( + 409, + json={"message": ["Another open merge request already exists"]}, + request=httpx.Request( + "POST", "https://gitlab.com/api/v4/projects/test%2Frepo/merge_requests" + ), + ) + mock_gitlab_http_client.create_merge_request = AsyncMock( + side_effect=httpx.HTTPStatusError( + "409 conflict", request=conflict_response.request, response=conflict_response + ) + ) + mock_gitlab_http_client.get_project = AsyncMock(return_value={"id": 100}) + mock_gitlab_http_client.get_merge_requests = AsyncMock(return_value=[]) + + with pytest.raises(ConflictError, match="already exists"): + await gitlab_adapter_with_mock_client.create_change_request( + gitlab_repo_ref, write_target, title="Test MR", body="body" + ) + + @pytest.mark.asyncio + async def test_non_409_http_error_propagates_through_translate( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + """A non-409 HTTPStatusError must still reach @_translate's neutral + mapping, not get swallowed by the 409-specific handling.""" + write_target = WriteTarget( + clone_url="https://gitlab.com/test/repo.git", + push_remote_name="origin", + head_ref="forge/test/repo", + base_branch="main", + ) + error_response = httpx.Response( + 500, + request=httpx.Request( + "POST", "https://gitlab.com/api/v4/projects/test%2Frepo/merge_requests" + ), + ) + mock_gitlab_http_client.create_merge_request = AsyncMock( + side_effect=httpx.HTTPStatusError( + "server error", request=error_response.request, response=error_response + ) + ) + + with pytest.raises(TransientProviderError): + await gitlab_adapter_with_mock_client.create_change_request( + gitlab_repo_ref, write_target, title="Test MR", body="body" + ) + + +class TestUpdateChangeRequest: + @pytest.mark.asyncio + async def test_maps_closed_state_to_state_event( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=5 + ) + mock_gitlab_http_client.update_merge_request = AsyncMock( + return_value={ + "iid": 5, + "title": "T", + "description": "", + "state": "closed", + "source_branch": "f", + "target_branch": "main", + } + ) + + cr = await gitlab_adapter_with_mock_client.update_change_request( + gitlab_repo_ref, identity, state=ChangeRequestState.CLOSED + ) + + assert ( + mock_gitlab_http_client.update_merge_request.await_args.kwargs["state_event"] == "close" + ) + assert cr.state == ChangeRequestState.CLOSED + + @pytest.mark.asyncio + async def test_rejects_merged_state( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=5 + ) + mock_gitlab_http_client.update_merge_request = AsyncMock() + + with pytest.raises(ValueError, match="MERGED"): + await gitlab_adapter_with_mock_client.update_change_request( + gitlab_repo_ref, identity, state=ChangeRequestState.MERGED + ) + mock_gitlab_http_client.update_merge_request.assert_not_awaited() + + @pytest.mark.asyncio + async def test_raises_on_missing_native_id( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=None + ) + mock_gitlab_http_client.get_merge_request = AsyncMock() + + with pytest.raises(ValueError, match="native_id"): + await gitlab_adapter_with_mock_client.get_change_request(gitlab_repo_ref, identity) + mock_gitlab_http_client.get_merge_request.assert_not_awaited() + + +class TestCreateComment: + @pytest.mark.asyncio + async def test_creates_note_and_maps_result( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=42 + ) + mock_gitlab_http_client.create_note = AsyncMock( + return_value={"id": 1, "body": "hi", "author": {"username": "forge-bot"}} + ) + + comment = await gitlab_adapter_with_mock_client.create_comment( + gitlab_repo_ref, identity, "hi" + ) + + mock_gitlab_http_client.create_note.assert_awaited_once_with("test/repo", 42, "hi") + assert comment.body == "hi" + assert comment.author == "forge-bot" + + +class TestReplyToComment: + @pytest.mark.asyncio + async def test_finds_discussion_containing_note_and_replies( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=42 + ) + mock_gitlab_http_client.get_discussions = AsyncMock( + return_value=[ + {"id": "disc-1", "notes": [{"id": 10}]}, + {"id": "disc-2", "notes": [{"id": 20}, {"id": 21}]}, + ] + ) + mock_gitlab_http_client.reply_to_discussion = AsyncMock( + return_value={"id": 22, "body": "reply", "author": {"username": "forge-bot"}} + ) + + comment = await gitlab_adapter_with_mock_client.reply_to_comment( + gitlab_repo_ref, identity, comment_id="20", body="reply" + ) + + mock_gitlab_http_client.reply_to_discussion.assert_awaited_once_with( + "test/repo", 42, "disc-2", "reply" + ) + assert comment.in_reply_to == "20" + + @pytest.mark.asyncio + async def test_raises_not_found_when_no_discussion_contains_the_note( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=42 + ) + mock_gitlab_http_client.get_discussions = AsyncMock( + return_value=[{"id": "disc-1", "notes": [{"id": 10}]}] + ) + mock_gitlab_http_client.reply_to_discussion = AsyncMock() + + with pytest.raises(SCNotFoundError): + await gitlab_adapter_with_mock_client.reply_to_comment( + gitlab_repo_ref, identity, comment_id="999", body="x" + ) + mock_gitlab_http_client.reply_to_discussion.assert_not_awaited() + + +class TestGetReviewThreads: + @pytest.mark.asyncio + async def test_maps_approvals_to_approved_reviews( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=42 + ) + mock_gitlab_http_client.get_approvals = AsyncMock( + return_value={"approved_by": [{"user": {"id": 1, "username": "alice"}}]} + ) + + reviews = await gitlab_adapter_with_mock_client.get_review_threads( + gitlab_repo_ref, identity + ) + + assert len(reviews) == 1 + assert reviews[0].state == ReviewState.APPROVED + assert reviews[0].author == "alice" + assert reviews[0].comments == [] + + @pytest.mark.asyncio + async def test_no_approvals_returns_empty_list( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=42 + ) + mock_gitlab_http_client.get_approvals = AsyncMock(return_value={"approved_by": []}) + + reviews = await gitlab_adapter_with_mock_client.get_review_threads( + gitlab_repo_ref, identity + ) + + assert reviews == [] + + @pytest.mark.asyncio + async def test_degrades_to_empty_list_on_confirmed_404_and_caches( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=42 + ) + approvals_response = httpx.Response( + 404, + request=httpx.Request( + "GET", + "https://gitlab.com/api/v4/projects/test%2Frepo/merge_requests/42/approvals", + ), + ) + mock_gitlab_http_client.get_approvals = AsyncMock( + side_effect=httpx.HTTPStatusError( + "not found", request=approvals_response.request, response=approvals_response + ) + ) + mock_gitlab_http_client.get_merge_request = AsyncMock(return_value={"iid": 42}) + + reviews = await gitlab_adapter_with_mock_client.get_review_threads( + gitlab_repo_ref, identity + ) + assert reviews == [] + mock_gitlab_http_client.get_merge_request.assert_awaited_once_with("test/repo", 42) + + mock_gitlab_http_client.get_approvals.reset_mock() + reviews_again = await gitlab_adapter_with_mock_client.get_review_threads( + gitlab_repo_ref, identity + ) + assert reviews_again == [] + mock_gitlab_http_client.get_approvals.assert_not_awaited() + + @pytest.mark.asyncio + async def test_missing_mr_404_still_raises_and_does_not_poison_other_mrs( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity_missing = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=1 + ) + identity_real = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=2 + ) + + approvals_response = httpx.Response( + 404, + request=httpx.Request( + "GET", + "https://gitlab.com/api/v4/projects/test%2Frepo/merge_requests/1/approvals", + ), + ) + mr_response = httpx.Response( + 404, + request=httpx.Request( + "GET", "https://gitlab.com/api/v4/projects/test%2Frepo/merge_requests/1" + ), + ) + mock_gitlab_http_client.get_approvals = AsyncMock( + side_effect=httpx.HTTPStatusError( + "not found", request=approvals_response.request, response=approvals_response + ) + ) + mock_gitlab_http_client.get_merge_request = AsyncMock( + side_effect=httpx.HTTPStatusError( + "not found", request=mr_response.request, response=mr_response + ) + ) + + with pytest.raises(httpx.HTTPStatusError): + await gitlab_adapter_with_mock_client.get_review_threads( + gitlab_repo_ref, identity_missing + ) + + mock_gitlab_http_client.get_approvals = AsyncMock( + return_value={"approved_by": [{"user": {"id": 9, "username": "bob"}}]} + ) + reviews = await gitlab_adapter_with_mock_client.get_review_threads( + gitlab_repo_ref, identity_real + ) + assert len(reviews) == 1 + assert reviews[0].author == "bob" + + @pytest.mark.asyncio + async def test_non_404_error_on_approvals_still_raises( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=42 + ) + error_response = httpx.Response( + 500, + request=httpx.Request( + "GET", + "https://gitlab.com/api/v4/projects/test%2Frepo/merge_requests/42/approvals", + ), + ) + mock_gitlab_http_client.get_approvals = AsyncMock( + side_effect=httpx.HTTPStatusError( + "server error", request=error_response.request, response=error_response + ) + ) + + with pytest.raises(TransientProviderError): + await gitlab_adapter_with_mock_client.get_review_threads(gitlab_repo_ref, identity) + + @pytest.mark.asyncio + async def test_raises_on_missing_native_id_even_after_approvals_confirmed_unsupported( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + """The _approvals_supported=False short-circuit must not bypass + native_id validation -- a malformed identity should still raise, + not silently return [] once the capability cache is warmed.""" + gitlab_adapter_with_mock_client._approvals_supported = False + mock_gitlab_http_client.get_approvals = AsyncMock() + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=None + ) + + with pytest.raises(ValueError, match="native_id"): + await gitlab_adapter_with_mock_client.get_review_threads(gitlab_repo_ref, identity) + mock_gitlab_http_client.get_approvals.assert_not_awaited() + + +class TestGetReviewThreadComments: + @pytest.mark.asyncio + async def test_filters_to_unresolved_diff_notes( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=42 + ) + mock_gitlab_http_client.get_discussions = AsyncMock( + return_value=[ + { + "id": "disc-1", + "notes": [ + { + "id": 1, + "body": "fix this", + "author": {"username": "bob"}, + "type": "DiffNote", + "resolvable": True, + "resolved": False, + "position": {"new_path": "src/x.py", "new_line": 10}, + } + ], + }, + { + "id": "disc-2", + "notes": [ + { + "id": 2, + "body": "done", + "author": {"username": "carol"}, + "type": "DiffNote", + "resolvable": True, + "resolved": True, + } + ], + }, + { + "id": "disc-3", + "notes": [ + { + "id": 3, + "body": "general comment", + "author": {"username": "dave"}, + "type": None, + } + ], + }, + ] + ) + + reviews = await gitlab_adapter_with_mock_client.get_review_thread_comments( + gitlab_repo_ref, identity + ) + + assert [r.id for r in reviews] == ["disc-1"] + assert reviews[0].comments[0].body == "fix this" + assert reviews[0].comments[0].path == "src/x.py" + assert reviews[0].comments[0].line == 10 + + +class TestGetReviewCommentsForSubmission: + @pytest.mark.asyncio + async def test_returns_matching_discussion_notes( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=42 + ) + mock_gitlab_http_client.get_discussions = AsyncMock( + return_value=[ + {"id": "disc-1", "notes": [{"id": 1, "body": "x", "author": {"username": "bob"}}]} + ] + ) + + comments = await gitlab_adapter_with_mock_client.get_review_comments_for_submission( + gitlab_repo_ref, identity, review_id="disc-1" + ) + + assert len(comments) == 1 + assert comments[0].body == "x" + + @pytest.mark.asyncio + async def test_resolved_note_maps_resolved_true( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + """_map_note_response must surface GitLab's `resolved` field rather + than leaving ReviewComment.resolved at its dataclass default (False) + for every note, resolved or not.""" + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=42 + ) + mock_gitlab_http_client.get_discussions = AsyncMock( + return_value=[ + { + "id": "disc-1", + "notes": [ + { + "id": 1, + "body": "fixed now", + "author": {"username": "bob"}, + "resolved": True, + } + ], + } + ] + ) + + comments = await gitlab_adapter_with_mock_client.get_review_comments_for_submission( + gitlab_repo_ref, identity, review_id="disc-1" + ) + + assert len(comments) == 1 + assert comments[0].resolved is True + + @pytest.mark.asyncio + async def test_unmatched_id_returns_empty_list_not_error( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=42 + ) + mock_gitlab_http_client.get_discussions = AsyncMock(return_value=[]) + + comments = await gitlab_adapter_with_mock_client.get_review_comments_for_submission( + gitlab_repo_ref, identity, review_id="does-not-exist" + ) + + assert comments == [] + + +class TestGetChecks: + @pytest.mark.asyncio + async def test_maps_status_and_parses_job_id_from_target_url( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_commit_statuses = AsyncMock( + return_value=[ + { + "name": "build", + "status": "success", + "target_url": "https://gitlab.com/test/repo/-/jobs/987654", + } + ] + ) + + checks = await gitlab_adapter_with_mock_client.get_checks(gitlab_repo_ref, "abc123") + + mock_gitlab_http_client.get_commit_statuses.assert_awaited_once_with("test/repo", "abc123") + assert checks[0].status == CheckStatus.COMPLETED + assert checks[0].conclusion == CheckConclusion.SUCCESS + assert checks[0].logs_url == "987654" + + @pytest.mark.asyncio + async def test_external_ci_target_url_has_no_logs_url( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_commit_statuses = AsyncMock( + return_value=[ + { + "name": "jenkins", + "status": "success", + "target_url": "https://ci.example.com/build/1", + } + ] + ) + + checks = await gitlab_adapter_with_mock_client.get_checks(gitlab_repo_ref, "abc123") + + assert checks[0].logs_url is None + + @pytest.mark.asyncio + async def test_manual_status_maps_to_queued_none( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_commit_statuses = AsyncMock( + return_value=[{"name": "deploy", "status": "manual", "target_url": None}] + ) + + checks = await gitlab_adapter_with_mock_client.get_checks(gitlab_repo_ref, "abc123") + + assert checks[0].status == CheckStatus.QUEUED + assert checks[0].conclusion == CheckConclusion.NONE + + @pytest.mark.asyncio + @pytest.mark.parametrize("gitlab_status", ["failed", "canceled", "manual"]) + async def test_allowed_failure_terminal_status_maps_to_neutral( + self, + gitlab_adapter_with_mock_client, + gitlab_repo_ref, + mock_gitlab_http_client, + gitlab_status, + ): + mock_gitlab_http_client.get_commit_statuses = AsyncMock( + return_value=[ + { + "name": "optional-build", + "status": gitlab_status, + "allow_failure": True, + "target_url": None, + } + ] + ) + + checks = await gitlab_adapter_with_mock_client.get_checks(gitlab_repo_ref, "abc123") + + assert checks[0].status == CheckStatus.COMPLETED + assert checks[0].conclusion == CheckConclusion.NEUTRAL + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("gitlab_status", "allow_failure", "expected_status"), + [ + ("running", False, CheckStatus.IN_PROGRESS), + ("pending", False, CheckStatus.QUEUED), + ("pending", True, CheckStatus.QUEUED), + ], + ) + async def test_active_statuses_preserve_their_normal_mapping( + self, + gitlab_adapter_with_mock_client, + gitlab_repo_ref, + mock_gitlab_http_client, + gitlab_status, + allow_failure, + expected_status, + ): + mock_gitlab_http_client.get_commit_statuses = AsyncMock( + return_value=[ + { + "name": "build", + "status": gitlab_status, + "allow_failure": allow_failure, + "target_url": None, + } + ] + ) + + checks = await gitlab_adapter_with_mock_client.get_checks(gitlab_repo_ref, "abc123") + + assert checks[0].status == expected_status + assert checks[0].conclusion == CheckConclusion.NONE + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("gitlab_status", "expected_status", "expected_conclusion"), + [ + ("failed", CheckStatus.COMPLETED, CheckConclusion.FAILURE), + ("canceled", CheckStatus.COMPLETED, CheckConclusion.CANCELLED), + ("skipped", CheckStatus.COMPLETED, CheckConclusion.SKIPPED), + ], + ) + async def test_maps_terminal_statuses( + self, + gitlab_adapter_with_mock_client, + gitlab_repo_ref, + mock_gitlab_http_client, + gitlab_status, + expected_status, + expected_conclusion, + ): + mock_gitlab_http_client.get_commit_statuses = AsyncMock( + return_value=[{"name": "build", "status": gitlab_status, "target_url": None}] + ) + + checks = await gitlab_adapter_with_mock_client.get_checks(gitlab_repo_ref, "abc123") + + assert checks[0].status == expected_status + assert checks[0].conclusion == expected_conclusion + + @pytest.mark.asyncio + async def test_fork_mode_queries_source_project_not_upstream( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + """A fork-mode MR runs its pipeline in the source (fork) project, not + repo_ref.namespace (the upstream/target project) -- querying the + upstream for a fork commit's statuses returns nothing and CI + evaluation never progresses. get_change_request must have primed the + source-project cache (via _map_change_request) before get_checks is + called, mirroring how ci_evaluator always calls them in sequence.""" + mock_gitlab_http_client.get_merge_request = AsyncMock( + return_value={ + "iid": 5, + "title": "T", + "description": "", + "state": "opened", + "source_branch": "forge/test/repo", + "target_branch": "main", + "web_url": "u", + "source_project_id": 200, + "target_project_id": 100, + "last_commit": {"id": "abc123"}, + } + ) + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=5 + ) + await gitlab_adapter_with_mock_client.get_change_request(gitlab_repo_ref, identity) + + mock_gitlab_http_client.get_commit_statuses = AsyncMock( + return_value=[ + { + "name": "build", + "status": "success", + "target_url": "https://gitlab.com/forge-bot/repo/-/jobs/987654", + } + ] + ) + + checks = await gitlab_adapter_with_mock_client.get_checks(gitlab_repo_ref, "abc123") + + mock_gitlab_http_client.get_commit_statuses.assert_awaited_once_with("200", "abc123") + assert checks[0].logs_url == "987654" + + @pytest.mark.asyncio + async def test_same_project_mr_queries_upstream( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + """Direct-mode (non-fork) MRs have matching source/target project ids + -- must keep querying repo_ref.namespace, not start using the numeric + project id for every MR.""" + mock_gitlab_http_client.get_merge_request = AsyncMock( + return_value={ + "iid": 5, + "title": "T", + "description": "", + "state": "opened", + "source_branch": "forge/test/repo", + "target_branch": "main", + "web_url": "u", + "source_project_id": 100, + "target_project_id": 100, + "last_commit": {"id": "def456"}, + } + ) + identity = ChangeRequestIdentity( + connection="test-gitlab", repository_id="test/repo", native_id=5 + ) + await gitlab_adapter_with_mock_client.get_change_request(gitlab_repo_ref, identity) + + mock_gitlab_http_client.get_commit_statuses = AsyncMock(return_value=[]) + + await gitlab_adapter_with_mock_client.get_checks(gitlab_repo_ref, "def456") + + mock_gitlab_http_client.get_commit_statuses.assert_awaited_once_with("test/repo", "def456") + + +class TestGetCheckLogs: + @pytest.mark.asyncio + async def test_fetches_trace_directly_by_job_id( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_job_trace = AsyncMock(return_value="log output") + check = CheckRun( + name="build", + status=CheckStatus.COMPLETED, + conclusion=CheckConclusion.SUCCESS, + logs_url="987654", + ) + + logs = await gitlab_adapter_with_mock_client.get_check_logs(gitlab_repo_ref, check) + + mock_gitlab_http_client.get_job_trace.assert_awaited_once_with("test/repo", 987654) + assert logs == "log output" + + @pytest.mark.asyncio + async def test_raises_not_found_when_no_logs_url( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_job_trace = AsyncMock() + check = CheckRun( + name="jenkins", + status=CheckStatus.COMPLETED, + conclusion=CheckConclusion.SUCCESS, + logs_url=None, + ) + + with pytest.raises(SCNotFoundError, match="No logs available"): + await gitlab_adapter_with_mock_client.get_check_logs(gitlab_repo_ref, check) + mock_gitlab_http_client.get_job_trace.assert_not_awaited() + + @pytest.mark.asyncio + async def test_raises_source_control_error_for_non_numeric_logs_url( + self, + gitlab_adapter_with_mock_client, + gitlab_repo_ref, + mock_gitlab_http_client, # noqa: ARG002 + ): + check = CheckRun( + name="build", + status=CheckStatus.COMPLETED, + conclusion=CheckConclusion.SUCCESS, + logs_url="not-a-number", + ) + + with pytest.raises(SourceControlError, match="non-numeric logs_url"): + await gitlab_adapter_with_mock_client.get_check_logs(gitlab_repo_ref, check) + + @pytest.mark.asyncio + async def test_raises_not_found_when_trace_returns_404( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + response = httpx.Response( + 404, + request=httpx.Request( + "GET", "https://gitlab.com/api/v4/projects/test%2Frepo/jobs/987654/trace" + ), + ) + mock_gitlab_http_client.get_job_trace = AsyncMock( + side_effect=httpx.HTTPStatusError("boom", request=response.request, response=response) + ) + check = CheckRun( + name="build", + status=CheckStatus.COMPLETED, + conclusion=CheckConclusion.SUCCESS, + logs_url="987654", + ) + + with pytest.raises(SCNotFoundError): + await gitlab_adapter_with_mock_client.get_check_logs(gitlab_repo_ref, check) + + @pytest.mark.asyncio + async def test_fork_mode_fetches_trace_from_source_project( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + """The job id in logs_url only exists in the fork project that ran + it -- get_checks must have recorded which project each job id came + from (see get_checks) so get_check_logs queries that project rather + than the upstream, where the job doesn't exist.""" + mock_gitlab_http_client.get_commit_statuses = AsyncMock( + return_value=[ + { + "name": "build", + "status": "success", + "target_url": "https://gitlab.com/forge-bot/repo/-/jobs/987654", + } + ] + ) + gitlab_adapter_with_mock_client._source_project_by_ref[ + (gitlab_repo_ref.namespace, "abc123") + ] = "200" + checks = await gitlab_adapter_with_mock_client.get_checks(gitlab_repo_ref, "abc123") + mock_gitlab_http_client.get_job_trace = AsyncMock(return_value="log output") + + logs = await gitlab_adapter_with_mock_client.get_check_logs(gitlab_repo_ref, checks[0]) + + mock_gitlab_http_client.get_job_trace.assert_awaited_once_with("200", 987654) + assert logs == "log output" + + +class TestGetCheckArtifacts: + @pytest.mark.asyncio + async def test_returns_single_zip_entry( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_job_artifacts = AsyncMock(return_value=b"zipbytes") + check = CheckRun( + name="build", + status=CheckStatus.COMPLETED, + conclusion=CheckConclusion.SUCCESS, + logs_url="987654", + ) + + artifacts = await gitlab_adapter_with_mock_client.get_check_artifacts( + gitlab_repo_ref, check + ) + + assert artifacts == [("artifacts.zip", b"zipbytes")] + + @pytest.mark.asyncio + async def test_returns_empty_list_when_no_logs_url( + self, + gitlab_adapter_with_mock_client, + gitlab_repo_ref, + mock_gitlab_http_client, # noqa: ARG002 + ): + check = CheckRun( + name="jenkins", + status=CheckStatus.COMPLETED, + conclusion=CheckConclusion.SUCCESS, + logs_url=None, + ) + + assert ( + await gitlab_adapter_with_mock_client.get_check_artifacts(gitlab_repo_ref, check) == [] + ) + + @pytest.mark.asyncio + async def test_raises_source_control_error_for_non_numeric_logs_url( + self, + gitlab_adapter_with_mock_client, + gitlab_repo_ref, + mock_gitlab_http_client, # noqa: ARG002 + ): + check = CheckRun( + name="build", + status=CheckStatus.COMPLETED, + conclusion=CheckConclusion.SUCCESS, + logs_url="not-a-number", + ) + + with pytest.raises(SourceControlError, match="non-numeric logs_url"): + await gitlab_adapter_with_mock_client.get_check_artifacts(gitlab_repo_ref, check) + + @pytest.mark.asyncio + async def test_fork_mode_fetches_artifacts_from_source_project( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_commit_statuses = AsyncMock( + return_value=[ + { + "name": "build", + "status": "success", + "target_url": "https://gitlab.com/forge-bot/repo/-/jobs/987654", + } + ] + ) + gitlab_adapter_with_mock_client._source_project_by_ref[ + (gitlab_repo_ref.namespace, "abc123") + ] = "200" + checks = await gitlab_adapter_with_mock_client.get_checks(gitlab_repo_ref, "abc123") + mock_gitlab_http_client.get_job_artifacts = AsyncMock(return_value=b"zipbytes") + + artifacts = await gitlab_adapter_with_mock_client.get_check_artifacts( + gitlab_repo_ref, checks[0] + ) + + mock_gitlab_http_client.get_job_artifacts.assert_awaited_once_with("200", 987654) + assert artifacts == [("artifacts.zip", b"zipbytes")] + + +class TestGetFile: + @pytest.mark.asyncio + async def test_returns_raw_content( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_file_raw = AsyncMock(return_value="print('hi')\n") + + content = await gitlab_adapter_with_mock_client.get_file( + gitlab_repo_ref, "src/x.py", "main" + ) + + mock_gitlab_http_client.get_file_raw.assert_awaited_once_with( + "test/repo", "src/x.py", "main" + ) + assert content == "print('hi')\n" + + @pytest.mark.asyncio + async def test_raises_not_found_when_missing( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_file_raw = AsyncMock(return_value=None) + + with pytest.raises(SCNotFoundError): + await gitlab_adapter_with_mock_client.get_file(gitlab_repo_ref, "missing.py", "main") + + +class TestPutFile: + @pytest.mark.asyncio + async def test_updates_existing_file_with_last_commit_id( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_file_metadata = AsyncMock( + return_value={"last_commit_id": "deadbeef"} + ) + mock_gitlab_http_client.update_file = AsyncMock() + mock_gitlab_http_client.create_file = AsyncMock() + + await gitlab_adapter_with_mock_client.put_file( + gitlab_repo_ref, "src/x.py", "new content", "update x", "main" + ) + + mock_gitlab_http_client.update_file.assert_awaited_once_with( + "test/repo", + "src/x.py", + branch="main", + content="new content", + commit_message="update x", + last_commit_id="deadbeef", + ) + mock_gitlab_http_client.create_file.assert_not_awaited() + + @pytest.mark.asyncio + async def test_creates_new_file_when_absent( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_file_metadata = AsyncMock(return_value=None) + mock_gitlab_http_client.create_file = AsyncMock() + mock_gitlab_http_client.update_file = AsyncMock() + + await gitlab_adapter_with_mock_client.put_file( + gitlab_repo_ref, "new.py", "content", "add new", "main" + ) + + mock_gitlab_http_client.create_file.assert_awaited_once_with( + "test/repo", "new.py", branch="main", content="content", commit_message="add new" + ) + mock_gitlab_http_client.update_file.assert_not_awaited() + + @pytest.mark.asyncio + async def test_stale_commit_id_translates_to_conflict_error( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_file_metadata = AsyncMock( + return_value={"last_commit_id": "stale"} + ) + response = httpx.Response( + 400, + json={ + "message": "You are attempting to update a file that has changed since you started editing it" + }, + request=httpx.Request("PUT", "https://gitlab.com/api/v4/x"), + ) + mock_gitlab_http_client.update_file = AsyncMock( + side_effect=httpx.HTTPStatusError("boom", request=response.request, response=response) + ) + + with pytest.raises(ConflictError): + await gitlab_adapter_with_mock_client.put_file( + gitlab_repo_ref, "src/x.py", "content", "update", "main" + ) + + @pytest.mark.asyncio + async def test_unrelated_400_propagates_unchanged( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.get_file_metadata = AsyncMock(return_value={"last_commit_id": "x"}) + response = httpx.Response( + 400, + json={"message": "Path is invalid"}, + request=httpx.Request("PUT", "https://gitlab.com/api/v4/x"), + ) + mock_gitlab_http_client.update_file = AsyncMock( + side_effect=httpx.HTTPStatusError("boom", request=response.request, response=response) + ) + + with pytest.raises(httpx.HTTPStatusError): + await gitlab_adapter_with_mock_client.put_file( + gitlab_repo_ref, "src/x.py", "content", "update", "main" + ) + + +class TestCreateBranch: + @pytest.mark.asyncio + async def test_creates_branch( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + mock_gitlab_http_client.create_branch = AsyncMock() + + await gitlab_adapter_with_mock_client.create_branch(gitlab_repo_ref, "feature", "main") + + mock_gitlab_http_client.create_branch.assert_awaited_once_with( + "test/repo", "feature", "main" + ) + + @pytest.mark.asyncio + async def test_already_exists_is_swallowed_as_idempotent( + self, gitlab_adapter_with_mock_client, gitlab_repo_ref, mock_gitlab_http_client + ): + response = httpx.Response( + 400, + json={"message": "Branch already exists"}, + request=httpx.Request("POST", "https://gitlab.com/api/v4/x"), + ) + mock_gitlab_http_client.create_branch = AsyncMock( + side_effect=httpx.HTTPStatusError("boom", request=response.request, response=response) + ) + + await gitlab_adapter_with_mock_client.create_branch( + gitlab_repo_ref, "feature", "main" + ) # must not raise + + +def test_gitlab_adapter_satisfies_source_control_provider_protocol(gitlab_adapter_with_mock_client): + assert isinstance(gitlab_adapter_with_mock_client, SourceControlProvider) diff --git a/tests/unit/api/routes/test_gitlab_webhook.py b/tests/unit/api/routes/test_gitlab_webhook.py new file mode 100644 index 000000000..16c3cc117 --- /dev/null +++ b/tests/unit/api/routes/test_gitlab_webhook.py @@ -0,0 +1,349 @@ +"""Tests for the GitLab webhook route.""" + +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from forge.integrations.source_control.contracts import ( + Connection, + EventKind, + NormalizedEvent, + Provider, + RepositoryRef, + ResolvedRepository, +) +from forge.main import create_app + + +@pytest.fixture +def client(mock_settings): + with patch("forge.config.get_settings", return_value=mock_settings): + app = create_app() + yield TestClient(app) + + +def _mr_opened_payload() -> dict: + return { + "object_kind": "merge_request", + "user": {"username": "alice"}, + "project": {"path_with_namespace": "acme/widgets"}, + "object_attributes": { + "iid": 1, + "title": "AISOS-1: Test MR", + "description": "", + "state": "opened", + "action": "open", + "source_branch": "forge/AISOS-1", + "target_branch": "main", + "url": "https://gitlab.com/acme/widgets/-/merge_requests/1", + }, + } + + +def _push_payload(ref: str = "refs/heads/forge/AISOS-42") -> dict: + return { + "object_kind": "push", + "user_username": "alice", + "project": {"path_with_namespace": "acme/widgets"}, + "ref": ref, + } + + +def _note_on_mr_payload() -> dict: + """A note-webhook payload with the top-level `merge_request` object GitLab + always includes for notes on a merge request.""" + return { + "object_kind": "note", + "user": {"username": "alice"}, + "project": {"path_with_namespace": "acme/widgets"}, + "object_attributes": { + "id": 555, + "note": "/forge skip-gate flaky-test", + "noteable_type": "MergeRequest", + }, + "merge_request": { + "iid": 1, + "title": "AISOS-1: Test MR", + "description": "", + "state": "opened", + "source_branch": "forge/AISOS-1", + "target_branch": "main", + "url": "https://gitlab.com/acme/widgets/-/merge_requests/1", + }, + } + + +def _pipeline_without_mr_payload(ref: str = "forge/AISOS-77") -> dict: + """A pipeline-webhook payload for a plain-branch pipeline with no MR + attached -- the branch lives at object_attributes.ref, not a top-level + `ref` (unlike push events).""" + return { + "object_kind": "pipeline", + "user": {"username": "ci-bot"}, + "project": {"path_with_namespace": "acme/widgets"}, + "object_attributes": {"id": 999, "status": "running", "ref": ref}, + } + + +def _resolved_gitlab_connection() -> ResolvedRepository: + """A resolvable repos.yaml-shaped connection for acme/widgets, with its + webhook secret sourced from ACME_GITLAB_WEBHOOK_SECRET (set via + monkeypatch by the tests that need it). GitLab has no implicit default + connection, so tests that want to reach verify_webhook or the + publish_event call must patch Registry.resolve to return this.""" + return ResolvedRepository( + repo_ref=RepositoryRef( + id="widgets", + provider=Provider.GITLAB, + connection="acme-gitlab", + namespace="acme/widgets", + default_branch="main", + change_request_mode="direct", + ), + connection=Connection( + name="acme-gitlab", + provider=Provider.GITLAB, + base_url="https://gitlab.com/api/v4", + credential_env="ACME_GITLAB_TOKEN", + webhook_secret_env="ACME_GITLAB_WEBHOOK_SECRET", + ), + ) + + +class TestGitLabWebhook: + def test_rejects_invalid_token(self, client, monkeypatch): + """A resolvable connection with a known secret rejects a wrong token -- + this exercises verify_webhook's comparison, not resolve().""" + monkeypatch.setenv("ACME_GITLAB_WEBHOOK_SECRET", "correct-secret") + resolved = _resolved_gitlab_connection() + + with patch( + "forge.integrations.source_control.registry.Registry.resolve", + return_value=resolved, + ): + response = client.post( + "/api/v1/webhooks/gitlab", + json=_mr_opened_payload(), + headers={"X-Gitlab-Event": "Merge Request Hook", "X-Gitlab-Token": "wrong"}, + ) + + assert response.status_code == 401 + + def test_accepts_valid_token_and_queues_event(self, client, monkeypatch): + """A resolvable connection with a matching token succeeds end-to-end: + 202 + 'queued', and publish_event is actually called with a + NormalizedEvent built from the merge-request-opened payload.""" + monkeypatch.setenv("ACME_GITLAB_WEBHOOK_SECRET", "correct-secret") + resolved = _resolved_gitlab_connection() + + published: list[NormalizedEvent] = [] + + async def fake_publish_event( + _self: object, event: NormalizedEvent, _ticket_key: str + ) -> str: + published.append(event) + return "1-0" + + with ( + patch( + "forge.integrations.source_control.registry.Registry.resolve", + return_value=resolved, + ), + patch("forge.queue.producer.QueueProducer.publish_event", fake_publish_event), + ): + response = client.post( + "/api/v1/webhooks/gitlab", + json=_mr_opened_payload(), + headers={ + "X-Gitlab-Event": "Merge Request Hook", + "X-Gitlab-Token": "correct-secret", + }, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "queued" + assert body["ticket_key"] == "AISOS-1" + + assert len(published) == 1 + assert published[0].kind == EventKind.CR_OPENED + assert published[0].repo_ref.namespace == "acme/widgets" + + def test_push_event_extracts_ticket_key_from_ref(self, client, monkeypatch): + """A push event has no change_request (GitLabAdapter.parse_webhook + doesn't populate one for push events), so the ticket key must come + from the raw payload's top-level `ref` -- mirroring the GitHub + route's raw.get("ref", "") fallback exactly.""" + monkeypatch.setenv("ACME_GITLAB_WEBHOOK_SECRET", "correct-secret") + resolved = _resolved_gitlab_connection() + + published: list[NormalizedEvent] = [] + + async def fake_publish_event( + _self: object, event: NormalizedEvent, _ticket_key: str + ) -> str: + published.append(event) + return "1-0" + + with ( + patch( + "forge.integrations.source_control.registry.Registry.resolve", + return_value=resolved, + ), + patch("forge.queue.producer.QueueProducer.publish_event", fake_publish_event), + ): + response = client.post( + "/api/v1/webhooks/gitlab", + json=_push_payload("refs/heads/forge/AISOS-42"), + headers={ + "X-Gitlab-Event": "Push Hook", + "X-Gitlab-Token": "correct-secret", + }, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "queued" + assert body["ticket_key"] == "AISOS-42" + + assert len(published) == 1 + assert published[0].kind == EventKind.PUSH + assert published[0].change_request is None + + def test_mr_comment_extracts_ticket_key_from_merge_request_object(self, client, monkeypatch): + """An MR-note webhook payload has no top-level `ref` to fall back to; + without GitLabAdapter.parse_webhook populating change_request from the + payload's top-level `merge_request` object, _extract_ticket_key + returns "" and worker.py drops the event before any workflow gate + (including /forge skip-gate, /forge rebase, and the PRD/spec + proposal-PR comment gates) ever sees it.""" + monkeypatch.setenv("ACME_GITLAB_WEBHOOK_SECRET", "correct-secret") + resolved = _resolved_gitlab_connection() + + published: list[NormalizedEvent] = [] + + async def fake_publish_event( + _self: object, event: NormalizedEvent, _ticket_key: str + ) -> str: + published.append(event) + return "1-0" + + with ( + patch( + "forge.integrations.source_control.registry.Registry.resolve", + return_value=resolved, + ), + patch("forge.queue.producer.QueueProducer.publish_event", fake_publish_event), + ): + response = client.post( + "/api/v1/webhooks/gitlab", + json=_note_on_mr_payload(), + headers={ + "X-Gitlab-Event": "Note Hook", + "X-Gitlab-Token": "correct-secret", + }, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "queued" + assert body["ticket_key"] == "AISOS-1" + assert body["ticket_key"] != "" + + assert len(published) == 1 + assert published[0].kind == EventKind.COMMENT_CREATED + assert published[0].change_request is not None + assert published[0].change_request.identity.native_id == 1 + + def test_pipeline_without_mr_extracts_ticket_key_from_object_attributes_ref( + self, client, monkeypatch + ): + """A plain-branch pipeline (no MR attached) carries its branch at + object_attributes.ref, not a top-level `ref` -- the push-event + fallback alone would miss it.""" + monkeypatch.setenv("ACME_GITLAB_WEBHOOK_SECRET", "correct-secret") + resolved = _resolved_gitlab_connection() + + published: list[NormalizedEvent] = [] + + async def fake_publish_event( + _self: object, event: NormalizedEvent, _ticket_key: str + ) -> str: + published.append(event) + return "1-0" + + with ( + patch( + "forge.integrations.source_control.registry.Registry.resolve", + return_value=resolved, + ), + patch("forge.queue.producer.QueueProducer.publish_event", fake_publish_event), + ): + response = client.post( + "/api/v1/webhooks/gitlab", + json=_pipeline_without_mr_payload("forge/AISOS-77"), + headers={ + "X-Gitlab-Event": "Pipeline Hook", + "X-Gitlab-Token": "correct-secret", + }, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "queued" + assert body["ticket_key"] == "AISOS-77" + + assert len(published) == 1 + assert published[0].change_request is None + + def test_ignored_event_returns_delivery_uuid_for_correlation(self, client, monkeypatch): + """When parse_webhook drops the event as unmanaged, the response still + carries GitLab's delivery UUID so the discarded event can be correlated + in logs rather than returning an empty event_id.""" + from forge.integrations.source_control.errors import NotFoundError + + monkeypatch.setenv("ACME_GITLAB_WEBHOOK_SECRET", "correct-secret") + resolved = _resolved_gitlab_connection() + + with ( + patch( + "forge.integrations.source_control.registry.Registry.resolve", + return_value=resolved, + ), + patch( + "forge.integrations.source_control.gitlab.adapter.GitLabAdapter.parse_webhook", + side_effect=NotFoundError("unmanaged"), + ), + ): + response = client.post( + "/api/v1/webhooks/gitlab", + json=_mr_opened_payload(), + headers={ + "X-Gitlab-Event": "Merge Request Hook", + "X-Gitlab-Token": "correct-secret", + "X-Gitlab-Event-UUID": "delivery-abc-123", + }, + ) + + assert response.status_code == 202 + body = response.json() + assert body["status"] == "ignored" + assert body["event_id"] == "delivery-abc-123" + + def test_unmanaged_repository_is_rejected_not_acked(self, client): + """No connection is configured for this namespace and GitLab has no + implicit default (unlike GitHub, which acks-and-drops unmanaged repos + via its implicit default connection). resolve() raises NotFoundError, + which the route treats as a 401 -- there is no unauthenticated + ack-and-drop path for GitLab.""" + payload = _mr_opened_payload() + payload["project"]["path_with_namespace"] = "totally/unmanaged" + + response = client.post( + "/api/v1/webhooks/gitlab", + json=payload, + headers={"X-Gitlab-Event": "Merge Request Hook", "X-Gitlab-Token": ""}, + ) + + assert response.status_code == 401 diff --git a/tests/unit/integrations/gitlab/__init__.py b/tests/unit/integrations/gitlab/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/unit/integrations/gitlab/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/unit/integrations/gitlab/test_client.py b/tests/unit/integrations/gitlab/test_client.py new file mode 100644 index 000000000..3307b93ad --- /dev/null +++ b/tests/unit/integrations/gitlab/test_client.py @@ -0,0 +1,666 @@ +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from forge.integrations.gitlab.client import GitLabClient +from forge.integrations.source_control.errors import SourceControlError, TransientProviderError + + +def test_default_base_url_is_gitlab_com(): + client = GitLabClient(credential="tok") + assert client.base_url == "https://gitlab.com/api/v4" + + +@pytest.mark.parametrize( + ("configured_url", "expected_url"), + [ + ("https://gitlab.example.com", "https://gitlab.example.com/api/v4"), + ("https://gitlab.example.com/", "https://gitlab.example.com/api/v4"), + ( + "https://gitlab.example.com/gitlab", + "https://gitlab.example.com/gitlab/api/v4", + ), + ( + "https://gitlab.example.com/gitlab/api/v4/", + "https://gitlab.example.com/gitlab/api/v4", + ), + ], +) +def test_custom_base_url_is_normalized_to_gitlab_v4_api(configured_url, expected_url): + """Catch requests sent to a GitLab HTML/path root instead of its REST API.""" + client = GitLabClient(credential="tok", base_url=configured_url) + + assert client.base_url == expected_url + + +def test_custom_api_base_url_and_ca_path_are_respected(): + client = GitLabClient( + credential="tok", base_url="https://gitlab.example.com/api/v4", ca_path="/ca.pem" + ) + assert client.base_url == "https://gitlab.example.com/api/v4" + assert client._ca_path == "/ca.pem" + + +@pytest.mark.asyncio +async def test_get_client_sends_private_token_header(): + client = GitLabClient(credential="glpat-secret") + http_client = await client._get_client() + assert http_client.headers["PRIVATE-TOKEN"] == "glpat-secret" + + +@pytest.mark.asyncio +async def test_get_project_encodes_namespace_and_returns_json(): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.json.return_value = {"id": 42, "path_with_namespace": "group/sub/proj"} + response.raise_for_status = MagicMock() + client._client.get = AsyncMock(return_value=response) + + result = await client.get_project("group/sub/proj") + + client._client.get.assert_awaited_once_with("/projects/group%2Fsub%2Fproj") + assert result["id"] == 42 + + +@pytest.mark.asyncio +async def test_get_authenticated_user(): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.json.return_value = {"username": "octocat-gl"} + response.raise_for_status = MagicMock() + client._client.get = AsyncMock(return_value=response) + + result = await client.get_authenticated_user() + + client._client.get.assert_awaited_once_with("/user") + assert result["username"] == "octocat-gl" + + +@pytest.mark.asyncio +async def test_close_closes_the_http_client(): + client = GitLabClient(credential="tok") + await client._get_client() + mock_aclose = AsyncMock() + client._client.aclose = mock_aclose + + await client.close() + + mock_aclose.assert_awaited_once() + + +class TestGetFork: + @pytest.mark.asyncio + async def test_matches_fork_by_owning_namespace_not_upstream_path(self): + """GitLab appends a suffix (e.g. repo1) when a same-named project already + exists under the fork owner, so the fork is found by its namespace, not + by assuming it kept the upstream project's path.""" + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = [ + {"id": 1, "path": "repo1", "namespace": {"full_path": "someone-else"}}, + {"id": 2, "path": "repo1", "namespace": {"full_path": "forge-bot"}}, + ] + client._client.get = AsyncMock(return_value=response) + + fork = await client.get_fork("upstream/repo", "forge-bot") + + client._client.get.assert_awaited_once_with( + "/projects/upstream%2Frepo/forks", params={"page": 1, "per_page": 100} + ) + assert fork["id"] == 2 + + @pytest.mark.asyncio + async def test_returns_none_when_no_fork_under_owner(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = [ + {"id": 1, "path": "repo", "namespace": {"full_path": "someone-else"}}, + ] + client._client.get = AsyncMock(return_value=response) + + assert await client.get_fork("upstream/repo", "forge-bot") is None + + @pytest.mark.asyncio + async def test_paginates_until_short_page(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + + page1 = MagicMock() + page1.raise_for_status = MagicMock() + page1.json.return_value = [ + {"id": i, "namespace": {"full_path": "someone-else"}} for i in range(100) + ] + page2 = MagicMock() + page2.raise_for_status = MagicMock() + page2.json.return_value = [{"id": 999, "namespace": {"full_path": "forge-bot"}}] + client._client.get = AsyncMock(side_effect=[page1, page2]) + + fork = await client.get_fork("upstream/repo", "forge-bot") + + assert fork["id"] == 999 + assert client._client.get.await_count == 2 + + +class TestGetOrCreateFork: + @pytest.mark.asyncio + async def test_returns_existing_fork_without_creating(self): + client = GitLabClient(credential="tok") + client.get_fork = AsyncMock(return_value={"id": 7, "import_status": "finished"}) + client.create_fork = AsyncMock() + + fork = await client.get_or_create_fork("upstream/repo", fork_owner="forge-bot") + + client.create_fork.assert_not_awaited() + assert fork["id"] == 7 + + @pytest.mark.asyncio + async def test_creates_and_waits_for_import_finished(self, monkeypatch): + client = GitLabClient(credential="tok") + client.get_fork = AsyncMock(return_value=None) + client.create_fork = AsyncMock(return_value={"id": 9, "import_status": "scheduled"}) + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + + poll_responses = [ + {"id": 9, "import_status": "started"}, + {"id": 9, "import_status": "finished"}, + ] + + async def _fake_get(_path, **_kwargs): + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = poll_responses.pop(0) + return response + + client._client.get = AsyncMock(side_effect=_fake_get) + sleeps = [] + monkeypatch.setattr( + "forge.integrations.gitlab.client.asyncio.sleep", + AsyncMock(side_effect=lambda s: sleeps.append(s)), + ) + + fork = await client.get_or_create_fork("upstream/repo", fork_owner="forge-bot") + + assert fork["import_status"] == "finished" + assert sleeps == [2, 2] + + @pytest.mark.asyncio + async def test_raises_when_existing_fork_import_failed(self): + client = GitLabClient(credential="tok") + client.get_fork = AsyncMock( + return_value={ + "id": 7, + "path_with_namespace": "forge-bot/repo", + "import_status": "failed", + } + ) + client.create_fork = AsyncMock() + + with pytest.raises(SourceControlError, match="failed"): + await client.get_or_create_fork("upstream/repo", fork_owner="forge-bot") + client.create_fork.assert_not_awaited() + + @pytest.mark.asyncio + async def test_raises_when_newly_created_fork_import_fails(self, monkeypatch): + client = GitLabClient(credential="tok") + client.get_fork = AsyncMock(return_value=None) + client.create_fork = AsyncMock(return_value={"id": 9, "import_status": "scheduled"}) + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = {"id": 9, "import_status": "failed"} + client._client.get = AsyncMock(return_value=response) + monkeypatch.setattr( + "forge.integrations.gitlab.client.asyncio.sleep", + AsyncMock(), + ) + + with pytest.raises(SourceControlError, match="failed to import"): + await client.get_or_create_fork("upstream/repo", fork_owner="forge-bot") + + @pytest.mark.asyncio + async def test_raises_transient_error_when_import_never_finishes(self, monkeypatch): + client = GitLabClient(credential="tok") + client.get_fork = AsyncMock(return_value=None) + client.create_fork = AsyncMock(return_value={"id": 9, "import_status": "scheduled"}) + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = {"id": 9, "import_status": "started"} + client._client.get = AsyncMock(return_value=response) + monkeypatch.setattr( + "forge.integrations.gitlab.client.asyncio.sleep", + AsyncMock(), + ) + + with pytest.raises(TransientProviderError, match="not ready"): + await client.get_or_create_fork( + "upstream/repo", fork_owner="forge-bot", max_wait_seconds=2 + ) + + +class TestCreateMergeRequest: + @pytest.mark.asyncio + async def test_creates_mr_in_source_project(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = {"iid": 5, "title": "Test"} + client._client.post = AsyncMock(return_value=response) + + result = await client.create_merge_request( + "forge-bot/repo", + source_branch="feature", + target_branch="main", + title="Test", + description="body", + target_project_id=100, + ) + + client._client.post.assert_awaited_once_with( + "/projects/forge-bot%2Frepo/merge_requests", + json={ + "source_branch": "feature", + "target_branch": "main", + "title": "Test", + "description": "body", + "target_project_id": 100, + }, + ) + assert result["iid"] == 5 + + @pytest.mark.asyncio + async def test_get_merge_requests_filters_by_source_branch_and_state(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = [{"iid": 7, "source_branch": "feature"}] + client._client.get = AsyncMock(return_value=response) + + result = await client.get_merge_requests("test/repo", source_branch="feature") + + client._client.get.assert_awaited_once_with( + "/projects/test%2Frepo/merge_requests", + params={"source_branch": "feature", "state": "opened"}, + ) + assert result == [{"iid": 7, "source_branch": "feature"}] + + @pytest.mark.asyncio + async def test_get_merge_requests_filters_by_source_project_and_target_branch(self): + """Catch duplicate/MR selection across fork sources or target branches.""" + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = [{"iid": 7, "source_branch": "feature"}] + client._client.get = AsyncMock(return_value=response) + + await client.get_merge_requests( + "test/repo", + source_branch="feature", + source_project_id=123, + target_branch="main", + ) + + client._client.get.assert_awaited_once_with( + "/projects/test%2Frepo/merge_requests", + params={ + "source_branch": "feature", + "state": "opened", + "source_project_id": 123, + "target_branch": "main", + }, + ) + + +class TestNotesAndDiscussions: + @pytest.mark.asyncio + async def test_create_note(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = {"id": 1, "body": "hi"} + client._client.post = AsyncMock(return_value=response) + + result = await client.create_note("test/repo", 42, "hi") + + client._client.post.assert_awaited_once_with( + "/projects/test%2Frepo/merge_requests/42/notes", json={"body": "hi"} + ) + assert result["id"] == 1 + + @pytest.mark.asyncio + async def test_get_discussions(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = [{"id": "abc"}] + client._client.get = AsyncMock(return_value=response) + + result = await client.get_discussions("test/repo", 42) + + client._client.get.assert_awaited_once_with( + "/projects/test%2Frepo/merge_requests/42/discussions", + params={"page": 1, "per_page": 100}, + ) + assert result == [{"id": "abc"}] + + @pytest.mark.asyncio + async def test_get_discussions_paginates_past_the_first_page(self): + """GitLab returns at most 100 discussions per page; without pagination + replying to a comment or reading review threads beyond the first page + would silently miss them (or 404 on reply).""" + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + + page1 = MagicMock() + page1.raise_for_status = MagicMock() + page1.json.return_value = [{"id": f"d{i}"} for i in range(100)] + page2 = MagicMock() + page2.raise_for_status = MagicMock() + page2.json.return_value = [{"id": "d100"}] + client._client.get = AsyncMock(side_effect=[page1, page2]) + + result = await client.get_discussions("test/repo", 42) + + assert len(result) == 101 + assert result[-1] == {"id": "d100"} + assert client._client.get.await_count == 2 + + @pytest.mark.asyncio + async def test_reply_to_discussion(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = {"id": 2, "body": "reply"} + client._client.post = AsyncMock(return_value=response) + + await client.reply_to_discussion("test/repo", 42, "abc", "reply") + + client._client.post.assert_awaited_once_with( + "/projects/test%2Frepo/merge_requests/42/discussions/abc/notes", json={"body": "reply"} + ) + + @pytest.mark.asyncio + async def test_get_approvals(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = {"approved_by": [{"user": {"id": 1, "username": "alice"}}]} + client._client.get = AsyncMock(return_value=response) + + result = await client.get_approvals("test/repo", 42) + + client._client.get.assert_awaited_once_with( + "/projects/test%2Frepo/merge_requests/42/approvals" + ) + assert result["approved_by"][0]["user"]["username"] == "alice" + + +class TestChecks: + @pytest.mark.asyncio + async def test_get_commit_statuses(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = [{"name": "build", "status": "success"}] + client._client.get = AsyncMock(return_value=response) + + result = await client.get_commit_statuses("test/repo", "abc123") + + client._client.get.assert_awaited_once_with( + "/projects/test%2Frepo/repository/commits/abc123/statuses", + params={"page": 1, "per_page": 100}, + ) + assert result[0]["name"] == "build" + + @pytest.mark.asyncio + async def test_get_commit_statuses_encodes_multi_segment_ref(self): + """ci_evaluator.py falls back to change_request.source_branch when + head_sha is unavailable, and this codebase's own branches are + multi-segment (forge/, per ensure_write_target). An + unencoded ref with a `/` produces a malformed path GitLab 404s on.""" + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = [] + client._client.get = AsyncMock(return_value=response) + + await client.get_commit_statuses("test/repo", "forge/acme/widgets") + + client._client.get.assert_awaited_once_with( + "/projects/test%2Frepo/repository/commits/forge%2Facme%2Fwidgets/statuses", + params={"page": 1, "per_page": 100}, + ) + + @pytest.mark.asyncio + async def test_get_commit_statuses_paginates_until_short_page(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + + page1_items = [{"name": f"job{i}", "status": "success"} for i in range(100)] + page2_items = [{"name": "job100", "status": "success"}] + + page1_response = MagicMock() + page1_response.raise_for_status = MagicMock() + page1_response.json.return_value = page1_items + + page2_response = MagicMock() + page2_response.raise_for_status = MagicMock() + page2_response.json.return_value = page2_items + + client._client.get = AsyncMock(side_effect=[page1_response, page2_response]) + + result = await client.get_commit_statuses("test/repo", "abc123") + + assert result == page1_items + page2_items + assert client._client.get.await_count == 2 + client._client.get.assert_any_await( + "/projects/test%2Frepo/repository/commits/abc123/statuses", + params={"page": 1, "per_page": 100}, + ) + client._client.get.assert_any_await( + "/projects/test%2Frepo/repository/commits/abc123/statuses", + params={"page": 2, "per_page": 100}, + ) + + @pytest.mark.asyncio + async def test_get_job_trace_returns_raw_text(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + response.text = "line1\nline2\n" + client._client.get = AsyncMock(return_value=response) + + logs = await client.get_job_trace("test/repo", 987654) + + client._client.get.assert_awaited_once_with("/projects/test%2Frepo/jobs/987654/trace") + assert logs == "line1\nline2\n" + + @pytest.mark.asyncio + async def test_get_job_artifacts_returns_bytes(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.status_code = 200 + response.raise_for_status = MagicMock() + response.content = b"PK\x03\x04zipbytes" + client._client.get = AsyncMock(return_value=response) + + artifacts = await client.get_job_artifacts("test/repo", 987654) + + assert artifacts == b"PK\x03\x04zipbytes" + + @pytest.mark.asyncio + async def test_get_job_artifacts_returns_none_on_404(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.status_code = 404 + client._client.get = AsyncMock(return_value=response) + + artifacts = await client.get_job_artifacts("test/repo", 987654) + + assert artifacts is None + + +class TestFileOperations: + @pytest.mark.asyncio + async def test_get_file_raw_returns_text_on_200(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.status_code = 200 + response.raise_for_status = MagicMock() + response.text = "print('hi')\n" + client._client.get = AsyncMock(return_value=response) + + content = await client.get_file_raw("test/repo", "src/x.py", "main") + + client._client.get.assert_awaited_once_with( + "/projects/test%2Frepo/repository/files/src%2Fx.py/raw", params={"ref": "main"} + ) + assert content == "print('hi')\n" + + @pytest.mark.asyncio + async def test_get_file_raw_returns_none_on_404(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.status_code = 404 + client._client.get = AsyncMock(return_value=response) + + assert await client.get_file_raw("test/repo", "missing.py", "main") is None + + @pytest.mark.asyncio + async def test_get_file_metadata_returns_last_commit_id(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.status_code = 200 + response.raise_for_status = MagicMock() + response.headers = {"X-Gitlab-Last-Commit-Id": "deadbeef"} + client._client.head = AsyncMock(return_value=response) + + metadata = await client.get_file_metadata("test/repo", "src/x.py", "main") + + client._client.head.assert_awaited_once_with( + "/projects/test%2Frepo/repository/files/src%2Fx.py", params={"ref": "main"} + ) + client._client.get.assert_not_called() + assert metadata["last_commit_id"] == "deadbeef" + + @pytest.mark.asyncio + async def test_get_file_metadata_returns_none_on_404(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.status_code = 404 + client._client.head = AsyncMock(return_value=response) + + assert await client.get_file_metadata("test/repo", "missing.py", "main") is None + + @pytest.mark.asyncio + async def test_create_file(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + client._client.post = AsyncMock(return_value=response) + + await client.create_file( + "test/repo", "new.py", branch="main", content="x = 1", commit_message="add file" + ) + + client._client.post.assert_awaited_once_with( + "/projects/test%2Frepo/repository/files/new.py", + json={"branch": "main", "content": "x = 1", "commit_message": "add file"}, + ) + + @pytest.mark.asyncio + async def test_update_file_passes_last_commit_id(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + client._client.put = AsyncMock(return_value=response) + + await client.update_file( + "test/repo", + "existing.py", + branch="main", + content="x = 2", + commit_message="update", + last_commit_id="deadbeef", + ) + + client._client.put.assert_awaited_once_with( + "/projects/test%2Frepo/repository/files/existing.py", + json={ + "branch": "main", + "content": "x = 2", + "commit_message": "update", + "last_commit_id": "deadbeef", + }, + ) + + @pytest.mark.asyncio + async def test_create_branch(self): + client = GitLabClient(credential="tok") + client._client = AsyncMock(spec=httpx.AsyncClient) + client._client.is_closed = False + response = MagicMock() + response.raise_for_status = MagicMock() + client._client.post = AsyncMock(return_value=response) + + await client.create_branch("test/repo", "feature", "main") + + client._client.post.assert_awaited_once_with( + "/projects/test%2Frepo/repository/branches", + json={"branch": "feature", "ref": "main"}, + ) diff --git a/tests/unit/integrations/source_control/gitlab/test_factory_registration.py b/tests/unit/integrations/source_control/gitlab/test_factory_registration.py new file mode 100644 index 000000000..36a6e937a --- /dev/null +++ b/tests/unit/integrations/source_control/gitlab/test_factory_registration.py @@ -0,0 +1,64 @@ +from forge.integrations.source_control.contracts import Connection, Provider +from forge.integrations.source_control.gitlab import GitLabAdapter +from forge.integrations.source_control.registry import _ADAPTER_FACTORIES + + +def test_gitlab_factory_registered_on_import(): + factory = _ADAPTER_FACTORIES.get(Provider.GITLAB) + assert factory is not None + conn = Connection( + name="c", + provider=Provider.GITLAB, + base_url="https://gitlab.com/api/v4", + credential_env="GITLAB_TOKEN", + webhook_secret_env="GITLAB_WEBHOOK_SECRET", + ) + adapter = factory(conn) + assert isinstance(adapter, GitLabAdapter) + + +def test_factory_resolves_credential_env_via_os_environ(monkeypatch): + """GitLab has no dedicated Settings field, so credential_env resolution + must fall back to os.environ (resolve_env_value's documented fallback + for names Settings doesn't model).""" + monkeypatch.setenv("GITLAB_TOKEN", "glpat-from-env") + factory = _ADAPTER_FACTORIES[Provider.GITLAB] + conn = Connection( + name="c", + provider=Provider.GITLAB, + base_url="https://gitlab.com/api/v4", + credential_env="GITLAB_TOKEN", + webhook_secret_env="GITLAB_WEBHOOK_SECRET", + ) + + adapter = factory(conn) + + assert adapter._credential == "glpat-from-env" + + +def test_registry_resolve_returns_gitlab_adapter_for_explicit_repository( + mock_settings, monkeypatch, tmp_path +): + from forge.integrations.source_control.registry import load_registry + + monkeypatch.setenv("GITLAB_TOKEN", "glpat-from-env") + repos_yaml = tmp_path / "repos.yaml" + repos_yaml.write_text( + """ +connections: + gitlab-main: + provider: gitlab + base_url: https://gitlab.com/api/v4 + credential_env: GITLAB_TOKEN +repositories: + acme-gitlab: + provider: gitlab + connection: gitlab-main + namespace: acme/widgets +""" + ) + + registry = load_registry(config_path=repos_yaml, settings=mock_settings) + resolved = registry.resolve("acme-gitlab") + + assert isinstance(resolved.adapter, GitLabAdapter)