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.

github_private_key and github_webhook_secret are write-only: this PATCH sets them, and no GET ever returns them. Keep your own copy of the PEM — the API is not a place to read it back from. GET /admin/settings reports secrets_set.github_private_key: true once one is stored, which is the only readback available. In the dashboard (Platform Admin → Settings → GitHub) the fields render blank for the same reason: leave one blank to keep the stored value, paste a new one to replace it, or switch its toggle off to remove it.

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).

Creating the App is not the same as installing it. At this point the App exists under your account but is attached to no repositories, so it receives no pull request events and installation_id is still null. This step happens entirely in GitHub’s UI — nothing in the dashboard can do it for you.

The integration record stores the App’s html_url (https://github.com/apps/<app-slug>). Its install page is that URL plus /installations/new:

https://github.com/apps/<app-slug>/installations/new

The dashboard shows this as an Install on GitHub button on the GitHub integration page, in a notice that stays up until an installation is actually linked. To get there manually instead, open Settings → Developer settings → GitHub Apps on the owning account, pick your App, and choose Install App in its left-hand menu.

On GitHub’s install page:

  1. Pick the account or organization holding the repositories you want analyzed. The manifest creates the App as private ("public": false), so only the account it was created under is offered — a private App cannot be installed elsewhere. If you created it under your personal account but need it on an organization’s repos, delete it and re-run the flow with the org selected on GitHub’s App-creation page.
  2. Choose the repository scope — All repositories (including ones created later) or Only select repositories, then pick them. This is editable afterward; it isn’t a one-time decision.
  3. Click Install. Organization-owned repositories may require an owner to approve the request rather than installing immediately — the App then sits pending until they do.

Nothing needs to be copied back. Installing fires an installation created webhook, and the server stores installation_id from it automatically — the one real convenience of this path over the platform App, where you paste that id by hand. The dashboard notice disappears once it lands; use Check again to re-poll if you’re watching the page.

To change the repository scope later, or to uninstall, return to the same App under Settings → Applications → Installed GitHub Apps → Configure on the owning account. Uninstalling fires installation deleted, which clears installation_id and stops analysis without discarding the App’s stored credentials.

Same call 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"}'

In the dashboard, the LLM fields appear on the Your own GitHub App tab only after the App is registered — before that the tab has nothing to configure yet, so it shows just the create button. The manifest callback seeds the integration with anthropic and an empty model, so this step is not optional: a pull request arriving before a model is set fails the job rather than analyzing anything.

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.

One comment per pull request. Every push re-runs the analysis, and the re-run edits the comment it posted the first time rather than appending another copy — a PR pushed to ten times ends with one current report, not ten reports of which nine are stale. The comment is found again by an invisible marker on its first line, <!-- opentremor-report:{namespace} -->, and only a comment written by the App itself is ever a candidate, so pasting that marker into a comment of your own doesn’t hijack it. If the comment has been deleted, or can’t be edited for any other reason, the run posts a fresh one instead of losing the report.

Because an edit sends no notification the way a new comment does, the comment ends with a line saying when it was last rewritten and for which commit — Updated 2026-08-25 13:30 UTC — re-analysed after commit a1b2c3d. — in UTC, because the server has no idea what timezone the reader is in. The first post carries the same line in the form Analysed commit a1b2c3d · 2026-08-25 13:30 UTC.

A second marker, for replies. The body also carries <!-- opentremor:ns={namespace} -->, which the review feedback loop uses to resolve a reply back to the analysis it is replying to — a pull request is analysed many times and this comment is rewritten each time, so the reply alone doesn’t say which run it means. It is also how the loop recognises this comment as its own and declines to ingest OpenTremor’s report as reviewer feedback. Both markers are re-applied on every rewrite, including the triage refresh below, since each rewrite replaces the whole body.

Under the heading it carries up to two links to the same analysis, and which of them appear depends on what the deployment has configured:

LinkBuilt fromWho it’s for
Open the full reportserver.public_base_urlAnyone reading the PR. A public report link — no login, but time-limited (24h by default, per-org via PATCH /orgs/{org_id}/report-link-ttl). Safe to leave in a comment on a public repo precisely because it expires.
Triage in the dashboardserver.dashboard_base_urlMembers of the org. Opens /findings?namespace={namespace}, the findings list filtered to this pull request, where findings can actually be acknowledged, suppressed or marked false-positive. Never expires; useless to anyone without an account.

The namespace is gh-{owner}-{repo}-{pr_number}, so the dashboard link lands pre-filtered to that pull request’s own findings. It points at the findings list rather than the namespace’s report page on purpose: the report renders a single run, while the findings list is where triage happens and where a status persists across re-scans.

Both are rendered as HTML anchors carrying target="_blank", since either one leads away from the change under review. GitHub’s comment sanitizer drops target (its allowlist for <a> covers href and rel only), so inside a PR comment they still open in the same tab; the attribute takes effect wherever else the light report is rendered as HTML.

Both links are emitted by the built-in markdown_light template. An org that has replaced that template with its own gets whatever its template renders — add {{ report_html_url }} and {{ dashboard_url }} to keep them.

Commit status — context: 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.

Acknowledging, suppressing or marking a finding false-positive in the dashboard changes what the check and the comment should say, and neither is a poll — GitHub keeps whatever was last written until something writes again. So PATCH /findings/{resource_hash}/{rule_id} re-posts the commit status and rewrites the report comment, for every open pull request the finding feeds into.

The comment is only ever edited on this path, never created: if the PR has no report comment — never analysed, or the comment was deleted — the triage refreshes the check and leaves it at that. A comment appearing on a pull request because someone clicked Acknowledge is exactly the noise the in-place update exists to remove. Its footer says as much: Updated … — findings re-triaged; commit a1b2c3d unchanged.

Both are best-effort, in that order. The triage itself is already saved by the time any of this runs, so a GitHub outage never turns a successful PATCH into a 500 — and a comment that can’t be edited any more costs neither its own check nor the other pull requests’ their refresh.


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
Manifest callback is a 404 page, not an API responseconfig.server.public_base_url points at the dashboard rather than the API. It must be the API’s own origin — the redirect URL and webhook URL are built from it and both are API routes. On a split-subdomain topology that means https://api.<domain>, with server.dashboard_base_url carrying https://<domain> separately (see Dashboard deployment). Fix the setting, delete the half-created App on GitHub, and re-run the flow — the App that was created has the wrong webhook URL baked in and no stored credentials
Custom App stays “Waiting for the App to be installed on a repository”The App was created but never installed — creating and installing are two separate steps, and the second one only happens in GitHub’s UI. Open https://github.com/apps/<app-slug>/installations/new (the dashboard’s Install on GitHub button) and install it. installation_id is filled in automatically from the resulting webhook — see step 4. If it stays empty after installing, the App’s webhook URL is unreachable: check the row above and the App’s Advanced → Recent Deliveries tab on GitHub
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, and issue_comment/pull_request_review/pull_request_review_comment created/submitted record reviewer feedback) or the installation isn’t registered to any org yet
Reviewer comments never show up in Rule HealthThe App isn’t subscribed to the three comment events. An App registered before the review feedback loop shipped keeps its original subscriptions — add Issue comment, Pull request review and Pull request review comment in its settings on GitHub. Comments from authors without write access are recorded but marked untrusted by design, and a comment that trips the injection scanner is demoted the same way
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)