mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

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