Skip to content

Commit 44c266e

Browse files
fix(webhook): reconcile org identity by immutable account id, not slug
Org rows were looked up and mutated by githubOrgSlug (mutable) everywhere, so a GitHub org rename or installation remove/re-add couldn't be reconciled: the old row became unreachable and a duplicate empty org row was created on the next installation webhook, orphaning its CLA text/signatures/bypass list. Add an `organization` webhook handler for action:"renamed" and harden handleInstallation/handleInstallationRepositories to resolve orgs by the existing (but unused) immutable githubAccountId first, reconciling a stale slug before any slug-keyed read/write. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 637c152 commit 44c266e

3 files changed

Lines changed: 174 additions & 1 deletion

File tree

app/api/webhook/github/route.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { type NextRequest, NextResponse } from "next/server"
1111
import { getGitHubClient, upsertMockPullRequest } from "@/lib/github"
1212
import {
1313
getOrganizationBySlug,
14+
getOrganizationByGithubAccountId,
15+
updateOrganizationSlug,
1416
isBypassAccountForOrg,
1517
getSignatureStatusByGithubId,
1618
getSignatureStatusByUsername,
@@ -116,6 +118,14 @@ type CheckSuitePayload = {
116118
}
117119
}
118120

121+
type OrganizationPayload = {
122+
action?: string
123+
organization?: {
124+
id?: number
125+
login?: string
126+
}
127+
}
128+
119129
type PingPayload = {
120130
zen?: string
121131
hook_id?: number
@@ -178,6 +188,10 @@ export async function POST(request: NextRequest) {
178188
return handleInstallationRepositories(payload as InstallationPayload)
179189
}
180190

191+
if (event === "organization") {
192+
return handleOrganization(payload as OrganizationPayload)
193+
}
194+
181195
if (event === "pull_request") {
182196
const prPayload = payload as PullRequestPayload
183197
const action = prPayload.action
@@ -894,6 +908,15 @@ async function handleInstallation(payload: InstallationPayload) {
894908
return NextResponse.json({ error: "Missing installation account login" }, { status: 400 })
895909
}
896910

911+
// Reconcile a renamed account before doing any slug-keyed lookup below --
912+
// the account id is immutable, the login/slug is not.
913+
const existingByAccountId = accountId
914+
? await getOrganizationByGithubAccountId(String(accountId))
915+
: undefined
916+
if (existingByAccountId && existingByAccountId.githubOrgSlug !== orgSlug) {
917+
await updateOrganizationSlug(existingByAccountId.id, orgSlug)
918+
}
919+
897920
if (payload.action === "created" || payload.action === "unsuspend") {
898921
let adminUserId = "user_1"
899922
if (payload.sender?.login && payload.sender?.id) {
@@ -909,7 +932,7 @@ async function handleInstallation(payload: InstallationPayload) {
909932
return NextResponse.json({ error: "Missing installation sender info" }, { status: 400 })
910933
}
911934

912-
const existing = await getOrganizationBySlug(orgSlug)
935+
const existing = existingByAccountId ?? (await getOrganizationBySlug(orgSlug))
913936
if (existing) {
914937
await setOrganizationActive(orgSlug, true)
915938
const updated = await updateOrganizationInstallationId(orgSlug, installationId ?? null, {
@@ -966,6 +989,13 @@ async function handleInstallationRepositories(payload: InstallationPayload) {
966989
return NextResponse.json({ error: "Missing installation account login" }, { status: 400 })
967990
}
968991

992+
if (accountId) {
993+
const existing = await getOrganizationByGithubAccountId(String(accountId))
994+
if (existing && existing.githubOrgSlug !== orgSlug) {
995+
await updateOrganizationSlug(existing.id, orgSlug)
996+
}
997+
}
998+
969999
await updateOrganizationInstallationId(orgSlug, installationId ?? null, {
9701000
githubAccountType: accountType,
9711001
githubAccountId: accountId,
@@ -977,6 +1007,35 @@ async function handleInstallationRepositories(payload: InstallationPayload) {
9771007
})
9781008
}
9791009

1010+
async function handleOrganization(payload: OrganizationPayload) {
1011+
if (payload.action !== "renamed") {
1012+
return NextResponse.json({ message: `Ignored organization action: ${payload.action}` })
1013+
}
1014+
1015+
const accountId = payload.organization?.id
1016+
const newSlug = payload.organization?.login
1017+
if (!accountId || !newSlug) {
1018+
return NextResponse.json({ error: "Missing organization id/login" }, { status: 400 })
1019+
}
1020+
1021+
const existing = await getOrganizationByGithubAccountId(String(accountId))
1022+
if (!existing) {
1023+
return NextResponse.json({
1024+
message: `No org record for account id ${accountId}, ignoring rename`,
1025+
})
1026+
}
1027+
1028+
if (existing.githubOrgSlug === newSlug) {
1029+
return NextResponse.json({ message: "Org slug already up to date" })
1030+
}
1031+
1032+
const updated = await updateOrganizationSlug(existing.id, newSlug)
1033+
return NextResponse.json({
1034+
message: `Renamed org slug ${existing.githubOrgSlug} -> ${newSlug}`,
1035+
org: updated,
1036+
})
1037+
}
1038+
9801039
export async function GET(request: NextRequest) {
9811040
if (process.env.NODE_ENV === "production") {
9821041
return NextResponse.json({ error: "Not found" }, { status: 404 })

lib/db/queries.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,33 @@ export async function getOrganizationById(id: string) {
252252
return rows[0] ?? undefined
253253
}
254254

255+
/**
256+
* Look up an org by its immutable GitHub account id. Slugs (org logins) are
257+
* mutable -- an org can be renamed on GitHub -- so any code path that needs
258+
* to survive a rename must resolve through this first and reconcile
259+
* `githubOrgSlug` via `updateOrganizationSlug` before falling back to
260+
* slug-keyed lookups/mutations.
261+
*/
262+
export async function getOrganizationByGithubAccountId(accountId: string) {
263+
const db = await ensureDbReady()
264+
const rows = await db
265+
.select()
266+
.from(organizations)
267+
.where(eq(organizations.githubAccountId, accountId))
268+
.limit(1)
269+
return rows[0] ?? undefined
270+
}
271+
272+
export async function updateOrganizationSlug(orgId: string, newSlug: string) {
273+
const db = await ensureDbReady()
274+
const rows = await db
275+
.update(organizations)
276+
.set({ githubOrgSlug: newSlug })
277+
.where(eq(organizations.id, orgId))
278+
.returning()
279+
return rows[0] ?? undefined
280+
}
281+
255282
export async function getBypassAccountsByOrg(orgId: string) {
256283
const db = await ensureDbReady()
257284
return db

tests/integration/api-suite.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1842,6 +1842,93 @@ test("Webhook: installation suspend and unsuspend toggles active state", async (
18421842
assertEqual(orgAfterUnsuspend.org.isActive, true, "org is active after unsuspend")
18431843
})
18441844

1845+
test("Webhook: organization renamed reconciles slug in place", async (baseUrl) => {
1846+
await resetDb(baseUrl)
1847+
1848+
const before = await fetch(`${baseUrl}/api/orgs/fiveonefour`).then((r) => r.json())
1849+
1850+
const { res, data } = await sendWebhook(baseUrl, "organization", {
1851+
action: "renamed",
1852+
organization: { id: 2001, login: "fiveonefour-renamed" },
1853+
})
1854+
assertEqual(res.status, 200, "status")
1855+
assert(data.message.includes("fiveonefour -> fiveonefour-renamed"), "rename message")
1856+
1857+
const oldSlugRes = await fetch(`${baseUrl}/api/orgs/fiveonefour`)
1858+
assertEqual(oldSlugRes.status, 404, "old slug no longer resolves")
1859+
1860+
const after = await fetch(`${baseUrl}/api/orgs/fiveonefour-renamed`).then((r) => r.json())
1861+
assertEqual(after.org.id, before.org.id, "same underlying org row, not a duplicate")
1862+
assertEqual(after.org.isActive, true, "org still active after rename")
1863+
assertEqual(after.signers.length, before.signers.length, "signatures preserved across rename")
1864+
})
1865+
1866+
test("Webhook: organization renamed for unknown account id is a no-op", async (baseUrl) => {
1867+
await resetDb(baseUrl)
1868+
1869+
const { res, data } = await sendWebhook(baseUrl, "organization", {
1870+
action: "renamed",
1871+
organization: { id: 999999, login: "someone-elses-org" },
1872+
})
1873+
assertEqual(res.status, 200, "status")
1874+
assert(data.message.includes("No org record"), "ignored: no matching account id")
1875+
1876+
const orgRes = await fetch(`${baseUrl}/api/orgs/someone-elses-org`)
1877+
assertEqual(orgRes.status, 404, "no org created for unrelated rename")
1878+
})
1879+
1880+
test("Webhook: installation created after external rename reconciles slug instead of duplicating", async (baseUrl) => {
1881+
await resetDb(baseUrl)
1882+
1883+
// Simulates a rename GitHub delivered as an `installation` event (e.g. a
1884+
// re-sent/backfilled `created` delivery) without a prior `organization`
1885+
// webhook having reconciled the slug yet.
1886+
const { res, data } = await sendWebhook(baseUrl, "installation", {
1887+
action: "created",
1888+
installation: {
1889+
id: 10001,
1890+
account: { login: "fiveonefour-new-name", id: 2001, type: "Organization" },
1891+
},
1892+
sender: { id: 1, login: "orgadmin" },
1893+
})
1894+
assertEqual(res.status, 200, "status")
1895+
assert(data.message.includes("fiveonefour-new-name"), "message references new slug")
1896+
1897+
const orgsRes = await fetch(`${baseUrl}/api/orgs`)
1898+
const orgsData = await orgsRes.json()
1899+
const matching = orgsData.orgs.filter((org: { githubOrgSlug: string }) =>
1900+
org.githubOrgSlug.startsWith("fiveonefour")
1901+
)
1902+
assertEqual(matching.length, 1, "no duplicate org row created for the renamed account")
1903+
1904+
const oldSlugRes = await fetch(`${baseUrl}/api/orgs/fiveonefour`)
1905+
assertEqual(oldSlugRes.status, 404, "old slug no longer resolves")
1906+
})
1907+
1908+
test("Webhook: installation_repositories after external rename reconciles slug", async (baseUrl) => {
1909+
await resetDb(baseUrl)
1910+
1911+
const { res, data } = await sendWebhook(baseUrl, "installation_repositories", {
1912+
action: "added",
1913+
installation: {
1914+
id: 10001,
1915+
account: { login: "fiveonefour-repos-renamed", id: 2001, type: "Organization" },
1916+
},
1917+
})
1918+
assertEqual(res.status, 200, "status")
1919+
assert(data.message.includes("fiveonefour-repos-renamed"), "message references new slug")
1920+
1921+
const renamedOrgRes = await fetch(`${baseUrl}/api/orgs/fiveonefour-repos-renamed`)
1922+
assertEqual(renamedOrgRes.status, 200, "org reachable under new slug")
1923+
1924+
const orgsRes = await fetch(`${baseUrl}/api/orgs`)
1925+
const orgsData = await orgsRes.json()
1926+
const matching = orgsData.orgs.filter((org: { githubOrgSlug: string }) =>
1927+
org.githubOrgSlug.startsWith("fiveonefour")
1928+
)
1929+
assertEqual(matching.length, 1, "no duplicate org row created")
1930+
})
1931+
18451932
test("Webhook: ping event is acknowledged", async (baseUrl) => {
18461933
await resetDb(baseUrl)
18471934
const { res, data } = await sendWebhook(baseUrl, "ping", {

0 commit comments

Comments
 (0)