mirror your GitHub repos to tangled.org automatically
1import { Agent } from '@atproto/api'
2import type { OAuthSession } from '@atproto/oauth-client-node'
3import { now as tidNow } from '@atcute/tid'
4import { sql } from 'drizzle-orm'
5import { repoMapping } from '../db/schema'
6import { useDb } from './db'
7import { installationOctokit } from './github-app'
8
9const REPO_LEXICON = 'sh.tangled.repo'
10const REPO_CREATE_NSID = 'sh.tangled.repo.create'
11
12/**
13 * Default knot for users with no `sh.tangled.knot` records. PLAN.md "Open
14 * questions" #1: confirm with the tangled team that this is the right
15 * appview-hosted default.
16 */
17const DEFAULT_KNOT = 'knot1.tangled.sh'
18
19export interface EnrolResult {
20 status: 'enrolled' | 'already' | 'skipped'
21 reason?: 'private' | 'fork' | 'no-identity'
22}
23
24/**
25 * Enroll a single GitHub repo on tangled.
26 *
27 * Flow:
28 * 1. Skip if a `repo_mapping` row already exists.
29 * 2. Fetch GitHub repo metadata via the install token. Skip private/fork.
30 * 3. Pick a knot (user default → `DEFAULT_KNOT`).
31 * 4. Get a service-auth JWT for `(aud=did:web:<knot>, lxm=sh.tangled.repo.create)`.
32 * 5. POST to `https://<knot>/xrpc/sh.tangled.repo.create` with
33 * `{ rkey, name, source, defaultBranch }`. The knot clones the repo from
34 * `source` and mints a `repoDid`.
35 * 6. Write a `sh.tangled.repo` record on the user's PDS.
36 * 7. Insert the `repo_mapping` row.
37 */
38export async function enrollRepo(opts: {
39 oauthSession: OAuthSession
40 installationId: number
41 githubRepoId: number
42}): Promise<EnrolResult> {
43 const db = useDb()
44
45 const existing = await db.select({ id: repoMapping.id })
46 .from(repoMapping)
47 .where(sql`${repoMapping.installationId} = ${opts.installationId} AND ${repoMapping.githubRepoId} = ${opts.githubRepoId}`)
48 if (existing.length > 0) {
49 return { status: 'already' }
50 }
51
52 // 1. GitHub repo metadata.
53 const octokit = await installationOctokit(opts.installationId)
54 const { data: repo } = await octokit.request('GET /repositories/{repository_id}', {
55 repository_id: opts.githubRepoId,
56 })
57
58 if (repo.private) return { status: 'skipped', reason: 'private' }
59 if (repo.fork) return { status: 'skipped', reason: 'fork' }
60
61 const [owner, name] = repo.full_name.split('/')
62 if (!owner || !name) {
63 throw new Error(`unexpected github full_name shape: ${repo.full_name}`)
64 }
65
66 // 2. Pick a knot. Users *can* configure additional knots; v1 always uses
67 // the default. Wiring user choice through is dashboard work.
68 const knot = DEFAULT_KNOT
69
70 // 3. Service-auth JWT for the knot procedure.
71 const agent = new Agent(opts.oauthSession)
72 const aud = `did:web:${knot}`
73 const exp = Math.floor(Date.now() / 1000) + 60
74 const { data: { token } } = await agent.com.atproto.server.getServiceAuth({
75 aud,
76 lxm: REPO_CREATE_NSID,
77 exp,
78 })
79
80 // 4. Knot procedure call. Tangled mints a repoDid here and starts cloning
81 // from `source`.
82 const rkey = tidNow()
83 const sourceUrl = `https://github.com/${owner}/${name}`
84 const knotResponse = await fetch(`https://${knot}/xrpc/${REPO_CREATE_NSID}`, {
85 method: 'POST',
86 headers: {
87 'authorization': `Bearer ${token}`,
88 'content-type': 'application/json',
89 },
90 body: JSON.stringify({
91 rkey,
92 name,
93 source: sourceUrl,
94 defaultBranch: repo.default_branch,
95 }),
96 })
97 if (!knotResponse.ok) {
98 const body = await knotResponse.text()
99 throw new Error(`knot ${knot} returned ${knotResponse.status}: ${body}`)
100 }
101 const knotJson: { repoDid?: string } = await knotResponse.json()
102 const { repoDid } = knotJson
103 if (!repoDid) {
104 throw new Error(`knot ${knot} returned no repoDid`)
105 }
106
107 // 5. PDS record so the appview firehose discovers the repo.
108 await agent.com.atproto.repo.putRecord({
109 repo: opts.oauthSession.did,
110 collection: REPO_LEXICON,
111 rkey,
112 record: {
113 $type: REPO_LEXICON,
114 name,
115 knot,
116 repoDid,
117 createdAt: new Date().toISOString(),
118 },
119 })
120
121 // 6. Persist mapping.
122 await db.insert(repoMapping).values({
123 installationId: opts.installationId,
124 githubRepoId: opts.githubRepoId,
125 githubFullName: repo.full_name,
126 tangledRepoDid: repoDid,
127 tangledFullName: `${opts.oauthSession.did}/${name}`,
128 knot,
129 status: 'active',
130 })
131
132 return { status: 'enrolled' }
133}