mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

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}