Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
10 changes: 10 additions & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/forge/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -10,6 +11,7 @@
__all__ = [
"executions_router",
"github_router",
"gitlab_router",
"effects_router",
"health_router",
"jira_router",
Expand Down
160 changes: 160 additions & 0 deletions src/forge/api/routes/gitlab.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions src/forge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
5 changes: 5 additions & 0 deletions src/forge/integrations/gitlab/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""GitLab integration for MR management and webhook handling."""

from forge.integrations.gitlab.client import GitLabClient

__all__ = ["GitLabClient"]
Loading
Loading