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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
pull_request:
branches:
- main
- integration/dev-to-main
- prototype/declarative-workflows

jobs:
helm-lint:
Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,12 @@ podman rm $(podman ps -a --filter name=forge- -q)
| `/forge unskip-gate <name>` | PR comment | Remove a previously set skip |
| `/forge rebase` | PR comment | Merge main into PR branch, resolving conflicts with AI |

Skip-gate commands are only active at CI stages (`wait_for_ci_gate`, `ci_evaluator`, `attempt_ci_fix`). Rebase works from any workflow stage.
Skip-gate commands are only active at CI stages (`ci_evaluator`, `attempt_ci_fix`, `human_review_gate`). Rebase works from any workflow stage.

## Source Control Registry (repos.yaml)

`load_registry()` parses the optional `repos.yaml`-shaped config referenced by `settings.forge_repos_config_path`, resolving repository identifiers to connections and adapters. `get_registry()` caches the result for the life of the process (`@lru_cache`) — editing `repos.yaml` requires a **process restart** to take effect; there is no runtime reload. A misconfigured `repos.yaml` (unknown provider, missing `credential_env`, etc.) fails app startup rather than the first inbound webhook.


## PRD & Spec Approval via GitHub PR

Expand Down
6 changes: 4 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Forge Product Roadmap

**Status:** Draft for discussion
**Planning horizon:** Outcome-based; dates and release assignments are intentionally TBD
**Status:** Draft for discussion

**Planning horizon:** Outcome-based; dates and release assignments are intentionally TBD

**Last reviewed:** 2026-08-10

## Product direction
Expand Down
160 changes: 97 additions & 63 deletions src/forge/api/routes/github.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""GitHub webhook endpoint for receiving repository events."""

import hashlib
import hmac
import json
import logging
import re
from typing import Any

from fastapi import APIRouter, Header, HTTPException, Request, status
Expand All @@ -13,20 +14,56 @@
record_webhook_received,
)
from forge.config import get_settings
from forge.integrations.github.webhooks import (
create_github_webhook_event,
parse_github_webhook,
)
from forge.models.events import EventSource
from forge.integrations.source_control.contracts import Connection, NormalizedEvent, Provider
from forge.integrations.source_control.errors import NotFoundError, ProviderConfigError
from forge.integrations.source_control.github.adapter import GitHubAdapter
from forge.integrations.source_control.registry import get_registry, resolve_env_value
from forge.observability.config import get_tracer
from forge.observability.context import get_correlation_id
from forge.queue.producer import QueueProducer

_DEFAULT_GITHUB_CONNECTION = Connection(
name="default-github",
provider=Provider.GITHUB,
base_url="https://api.github.com",
credential_env="GITHUB_TOKEN",
webhook_secret_env="GITHUB_WEBHOOK_SECRET",
)

logger = logging.getLogger(__name__)
tracer = get_tracer("forge.api.github")

router = APIRouter(prefix="/api/v1/webhooks", tags=["github"])

TICKET_PATTERN = re.compile(r"([A-Z][A-Z0-9]+-\d+)", re.IGNORECASE)


def _extract_ticket_key(event: NormalizedEvent) -> str:
"""Extract a Jira ticket key from a NormalizedEvent.

Prefers the change request's title/branch when one is present (PR, review,
and check events carrying a pull_requests stub). Falls back to whatever
branch name the raw payload carries when there is no change request:
a push event's ref, or a check_suite/check_run event that fired before
GitHub attached a pull_requests stub to it (both still carry head_branch).
"""
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()
raw = event.raw
branch_sources = (
raw.get("ref", ""),
raw.get("check_suite", {}).get("head_branch", ""),
raw.get("check_run", {}).get("check_suite", {}).get("head_branch", ""),
)
for text in branch_sources:
match = TICKET_PATTERN.search(str(text))
if match:
return match.group(1).upper()
return ""


@router.post(
"/github",
Expand All @@ -47,7 +84,7 @@ async def receive_github_webhook(

This endpoint:
1. Validates webhook signature
2. Parses the webhook payload
2. Parses the webhook payload into a NormalizedEvent
3. Queues the event for async processing
4. Returns immediately (<500ms target)

Expand Down Expand Up @@ -80,9 +117,35 @@ async def receive_github_webhook(
# Read raw body for signature verification
body = await request.body()

# Validate signature
secret = settings.github_webhook_secret.get_secret_value()
if secret and not _verify_github_signature(body, x_hub_signature_256, secret):
try:
sniff_payload = json.loads(body) if body else {}
except json.JSONDecodeError:
sniff_payload = {}
repo_namespace = sniff_payload.get("repository", {}).get("full_name", "")

registry = get_registry()
connection = _DEFAULT_GITHUB_CONNECTION
if repo_namespace:
try:
connection = registry.resolve(
repo_namespace, provider_hint=Provider.GITHUB
).connection
except (NotFoundError, ProviderConfigError):
connection = _DEFAULT_GITHUB_CONNECTION

webhook_secret = (
resolve_env_value(connection.webhook_secret_env, settings)
if connection.webhook_secret_env
else None
)
adapter = GitHubAdapter(connection=connection, webhook_secret=webhook_secret)
verify_headers = {
"X-GitHub-Event": x_github_event,
"X-GitHub-Delivery": x_github_delivery,
"X-Hub-Signature-256": x_hub_signature_256,
}

if not await adapter.verify_webhook(verify_headers, body):
span.set_attribute("error", True)
span.set_attribute("error.type", "auth_failure")
logger.warning("Invalid GitHub webhook signature")
Expand All @@ -94,7 +157,7 @@ async def receive_github_webhook(
# Parse JSON payload
try:
payload: dict[str, Any] = await request.json()
except Exception as e:
except (json.JSONDecodeError, UnicodeDecodeError) as e:
span.set_attribute("error", True)
span.set_attribute("error.type", "parse_error")
logger.error(f"Failed to parse GitHub webhook payload: {e}")
Expand All @@ -105,46 +168,43 @@ async def receive_github_webhook(

event_id = x_github_delivery or _generate_event_id(payload)
span.set_attribute("forge.event_id", event_id)
headers = {**verify_headers, "X-GitHub-Delivery": event_id}

# Parse webhook data
webhook_data = parse_github_webhook(payload, x_github_event, event_id)
try:
event = await adapter.parse_webhook(headers, body, registry)
except (NotFoundError, ProviderConfigError):
span.set_attribute("forge.skipped", True)
span.set_attribute("forge.skip_reason", "unmanaged_repository")
logger.info("Skipping webhook for unmanaged repository")
record_webhook_received(source="github", event_type=x_github_event)
return {"status": "ignored", "event_id": event_id}

ticket_key = _extract_ticket_key(event)
span.set_attribute("forge.ticket_key", ticket_key)

# Record webhook received metric
record_webhook_received(source="github", event_type=x_github_event)

span.set_attribute("forge.ticket_key", webhook_data.ticket_key or "")
webhook_event = create_github_webhook_event(webhook_data)

# Queue for async processing
producer = QueueProducer()
message_id = await producer.publish_once(
event_id=webhook_event.event_id,
source=EventSource.GITHUB,
event_type=webhook_event.event_type,
ticket_key=webhook_event.ticket_key,
payload=webhook_event.payload,
)
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": webhook_data.ticket_key or "",
}
return {"status": "duplicate", "event_id": event_id, "ticket_key": ticket_key}

span.set_attribute("forge.queued", True)
logger.info(f"Queued GitHub event {event_id} for {webhook_data.ticket_key}")
logger.info(
f"GitHub webhook queued: event_id={event_id}, "
f"kind={event.kind}, repo={event.repo_ref.namespace}"
)

# Record webhook processed metric
record_webhook_processed(source="github", event_type=x_github_event)

return {
"status": "accepted",
"event_id": event_id,
"ticket_key": webhook_data.ticket_key or "",
}
return {"status": "queued", "event_id": event_id, "ticket_key": ticket_key}

except HTTPException:
raise
Expand All @@ -162,54 +222,28 @@ async def receive_github_webhook(
except Exception as e:
span.set_attribute("error", True)
span.set_attribute("error.type", "internal_error")
logger.error(f"Failed to queue GitHub event: {e}")
logger.error(f"Failed to process GitHub webhook: {e}")
record_webhook_failed(
source="github", event_type=x_github_event, error_type="internal_error"
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to queue event",
detail="Failed to process webhook",
)
finally:
span.end()


def _verify_github_signature(payload: bytes, signature: str, secret: str) -> bool:
"""Verify GitHub webhook signature.

Args:
payload: Raw request body.
signature: X-Hub-Signature-256 header value.
secret: Webhook secret.

Returns:
True if signature is valid.
"""
if not signature:
return False

expected = (
"sha256="
+ hmac.new(
secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
)

return hmac.compare_digest(signature, expected)


def _generate_event_id(payload: dict[str, Any]) -> str:
"""Generate a deterministic event ID from payload.

Used as a fallback when X-GitHub-Delivery is empty.

Args:
payload: Webhook payload.

Returns:
SHA256-based event ID.
"""
import json

content = json.dumps(payload, sort_keys=True)
return hashlib.sha256(content.encode()).hexdigest()[:16]
8 changes: 5 additions & 3 deletions src/forge/api/routes/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from forge import __version__
from forge.orchestrator.checkpointer import get_redis_client
from forge.queue.producer import JIRA_STREAM, LEGACY_SOURCE_CONTROL_STREAM, SOURCE_CONTROL_STREAM

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -47,9 +48,10 @@ async def health_check() -> Any:

# Get approximate queue depth
try:
jira_len = await redis_client.xlen("forge:events:jira")
github_len = await redis_client.xlen("forge:events:github")
queue_depth = jira_len + github_len
jira_len = await redis_client.xlen(JIRA_STREAM)
source_control_len = await redis_client.xlen(SOURCE_CONTROL_STREAM)
legacy_len = await redis_client.xlen(LEGACY_SOURCE_CONTROL_STREAM)
queue_depth = jira_len + source_control_len + legacy_len
except Exception:
pass # Streams may not exist yet

Expand Down
1 change: 1 addition & 0 deletions src/forge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import sys
from typing import Any

import forge.integrations.source_control.github # noqa: F401 (registers GitHub adapter factory)
from forge.config import get_settings


Expand Down
13 changes: 12 additions & 1 deletion src/forge/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ def atlassian_auth_base64(self) -> str:
# GitHub Configuration
github_token: SecretStr = Field(description="GitHub personal access token")
github_webhook_secret: SecretStr = Field(
default=SecretStr(""), description="Shared secret for GitHub webhook validation"
default=SecretStr(""),
description=(
"Shared secret for GitHub webhook validation; leave empty to accept unsigned webhooks"
),
)
github_default_repo: str = Field(
default="",
Expand All @@ -104,6 +107,14 @@ def atlassian_auth_base64(self) -> str:
default="",
description="GitHub account/org where forks are created (defaults to authenticated user if empty)",
)
forge_repos_config_path: str = Field(
default="config/repos.yaml",
description=(
"Path to the source control provider/connection registry config file. "
"Optional — repositories not listed here resolve through the implicit "
"default GitHub connection built from GITHUB_TOKEN/GITHUB_WEBHOOK_SECRET."
),
)
forge_bot_comment_prefix: str = Field(
default="",
description="Prefix to use for all comments made by the Forge bot",
Expand Down
18 changes: 0 additions & 18 deletions src/forge/integrations/github/__init__.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,7 @@
"""GitHub integration for PR management and webhook handling."""

from forge.integrations.github.client import GitHubClient
from forge.integrations.github.webhooks import (
GitHubWebhookData,
create_github_webhook_event,
is_ci_failure,
is_ci_success,
is_pr_merged,
is_pr_review_approved,
is_pr_review_changes_requested,
parse_github_webhook,
)

__all__ = [
"GitHubClient",
"GitHubWebhookData",
"create_github_webhook_event",
"is_ci_failure",
"is_ci_success",
"is_pr_merged",
"is_pr_review_approved",
"is_pr_review_changes_requested",
"parse_github_webhook",
]
Loading
Loading