mirror your GitHub repos to tangled.org automatically
1import { sql } from 'drizzle-orm'
2import { userIdentity } from '../db/schema'
3import { useOAuthClient } from './atproto-oauth'
4import { useDb } from './db'
5import { installationOctokit } from './github-app'
6import type { JobEnvelope } from './queue'
7import { enqueue } from './queue'
8import { generateAndPublishKey } from './tangled-pubkey'
9import { enrollRepo } from './tangled-repo'
10
11/**
12 * Map of job kind → handler. Each commit fills in its slice:
13 * - 'github.push' → commit 12 (sync push events)
14 * - 'github.create' / 'github.delete' → commit 13 (branch/tag ref ops)
15 * - 'github.repository' → commit 14/15 (description, lifecycle)
16 * - 'github.installation_repositories' → this commit (fan-out enrolment)
17 * - 'tangled.backfill-installation' → this commit (paginate + fan-out)
18 * - 'tangled.create-repo' → this commit (per-repo enrolment)
19 * - 'atproto.publish-pubkey' → commit 9
20 *
21 * Unknown kinds throw so they surface as job failures rather than silent
22 * acknowledgement.
23 */
24const KNOWN_KINDS = new Set([
25 'github.push',
26 'github.create',
27 'github.delete',
28 'github.repository',
29 'github.installation_repositories',
30 'tangled.backfill-installation',
31 'tangled.create-repo',
32 'atproto.publish-pubkey',
33])
34
35const BACKFILL_PAGE_SIZE = 100
36
37interface PublishPubkeyPayload {
38 did: string
39 installationId: number
40}
41
42interface CreateRepoPayload {
43 installationId: number
44 githubRepoId: number
45}
46
47interface InstallationRepositoriesPayload {
48 installationId: number
49 action: 'added' | 'removed'
50 addedRepoIds: number[]
51 removedRepoIds: number[]
52}
53
54interface BackfillInstallationPayload {
55 installationId: number
56 page: number
57}
58
59export async function dispatch(envelope: JobEnvelope): Promise<void> {
60 if (!KNOWN_KINDS.has(envelope.kind)) {
61 throw new Error(`unknown job kind: ${envelope.kind}`)
62 }
63
64 if (envelope.kind === 'atproto.publish-pubkey') {
65 const { did, installationId } = envelope.payload as PublishPubkeyPayload
66 const client = await useOAuthClient()
67 const session = await client.restore(did)
68 await generateAndPublishKey({ oauthSession: session, installationId })
69 return
70 }
71
72 if (envelope.kind === 'tangled.create-repo') {
73 const { installationId, githubRepoId } = envelope.payload as CreateRepoPayload
74
75 // Find the user identity bound to this install. If OAuth hasn't completed
76 // yet, drop this job silently \u2014 OAuth callback re-enqueues for all
77 // accessible repos at completion time, so we'll get a fresh trigger.
78 const db = useDb()
79 const identity = await db.select({ did: userIdentity.did })
80 .from(userIdentity)
81 .where(sql`${userIdentity.installationId} = ${installationId}`)
82 if (identity.length === 0) return
83
84 const client = await useOAuthClient()
85 const session = await client.restore(identity[0]!.did)
86 await enrollRepo({ oauthSession: session, installationId, githubRepoId })
87 return
88 }
89
90 if (envelope.kind === 'tangled.backfill-installation') {
91 const { installationId, page } = envelope.payload as BackfillInstallationPayload
92 const octokit = await installationOctokit(installationId)
93 const { data } = await octokit.request('GET /installation/repositories', {
94 per_page: BACKFILL_PAGE_SIZE,
95 page,
96 })
97
98 // Fan out one tangled.create-repo job per repo on this page.
99 for (const repo of data.repositories) {
100 // eslint-disable-next-line no-await-in-loop -- enqueue is sequential by design
101 await enqueue('tangled.create-repo', { installationId, githubRepoId: repo.id })
102 }
103
104 // If there are more pages, re-queue ourselves for the next one. This
105 // keeps each tick small and bounded; an install with thousands of repos
106 // walks through over many minutes rather than blocking one worker.
107 const seenSoFar = (page - 1) * BACKFILL_PAGE_SIZE + data.repositories.length
108 if (seenSoFar < data.total_count && data.repositories.length > 0) {
109 await enqueue('tangled.backfill-installation', { installationId, page: page + 1 })
110 }
111 return
112 }
113
114 if (envelope.kind === 'github.installation_repositories') {
115 const { installationId, action, addedRepoIds } = envelope.payload as InstallationRepositoriesPayload
116 if (action !== 'added') return
117
118 // Fan out one tangled.create-repo job per added repo. The fan-out keeps
119 // each unit small enough to fit comfortably in the per-job lease, lets
120 // failures retry independently, and runs the OAuth precondition check
121 // per repo (an install can outlive a tangled identity disconnection).
122 for (const id of addedRepoIds) {
123 // eslint-disable-next-line no-await-in-loop -- fan-out enqueue is sequential by design
124 await enqueue('tangled.create-repo', { installationId, githubRepoId: id })
125 }
126 return
127 }
128
129 // Other kinds: still no-op until handlers land in their commits.
130}