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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ GITHUB_API_TIMEOUT_SECONDS=10
# PATCHFROG_REVIEW_MODEL=claude-opus-5 # e.g. gemini-3.6-flash for gemini
# PATCHFROG_REVIEW_CRITIC_MODEL= # optional; defaults to the reviewer model
# PATCHFROG_REVIEW_REQUEST_TIMEOUT_SECONDS= # optional; 30s default (120s default for gemini)
# PATCHFROG_ROUTER_CHEAP_PROVIDER= # optional cheap route for small reviews
# PATCHFROG_ROUTER_CHEAP_MODEL= # optional model for the cheap provider
# PATCHFROG_MAX_PROVIDER_CALLS=500
# PATCHFROG_MAX_RETRY_ATTEMPTS=200
# PATCHFROG_MAX_TOTAL_OUTPUT_TOKENS=250000
# PATCHFROG_MAX_ESTIMATED_COST_USD= # optional; requires pricing below
# PATCHFROG_MAX_REVIEW_ELAPSED_SECONDS= # optional hard provider-work ceiling
# PATCHFROG_PROVIDER_PRICING='{"provider/model":{"input_usd_per_million_tokens":1.0,"output_usd_per_million_tokens":4.0}}'
# PATCHFROG_CRITIC_FAILURE_POLICY=hold_for_review # optional; default fail_open
#
# Optional requests-per-minute ceiling, keyed like PATCHFROG_PROVIDER_PRICING
# above ("provider/model", falling back to a bare "provider" entry). Every
# reviewer/critic/retry/fallback call for that provider shares one
# process-wide sliding-window limiter -- see patchfrog/review/rate_limiter.py.
# Unset means unthrottled (today's behavior). A Gemini free-tier deployment
# (observed ceiling: 5 requests/minute/project/model) should set:
# PATCHFROG_PROVIDER_RATE_LIMIT_RPM='{"gemini":5}'
#
# Only the credential for the provider actually selected above needs to
# be set. Never set either of these in .patchfrog.yml or any
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ jobs:
VgAFWZ9YEAZPVROqM6i76BU=
-----END PRIVATE KEY-----
GITHUB_WEBHOOK_SECRET: ci-placeholder-not-a-real-secret
# The database-specific suite must fail if the declared CI service is
# unavailable or unmigrated. Local runs may skip these tests when no
# compatible Postgres is present.
PATCHFROG_REQUIRE_POSTGRES: "1"
SEMGREP_ENABLE_VERSION_CHECK: "0"
SEMGREP_SEND_METRICS: "off"

steps:
- uses: actions/checkout@v4
Expand All @@ -88,6 +94,11 @@ jobs:
- name: ruff
run: ruff check .

- name: Verify required static analyzers
run: |
ruff --version
semgrep --version

- name: mypy --strict
run: mypy . --strict

Expand Down
15 changes: 15 additions & 0 deletions .patchfrog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# PatchFrog repository review configuration.
#
# Controls review *behavior* only (candidate/token/confidence budgets,
# publication policy) -- never provider, model, credentials, or Cloud
# routing, which stay operator/deployment-controlled (see
# patchfrog/review/runtime_config.py, patchfrog/config/settings.py, and
# CLAUDE.md's "Source-available / Cloud boundary" section).
#
# publish.enabled defaults to false (patchfrog/publishing/config.py) --
# a repository can opt itself OUT of publication even when an operator
# requests it, but can never opt itself IN just by content in this
# file; that decision is always made by the operator running PatchFrog.
# This repo is the operator's own dogfooding target, so it opts in here.
publish:
enabled: true
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ mypy . --strict
pytest
```

The default suite is deterministic and never requires provider credentials or
makes paid provider calls. Real-Postgres concurrency/schema tests skip locally
when the documented test database is unavailable; CI requires that database and
fails if it cannot be used. Host-isolation and distributed-verifier cases are
reported as optional skips when their explicit `bwrap`/`prlimit`/Redis
prerequisites are absent. See [`docs/ci-health.md`](docs/ci-health.md) for the
suite contracts and reproducible commands.

## Architecture and brand

See [`docs/brand.md`](docs/brand.md) for identity/tone guidelines and asset usage, [`docs/product-boundary.md`](docs/product-boundary.md) for the self-hosted vs. PatchFrog Cloud architecture, and the `docs/` directory for phase-by-phase design notes.
Expand Down
2 changes: 1 addition & 1 deletion alembic.ini
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
path_separator = os

[loggers]
keys = root,sqlalchemy,alembic
Expand Down
81 changes: 43 additions & 38 deletions apps/worker/tasks/process_pull_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from patchfrog.ops import metrics
from patchfrog.ops.orchestrator import schedule_pipeline_if_eligible
from patchfrog.persistence.database import create_engine, create_session_factory
from patchfrog.publishing.checks import github_check_publisher
from patchfrog.services.pull_request_ingestion import (
IngestionOutcome,
IngestionOutcomeStatus,
Expand Down Expand Up @@ -109,44 +110,48 @@ async def _ingest(event: PullRequestWebhookEvent, settings: Settings) -> Ingesti
else:
outcome = await service.ingest(event)

if outcome.status is IngestionOutcomeStatus.SUCCEEDED and event.action is not PullRequestEventAction.CLOSED:
# Only opened/reopened/synchronize ever reach here -- every
# one of those actions means "there is a commit that should
# be reviewed", so scheduling is unconditional on the action
# itself; patchfrog.ops.eligibility is what actually decides
# whether this specific installation/repository/PR may
# proceed.
#
# This call must never be allowed to propagate: ingestion's
# delivery_id uniqueness constraint means a re-delivered (or
# Celery-retried) webhook for an already-SUCCEEDED ingestion
# is recognized as a DUPLICATE and short-circuits before
# reaching this line again -- so a transient failure here
# (e.g. Redis briefly unreachable) would otherwise leave a
# successfully-ingested PR that silently never gets
# reviewed, undetectable by `ops failed`/`ops stale` (both
# only ever look at `review_runs`, and no such row would
# exist). Caught, logged with everything needed to manually
# recover, and surfaced on the one metric built for exactly
# this shape of outcome instead.
try:
await schedule_pipeline_if_eligible(
session_factory,
settings=settings,
repository_ref=event.repository,
commit_sha=event.head_sha,
pull_request_number=event.pull_request_number,
)
except Exception as exc:
logger.error(
"pipeline_scheduling_failed",
github_delivery_id=event.delivery_id,
repository=event.repository.full_name,
pull_request_number=event.pull_request_number,
commit_sha=event.head_sha,
error=str(exc),
)
metrics.reviews_skipped_total.labels(reason="scheduling_failed").inc()
if outcome.status is IngestionOutcomeStatus.SUCCEEDED and event.action is not PullRequestEventAction.CLOSED:
# Only opened/reopened/synchronize ever reach here -- every
# one of those actions means "there is a commit that should
# be reviewed", so scheduling is unconditional on the action
# itself; patchfrog.ops.eligibility is what actually decides
# whether this specific installation/repository/PR may
# proceed.
#
# This call must never be allowed to propagate: ingestion's
# delivery_id uniqueness constraint means a re-delivered (or
# Celery-retried) webhook for an already-SUCCEEDED ingestion
# is recognized as a DUPLICATE and short-circuits before
# reaching this line again -- so a transient failure here
# (e.g. Redis briefly unreachable) would otherwise leave a
# successfully-ingested PR that silently never gets
# reviewed, undetectable by `ops failed`/`ops stale` (both
# only ever look at `review_runs`, and no such row would
# exist). Caught, logged with everything needed to manually
# recover, and surfaced on the one metric built for exactly
# this shape of outcome instead.
try:
await schedule_pipeline_if_eligible(
session_factory,
settings=settings,
repository_ref=event.repository,
commit_sha=event.head_sha,
pull_request_number=event.pull_request_number,
check_publisher=github_check_publisher(
client=github_client,
installation_id=event.repository.installation.id,
),
)
except Exception as exc:
logger.error(
"pipeline_scheduling_failed",
github_delivery_id=event.delivery_id,
repository=event.repository.full_name,
pull_request_number=event.pull_request_number,
commit_sha=event.head_sha,
error=str(exc),
)
metrics.reviews_skipped_total.labels(reason="scheduling_failed").inc()

return outcome
finally:
Expand Down
59 changes: 58 additions & 1 deletion apps/worker/tasks/publish_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,21 @@

from apps.worker.celery_app import celery_app
from patchfrog.config.settings import Settings, get_settings
from patchfrog.domain.pull_request import PullRequestRef
from patchfrog.github.auth import InstallationTokenProvider
from patchfrog.github.client import GitHubClient
from patchfrog.merge_readiness.service import MergeReadinessService
from patchfrog.ops import metrics
from patchfrog.persistence.database import create_engine, create_session_factory
from patchfrog.persistence.models.pull_request import PullRequestModel
from patchfrog.persistence.models.repository import RepositoryModel
from patchfrog.persistence.models.review import ReviewRunModel
from patchfrog.persistence.repositories.ai_finding import AIFindingRepository
from patchfrog.publishing.checks import (
ReviewCheckState,
ReviewCheckUpdate,
github_check_publisher,
)
from patchfrog.publishing.config_resolution import resolve_repository_publication_config
from patchfrog.publishing.domain import (
ReviewPublicationMode,
Expand Down Expand Up @@ -119,7 +127,56 @@ async def _publish_review(
# alone (see patchfrog.publishing.queries.get_current_active_findings),
# so a publish retry/redelivery always recomputes it fresh --
# nothing to pass through here.
return await service.publish(review_run_id=review_run_id, mode=mode, config=config)
result = await service.publish(review_run_id=review_run_id, mode=mode, config=config)

async with session_factory() as session:
findings = await AIFindingRepository().list_for_run(
session,
review_run_id=review_run_id,
)
readiness = await MergeReadinessService().evaluate(
session,
repository_id=repository.id,
pull_request_number=pull_request.github_pr_number,
)

if result.status is ReviewPublicationStatus.FAILED:
check_state = ReviewCheckState.FAILED
elif result.status is ReviewPublicationStatus.STALE:
check_state = ReviewCheckState.SKIPPED
elif run.status.value == "partial":
check_state = ReviewCheckState.PARTIAL
elif findings:
check_state = ReviewCheckState.COMPLETED_WITH_FINDINGS
else:
check_state = ReviewCheckState.COMPLETED_CLEAN

try:
await github_check_publisher(
client=github_client,
installation_id=repository.installation_id,
).reconcile(
ref=PullRequestRef(
owner=repository.owner,
repository=repository.name,
number=pull_request.github_pr_number,
),
head_sha=run.commit_sha,
update=ReviewCheckUpdate(
state=check_state,
accepted_findings=len(findings),
detail=result.errors[0] if result.errors else result.status.value,
merge_readiness=readiness.decision if readiness is not None else None,
),
)
except Exception as exc:
logger.error(
"review_check_reconciliation_failed",
review_run_id=str(review_run_id),
publication_status=result.status.value,
error_type=type(exc).__name__,
)
return result
finally:
await engine.dispose()

Expand Down
Loading
Loading