mirror your GitHub repos to tangled.org automatically
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}