konflate

July 25, 2026 · View on GitHub

konflate

Review your GitOps pull requests as rendered Flux diffs — not raw file diffs.

CI Release License

A one-line bump to a Flux resource — a HelmRelease chart version, an OCIRepository tag, a Kustomization edit — can add, remove, or mutate dozens of rendered Kubernetes resources. The git diff shows the line; it doesn't show that. konflate does: it renders the Flux cluster at the PR's merge-base and at its head using flate, diffs the two, and presents the result as a GitHub-style review UI with the blast radius, image changes, render failures, and heuristic danger flags surfaced up front.

How it works

  1. konflate lists the open pull requests for one repository from its forge (GitHub / GitLab / Forgejo, cloud or self-hosted) using the native Go SDK.

  2. For each PR it clones the repo, computes merge-base(head, target), and extracts both trees (so changes that landed on the base branch after the PR opened don't pollute the diff — exactly how GitHub computes a PR diff).

  3. It renders the Flux cluster at both trees with flate (two orchestrators sharing one source cache) and pairs the outputs into resource-level changes.

  4. It produces a DiffResult: per-resource YAML diffs with server-side syntax highlighting (with word-level intra-line highlighting and expandable folded context), a navigation tree (HelmRelease/Kustomization → kind → resource), plus the review signals — impact (blast radius), image changes, render failures, and danger lint (data-loss, privilege, RBAC, availability, and immutable-field changes — a StatefulSet volumeClaimTemplates bump, a workload selector edit, a PVC storage-class swap or shrink, a roleRef change — that read like ordinary diffs but wedge the apply with "field is immutable" until the resource is recreated). The lint also reasons about what Flux will actually do on merge: changes under a suspended Kustomization/HelmRelease (they won't roll out), a PR flipping spec.suspend (resuming applies everything accumulated while parked, at once), and removal semantics under prune — a pruning Kustomization really deletes the resource in-cluster, a non-pruning one orphans it, silently left running.

    The image changes signal lists the container and initContainer image references that changed across every rendered workload — so it captures whatever the charts and kustomizations actually deploy: app images, sidecars, and controller images pulled in by OCI Helm charts alike, each keyed to the workloads that reference it. (A chart's own OCI artifact version bump shows up as a changed HelmRelease/OCIRepository resource in the diff; its effect on the running images surfaces here.)

  5. The three-panel web UI renders it — PRs on the left, changed resources in the middle, the diff on the right — and updates live over a websocket as renders complete. Diff rendering runs in a bounded, per-PR-coalescing job queue.

By default konflate is read-only toward your forge — it writes nothing back. Write-back is opt-in: turn it on and konflate posts a commit status and/or a summary comment on each rendered PR, linking back to the review. Even then its HTTP surface stays read-only — writes come only from konflate's own render loop, using a credential held by the process, never from a visitor's request — so a public instance still exposes no way for anyone to make it write.

PRs refresh automatically — each open PR re-renders on a configurable interval (the missed-webhook backstop), and an authenticated CI push or a verified inbound webhook updates one immediately. There is no manual refresh trigger, so a public instance exposes no unauthenticated way to make it do work.

Quick start

docker run --rm -p 8080:8080 \
  -e KONFLATE_REPO='github://onedr0p/home-ops' \
  -e KONFLATE_TOKEN="$GITHUB_TOKEN" \
  ghcr.io/home-operations/konflate:rolling

Open http://localhost:8080; konflate lists the open PRs and renders them. The token is optional — without one it works against public repositories, just with the forge's lower unauthenticated API rate limit (see Authentication).

Helm (Kubernetes)

konflate publishes an OCI Helm chart to oci://ghcr.io/home-operations/charts/konflate:

helm install konflate oci://ghcr.io/home-operations/charts/konflate \
  --namespace konflate --create-namespace \
  --set config.repo='github://onedr0p/home-ops' \
  --set secret.token="$GITHUB_TOKEN"

Every value is documented in the chart's generated README, charts/konflate/README.md, built from values.yaml — which also ships a values.schema.json for editor autocompletion and helm install-time validation. The config.* and secret.* chart values map onto the KONFLATE_* environment variables in Configuration below.

Configuration

All configuration is via environment variables.

VariableDefaultDescription
KONFLATE_REPO(required)The repository, as a forge URI, e.g. github://owner/repo.
KONFLATE_TOKEN(none)Forge API token. Optional — read-only auth that raises the API rate limit and unlocks private repos. Gates no feature (see Authentication).
KONFLATE_CLUSTER_PATH(repo root)Directory flate renders from (the GitRepository root that Flux spec.path resolves against). Empty = repo root — correct for the standard ./kubernetes/... layout.
KONFLATE_PR_FILTER_EXPRtrueCEL expression deciding which PRs konflate renders (and shows by default). PRs it excludes are still listed — greyed, under a "hidden" pill — but never rendered. Evaluates against a pr variable (fields: number, title, author, draft, open, merged, state, fork, headRef, headSha, baseRef, url, createdAt, and labels as [{name, color}]) and must return a boolean; compiled and type-checked at startup. Empty defaults to true — every open PR. Forks are gated separately by KONFLATE_RENDER_FORK_PRS, so editing this can't accidentally enable them — see Filtering & forks. Example: pr.labels.exists(l, l.name == "cluster/production") && !pr.draft.
KONFLATE_RENDER_FORK_PRSfalseRender fork PRs. ⚠️ A fork runs untrusted external code through flate (SSRF / resource-exhaustion surface). Off by default — forks are listed but hidden (never rendered) until this is true and the filter admits them. Kept separate from the filter so editing the expression can't silently enable forks.
KONFLATE_RESTRICT_EGRESS(auto)flate's SSRF egress guard for source fetches — blocks dials to private / loopback / link-local / cloud-metadata addresses and rejects non-https/ssh git schemes. Unset follows KONFLATE_RENDER_FORK_PRS (on while rendering untrusted forks, off otherwise). Set true to guard every render, or false to permit private-network sources — an internal Gitea / OCI registry — even in fork mode. ⚠️ Process-wide: enabling it blocks all renders from reaching private addresses, not just forks'.
KONFLATE_WEBHOOK_SECRET(none)Secret for verifying inbound webhooks. Set it to enable POST /hooks; unset ⇒ 501.
KONFLATE_PUSH_TOKEN(none)Bearer token for the CI push endpoint. Set it to enable POST /api/prs/{n}/refresh; unset ⇒ 501.
KONFLATE_STATUS_CHECKSfalseOpt-in: post a commit status on each rendered PR head. Needs a write credential (below) and stays off until both are set. See Write-back.
KONFLATE_STATUS_CHECK_NAMEKonflateName the commit status konflate posts under (the required-check name in a branch-protection rule). Empty defaults to Konflate. See Write-back.
KONFLATE_PR_COMMENTSfalseOpt-in: post (and update in place) a PR comment with the rendered summary on each successful render. Needs a write credential (below) and stays off until both are set; independent of KONFLATE_STATUS_CHECKS. See Write-back.
KONFLATE_PR_COMMENT_TEMPLATE_FILE(none)Path to a Go text/template rendering the PR-comment body, replacing the built-in summary. The konflate marker is injected automatically. Values: .PR, .Diff, .ReviewURL, .Summary. Empty = the default body. With the Helm chart, set config.prCommentTemplate (the content) instead. See Write-back.
KONFLATE_WRITE_TOKEN(none)Write credential for write-back, kept separate from KONFLATE_TOKEN so it carries only write scope. The universal option (and the only one on GitLab/Forgejo); on GitHub, prefer the App credentials below. See Write-back.
KONFLATE_APP_CLIENT_ID(none)GitHub App client id (GitHub only) — the preferred identity, authenticating both reads (raising the API rate limit, so no separate KONFLATE_TOKEN is needed) and write-back. With the key below, konflate mints short-lived installation tokens instead of carrying a standing PAT; the installation is auto-detected from the repo.
KONFLATE_APP_PRIVATE_KEY(none)GitHub App PEM private key (GitHub only).
KONFLATE_PUBLIC_URL(none)konflate's externally-reachable base URL, e.g. https://konflate.example.com. Used only to build the review link a posted status/comment points back to; unset ⇒ posted with no link.
KONFLATE_MCPfalseServe a read-only MCP endpoint at /mcp so an AI agent can query konflate's rendered-diff analysis (open PRs + their summaries) via the list_pull_requests / get_pr_summary / get_pr_diff tools. Read-only — the same data as the API, triggering no render or write. Off by default (a new surface); secure it at your ingress if exposed. See HTTP endpoints.
KONFLATE_VERIFY_IMAGESfalseCheck that each container image a PR newly references exists in its registry (a HEAD on the tag/digest) and raise an image-not-found caution for any that's absent — catching a typo'd or not-yet-pushed image before it ImagePullBackOffs in-cluster. Auth/network errors are skipped (never a false "missing"). ⚠️ Only trusted (non-fork) PRs are checked: a fork's images are attacker-chosen, so dialing a registry named there is an SSRF vector — fork verification (behind an egress allowlist) is a deliberate follow-up. Private registries: mount a dockerconfigjson and point DOCKER_CONFIG at it (go-containerregistry's keychain is host-scoped, so creds never leak cross-registry); konflate never reads a manifest's imagePullSecrets (it renders YAML and holds no cluster/decryption state).
KONFLATE_IMAGE_VERIFY_TIMEOUT5sPer-image registry-check timeout (see KONFLATE_VERIFY_IMAGES). The whole step is also bounded by KONFLATE_DIFF_TIMEOUT.
KONFLATE_PORT8080Main HTTP port (UI, API, websocket, webhook).
KONFLATE_METRICS_ENABLEDtrueServe Prometheus /metrics on the separate metrics port; false removes that listener entirely (probes are unaffected — they ride the main port).
KONFLATE_METRICS_PORT8081Metrics listen port (/metrics only), kept off the main, possibly public-facing port.
KONFLATE_LOG_LEVELinfodebug, info, warn, or error.
KONFLATE_LOG_FORMATjsonjson or text.
KONFLATE_CACHE_DIRXDG cacheflate source cache (Helm charts, OCI layers, git) and konflate's rendered diffs (a state/ subdir). Persist it across restarts so open PRs reload instantly and the merged shelf survives.
KONFLATE_CLONE_DIR$TMPDIRBase directory for ephemeral per-diff clones (cleaned up after each render).
KONFLATE_MAX_DIFF_CONC(auto)Max concurrent diff renders. Unset/0 auto-derives from the CPU budget (GOMAXPROCS, capped at 4); higher = more throughput, more memory. Renders whose previous run fanned out to a large share of the repo (e.g. a Flux distribution/operator bump re-rendering every app) are additionally serialized — at most one such sweeping render runs at a time, with routine renders keeping the full concurrency — so a batch of repo-wide PRs can't multiply peak memory past the pod's limit.
KONFLATE_REFRESH_INTERVAL30mGo duration. The open-PR list is reconciled this often, and an open PR whose head advanced re-renders — the missed-webhook backstop. 0 disables periodic refresh entirely (inbound webhooks/pushes become the only triggers); a positive value is floored to 1m so a tiny interval can't hot-loop the forge API. (A PR whose head is unchanged re-renders on KONFLATE_RERENDER_INTERVAL instead.)
KONFLATE_RERENDER_INTERVAL6hGo duration. How often the backstop re-renders an open PR whose head SHA is unchanged since its last render — only mutable upstream sources (floating chart tags, :latest, a moving GitRepository ref) can change the output at the same SHA, so this runs slower than the refresh interval to skip re-running the full helm/kustomize pipeline for an identical diff. A head-SHA change still re-renders promptly. 0 falls back to KONFLATE_REFRESH_INTERVAL (re-render unchanged PRs as often as the list reconciles); raise it for fully digest-pinned repos. Floored to 1m when positive.
KONFLATE_CLOSED_PR_MAX25Max merged PRs kept on the "recently merged" shelf below the open list (most-recent win). 0 disables the count cap. Each retained PR holds its rendered diff, so this bounds disk + memory; with KONFLATE_CLOSED_PR_TTL=0 too (and a persistent cache volume) merged diffs are kept forever — durable permalinks.
KONFLATE_CLOSED_PR_TTL336hHow long a merged PR stays on the shelf before pruning (Go duration, e.g. 720h = 30d). 0 disables the age cap. The shelf is persisted under KONFLATE_CACHE_DIR, so it survives a restart when that volume is durable.
KONFLATE_MERGE_COMMAND(per forge)Go text/template for the Copy to merge command shown on the review screen and PR list (konflate never runs it — you paste it into your own shell). Empty = the forge default (gh/glab/tea). Only .Number and .Repo are exposed, both shell-safe.

Merged PRs move to a collapsed Recently merged group below the open list (their diff is frozen at merge time); abandoned (closed-unmerged) PRs are dropped immediately.

Filtering & forks

konflate decides what to render with two independent gates, AND-ed together:

  1. KONFLATE_PR_FILTER_EXPR — a CEL boolean over the pr fields above (compiled and type-checked at startup; a malformed expression fails fast) that says which PRs to render. Default true (every open PR).
  2. KONFLATE_RENDER_FORK_PRS — a plain on/off switch for forks, off by default.

A PR renders only if the expression admits it and (it isn't a fork, or fork rendering is on). Anything excluded by either gate is still tracked and listed — greyed, under a "hidden" pill, out of the default view — but never rendered, so its code never runs.

Forks are off by default because rendering one runs untrusted external code through flate — it fetches whatever sources the fork declares (SSRF, resource exhaustion). Crucially the fork gate is separate from the expression, so narrowing the filter for unrelated reasons can never accidentally enable forks. Narrow what renders with the expression:

# only PRs labelled for the production cluster, excluding drafts
KONFLATE_PR_FILTER_EXPR='pr.labels.exists(l, l.name == "cluster/production") && !pr.draft'
# everything except Renovate's PRs, on the main branch
KONFLATE_PR_FILTER_EXPR='pr.author != "renovate[bot]" && pr.baseRef == "main"'

To render fork PRs, flip the gate on — and ideally still scope which ones with the expression:

# ⚠️ renders untrusted external code from every fork the filter admits
KONFLATE_RENDER_FORK_PRS=true
# forks only from one trusted contributor (gate on + an author filter)
KONFLATE_RENDER_FORK_PRS=true
KONFLATE_PR_FILTER_EXPR='!pr.fork || pr.author == "trusted-contributor"'

Turning the fork gate on also activates flate's SSRF egress guard by default (KONFLATE_RESTRICT_EGRESS unset ⇒ follows the gate): source fetches can no longer reach private / loopback / link-local / cloud-metadata addresses, closing the SSRF vector a fork's attacker-chosen sources open. If your own sources live on a private network (an internal Gitea / OCI registry), set KONFLATE_RESTRICT_EGRESS=false to opt back out — but understand you're re-opening that vector. The guard is process-wide, so it applies to every render, not just forks'.

Keep the fork gate off on any public or shared instance.

List pills

Above the PR list, a row of pills summarises the open set and doubles as a one-click filter (click a pill to narrow the list, click again to clear — or type status:<name> in the search box). Each shows a count of matching open PRs:

PillColourShows
openblueThe default view — every open, non-hidden PR.
failureredPRs with at least one resource flate could not render (broken templating), surfaced before merge.
cautionamberPRs carrying a heuristic danger flag — data-loss, privilege, RBAC, availability, or a major version bump. Advisory only; konflate never blocks.
routinegreenPRs whose diff is only container-image and/or chart-version changes — the helm.sh/chart / app.kubernetes.io/version labels and Flux source version refs — with no cautions and no failures. The ordinary Renovate/Flux bump.
mergedpurpleThe collapsed Recently merged shelf (each diff frozen at merge time).
hiddengreyPRs excluded by the render gates above — listed but never rendered.

routine describes the shape of the diff, not a safety verdict. It means konflate saw nothing change except image tags and chart-version metadata — it does not inspect what the new image does at runtime, so a routine-looking bump can still ship a regression, a new CVE, or a changed default. Treat it as "this is the easy pile to triage," not "this is safe to merge unread." (A major version bump raises a caution, so it surfaces under caution, not routine.)

Multi-cluster monorepos

A konflate instance tracks one repository and renders one cluster — the Flux entry point at KONFLATE_CLUSTER_PATH (the repo root by default). It has no built-in notion of several clusters living in one repo.

So for a monorepo that holds more than one cluster, run one konflate per cluster and scope each with the PR filter (and, for a folder-per-cluster layout, its cluster path). The usual convention is a per-cluster PR label:

# the production instance
KONFLATE_CLUSTER_PATH='kubernetes/clusters/production'   # render this cluster (folder-per-cluster)
KONFLATE_PR_FILTER_EXPR='pr.labels.exists(l, l.name == "cluster/production")'
# the staging instance
KONFLATE_CLUSTER_PATH='kubernetes/clusters/staging'
KONFLATE_PR_FILTER_EXPR='pr.labels.exists(l, l.name == "cluster/staging")'

The filter is what keeps each instance's list to its own cluster — without it every instance would list every PR and render an empty diff for the clusters a PR doesn't touch. (Branch-per-cluster instead? Filter on the target branch, e.g. pr.baseRef == "production".) With the Helm chart these are config.clusterPath and config.prFilterExpr — one release per cluster.

Give each instance its own KONFLATE_CACHE_DIR — don't point two at the same volume. Even for the same repo, konflate guards the bare mirror and the persisted diff state with in-process locks only, so two processes sharing them would race (the same reason konflate runs as a single instance). A separate PVC per release, or a distinct subPath of one, keeps them isolated.

First-class multi-cluster support — one instance spanning a folder- or branch-per-cluster monorepo — is tracked in #54.

The forge URI

KONFLATE_REPO encodes the forge type, the (optional) self-hosted host, and the repository path in one unambiguous value:

scheme://[host]/path
  • schemegithub, gitlab, or forgejo.
  • host — a self-hosted instance (host or host:port). Omit entirely for the cloud SaaS (github.com / gitlab.com / codeberg.org).
  • pathowner/repo, or group[/subgroup]/repo for GitLab.
Forge URIResolves to
github://onedr0p/home-opsGitHub cloud
github://ghe.example.com/team/clusterGitHub Enterprise Server
gitlab://group/subgroup/clusterGitLab cloud (gitlab.com)
gitlab://gl.example.com/group/clusterself-hosted GitLab
forgejo://me/home-opsForgejo cloud (codeberg.org)
forgejo://git.example.com/me/home-opsself-hosted Forgejo

Authentication

The forge token (KONFLATE_TOKEN) is optional and used only for forge read auth — it authenticates both the API calls and the renderer's git clone/fetch, raising the API rate limit and unlocking private repositories. It gates no behaviour: konflate works the same with or without it.

Without it, an unauthenticated instance shares the forge's low anonymous rate limit (60 req/hour on GitHub). So an anonymous instance disables forge CI-status checks entirely — polling every open PR's status is two forge calls per PR per refresh, which would blow that limit and start failing the PR list itself — and the red/amber/green check pill is hidden in the UI. Everything else (rendered diffs, blast radius, image changes, cautions) is unaffected. Configure a token or GitHub App and the checks come back, on the raised rate limit. The active feature gates are surfaced on /api/meta (see api.Features) so the UI shows only what the backend feeds.

On GitHub, configuring a GitHub App (KONFLATE_APP_CLIENT_ID + KONFLATE_APP_PRIVATE_KEY) authenticates reads too — its installation token is konflate's forge identity for the API, the renderer's git clone/fetch, and write-back alike (minted fresh, never a standing PAT) — so an App-only instance clones private repos and lifts the rate limit with no separate KONFLATE_TOKEN.

The inbound endpoints are gated solely by their own secret, independent of the token:

EndpointEnabled when…Otherwise
POST /hooksKONFLATE_WEBHOOK_SECRET set501
POST /api/prs/{n}/refreshKONFLATE_PUSH_TOKEN set501

So a public, secret-less instance — even one pointed at a repo you don't own — exposes no way to make it do work: there is no manual-refresh endpoint, and the webhook/push endpoints return 501 until you set their secret. PRs still stay current via the per-PR auto-refresh (see Triggering re-renders).

Self-signed forges and registries

A self-hosted forge or registry with a private CA fails TLS verification (x509: certificate signed by unknown authority). konflate never shells out — the forge API, git fetch, Helm repos/OCI pulls, and image verification all use Go's TLS stack — so extending trust is one knob. With the Helm chart, point it at a ConfigMap (or Secret) whose entries are PEM CA certificates:

tls:
  extraCaCertsConfigMap: internal-ca

The chart mounts it at /etc/ssl/konflate and sets SSL_CERT_DIR=/etc/ssl/konflate. This is additive — the image's public-CA bundle keeps working — and needs no OpenSSL hash naming; Go reads every file in the directory. Outside the chart, set SSL_CERT_DIR (or SSL_CERT_FILE with a full bundle) on the container yourself.

Write-back

By default konflate writes nothing to your forge. Enable write-back and it reports its own result back to the PR — as a commit status, a PR comment, or both. Each is independently opt-in and off by default.

A commit status is a single check named Konflate (configurable via KONFLATE_STATUS_CHECK_NAME) on the PR's head commit: success when the diff rendered (summarising the resource / caution / failure counts), failure when it didn't, linking to the konflate review. It's the same verdict the summary endpoint's X-Konflate-Render-Status header carries — now posted by konflate itself instead of by a CI step that polls.

A PR comment carries the rendered summary (the same Markdown the summary endpoint serves). konflate posts it on a successful render and edits that one comment in place on every later render — keyed by a hidden marker, so it never piles up duplicates. Render failures stay on the commit status, so konflate won't open a "render failed" comment on a PR it can't render.

Write-back is off until you opt in — it needs a write credential and at least one of the feature toggles:

  • KONFLATE_STATUS_CHECKS=true — report the render verdict as a status check.
  • KONFLATE_PR_COMMENTS=true — post (and update in place) the summary comment.
  • a write credential — a write token, or GitHub App credentials (below).

Set KONFLATE_PUBLIC_URL as well so the status and comment link back to the review; without it they're still posted, just without a link.

With KONFLATE_STATUS_CHECKS on, a GitHub App that holds the Checks permission posts a Check Run — a pass / neutral / fail conclusion plus the rendered summary (cautions, render failures, blast radius, image changes) in the PR's Checks tab, which you can require as a merge gate (cautions are a non-blocking neutral). A write PAT, GitLab, Forgejo, or an App without checks:write posts a plain commit status instead; konflate detects the missing permission and falls back automatically, so granting checks:write upgrades an existing App with no other change. The check is upserted per head commit, so a re-render refreshes one check rather than stacking duplicates.

Write-back is best-effort: each write runs off the render path, a transient forge failure (a 5xx, a blip, a brief outage) is retried a few times with backoff, and any write that still fails is re-attempted on the PR's next render — konflate logs it but never blocks or fails a render on it. Both writes are idempotent (the status is overwritten; the comment is found by its marker and edited), so a retry can't double-post.

konflate checks the write credential once at startup. A permanent rejection (a 401/403/404 — a bad token, missing permission, or a wrong GitHub App installation / unreachable repo) disables write-back with a single clear log line rather than warning on every render; a transient failure leaves it enabled to recover on a later render.

This does not make konflate writable from the outside. Its HTTP surface stays read-only — no request, authenticated or not, can trigger a write. The status and comment are posted only by konflate's own render loop, using a credential the process holds. The change is operational, not in the request surface: a standing write credential now lives in the deployment, so scope it narrowly and treat a konflate compromise as able to write commit statuses and PR comments on that repo.

Credentials

Scopes depend on which write-back you enable: commit statuses and PR comments are governed by different permissions.

ForgeCredentialScope
GitHubGitHub App (preferred) — KONFLATE_APP_CLIENT_ID + _PRIVATE_KEYApp permissions, installed on the repo: Checks: R/W (Check Run; falls back to Commit statuses: R/W) and/or Pull requests: R/W (comments).
GitHubor a write PAT — KONFLATE_WRITE_TOKENFine-grained Commit statuses and/or Pull requests (R/W); or a classic repo:status (statuses only) / repo (also comments).
GitLabKONFLATE_WRITE_TOKENToken with the api scope and at least Developer on the project (covers both statuses and notes).
Forgejo / GiteaKONFLATE_WRITE_TOKENToken with write:repository (statuses) and, for comments, write:issue.

On GitHub the App is preferred: konflate authenticates as the App and mints short-lived installation tokens, so no long-lived PAT sits in the deployment, the bot has its own identity on the status, and access is revocable by uninstalling the App. (konflate uses the App's client id as the JWT issuer, which GitHub now recommends — no numeric app id needed.) konflate auto-detects the App's installation for the repo, so there's no installation id to configure. A fully-configured App takes precedence over KONFLATE_WRITE_TOKEN; a partial App config is a startup error rather than a silent fallback. The write token is the universal option and the only one on GitLab and Forgejo — keep it separate from KONFLATE_TOKEN so it carries only the write scope a read token shouldn't.

# GitHub App (preferred): post the status and the summary comment, linking back
# to the review. Enable either toggle on its own — they're independent.
KONFLATE_STATUS_CHECKS=true
KONFLATE_PR_COMMENTS=true
KONFLATE_APP_CLIENT_ID=Iv23li...
KONFLATE_APP_PRIVATE_KEY="$(cat konflate.private-key.pem)"
KONFLATE_PUBLIC_URL=https://konflate.example.com

# …or a write token (the only option on GitLab / Forgejo)
KONFLATE_STATUS_CHECKS=true
KONFLATE_PR_COMMENTS=true
KONFLATE_WRITE_TOKEN=...
KONFLATE_PUBLIC_URL=https://konflate.example.com

The summary endpoint remains available either way — for pulling the Markdown into a CI step yourself, rather than letting konflate post.

Custom comment body

By default the comment is konflate's summary. To control it, point KONFLATE_PR_COMMENT_TEMPLATE_FILE at a Go text/template:

## konflate · #{{ .PR.Number }} — {{ .PR.Title }}

{{ .Summary }}

_Rendered `{{ .PR.HeadSHA }}` · [review →]({{ .ReviewURL }})_

It renders against:

FieldWhat
.PRthe pull request — .PR.Number, .PR.Title, .PR.Author, .PR.HeadRef, .PR.HeadSHA, .PR.BaseRef, .PR.URL
.Diffthe rendered diff — .Diff.Impact.Resources, .Diff.Warnings, .Diff.Images, .Diff.Failures, …
.ReviewURLkonflate's review link (from KONFLATE_PUBLIC_URL), or empty
.Summarykonflate's default summary body, so you can wrap or extend it with {{ .Summary }}
.Sectionsthe summary's blocks individually, as rendered Markdown — .Sections.Impact, .Sections.Cautions, .Sections.Failures, .Sections.Images, .Sections.BlastRadius — to place à la carte instead of the whole .Summary. Each is empty when that block has nothing to show.

So you can drop the cautions and image changes wherever you like and skip the rest:

{{ if .Sections.Cautions }}> Heads up:
{{ .Sections.Cautions }}
{{ end }}
**Images bumped on #{{ .PR.Number }}**
{{ .Sections.Images }}

konflate injects its hidden marker automatically, so the comment is still found and edited in place — your template needn't include it. The template is parsed once at startup; one that fails to parse, or to render for a given PR, falls back to the built-in summary (and logs) rather than dropping the comment.

With the Helm chart, put the template content in config.prCommentTemplate (a multi-line string) — the chart mounts it as a ConfigMap and wires KONFLATE_PR_COMMENT_TEMPLATE_FILE to it:

config:
  prComments: true
  # Passed to konflate verbatim — write plain Go-template braces, no escaping.
  prCommentTemplate: |
    ## konflate · #{{ .PR.Number }} — {{ .PR.Title }}
    {{ .Summary }}

HTTP endpoints

Main server (KONFLATE_PORT):

Method & pathPurpose
GET /The web UI.
GET /api/prsTracked PRs and each one's diff-job status.
GET /api/prs/{n}/diffA PR's rendered diff (200 ready/error, 202 still rendering).
GET /api/prs/{n}/summaryThe diff's headline facts only — impact, cautions, image bumps, failures — without the per-resource render. JSON by default; Accept: text/markdown returns a paste-ready comment body (?forge=github for [!CAUTION] admonitions, else plain). Ready ⇒ 200; while rendering, JSON returns 202 and Markdown returns 503 + Retry-After (so curl --retry waits it out). Every response carries an X-Konflate-Render-Status header (ok/failures/error/pending) for CI gating — see below.
POST /api/prs/{n}/refreshAuth (bearer KONFLATE_PUSH_TOKEN) — re-render one PR. 501 unless the token is set.
POST /hooksVerified forge webhook — re-renders the affected PR. 501 unless the secret is set.
GET /wsWebsocket stream of diff-job status events.
/mcpOpt-in (KONFLATE_MCP=true) — a read-only MCP endpoint (streamable HTTP). Absent unless enabled.
GET /healthz, GET /readyzLiveness / readiness (the chart's probes target these on the main port).

Metrics server (KONFLATE_METRICS_PORT, default 8081, disable with KONFLATE_METRICS_ENABLED=false): GET /metrics only — kept off the main, possibly public-facing port. Health probes ride the main port.

MCP (AI agents). With KONFLATE_MCP=true, konflate serves a read-only Model Context Protocol endpoint at /mcp (streamable HTTP) exposing three tools — list_pull_requests (paginated, newest first), get_pr_summary, and get_pr_diff (the rendered YAML as a plain-text diff, per resource) — so an AI reviewer (Claude Code, an editor, Claude Desktop) can pull konflate's rendered blast radius, cautions, and image changes for a Flux PR instead of guessing from the raw git diff. Each PR's full diff is also an MCP resource (konflate://pr/<number>/diff) that get_pr_summary links to, so a client can fetch it on demand rather than inlining it. It serves the same data as the read API and triggers no render or forge write. Off by default (a new surface); secure it at your ingress if you expose it. Cross-origin and DNS-rebinding requests are rejected, but tool output carries PR-author-controlled text (titles, branch names, resource identifiers) — treat it as untrusted in the consuming agent, the usual prompt-injection caveat for surfacing PR content to an LLM.

The summary endpoint doubles as a PR-comment source for CI — ask for Markdown and post it straight back (one comment, edited in place on each push). While a render is in flight it answers 503 + Retry-After, so curl --retry waits it out with no polling loop of your own:

curl -fsS --retry 10 --retry-delay 3 -H 'Accept: text/markdown' \
  "https://konflate.example.com/api/prs/${PR_NUMBER}/summary" \
  | gh pr comment "${PR_NUMBER}" --body-file - --edit-last

The comment carries a <!-- konflate:pr-N --> marker so a poster can find and update its own comment. konflate keeps PRs rendered on its own (webhook / interval), so the diff is normally ready by the time CI asks anyway.

Gating the workflow on the render. Every summary response (Markdown or JSON) carries an X-Konflate-Render-Status header, so the same request that fetches the comment also tells CI whether to pass:

ValueMeaning
okRendered cleanly.
failuresRendered, but one or more resources failed to render (the diff is shown, minus those).
errorThe render itself errored — no diff produced.
pendingStill rendering. The Markdown path 503s until a terminal verdict, so --retry never leaves you here.
status=$(curl -fsS --retry 10 --retry-delay 3 -H 'Accept: text/markdown' \
  -o body.md -w '%header{x-konflate-render-status}' \
  "https://konflate.example.com/api/prs/${PR_NUMBER}/summary")
gh pr comment "${PR_NUMBER}" --body-file body.md --edit-last   # always post what rendered
[ "$status" = ok ] || { echo "::error::konflate render: ${status}"; exit 1; }

The check above blocks on both error and failures; relax it to case "$status" in ok | failures) ;; *) exit 1 ;; esac if a partial render shouldn't fail the PR. (%header{} needs curl ≥ 8.3; older curl can -D - and grep the header instead.)

Triggering re-renders

konflate lists and renders PRs at startup; after that it keeps them current itself, with two optional triggers for immediacy:

Automatically (always on) — every open PR re-renders once its last render is older than KONFLATE_REFRESH_INTERVAL (default 30m), and the open-PR list is reconciled on the same interval to pick up newly opened and merged PRs. This is the missed-webhook backstop and needs no configuration. (Merged PRs are frozen and never auto-refresh.) A webhook or push refreshing a PR resets its clock, so a busy PR isn't needlessly re-rendered and load staggers across PRs.

From a CI workflow (KONFLATE_PUSH_TOKEN set) — re-render a PR immediately after you push to it:

curl -fsS -X POST \
  -H "Authorization: Bearer ${KONFLATE_PUSH_TOKEN}" \
  https://konflate.example.com/api/prs/${PR_NUMBER}/refresh

Native webhooks (authenticated mode, KONFLATE_WEBHOOK_SECRET set) — point a forge webhook at https://konflate.example.com/hooks with the shared secret. konflate verifies the signature with the per-forge scheme automatically:

ForgeHeaderVerification
GitHubX-Hub-Signature-256HMAC-SHA256, sha256= + hex
ForgejoX-Gitea-SignatureHMAC-SHA256, bare hex
GitLabX-Gitlab-Tokenconstant-time compare of the secret

Select exactly these events when creating the webhook. konflate acts only on the event identifiers below (shown raw, since forge UI labels vary); anything else it receives just triggers a harmless coalesced re-list. The PR events keep the PR list and diffs live (including a re-render when a PR's head advances); the CI-status events drive the per-PR check indicator.

ForgePR events (raw type)CI-status events (raw type)
GitHubpull_request (UI: Pull requests)check_run + check_suite (UI: Check runs / Check suites — Actions and the Checks API) and/or status (UI: Statuses — legacy commit-status CI)
Forgejopull_request (UI: Pull Request)status (UI: Commit status)
GitLabmerge_request (Merge request events)pipeline (Pipeline events) + build (Job events)

Two clarifications, since they're easy to get wrong:

  • push events are not needed. pull_request / merge_request already fire on new commits (GitHub/Forgejo synchronize, GitLab MR update) and on open-set changes, so a push hook would only add redundant re-lists. Leave "Pushes" / "Push events" unchecked.
  • CI-status events are optional but recommended. Without them the check indicator still updates, but only on the KONFLATE_REFRESH_INTERVAL poll (default 30m) — not in real time. On checks-API CI (e.g. GitHub Actions) the status event alone won't carry check state; subscribe to check_run / check_suite too.

Rate limiting is intentionally not built in — put konflate behind your reverse proxy / ingress and rate-limit there.

Metrics

Served on the separate metrics port (KONFLATE_METRICS_PORT, default 8081 — keep it off your public ingress; health probes ride the main port):

MetricTypeMeaning
konflate_diff_jobs_totalcounterCompleted renders, by result.
konflate_diff_duration_secondshistogramRender wall-clock (clone + 2 renders).
konflate_diff_queue_depthgaugePRs queued or rendering.
konflate_pull_requestsgaugeOpen PRs tracked.
konflate_http_requests_totalcounterMain-server requests, by status class.
konflate_forge_list_errors_totalcounterFailed PR-list polls, by reason (rate_limited/error).
konflate_forge_rate_limitedgauge1 when the last PR-list poll hit a rate limit, else 0.
konflate_forge_rate_limit_reset_timestamp_secondsgaugeUnix time the rate limit resets (0 when not limited).

Plus the standard Go runtime and process collectors.

Development

mise is the single source of truth for the toolchain — both the go and node versions are pinned in .mise/config.toml, shared with go.mod and the container build, and grouped (non-automerged) in Renovate — and it is the task runner. The UI is Svelte 5 + Vite + Tailwind v4 (all latest), built into internal/web/dist and embedded via go:embed. All UI dependencies are declared in internal/web/package.json.

mise run ui-install     # install UI deps (npm ci)
mise run ui-typecheck   # svelte-check
mise run ui-build       # build the UI bundles into internal/web/dist
mise run ui-test        # Playwright headless-Chromium UI tests
mise run build          # go build ./...
mise run test           # unit + server tests (race-enabled in CI)
mise run lint           # golangci-lint
mise run generate       # regenerate the chart README + values.schema.json
mise run helm-lint      # lint the Helm chart
mise run helm-unittest  # helm-unittest template tests
mise run dev            # run konflate locally (set KONFLATE_REPO first)

Tests come in four tiers:

  • Unit — pure logic (config, diff render/lint/impact, engine pairing, webhook crypto, provider mapping) plus the HTTP server and the websocket hub driven over real sockets with a fake engine. Run by mise run test.

  • UI (mise run ui-test) — Playwright drives the real built UI in headless Chromium with the API and websocket stubbed by a fixture, asserting the 3-panel render, filtering, and split view. Runs in CI.

  • Charthelm lint, helm-unittest template tests (image/digest, secret conditionals, verbatim mergeCommand, conditional env), and a kind-backed helm test smoke check that installs the chart and probes /readyz (mise run helm-test). Run in CI.

  • Integration (-tags integration, env-gated) — renders a real PR with the real engine; skips unless KONFLATE_REPO + KONFLATE_INTEGRATION_PR are set:

    KONFLATE_REPO=github://owner/repo KONFLATE_INTEGRATION_PR=123 \
      mise run test-integration
    

Security

konflate is designed to be safe to expose internally, and to leak nothing even if it were public:

  • Read-only by default; read-only request surface always. Out of the box konflate writes nothing to your forge. Write-back (commit statuses) is opt-in and posts only from konflate's own render loop using a process-held credential — no request, authenticated or not, can make konflate write. Leave it off and konflate never touches forge state.
  • No secret leakage. Renders run with flate's missing-secrets allowance, so Kubernetes Secret values are never materialized; no API type or log line carries the forge token.
  • XSS-safe rendering. Only chroma-produced, HTML-escaped token spans are inserted as markup; every other value is set as text. A strict Content-Security-Policy (script-src 'self') blocks injected inline scripts as a backstop.
  • No unauthenticated trigger surface. There is no manual-refresh endpoint, and the webhook/push endpoints return 501 until their secret is set. See Authentication.
  • Constant-time comparison for the push token and the GitLab webhook token.

License

See LICENSE.