···11import { userIdentity } from '~~/server/db/schema'
22+import { enqueue } from '~~/server/utils/queue'
23import { generateAndPublishKey } from '~~/server/utils/tangled-pubkey'
3445export default defineEventHandler(async event => {
···3233 oauthSession: session,
3334 installationId,
3435 })
3636+3737+ // Backfill: enqueue a single job that walks the installation's repo list
3838+ // and fans out per-repo enrolment. Doing this in the worker (rather than
3939+ // inline here) keeps the OAuth callback fast regardless of repo count, and
4040+ // gives us proper retry semantics if pagination doesn't finish in one
4141+ // worker tick.
4242+ await enqueue('tangled.backfill-installation', { installationId, page: 1 })
35433644 await sendRedirect(event, '/dashboard', 302)
3745})
+31
server/utils/github-app.ts
···11+import { App } from '@octokit/app'
22+33+let cachedApp: App | undefined
44+55+function useApp(): App {
66+ if (cachedApp) return cachedApp
77+ const appId = process.env.NUXT_GITHUB_APP_ID
88+ const privateKey = process.env.NUXT_GITHUB_APP_PRIVATE_KEY
99+ if (!appId || !privateKey) {
1010+ throw new Error('NUXT_GITHUB_APP_ID and NUXT_GITHUB_APP_PRIVATE_KEY must be set')
1111+ }
1212+ cachedApp = new App({
1313+ appId,
1414+ // Vercel env vars escape newlines; restore them so PEM parsing works.
1515+ privateKey: privateKey.replaceAll('\\n', '\n'),
1616+ })
1717+ return cachedApp
1818+}
1919+2020+export type InstallationOctokit = Awaited<ReturnType<App['getInstallationOctokit']>>
2121+2222+/** Get an Octokit pre-authed for a specific GitHub App installation. */
2323+export async function installationOctokit(installationId: number): Promise<InstallationOctokit> {
2424+ const app = useApp()
2525+ return app.getInstallationOctokit(installationId)
2626+}
2727+2828+/** Test hook. */
2929+export function clearGitHubAppCache() {
3030+ cachedApp = undefined
3131+}
+92-7
server/utils/job-handlers.ts
···11+import { sql } from 'drizzle-orm'
22+import { userIdentity } from '../db/schema'
33+import { useOAuthClient } from './atproto-oauth'
44+import { useDb } from './db'
55+import { installationOctokit } from './github-app'
16import type { JobEnvelope } from './queue'
22-import { useOAuthClient } from './atproto-oauth'
77+import { enqueue } from './queue'
38import { generateAndPublishKey } from './tangled-pubkey'
99+import { enrollRepo } from './tangled-repo'
410511/**
66- * Map of job kind → handler. Handlers are filled in by later commits:
77- * - 'github.push' → commit 12 (sync push events)
88- * - 'github.create' / 'github.delete' → commit 13 (branch/tag ref ops)
99- * - 'github.repository' → commit 14/15 (description, lifecycle)
1010- * - 'tangled.create-repo' → commit 10 (initial enrolment)
1111- * - 'atproto.publish-pubkey' → this commit (key rotation)
1212+ * Map of job kind → handler. Each commit fills in its slice:
1313+ * - 'github.push' → commit 12 (sync push events)
1414+ * - 'github.create' / 'github.delete' → commit 13 (branch/tag ref ops)
1515+ * - 'github.repository' → commit 14/15 (description, lifecycle)
1616+ * - 'github.installation_repositories' → this commit (fan-out enrolment)
1717+ * - 'tangled.backfill-installation' → this commit (paginate + fan-out)
1818+ * - 'tangled.create-repo' → this commit (per-repo enrolment)
1919+ * - 'atproto.publish-pubkey' → commit 9
1220 *
1321 * Unknown kinds throw so they surface as job failures rather than silent
1422 * acknowledgement.
···1927 'github.delete',
2028 'github.repository',
2129 'github.installation_repositories',
3030+ 'tangled.backfill-installation',
2231 'tangled.create-repo',
2332 'atproto.publish-pubkey',
2433])
3434+3535+const BACKFILL_PAGE_SIZE = 100
25362637interface PublishPubkeyPayload {
2738 did: string
2839 installationId: number
2940}
30414242+interface CreateRepoPayload {
4343+ installationId: number
4444+ githubRepoId: number
4545+}
4646+4747+interface InstallationRepositoriesPayload {
4848+ installationId: number
4949+ action: 'added' | 'removed'
5050+ addedRepoIds: number[]
5151+ removedRepoIds: number[]
5252+}
5353+5454+interface BackfillInstallationPayload {
5555+ installationId: number
5656+ page: number
5757+}
5858+3159export async function dispatch(envelope: JobEnvelope): Promise<void> {
3260 if (!KNOWN_KINDS.has(envelope.kind)) {
3361 throw new Error(`unknown job kind: ${envelope.kind}`)
···3866 const client = await useOAuthClient()
3967 const session = await client.restore(did)
4068 await generateAndPublishKey({ oauthSession: session, installationId })
6969+ return
7070+ }
7171+7272+ if (envelope.kind === 'tangled.create-repo') {
7373+ const { installationId, githubRepoId } = envelope.payload as CreateRepoPayload
7474+7575+ // Find the user identity bound to this install. If OAuth hasn't completed
7676+ // yet, drop this job silently \u2014 OAuth callback re-enqueues for all
7777+ // accessible repos at completion time, so we'll get a fresh trigger.
7878+ const db = useDb()
7979+ const identity = await db.select({ did: userIdentity.did })
8080+ .from(userIdentity)
8181+ .where(sql`${userIdentity.installationId} = ${installationId}`)
8282+ if (identity.length === 0) return
8383+8484+ const client = await useOAuthClient()
8585+ const session = await client.restore(identity[0]!.did)
8686+ await enrollRepo({ oauthSession: session, installationId, githubRepoId })
8787+ return
8888+ }
8989+9090+ if (envelope.kind === 'tangled.backfill-installation') {
9191+ const { installationId, page } = envelope.payload as BackfillInstallationPayload
9292+ const octokit = await installationOctokit(installationId)
9393+ const { data } = await octokit.request('GET /installation/repositories', {
9494+ per_page: BACKFILL_PAGE_SIZE,
9595+ page,
9696+ })
9797+9898+ // Fan out one tangled.create-repo job per repo on this page.
9999+ for (const repo of data.repositories) {
100100+ // eslint-disable-next-line no-await-in-loop -- enqueue is sequential by design
101101+ await enqueue('tangled.create-repo', { installationId, githubRepoId: repo.id })
102102+ }
103103+104104+ // If there are more pages, re-queue ourselves for the next one. This
105105+ // keeps each tick small and bounded; an install with thousands of repos
106106+ // walks through over many minutes rather than blocking one worker.
107107+ const seenSoFar = (page - 1) * BACKFILL_PAGE_SIZE + data.repositories.length
108108+ if (seenSoFar < data.total_count && data.repositories.length > 0) {
109109+ await enqueue('tangled.backfill-installation', { installationId, page: page + 1 })
110110+ }
111111+ return
112112+ }
113113+114114+ if (envelope.kind === 'github.installation_repositories') {
115115+ const { installationId, action, addedRepoIds } = envelope.payload as InstallationRepositoriesPayload
116116+ if (action !== 'added') return
117117+118118+ // Fan out one tangled.create-repo job per added repo. The fan-out keeps
119119+ // each unit small enough to fit comfortably in the per-job lease, lets
120120+ // failures retry independently, and runs the OAuth precondition check
121121+ // per repo (an install can outlive a tangled identity disconnection).
122122+ for (const id of addedRepoIds) {
123123+ // eslint-disable-next-line no-await-in-loop -- fan-out enqueue is sequential by design
124124+ await enqueue('tangled.create-repo', { installationId, githubRepoId: id })
125125+ }
41126 return
42127 }
43128
+132
server/utils/tangled-repo.ts
···11+import { Agent } from '@atproto/api'
22+import type { OAuthSession } from '@atproto/oauth-client-node'
33+import { now as tidNow } from '@atcute/tid'
44+import { sql } from 'drizzle-orm'
55+import { repoMapping } from '../db/schema'
66+import { useDb } from './db'
77+import { installationOctokit } from './github-app'
88+99+const REPO_LEXICON = 'sh.tangled.repo'
1010+const REPO_CREATE_NSID = 'sh.tangled.repo.create'
1111+1212+/**
1313+ * Default knot for users with no `sh.tangled.knot` records. PLAN.md "Open
1414+ * questions" #1: confirm with the tangled team that this is the right
1515+ * appview-hosted default.
1616+ */
1717+const DEFAULT_KNOT = 'knot1.tangled.sh'
1818+1919+export interface EnrolResult {
2020+ status: 'enrolled' | 'already' | 'skipped'
2121+ reason?: 'private' | 'fork' | 'no-identity'
2222+}
2323+2424+/**
2525+ * Enroll a single GitHub repo on tangled.
2626+ *
2727+ * Flow:
2828+ * 1. Skip if a `repo_mapping` row already exists.
2929+ * 2. Fetch GitHub repo metadata via the install token. Skip private/fork.
3030+ * 3. Pick a knot (user default → `DEFAULT_KNOT`).
3131+ * 4. Get a service-auth JWT for `(aud=did:web:<knot>, lxm=sh.tangled.repo.create)`.
3232+ * 5. POST to `https://<knot>/xrpc/sh.tangled.repo.create` with
3333+ * `{ rkey, name, source, defaultBranch }`. The knot clones the repo from
3434+ * `source` and mints a `repoDid`.
3535+ * 6. Write a `sh.tangled.repo` record on the user's PDS.
3636+ * 7. Insert the `repo_mapping` row.
3737+ */
3838+export async function enrollRepo(opts: {
3939+ oauthSession: OAuthSession
4040+ installationId: number
4141+ githubRepoId: number
4242+}): Promise<EnrolResult> {
4343+ const db = useDb()
4444+4545+ const existing = await db.select({ id: repoMapping.id })
4646+ .from(repoMapping)
4747+ .where(sql`${repoMapping.installationId} = ${opts.installationId} AND ${repoMapping.githubRepoId} = ${opts.githubRepoId}`)
4848+ if (existing.length > 0) {
4949+ return { status: 'already' }
5050+ }
5151+5252+ // 1. GitHub repo metadata.
5353+ const octokit = await installationOctokit(opts.installationId)
5454+ const { data: repo } = await octokit.request('GET /repositories/{repository_id}', {
5555+ repository_id: opts.githubRepoId,
5656+ })
5757+5858+ if (repo.private) return { status: 'skipped', reason: 'private' }
5959+ if (repo.fork) return { status: 'skipped', reason: 'fork' }
6060+6161+ const [owner, name] = repo.full_name.split('/')
6262+ if (!owner || !name) {
6363+ throw new Error(`unexpected github full_name shape: ${repo.full_name}`)
6464+ }
6565+6666+ // 2. Pick a knot. Users *can* configure additional knots; v1 always uses
6767+ // the default. Wiring user choice through is dashboard work.
6868+ const knot = DEFAULT_KNOT
6969+7070+ // 3. Service-auth JWT for the knot procedure.
7171+ const agent = new Agent(opts.oauthSession)
7272+ const aud = `did:web:${knot}`
7373+ const exp = Math.floor(Date.now() / 1000) + 60
7474+ const { data: { token } } = await agent.com.atproto.server.getServiceAuth({
7575+ aud,
7676+ lxm: REPO_CREATE_NSID,
7777+ exp,
7878+ })
7979+8080+ // 4. Knot procedure call. Tangled mints a repoDid here and starts cloning
8181+ // from `source`.
8282+ const rkey = tidNow()
8383+ const sourceUrl = `https://github.com/${owner}/${name}`
8484+ const knotResponse = await fetch(`https://${knot}/xrpc/${REPO_CREATE_NSID}`, {
8585+ method: 'POST',
8686+ headers: {
8787+ 'authorization': `Bearer ${token}`,
8888+ 'content-type': 'application/json',
8989+ },
9090+ body: JSON.stringify({
9191+ rkey,
9292+ name,
9393+ source: sourceUrl,
9494+ defaultBranch: repo.default_branch,
9595+ }),
9696+ })
9797+ if (!knotResponse.ok) {
9898+ const body = await knotResponse.text()
9999+ throw new Error(`knot ${knot} returned ${knotResponse.status}: ${body}`)
100100+ }
101101+ const { repoDid } = await knotResponse.json() as { repoDid?: string }
102102+ if (!repoDid) {
103103+ throw new Error(`knot ${knot} returned no repoDid`)
104104+ }
105105+106106+ // 5. PDS record so the appview firehose discovers the repo.
107107+ await agent.com.atproto.repo.putRecord({
108108+ repo: opts.oauthSession.did,
109109+ collection: REPO_LEXICON,
110110+ rkey,
111111+ record: {
112112+ $type: REPO_LEXICON,
113113+ name,
114114+ knot,
115115+ repoDid,
116116+ createdAt: new Date().toISOString(),
117117+ },
118118+ })
119119+120120+ // 6. Persist mapping.
121121+ await db.insert(repoMapping).values({
122122+ installationId: opts.installationId,
123123+ githubRepoId: opts.githubRepoId,
124124+ githubFullName: repo.full_name,
125125+ tangledRepoDid: repoDid,
126126+ tangledFullName: `${opts.oauthSession.did}/${name}`,
127127+ knot,
128128+ status: 'active',
129129+ })
130130+131131+ return { status: 'enrolled' }
132132+}