Skip to content

GitHub App Integration

The server has a native GitHub App integration: install an App on a repo, and every pull request gets its Terraform changes analysed automatically, with the result posted back as a PR comment and a commit status. No CI workflow to write or maintain.


Platform AppYour own App
Who registers itThe deployment operator, once, via PATCH /admin/settingsEach org, for itself, via the dashboard or API
Where the private key livesPlatform-settings database overlay (superadmin-only)Encrypted at rest per org (Fernet, same mechanism as stored LLM credentials)
SetupOrg pastes an installation_id after installing the shared AppOrg clicks “Create GitHub App” — GitHub creates it, hands credentials straight to the server, installation_id is captured automatically
Good forGetting started fast, single-tenant or trusted-tenant deploymentsMulti-tenant SaaS where each org wants their own audit trail / GitHub org visibility / independent uninstall

Both can coexist on the same deployment — some orgs use the shared App, others bring their own. Every webhook-secret lookup and installation-token mint prefers an org’s own App when it has registered one, falling back to the platform App otherwise.


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 (HMAC-signed)
    WH->>WH: look up integration by installation.id (or installation.app_id)
    WH->>WH: verify X-Hub-Signature-256 against that integration's own secret
    WH-->>GH: 202 {job_id}
    WH->>Job: asyncio.create_task (non-blocking)
    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
    Job->>GH: post PR comment + commit status

The org is identified from the still-unverified installation.id (or, for the very first event a brand-new custom App ever receives, installation.app_id) purely to select which stored secret to verify the signature against — the signature check itself is what actually gates any action. An unrecognised or forged id just fails verification, same as before there was more than one possible secret.

The PR diff is auto-routed (see Auto-routing) — each changed file runs through whichever org-enabled analyzer’s file_globs matches it, not a single hardcoded analyzer. In practice that’s terraform-code-change for .tf files today (terraform-plan never matches PR diffs — it needs actual terraform plan CLI output, which nothing in this server runs), but any file type another installed analyzer recognises is picked up automatically too, with no webhook-side change needed. A file no installed analyzer recognises is reported in the job’s detections (GET /jobs/{job_id}), not silently skipped — see POST /{namespace}/ingest/auto for the exact shape.


Setup — Platform App (operator, once per deployment)

Section titled “Setup — Platform App (operator, once per deployment)”

On GitHub: Settings → Developer settings → GitHub Apps → New GitHub App.

SettingValue
Webhook URLhttps://<your-deployment>/integrations/github/webhook
Webhook secretGenerate a strong random value — you’ll set this via PATCH /admin/settings as github_webhook_secret
PermissionsRepository: Pull requests (read & write), Contents (read), Commit statuses (write)
Subscribe to eventsPull request

Generate a private key for the App (Generate a private key button) and download the .pem file.

github is one of the sections managed exclusively through the platform admin panel — it isn’t read from the config file or any GITHUB_* environment variable (see Configuration Reference — Platform settings). Set it once the server is up:

Terminal window
curl -X PATCH http://localhost:8000/admin/settings \
-H "X-API-Key: <admin-key>" \
-H "Content-Type: application/json" \
-d '{
"github_app_id": "123456",
"github_private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----",
"github_webhook_secret": "whsec_..."
}'

All three are null by default. Leaving them unset is fine as long as every org on the deployment brings its own App instead (see below) — config.github is now the fallback identity, not the only one. If an inbound webhook belongs to an installation nobody registered (platform or custom), and config.github is unset, the webhook still returns 501.

From the App’s public page (https://github.com/apps/<your-app-slug>), install it on the repos you want analysed. Note the installation ID from the URL after installing (https://github.com/settings/installations/<installation_id>) — or from the installation.id field of any webhook GitHub sends afterward.

Webhook-triggered runs have no interactive caller to supply an API key inline — they always use a credential stored for the org:

Terminal window
curl -X POST http://localhost:8000/orgs/{org_id}/llm-credentials \
-H "X-API-Key: <admin-key>" -H "Content-Type: application/json" \
-d '{"provider": "anthropic", "api_key": "sk-ant-..."}'
Terminal window
curl -X POST http://localhost:8000/orgs/{org_id}/integrations/github \
-H "X-API-Key: <admin-key>" -H "Content-Type: application/json" \
-d '{
"installation_id": "12345678",
"llm_backend": "anthropic",
"model": "claude-sonnet-5"
}'

llm_backend/model are the defaults used for every webhook-triggered analysis on this org — there’s no per-PR way to override them. Each org supports exactly one GitHub connection today. rule_type still exists on this endpoint for backward compatibility but is no longer applied to webhook analysis — auto-routing always uses each matched analyzer’s own default rule variant (a single filter can’t mean the same thing across different analyzers matched in one PR).

That’s it — open (or push to) a PR touching .tf (or any other file type an installed analyzer recognises) on a connected repo and the App comments within a few seconds to a few minutes, depending on diff size and LLM latency.


Setup — Your own App (org admin, self-service)

Section titled “Setup — Your own App (org admin, self-service)”

Instead of installing the platform’s shared App, an org can register a brand-new GitHub App under its own GitHub account/organization, using GitHub’s App Manifest flow. The App’s private key and webhook secret never pass through your browser — GitHub hands them to the server directly, server-to-server, during the flow’s final step.

This is exposed in the dashboard as a “Create GitHub App” button on the GitHub Integration page (Your own GitHub App tab), or directly via the API:

Terminal window
curl -X POST http://localhost:8000/orgs/{org_id}/integrations/github/manifest \
-H "X-API-Key: <admin-key>"
{
"manifest": { "name": "acme-review-analyzer", "hook_attributes": { "url": "..." }, "...": "..." },
"state": "a1b2c3...",
"target_url": "https://github.com/settings/apps/new"
}

state is a single-use, 10-minute token — GitHub round-trips it back verbatim. manifest is the JSON payload GitHub’s App-creation form expects.

This has to be a real, top-level browser form submission (not a fetch/XHR call) — GitHub performs its own redirect afterward, which only a top-level navigation can receive:

<form method="post" action="https://github.com/settings/apps/new?state=a1b2c3...">
<input type="hidden" name="manifest" value='{"name": "acme-review-analyzer", ...}' />
<button type="submit">Create GitHub App</button>
</form>

The dashboard does exactly this. The admin lands on GitHub’s own App-creation confirmation page, reviews the requested permissions, and clicks Create GitHub App.

3. GitHub redirects back — the server completes the exchange

Section titled “3. GitHub redirects back — the server completes the exchange”

GitHub redirects the browser to GET /integrations/github/manifest/callback?code=...&state=.... The server:

  1. Resolves state (single-use — consumed on this call) back to the org/admin that started the flow.
  2. Exchanges code for the new App’s identity via GitHub’s POST /app-manifests/{code}/conversions — this is where the private key and webhook secret actually arrive, straight from GitHub to the server.
  3. Encrypts and stores them on the org’s integration record.
  4. Redirects the browser back to the dashboard ({dashboard_base_url or public_base_url}/integrations/github?app_created=1).

The callback response includes an html_url — GitHub’s page for installing the newly created App on repositories. Once installed, the App’s installation_id is captured automatically from the installation webhook event GitHub fires — no manual paste needed, unlike the platform-App path.

Set the LLM backend/model the same way as the platform-App path (POST /orgs/{org_id}/integrations/github), just omit installation_id — omitting it never overwrites the auto-captured value:

Terminal window
curl -X POST http://localhost:8000/orgs/{org_id}/integrations/github \
-H "X-API-Key: <admin-key>" -H "Content-Type: application/json" \
-d '{"llm_backend": "anthropic", "model": "claude-sonnet-5"}'
Terminal window
curl -X DELETE http://localhost:8000/orgs/{org_id}/integrations/github/app \
-H "X-API-Key: <admin-key>"

Clears the org’s custom App credentials only — installation_id and LLM config are left as-is (you’ll need to reconnect an installation of the platform App afterward). This does not delete the App on GitHub’s side; do that yourself via GitHub’s UI if you want it gone entirely.


PR comment — the same terse markdown_light report format used elsewhere (CRITICAL/HIGH findings only, with suggested fixes), already filtered through each finding’s triage status: a finding suppressed via PATCH /findings/{resource_hash}/{rule_id} on a previous push won’t reappear on the next one.

Commit statuscontext: opentremor-core. state: failure if any open finding is CRITICAL or HIGH severity. Otherwise state: pending if any finding is needs_review (see Human-in-the-loop) — routed there automatically on low LLM confidence, or manually via a rule marked requires_review, or a human PATCHing the status directly; pending reads as “awaiting a decision” rather than “broken,” but still holds a required check the same way failure does. Otherwise state: success. Suppressed, acknowledged, and false-positive findings never block the status — that’s the point of triaging them. Neither threshold is yet configurable per org.


MethodPathAuthDescription
POST/orgs/{org_id}/integrations/githuborg-adminConnect (upsert) LLM config; installation_id optional
GET/orgs/{org_id}/integrations/githuborg-adminCurrent config, including custom App info if any (never the private key/webhook secret)
DELETE/orgs/{org_id}/integrations/githuborg-adminDisconnect everything
POST/orgs/{org_id}/integrations/github/manifestorg-adminStart the manifest flow for a custom App
DELETE/orgs/{org_id}/integrations/github/apporg-adminDrop the custom App only, revert to the platform App
GET/integrations/github/manifest/callbackpublic (state token)GitHub’s redirect target — completes App creation
POST/integrations/github/webhooksignatureWebhook receiver

See Integrations for full request/response shapes and status codes.


SymptomLikely cause
Manifest trigger returns 501config.server.public_base_url isn’t set — required so GitHub has a real URL to call back into
Manifest callback returns 400State token expired (10-minute TTL) or already used — restart the flow
Webhook returns 501Neither this installation’s own App nor the platform’s config.github is configured
Webhook returns 401Signature mismatch — for a custom App, check the webhook secret GitHub shows on the App’s settings page still matches what the manifest exchange stored (re-run the manifest flow to rotate it); for the platform App, check config.github.webhook_secret
Webhook returns 200 but nothing happensEither the event/action isn’t handled (only pull_request opened/synchronize/reopened trigger analysis, installation created/deleted (auto-)connect/disconnect installation_id) or the installation isn’t registered to any org yet
Job ends up failed, no comment postedPoll GET /jobs/{job_id} and read error — usually a missing/invalid stored LLM credential, or the org’s monthly budget already exceeded (see Billing / quota)
Job ends up done with resource_count: 0, no findingsNo installed analyzer matched any changed file — check the job’s detections (GET /jobs/{job_id}) for why: nothing recognises that file type, or the matching analyzer is disabled for the org (POST /orgs/{org_id}/analyzers/{name} with enabled: true to re-enable)