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 { repoDid } = await knotResponse.json() as { repoDid?: string } 102 if (!repoDid) { 103 throw new Error(`knot ${knot} returned no repoDid`) 104 } 105 106 // 5. PDS record so the appview firehose discovers the repo. 107 await agent.com.atproto.repo.putRecord({ 108 repo: opts.oauthSession.did, 109 collection: REPO_LEXICON, 110 rkey, 111 record: { 112 $type: REPO_LEXICON, 113 name, 114 knot, 115 repoDid, 116 createdAt: new Date().toISOString(), 117 }, 118 }) 119 120 // 6. Persist mapping. 121 await db.insert(repoMapping).values({ 122 installationId: opts.installationId, 123 githubRepoId: opts.githubRepoId, 124 githubFullName: repo.full_name, 125 tangledRepoDid: repoDid, 126 tangledFullName: `${opts.oauthSession.did}/${name}`, 127 knot, 128 status: 'active', 129 }) 130 131 return { status: 'enrolled' } 132}