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.
Two ways to connect an App
Section titled “Two ways to connect an App”| Platform App | Your own App | |
|---|---|---|
| Who registers it | The deployment operator, once, via PATCH /admin/settings | Each org, for itself, via the dashboard or API |
| Where the private key lives | Platform-settings database overlay (superadmin-only) | Encrypted at rest per org (Fernet, same mechanism as stored LLM credentials) |
| Setup | Org pastes an installation_id after installing the shared App | Org clicks “Create GitHub App” — GitHub creates it, hands credentials straight to the server, installation_id is captured automatically |
| Good for | Getting started fast, single-tenant or trusted-tenant deployments | Multi-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.
How it works
Section titled “How it works”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)”1. Register the GitHub App
Section titled “1. Register the GitHub App”On GitHub: Settings → Developer settings → GitHub Apps → New GitHub App.
| Setting | Value |
|---|---|
| Webhook URL | https://<your-deployment>/integrations/github/webhook |
| Webhook secret | Generate a strong random value — you’ll set this via PATCH /admin/settings as github_webhook_secret |
| Permissions | Repository: Pull requests (read & write), Contents (read), Commit statuses (write) |
| Subscribe to events | Pull request |
Generate a private key for the App (Generate a private key button) and download the .pem file.
2. Configure the server
Section titled “2. Configure the server”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:
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.
3. Org admin: install the App
Section titled “3. Org admin: install the App”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.
4. Org admin: store an LLM credential
Section titled “4. Org admin: store an LLM credential”Webhook-triggered runs have no interactive caller to supply an API key inline — they always use a credential stored for the org:
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-..."}'5. Org admin: connect the integration
Section titled “5. Org admin: connect the integration”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:
1. Start the flow
Section titled “1. Start the flow”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.
2. Submit the manifest to GitHub
Section titled “2. Submit the manifest to GitHub”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:
- Resolves
state(single-use — consumed on this call) back to the org/admin that started the flow. - Exchanges
codefor the new App’s identity via GitHub’sPOST /app-manifests/{code}/conversions— this is where the private key and webhook secret actually arrive, straight from GitHub to the server. - Encrypts and stores them on the org’s integration record.
- Redirects the browser back to the dashboard (
{dashboard_base_url or public_base_url}/integrations/github?app_created=1).
4. Install the App on your repositories
Section titled “4. Install the App on your repositories”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/newThe 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:
- 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. - 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.
- 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.
5. Set the LLM backend and model
Section titled “5. Set the LLM backend and model”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:
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.
Reverting to the platform App
Section titled “Reverting to the platform App”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.
What gets posted
Section titled “What gets posted”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:
| Link | Built from | Who it’s for |
|---|---|---|
| Open the full report | server.public_base_url | Anyone 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 dashboard | server.dashboard_base_url | Members 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.
Triaging a finding refreshes both
Section titled “Triaging a finding refreshes both”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.
Full endpoint reference
Section titled “Full endpoint reference”| Method | Path | Auth | Description |
|---|---|---|---|
POST | /orgs/{org_id}/integrations/github | org-admin | Connect (upsert) LLM config; installation_id optional |
GET | /orgs/{org_id}/integrations/github | org-admin | Current config, including custom App info if any (never the private key/webhook secret) |
DELETE | /orgs/{org_id}/integrations/github | org-admin | Disconnect everything |
POST | /orgs/{org_id}/integrations/github/manifest | org-admin | Start the manifest flow for a custom App |
DELETE | /orgs/{org_id}/integrations/github/app | org-admin | Drop the custom App only, revert to the platform App |
GET | /integrations/github/manifest/callback | public (state token) | GitHub’s redirect target — completes App creation |
POST | /integrations/github/webhook | signature | Webhook receiver |
See Integrations for full request/response shapes and status codes.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
Manifest trigger returns 501 | config.server.public_base_url isn’t set — required so GitHub has a real URL to call back into |
Manifest callback returns 400 | State token expired (10-minute TTL) or already used — restart the flow |
| Manifest callback is a 404 page, not an API response | config.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 501 | Neither this installation’s own App nor the platform’s config.github is configured |
Webhook returns 401 | Signature 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 happens | Either 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 Health | The 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 posted | Poll 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 findings | No 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) |