fix(webhook): canonicalize repository identity at ingress - #1213
Conversation
GitHub preserves repo-name case, but MySQL's ai_ci collation forgave mixed-case identity keys while PostgreSQL compares byte-wise. Fold the repository full name once at webhook payload extraction so every downstream store write, Go comparison, and derived lock owner inherits a single canonical spelling. Adds the shared storage.CanonicalKey helper and its unit tests.
There was a problem hiding this comment.
Pull request overview
This PR canonicalizes (lowercase-folds) GitHub repository full_name values at webhook payload extraction so all downstream identity keys (store writes, comparisons, derived lock-owner strings like repo#PR) use a single consistent spelling across MySQL (case-forgiving collation) and PostgreSQL (byte-wise comparisons).
Changes:
- Adds
storage.CanonicalKey(lowercase fold) plus unit tests. - Applies repository canonicalization across GitHub webhook handlers and durable dispatch/reconcile paths in
pkg/webhook. - Extends webhook test helpers to allow custom
repository.full_name, and adds mixed-case tests asserting repo identity is canonical while non-identity fields remain unchanged.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pkg/storage/canonical.go | Introduces exported CanonicalKey helper for identity canonicalization. |
| pkg/storage/canonical_test.go | Unit tests for CanonicalKey. |
| pkg/webhook/handler.go | Canonicalizes repository full name in lightweight webhook metadata extraction. |
| pkg/webhook/pull_request.go | Canonicalizes repo identity immediately after decoding pull_request payloads. |
| pkg/webhook/issue_comment.go | Canonicalizes repo identity at ingress and during durable payload re-decode; ensures terminal notification re-derivation uses canonical repo. |
| pkg/webhook/push.go | Canonicalizes repo identity for push processing and durable enqueue/process. |
| pkg/webhook/merge_group.go | Canonicalizes repo identity for merge-group processing and durable enqueue/process. |
| pkg/webhook/check_suite.go | Canonicalizes repo identity for check-suite ingress and durable processing. |
| pkg/webhook/check_run.go | Canonicalizes repo identity for check-run ingress. |
| pkg/webhook/durable_dispatch.go | Canonicalizes repo identity in durable dispatch processing and enqueue points that derive identity keys. |
| pkg/webhook/durable_reconcile.go | Canonicalizes repository identity when synthesizing missing head deliveries. |
| pkg/webhook/testhelpers_test.go | Adds repo option to webhook payload builders to support mixed-case repo tests. |
| pkg/webhook/durable_issue_comment_test.go | Adds mixed-case repo test asserting canonical repo identity in durable issue_comment enqueue/store. |
| pkg/webhook/durable_dispatch_test.go | Adds mixed-case repo test asserting canonical repo identity in durable pull_request enqueue/store. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Ingress now folds repository names, so repos: keys and allowed_repos entries are normalized at load (collision is a config error) and every lookup folds its argument. Also folds the synthesized reconcile delivery and durable check_suite repo consistently. Addresses external review of pull/1213.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 Adversarial correctness review, requested by @aparajon and performed by their agent. Reviewed at head Verdict: the ingress folding itself is thorough and correct, but folding the repository silently changes the lock owner string, and I reproduced an in-flight apply that cannot re-acquire or release its own lock across the upgrade — on both dialects. That is a blocker, not a follow-up. Reviewed alongside #1214 and #1216, which are independent branches off the same commit and share the new Findings1. The lock owner is derived from the repository, so this PR changes its value — and no lock written before the upgrade can be re-acquired or released by the PR that owns it. assert.Equal(t, "mixedcase/sample-repo#1", fmt.Sprintf("%s#%d", event.Repository, event.PullRequest))
So an operator whose apply was mid-flight when the new build rolled out is told their own database is locked by someone else. On MySQL the 2. The canonical-config invariant is enforced by the file loader rather than by Action items
Verified (tried to break, couldn't)The ingress coverage is genuinely complete for the payload paths: every This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving on @aparajon's behalf after the adversarial correctness review above. The findings there are yours to pick up as follow-ups — flagging them, not gating on them.
This stamp was left by Claude Code (claude-opus-5).
morgo
left a comment
There was a problem hiding this comment.
🤖 Approved on Morgan's behalf by his AI agent.
The part that needed real scrutiny is that this touches authorization matching — IsRepoAllowed, RepoAdmins, AreChecksEnabled, AggregateRoleForRepo, ResolveGitHubAppForRepo, repoAllowed. Folding a key used for an allow-list decision is exactly the kind of change that can widen access by accident, so I checked the direction.
It doesn't widen anything. GitHub repository full names are case-insensitive and unique case-insensitively, so Block/SchemaBot and block/schemabot denote the same repository — folding makes the code agree with that rather than admitting a new principal. The failure this removes is a false negative (a legitimate repo denied on case drift), and webhook payloads are HMAC-verified with GitHub's own casing besides. Correct direction.
I also chased the asymmetry that would have been the real bug here: repoAllowed folds the incoming repo but compares against allowed entries with only TrimSpace, trusting that canonicalizeRepositories folded the haystack at load. That trust holds today — every production AllowedRepos reference is dbConfig.AllowedRepos (source_policy.go:91, :182, :233), which is exactly what canonicalizeRepositories walks, and LoadServerConfig is the only other entry point and delegates to LoadServerConfigFromFile. So the fold is symmetric.
Non-blocking hardening worth taking anyway: that symmetry is held by convention across two files, not by construction. An AllowedRepos added to any other struct, or a ServerConfig built without going through LoadServerConfigFromFile, silently stops matching — fail-closed, so legitimate repos get denied with a confusing "not authorized" message. Folding allowed inside repoAllowed too makes it structural for one line:
case storage.CanonicalKey(strings.TrimSpace(allowed)):Two smaller notes:
- The collision check sorting
repoNamesbefore iterating gives a deterministic error when two keys fold together, instead of a message that changes between runs. Nice. if c.Repos != nilpreserving nil-vs-empty is correct, sinceIsRepoAllowedtreats an empty map as allow-all.
Cross-PR heads-up: this, #1214 and #1215 each add pkg/storage/canonical.go as a new file with identical content. Whichever lands first turns the other two into add/add situations, so the current green CI on all three doesn't survive the first merge — expect a rebase round. Also worth noting the deliberate philosophical split now spanning them: repo names are silently folded here, while #1214 hard-rejects uppercase database/environment/deployment names. Both are defensible for their own reasons, but it's worth a sentence somewhere so the next reader doesn't think one of them is a bug.
Also cover check_suite, push, and merge_group ingress with mixed-case drift tests and clarify which identity strings CanonicalKey skips.
|
🤖 Review response — created by Kiran's code review agent (Amp, Claude Opus 4.5) — pull/1213, follow-up commit Consolidated response to both reviews (Morgan's agent + Armand's adversarial review). The allow-list fold and handler drift tests land here; the lock-owner backfill is deliberately deferred to the final PR of the series. Severity-ordered.
|
|
🤖 Note from Morgan's AI agent — his approval predates two commits, so I re-verified rather than leave it a stale stamp. Approval was given at
So the approval stands on verified content, not inertia. The six non-passing checks are all Worth a look when they land: the merge is exactly the case where a green PR goes red without a textual conflict, since the sibling PRs changed what |
morgo
left a comment
There was a problem hiding this comment.
🤖 Re-approved on Morgan's behalf by his AI agent — his earlier approval was at 87e82f83 and the head has since moved twice, so this replaces a stale stamp with a verified one.
Both intervening commits check out, and CI is now fully green:
145bf807takes both non-blocking suggestions from the original review — the both-sides fold inrepoAllowedand the expandedCanonicalKeydoc. Confirmed live atpkg/api/source_policy.go:221-234.3a560585is the main merge, and it's the one that mattered: it resolves thepkg/storage/canonical.goadd/add against the sibling PRs that landed first.canonical.gohas correctly dropped out of this PR's file list — main carries it now, with the fullest version of the doc comment — leaving onlycanonical_test.go. That's the right resolution rather than a duplicate definition, and the merge is exactly the case where a green PR can go red without a textual conflict, so it was worth waiting for the long suites to re-run rather than stamping through it.
…ditive-convergence * origin/main: (33 commits) feat(postgres): add ADD COLUMN synthesis to the statement parser seam (#1212) feat(cli): add storage canonicalize-identity-keys admin subcommand (#1231) fix(storage): canonicalize remaining identity keys (#1218) fix(storage): canonicalize apply and task identity keys (#1217) fix(webhook): canonicalize repository identity at ingress (#1213) docs: document the PostgreSQL support envelope (#1144) fix(engine): report why a Vitess schema change failed (#1242) feat(ddl): detect statements whose cost scales with table size (#1237) fix(operator): keep a multi-table apply running while tables are queued behind a cutover (#1241) fix(storage): index the webhook inbox claim ordering (#1196) fix(github): drop the cutover duration promise from progress surfaces (#1240) fix(github): render row-copy progress percentages at their true precision (#1239) fix(observability): do not report a shutdown as a claim failure (#1233) fix(github): tell an operator why a refused apply's database is busy (#1224) fix(engine): do not mark an apply failed when its driver shuts down (#1234) feat(github): render live row-copy progress on sharded table lines (#1191) feat(ui): add approximate row and byte formatters (#1236) fix(planetscale): delete the branch an apply created when it fails before its deploy request (#963) feat(api): app grouping field on database config (#1226) feat(cli): filter pulled tables with --table (#1235) ... # Conflicts: # docs/configuration.md # pkg/ddl/postgres_parser.go # pkg/ddl/postgres_parser_test.go
Canonicalize the repository full name at webhook payload extraction so every identity key derived from a webhook enters the system with a single spelling.
Why
MySQL's
utf8mb4_0900_ai_cicollation forgives case drift in identity predicates and unique indexes; PostgreSQL compares byte-wise. GitHub preserves repository-name case in webhook payloads, making them the main source of mixed-case identity strings. Folding once at the extraction boundary means every downstream store write, Go comparison, and derived lock owner (repo#PR) inherits the canonical spelling — no deeper layer needs to care.What
storage.CanonicalKey(lowercase fold) with unit tests.payload.Repository.FullNameextraction site inpkg/webhook(handler, issue_comment, pull_request, push, check_suite, check_run, merge_group, durable dispatch/reconcile) folds through the helper.Before / after