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 { 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}