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