mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

at main 6.5 kB View raw
1import type { 2 CreateEvent, 3 DeleteEvent, 4 InstallationEvent, 5 InstallationRepositoriesEvent, 6 PushEvent, 7 RepositoryEvent, 8} from '@octokit/webhooks-types' 9import { verify } from '@octokit/webhooks-methods' 10import { sql } from 'drizzle-orm' 11import { installation, webhookEvent } from '#server/db/schema' 12import { kickWorker } from '#server/utils/kick-worker' 13import { enqueue } from '#server/utils/queue' 14import { revokeKeysForInstallation } from '#server/utils/tangled-pubkey' 15 16const RECOGNISED_EVENTS = new Set([ 17 'push', 18 'create', 19 'delete', 20 'repository', 21 'installation', 22 'installation_repositories', 23]) 24 25export default defineEventHandler(async event => { 26 const { githubWebhookSecret } = useRuntimeConfig() 27 if (!githubWebhookSecret) { 28 throw createError({ statusCode: 500, statusMessage: 'webhook secret not configured' }) 29 } 30 31 const signature = getRequestHeader(event, 'x-hub-signature-256') 32 const deliveryHeader = getRequestHeader(event, 'x-github-delivery') 33 const eventName = getRequestHeader(event, 'x-github-event') 34 35 if (!signature || !deliveryHeader || !eventName) { 36 throw createError({ statusCode: 400, statusMessage: 'missing required headers' }) 37 } 38 39 const rawBody = await readRawBody(event, 'utf8') 40 if (!rawBody) { 41 throw createError({ statusCode: 400, statusMessage: 'empty body' }) 42 } 43 44 const valid = await verify(githubWebhookSecret, rawBody, signature) 45 if (!valid) { 46 throw createError({ statusCode: 401, statusMessage: 'invalid signature' }) 47 } 48 49 const deliveryId = deliveryHeader.toLowerCase() 50 51 // Idempotent insert. If this delivery has already been seen, the row is not 52 // re-inserted and `inserted` is empty; we 200 and skip downstream work. 53 const db = useDb() 54 const inserted = await db 55 .insert(webhookEvent) 56 .values({ 57 deliveryId, 58 source: 'github', 59 event: eventName, 60 }) 61 .onConflictDoNothing({ target: webhookEvent.deliveryId }) 62 .returning({ deliveryId: webhookEvent.deliveryId }) 63 64 if (inserted.length === 0) { 65 return { ok: true, duplicate: true } 66 } 67 68 // Bookkeeping for installation lifecycle events. Sync work itself is enqueued 69 // by later commits; for now we just keep the `installation` table in step so 70 // those commits can FK-reference rows that already exist. 71 if (eventName === 'installation') { 72 const body = await readBody<InstallationEvent>(event) 73 const action = body.action 74 75 if (action === 'created') { 76 const account = body.installation.account 77 // GitHub's `installation.account` is `User | Enterprise | null`, but the 78 // ones we accept are user/org accounts; bail loudly on anything else. 79 if (!account || !('login' in account) || !('type' in account)) { 80 throw createError({ statusCode: 400, statusMessage: 'unsupported installation account' }) 81 } 82 const accountType = account.type === 'Organization' ? 'Organization' : 'User' 83 await db.insert(installation).values({ 84 id: body.installation.id, 85 accountLogin: account.login, 86 accountId: account.id, 87 accountType, 88 }).onConflictDoNothing({ target: installation.id }) 89 } 90 else if (action === 'deleted') { 91 // Revoke the user's sh.tangled.publicKey PDS records first; the cascade 92 // below drops the local ssh_key rows that hold the rkeys we need. 93 await revokeKeysForInstallation(body.installation.id) 94 // installation row deletion cascades to user_identity, ssh_key, repo_mapping. 95 await db.delete(installation).where(sql`${installation.id} = ${body.installation.id}`) 96 } 97 else if (action === 'suspend') { 98 await db.update(installation) 99 .set({ suspendedAt: new Date() }) 100 .where(sql`${installation.id} = ${body.installation.id}`) 101 } 102 else if (action === 'unsuspend') { 103 await db.update(installation) 104 .set({ suspendedAt: null }) 105 .where(sql`${installation.id} = ${body.installation.id}`) 106 } 107 } 108 109 // Enqueue work for events we care about. Envelope shape is the minimum the 110 // handler needs to re-fetch fresh data via the GitHub API; we never persist 111 // the raw webhook body. See PLAN.md "Deferred / follow-ups". 112 if (RECOGNISED_EVENTS.has(eventName)) { 113 await enqueueForEvent(event, eventName, deliveryId) 114 // Nudge the worker to drain the just-enqueued job now rather than on the 115 // next cron tick. Fire-and-forget via waitUntil; cron is the safety net. 116 kickWorker(event) 117 } 118 119 return { ok: true, deliveryId } 120}) 121 122async function enqueueForEvent(event: Parameters<typeof readBody>[0], eventName: string, deliveryId: string) { 123 if (eventName === 'push') { 124 const body = await readBody<PushEvent>(event) 125 if (!body.installation) return 126 await enqueue('github.push', { 127 deliveryId, 128 installationId: body.installation.id, 129 githubRepoId: body.repository.id, 130 ref: body.ref, 131 before: body.before, 132 after: body.after, 133 }) 134 } 135 else if (eventName === 'create') { 136 const body = await readBody<CreateEvent>(event) 137 if (!body.installation) return 138 await enqueue('github.create', { 139 deliveryId, 140 installationId: body.installation.id, 141 githubRepoId: body.repository.id, 142 refType: body.ref_type, 143 ref: body.ref, 144 }) 145 } 146 else if (eventName === 'delete') { 147 const body = await readBody<DeleteEvent>(event) 148 if (!body.installation) return 149 await enqueue('github.delete', { 150 deliveryId, 151 installationId: body.installation.id, 152 githubRepoId: body.repository.id, 153 refType: body.ref_type, 154 ref: body.ref, 155 }) 156 } 157 else if (eventName === 'repository') { 158 const body = await readBody<RepositoryEvent>(event) 159 if (!body.installation) return 160 await enqueue('github.repository', { 161 deliveryId, 162 installationId: body.installation.id, 163 githubRepoId: body.repository.id, 164 action: body.action, 165 }) 166 } 167 else if (eventName === 'installation_repositories') { 168 const body = await readBody<InstallationRepositoriesEvent>(event) 169 await enqueue('github.installation_repositories', { 170 deliveryId, 171 installationId: body.installation.id, 172 action: body.action, 173 addedRepoIds: 'repositories_added' in body ? body.repositories_added.map(r => r.id) : [], 174 removedRepoIds: 'repositories_removed' in body ? body.repositories_removed.map(r => r.id) : [], 175 }) 176 } 177 // 'installation' is handled inline above for bookkeeping; no job enqueued. 178}