GitHub App Integration
The only inbound-triggered flow in the system — every other endpoint is driven by an explicit client call.
sequenceDiagram
participant GH as GitHub
participant WH as POST /integrations/github/webhook
participant Job as background job
participant LLM as LLM provider
GH->>WH: pull_request opened/synchronize (signed)
WH->>WH: look up integration by installation.id (or installation.app_id)
WH->>WH: verify X-Hub-Signature-256 against that integration's secret (or config.github, if none matched)
WH-->>GH: 202 {job_id}
WH->>Job: asyncio.create_task
Job->>GH: mint App JWT (org's own App, or the platform App) -> installation token
Job->>GH: fetch PR diff
Job->>LLM: run_analysis_auto() — auto-route each changed file, see above
Job->>Job: report_service.build_report_data()
Job->>Job: pr_gate.record_pr_gate() — remember owner/repo/SHA
Job->>GH: post PR comment + commit status
A triage action writes to the same check later, without a second push:
sequenceDiagram
autonumber
participant U as dashboard / API
participant F as PATCH /findings/{hash}/{rule_id}
participant GH as GitHub
U->>F: acknowledge / suppress / reopen
F->>F: storage.update_finding_status()
F->>F: pr_gate.refresh_pr_gates_for_resource()
F->>GH: re-post commit status for every gated namespace
F-->>U: 200 (best-effort: a GitHub failure never fails the triage)
Two ways an org connects a GitHub App, coexisting on the same deployment:
- Platform App — one identity (
config.github: App ID, RSA private key, webhook secret) shared across every org that doesn’t register its own, set once by the operator. An org “installs” it on its own repos, producing aninstallation_idregistered viaPOST /orgs/{org_id}/integrations/github. - Custom App — an org registers its own App via the manifest flow:
POST /orgs/{org_id}/integrations/github/manifestmints a short-lived, single-usestatetoken (github_manifest_states, same “opaque token + SHA-256 hash + explicit expiry check” pattern asreport_links) and a manifest; the admin’s browser does a real top-level form POST togithub.com; GitHub redirects back to the publicGET /integrations/github/manifest/callback, which resolvesstate, exchanges the one-timecodevia GitHub’s ownPOST /app-manifests/{code}/conversions(libs/github_client.exchange_manifest_code), and stores the resulting App ID/private key/webhook secret on the org’sintegrationsdoc (github_app, encrypted the same way as stored LLM credentials —libs/crypto.py, Fernet). Itsinstallation_idis captured automatically from theinstallationcreatedwebhook event rather than pasted manually.
Every webhook-secret lookup and GitHubClient construction (services/github_app.py’s webhook_secret_for/github_client_for) prefers an org’s own github_app when present, falling back to config.github otherwise — this is what lets the two paths coexist. Identifying which secret to try from the still-unverified payload (via installation.id, or installation.app_id for a brand-new custom App’s first-ever event, before any App has an installation_id on file) is safe precisely because it only selects a candidate to verify against — the HMAC check is still what gates any actual effect.
libs/github_client.py’s GitHubClient is a thin httpx wrapper (no GitHub SDK): it signs an RS256 JWT as whichever App identity applies, exchanges it for a scoped installation token, fetches the PR diff, and posts back a comment + commit status — failure if any open finding is CRITICAL/HIGH, else pending if any finding is needs_review or an untriaged HIGH/CRITICAL agent-injection finding is holding the merge, else success. That mapping lives in services/pr_gate.py::compute_gate_state(), not in the webhook job, because it has two callers.
The second caller is triage. A commit status is a push, not a poll — GitHub keeps whatever was last written to a SHA — so acknowledging or suppressing a finding in the dashboard re-posts the check against the same commit, and a blocked PR clears without another push. The job records where it posted (pr_gates) immediately before posting, which is what makes that re-post addressable; see Clearing a held check.
The PR diff is handed to content_router.run_analysis_auto() (see
Auto-routing
above) rather than a hardcoded analyzer — each changed file is routed to
whichever org-enabled analyzer’s file_globs matches it. terraform-plan
never matches a PR diff (it declares no file_globs/sniff() for diff
input — it needs actual terraform plan output, which nothing in this
codebase produces). The job document’s analyzer field is the literal
"auto", with analyzers_used/detections carrying the real breakdown —
this is also what fixed a real gap: before auto-routing, the webhook always
called terraform-code-change directly, whose own parser silently dropped
every non-.tf file in the diff with no trace in the job record at all.
There is deliberately no IntegrationProvider abstraction yet — with exactly one provider implemented (GitHub; GitLab is on the roadmap), an interface to “support more providers later” would have nothing to validate it against. The platform-vs-custom-App split is a property of the integrations document, not a second provider.