Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ STUDIES_DEFAULT_TIMEOUT_S=60
# RELYLOOP_GIT_AUTHOR_NAME=relyloop-bot
# RELYLOOP_GIT_AUTHOR_EMAIL=relyloop-bot@your-company.com

# --- feat_github_webhook -----------------------------------------------
# Cron cadence (in minutes) for the reconcile_pr_state worker, which polls
# GitHub for PR-state changes on `proposals` that webhook delivery missed.
# Default 15 covers the spec's 15-minute SLA. Operators with low PR volume
# may raise this to reduce GitHub API spend. Story 3.1 will narrow valid
# values to a whitelist of cron-expressible cadences.
# RELYLOOP_PR_POLL_MINUTES=15

# --- Build-time only --------------------------------------------------
# RELYLOOP_GIT_SHA is injected at `docker buildx build` via --build-arg.
# `make up` propagates the current short-SHA when invoking compose.
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ If you slip and a stub leaks into a committed file, capture it as a `bug_<slug>`
| 5 | [`feat_llm_judgments`](docs/00_overview/implemented_features/2026_05_11_feat_llm_judgments/) | **Complete (PR #35, merged 2026-05-11)** |
| 6 | [`feat_digest_proposal`](docs/00_overview/implemented_features/2026_05_11_feat_digest_proposal/) | **Complete (PR #41, merged 2026-05-11)** |
| 7 | [`feat_github_pr_worker`](docs/00_overview/implemented_features/2026_05_12_feat_github_pr_worker/) | **Complete (PR #45, merged 2026-05-12)** |
| 8 | [`feat_github_webhook`](docs/02_product/planned_features/feat_github_webhook/) | Spec approved, plan pending |
| 8 | [`feat_github_webhook`](docs/02_product/planned_features/feat_github_webhook/) | Implementation complete (all 10 stories), pending push + CI + merge on `feature/feat-github-webhook` |
| 9 | [`feat_studies_ui`](docs/00_overview/implemented_features/2026_05_12_feat_studies_ui/) | **Complete (PR #50, pending merge)** |
| 10 | [`feat_chat_agent`](docs/02_product/planned_features/feat_chat_agent/) | Spec approved, plan pending |
| 11 | [`feat_proposals_ui`](docs/02_product/planned_features/feat_proposals_ui/) | Spec approved, plan pending |
Expand All @@ -441,7 +441,7 @@ Run `/pipeline status` for the live view from spec dependencies.
| Local dev start/stop | [`docs/03_runbooks/local-dev.md`](docs/03_runbooks/local-dev.md) (lands in `infra_foundation` Story 5.2) |
| Test layer convention + 80% coverage gate | [`docs/05_quality/testing.md`](docs/05_quality/testing.md) (lands in `infra_foundation` Story 5.2) |
| DB revision mismatch | TBA — lands when `feat_study_lifecycle` ships its first business-table migration |
| GitHub webhook setup | TBA — lands with `feat_github_webhook` |
| GitHub webhook debugging + secret rotation + register_webhook triage | [`docs/03_runbooks/webhook-debugging.md`](docs/03_runbooks/webhook-debugging.md) (`feat_github_webhook`) |
| `open_pr` worker debugging + per-repo PAT rotation + closing orphan branches | [`docs/03_runbooks/pr-open-debugging.md`](docs/03_runbooks/pr-open-debugging.md) (`feat_github_pr_worker`) |
| GitHub PAT storage / rotation / leak prevention | [`docs/04_security/github-token-handling.md`](docs/04_security/github-token-handling.md) (`feat_github_pr_worker`) |
| Local LLM (Ollama / LM Studio / vLLM / TGI) configuration | [`docs/01_architecture/llm-orchestration.md` §"OpenAI-compatible endpoints"](docs/01_architecture/llm-orchestration.md); operator-facing runbook lands with `chore_tutorial_polish` |
Expand Down
34 changes: 33 additions & 1 deletion backend/app/api/v1/config_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from typing import Annotated

import uuid_utils
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

Expand Down Expand Up @@ -119,6 +119,7 @@ def _to_detail(row: object) -> ConfigRepoDetail:
)
async def create_config_repo_endpoint(
body: CreateConfigRepoRequest,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
) -> ConfigRepoDetail:
"""Register a new config repo. ``provider`` is server-derived from ``repo_url``.
Expand All @@ -132,6 +133,11 @@ async def create_config_repo_endpoint(
populate the file between registration and first PR-open.
3. ``name`` uniqueness check → 409 ``CONFIG_REPO_NAME_TAKEN`` on collision.
4. Insert with server-derived ``provider="github"``.
5. **feat_github_webhook Story 4.2** — when ``webhook_secret_ref`` is
populated, best-effort enqueue ``register_webhook`` against the
newly created config_repo id. Enqueue failure (Redis down, pool
absent, transient blip) does NOT break the 201 — it logs WARN
and the operator drives recovery via the runbook.
"""
try:
validate_repo_url(body.repo_url)
Expand Down Expand Up @@ -183,6 +189,32 @@ async def create_config_repo_endpoint(
f"config_repo name {body.name!r} is already registered (concurrent registration race)",
False,
) from exc

# feat_github_webhook Story 4.2 — best-effort enqueue of the
# register_webhook worker. Established pattern from proposals.py:516 /
# studies.py:167 — getattr(request.app.state, "arq_pool", None), not
# a Depends() factory (which doesn't exist in the codebase).
if inserted.webhook_secret_ref is not None:
arq_pool = getattr(request.app.state, "arq_pool", None)
if arq_pool is None:
logger.warning(
"register_webhook_enqueue_skipped_no_pool",
config_repo_id=inserted.id,
)
else:
try:
await arq_pool.enqueue_job(
"register_webhook",
inserted.id,
_job_id=f"register_webhook:{inserted.id}",
)
except Exception as exc: # noqa: BLE001 — best-effort enqueue
logger.warning(
"register_webhook_enqueue_failed",
config_repo_id=inserted.id,
exc_type=type(exc).__name__,
)

return _to_detail(inserted)


Expand Down
7 changes: 7 additions & 0 deletions backend/app/api/webhooks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Webhook receivers (feat_github_webhook).

Per ``docs/01_architecture/api-conventions.md`` webhook endpoints
mount unprefixed (no ``/api/v1``) so external providers don't have to
encode an unexpected path component. Same exception as ``/healthz``
(CLAUDE.md Rule #6 carve-out).
"""
208 changes: 208 additions & 0 deletions backend/app/api/webhooks/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""GitHub webhook receiver (feat_github_webhook Story 2.1 / FR-1).

Single endpoint ``POST /webhooks/github``. Unprefixed mount per
CLAUDE.md Rule #6 + ``docs/01_architecture/api-conventions.md``.

Order of operations (per spec FR-1):

1. Read raw body bytes (HMAC must hash the exact bytes GitHub sent).
2. Extract ``repository.full_name`` and parse to ``(owner, repo)``.
3. Look up the matching ``config_repos`` row. Unknown repo → 403
``INVALID_SIGNATURE`` (don't reveal repo enumeration).
4. Read the per-repo HMAC secret from the mounted-secrets bundle
(``webhook_secret_ref``). Missing secret → 403.
5. Verify ``X-Hub-Signature-256`` via constant-time HMAC compare.
Mismatch → 403.
6. Dispatch via the pure-domain ``dispatch_event``; the dispatcher
returns a :class:`WebhookDecision` with ``action ∈ {applied, noop,
ping}`` — it NEVER emits ``unknown_pr`` (router-only).
7. If the decision asks for a mutation, look up the proposal by
``decision.pr_url``. Missing proposal → override ``wire_action`` to
``unknown_pr`` and skip the mutation (spec §11 downstream-invariant
audit). Otherwise call the matching ``mark_proposal_pr_*`` repo
function and commit.
8. Emit one structured ``webhook_received`` log line carrying spec
§13 NFR-Operability fields: ``delivery_id``, ``event``, ``action``,
``proposal_id``, ``result`` (= wire action).

The webhook secret itself is never logged. The static-grep assertion in
``backend/tests/contract/test_webhook_api_contract.py`` enforces this.

Re-exports ``WEBHOOK_ACTION_VALUES`` from
``backend.app.domain.git.webhook_dispatch`` so spec §8.4's grep cite at
this path also passes (the wire-action source of truth lives in one
module — the dispatch one — and is consumed from there).
"""

from __future__ import annotations

import json
from typing import Annotated, Any

import structlog
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession

from backend.app.db import repo
from backend.app.db.session import get_db
from backend.app.domain.git import (
WEBHOOK_ACTION_VALUES,
dispatch_event,
parse_repository_full_name,
verify_webhook_signature,
)
from backend.app.git import read_mounted_secret

logger = structlog.get_logger(__name__)

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

# Re-exported so spec §8.4's grep cite at `backend/app/api/webhooks/github.py`
# also passes (the canonical wire-action source of truth lives in
# `backend.app.domain.git.webhook_dispatch`).
__all__ = ["WEBHOOK_ACTION_VALUES", "router"]


def _err(status_code: int, code: str, message: str, retryable: bool) -> HTTPException:
"""Build the project-wide error envelope (mirror of every v1 router)."""
return HTTPException(
status_code=status_code,
detail={"error_code": code, "message": message, "retryable": retryable},
)


def _invalid_signature() -> HTTPException:
return _err(
status.HTTP_403_FORBIDDEN,
"INVALID_SIGNATURE",
"Signature mismatch or unknown repository.",
retryable=False,
)


@router.post("/github", status_code=status.HTTP_200_OK)
async def github_webhook(
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
) -> dict[str, str]:
"""Receive a single GitHub webhook delivery.

Returns ``{"status": "ok", "action": <wire_action>}`` where
``wire_action`` is one of the four values in
:data:`WEBHOOK_ACTION_VALUES`.

Raises:
HTTPException(403, INVALID_SIGNATURE): bad signature or unknown
repository. Both share one error code so the receiver does
not reveal repo enumeration.
"""
delivery_id = request.headers.get("x-github-delivery", "")
event_type = request.headers.get("x-github-event", "")
signature_header = request.headers.get("x-hub-signature-256")
body = await request.body()

try:
parsed_body: Any = json.loads(body) if body else {}
except json.JSONDecodeError:
# Malformed JSON from a verified-signature payload is unexpected —
# treat as a signature failure (we can't validate intent without
# parseable fields). The HMAC compare would fail anyway because
# the signature was computed against this same body.
logger.warning(
"webhook_invalid_signature",
delivery_id=delivery_id,
gh_event=event_type,
reason="malformed_payload",
)
raise _invalid_signature() from None
if not isinstance(parsed_body, dict):
# GPT-5.5 final-review F2 — a valid JSON non-object (e.g. ``[]``,
# ``"foo"``, ``null``) would crash payload.get(...) with
# AttributeError → 500. Treat as signature failure.
logger.warning(
"webhook_invalid_signature",
delivery_id=delivery_id,
gh_event=event_type,
reason="non_object_payload",
)
raise _invalid_signature()
payload: dict[str, Any] = parsed_body

full_name = ""
repository = payload.get("repository")
if isinstance(repository, dict):
candidate = repository.get("full_name")
if isinstance(candidate, str):
full_name = candidate
owner_repo = parse_repository_full_name(full_name) if full_name else None
if owner_repo is None:
logger.warning(
"webhook_invalid_signature",
delivery_id=delivery_id,
gh_event=event_type,
reason="unparseable_repository",
)
raise _invalid_signature()

config_repo_row = await repo.lookup_config_repo_by_owner_repo(db, *owner_repo)
if config_repo_row is None or not config_repo_row.webhook_secret_ref:
logger.warning(
"webhook_invalid_signature",
delivery_id=delivery_id,
gh_event=event_type,
reason="unknown_repo",
)
raise _invalid_signature()

secret = read_mounted_secret(config_repo_row.webhook_secret_ref)
if not secret or not verify_webhook_signature(body, signature_header, secret):
logger.warning(
"webhook_invalid_signature",
delivery_id=delivery_id,
gh_event=event_type,
reason="bad_signature",
)
raise _invalid_signature()

decision = dispatch_event(event_type, payload)
wire_action: str = decision.action
proposal_id: str | None = None

if decision.mutation != "none":
# The dispatcher never emits unknown_pr — only the router does,
# after the lookup miss. See spec §11 + the dispatcher's
# `_NOOP`/`_PING` carve-out.
assert decision.pr_url is not None # noqa: S101 — invariant of dispatcher
proposal_row = await repo.lookup_proposal_by_pr_url(db, decision.pr_url)
if proposal_row is None:
wire_action = "unknown_pr"
else:
proposal_id = proposal_row.id
if decision.mutation == "merged":
# GPT-5.5 final-review F3 — GitHub eventual-consistency
# can yield merged=true with merged_at missing/null.
# Fall back to closed-state mutation (PR is no longer open)
# rather than crash the receiver; the polling reconciler
# will catch up on the merged_at value on the next tick.
if decision.pr_merged_at is None:
await repo.mark_proposal_pr_closed(db, proposal_id)
else:
await repo.mark_proposal_pr_merged(
db, proposal_id, pr_merged_at=decision.pr_merged_at
)
elif decision.mutation == "closed":
await repo.mark_proposal_pr_closed(db, proposal_id)
elif decision.mutation == "reopened":
await repo.mark_proposal_pr_reopened(db, proposal_id)
await db.commit()

logger.info(
"webhook_received",
delivery_id=delivery_id,
gh_event=event_type,
action=wire_action,
proposal_id=proposal_id,
result=wire_action,
)

return {"status": "ok", "action": wire_action}
35 changes: 34 additions & 1 deletion backend/app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from functools import cached_property, lru_cache
from pathlib import Path

from pydantic import Field
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


Expand Down Expand Up @@ -156,6 +156,39 @@ class Settings(BaseSettings):
"the install-domain bot address."
),
)
relyloop_pr_poll_minutes: int = Field(
default=15,
ge=1,
le=1440,
description=(
"Cron cadence for the reconcile_pr_state worker (feat_github_webhook "
"FR-2). MVP1 default 15. Restricted to the whitelist of "
"cron-expressible values: divisors of 60 (1, 2, 3, 4, 5, 6, 10, 12, "
"15, 20, 30, 60) plus multiples of 60 that divide 1440 (120, 180, "
"240, 360, 720, 1440). Values outside this set raise at startup; "
"see backend.workers.pr_reconcile.SUPPORTED_POLL_MINUTES."
),
)

@field_validator("relyloop_pr_poll_minutes")
@classmethod
def _validate_pr_poll_minutes(cls, value: int) -> int:
"""Narrow ``relyloop_pr_poll_minutes`` to the cron-expressible whitelist.

Whitelist lives in :data:`backend.workers.pr_reconcile.SUPPORTED_POLL_MINUTES`
— keeping the validator here means a misconfigured operator sees the
error at boot rather than at the first cron tick.
"""
from backend.workers.pr_reconcile import SUPPORTED_POLL_MINUTES

if value not in SUPPORTED_POLL_MINUTES:
raise ValueError(
f"RELYLOOP_PR_POLL_MINUTES={value} is not in the supported set "
f"{sorted(SUPPORTED_POLL_MINUTES)}. Pick a divisor of 60 (≤60) or a "
"multiple of 60 that divides 1440 (>60)."
)
return value

es_heap_size: str = Field(
default="512m",
description="ES_JAVA_OPTS heap sizing for the elasticsearch+opensearch containers",
Expand Down
15 changes: 15 additions & 0 deletions backend/app/db/repo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
get_config_repo,
get_config_repo_by_name,
list_config_repos,
lookup_config_repo_by_owner_repo,
set_webhook_registration_error,
)
from backend.app.db.repo.digest import (
create_digest,
Expand Down Expand Up @@ -52,8 +54,13 @@
create_proposal,
get_proposal,
list_pending_proposals_for_boot_scan,
list_pr_opened_proposals_for_reconcile,
list_proposals_paginated,
lookup_proposal_by_pr_url,
mark_proposal_pr_closed,
mark_proposal_pr_merged,
mark_proposal_pr_opened,
mark_proposal_pr_reopened,
reject_proposal,
set_proposal_pr_open_error,
update_proposal_for_digest,
Expand Down Expand Up @@ -167,4 +174,12 @@
"list_config_repos",
"mark_proposal_pr_opened",
"set_proposal_pr_open_error",
# feat_github_webhook Story 1.4 (webhook receiver + polling reconciler + auto-register)
"list_pr_opened_proposals_for_reconcile",
"lookup_config_repo_by_owner_repo",
"lookup_proposal_by_pr_url",
"mark_proposal_pr_closed",
"mark_proposal_pr_merged",
"mark_proposal_pr_reopened",
"set_webhook_registration_error",
]
Loading
Loading