mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

1import { and, eq, sql } from 'drizzle-orm' 2import { repoMapping } from '../db/schema' 3import { useDb } from './db' 4import { RemoteRejectedError } from './git-wire/errors' 5import { installationOctokit } from './github-app' 6import { splicePush } from './splice' 7 8const ZERO_SHA = '0000000000000000000000000000000000000000' 9 10export interface PushPayload { 11 installationId: number 12 githubRepoId: number 13 ref: string 14 before: string 15 after: string 16} 17 18export interface PushResult { 19 status: 'synced' | 'skipped' 20 reason?: 'no-mapping' | 'disabled' | 'already-synced' | 'deletion' | 'repo-gone' 21} 22 23/** 24 * Mirror a single push from GitHub to the configured knot. 25 * 26 * 1. Look up the repo_mapping (installationId, githubRepoId). Skip if absent 27 * or disabled. 28 * 2. Ref-tip dedupe: if lastSyncedRefs[ref] === after, no-op. Guards against 29 * GitHub redeliveries. This is a cache only; correctness comes from the 30 * protocol-level compare-and-swap in the splice. 31 * 3. Skip ref deletions (after = 0000…). Handled by github.delete. 32 * 4. Splice: open receive-pack to the knot, fetch a thin pack of `after` 33 * from GitHub with the knot's tips as haves, pipe it straight through. 34 * Nothing touches disk. 35 * 5. Update lastSyncedRefs[ref] = after. 36 * 37 * On terminal failures (repo gone from knot, auth rejected, pack too big) we 38 * mark the mapping `status='error'` so the worker stops retrying. A lost 39 * compare-and-swap (`stale-old-sha`) and other transient failures re-throw so 40 * the queue retries with backoff; the retry re-reads the knot's tip. 41 */ 42export async function syncPush(payload: PushPayload): Promise<PushResult> { 43 const db = useDb() 44 45 const mapping = await db.select().from(repoMapping).where( 46 and( 47 eq(repoMapping.installationId, payload.installationId), 48 eq(repoMapping.githubRepoId, payload.githubRepoId), 49 ), 50 ).limit(1) 51 if (mapping.length === 0) return { status: 'skipped', reason: 'no-mapping' } 52 const row = mapping[0]! 53 54 if (row.disabledAt) return { status: 'skipped', reason: 'disabled' } 55 if (!row.tangledRepoDid || !row.knot) return { status: 'skipped', reason: 'no-mapping' } 56 57 if (payload.after === ZERO_SHA) return { status: 'skipped', reason: 'deletion' } 58 59 const lastSynced = (row.lastSyncedRefs as Record<string, string>)[payload.ref] 60 if (lastSynced === payload.after) return { status: 'skipped', reason: 'already-synced' } 61 62 const octokit = await installationOctokit(payload.installationId) 63 const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 64 65 try { 66 const result = await splicePush({ 67 installationId: payload.installationId, 68 repoFullName: row.githubFullName, 69 knot: row.knot, 70 repoDid: row.tangledRepoDid, 71 ref: payload.ref, 72 want: payload.after, 73 token, 74 }) 75 76 await db.update(repoMapping) 77 .set({ 78 lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(payload.ref)}}`}::text[], ${`"${result.sha}"`}::jsonb, true)`, 79 updatedAt: new Date(), 80 }) 81 .where(eq(repoMapping.id, row.id)) 82 83 return { status: 'synced' } 84 } 85 catch (err) { 86 if (err instanceof RemoteRejectedError && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big')) { 87 await markMappingError(row.id, terminalMessage(err)) 88 return { status: 'skipped', reason: 'repo-gone' } 89 } 90 throw err 91 } 92} 93 94function terminalMessage(err: RemoteRejectedError): string { 95 if (err.reason === 'too-big') return `pack exceeded the configured size limit; stopping sync (${err.message})` 96 if (err.reason === 'auth-rejected') return 'knot rejected our ssh key; stopping sync' 97 return 'knot reports repo no longer exists; stopping sync' 98} 99 100/** jsonb_set path argument: `refs/heads/main` becomes a single text array element. */ 101function jsonbPath(ref: string): string { 102 return `"${ref.replaceAll('"', '\\"')}"` 103} 104 105async function markMappingError(mappingId: number, message: string): Promise<void> { 106 const db = useDb() 107 await db.update(repoMapping) 108 .set({ status: 'error', lastError: message, updatedAt: new Date() }) 109 .where(eq(repoMapping.id, mappingId)) 110}