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