mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

1import { mkdtempSync, rmSync } from 'node:fs' 2import os from 'node:os' 3import path from 'node:path' 4import { and, eq, sql } from 'drizzle-orm' 5import { repoMapping } from '../db/schema' 6import { useDb } from './db' 7import { classifyPushFailure, git, RemoteRejectedPushError } from './git' 8import { installationOctokit } from './github-app' 9import { loadSshCommandForInstall } from './ssh-cmd' 10import { sshHostForKnot } from './sync-push-host' 11 12const ZERO_SHA = '0000000000000000000000000000000000000000' 13 14export interface PushPayload { 15 installationId: number 16 githubRepoId: number 17 ref: string 18 before: string 19 after: string 20} 21 22export interface PushResult { 23 status: 'synced' | 'skipped' 24 reason?: 'no-mapping' | 'disabled' | 'already-synced' | 'deletion' | 'repo-gone' 25} 26 27/** 28 * Mirror a single push from GitHub to the configured knot. 29 * 30 * 1. Look up the repo_mapping (installationId, githubRepoId). Skip if absent 31 * or disabled. 32 * 2. Ref-tip dedupe: if lastSyncedRefs[ref] === after, no-op. Guards against 33 * GitHub redeliveries and v1.1's tangled-primary loop (PLAN.md). 34 * 3. Skip ref deletions (after = 0000…). Handled by github.delete in commit 13. 35 * 4. Bare-init /tmp scratch; fetch `after` from GitHub via smart-HTTP using 36 * the install token; push that ref to the knot over SSH with the 37 * install's key, force-with-lease against our last known tip. 38 * 5. Update lastSyncedRefs[ref] = after. 39 * 40 * On terminal failures (repo gone from knot, auth rejected) we mark the 41 * mapping as `status='error'` so the worker stops retrying. Transient 42 * failures (network blips, missing objects) re-throw and the queue retries 43 * with backoff. 44 */ 45export async function syncPush(payload: PushPayload): Promise<PushResult> { 46 const db = useDb() 47 48 const mapping = await db.select().from(repoMapping).where( 49 and( 50 eq(repoMapping.installationId, payload.installationId), 51 eq(repoMapping.githubRepoId, payload.githubRepoId), 52 ), 53 ).limit(1) 54 if (mapping.length === 0) return { status: 'skipped', reason: 'no-mapping' } 55 const row = mapping[0]! 56 57 if (row.disabledAt) return { status: 'skipped', reason: 'disabled' } 58 if (!row.tangledRepoDid || !row.knot) return { status: 'skipped', reason: 'no-mapping' } 59 60 if (payload.after === ZERO_SHA) return { status: 'skipped', reason: 'deletion' } 61 62 const lastSynced = (row.lastSyncedRefs as Record<string, string>)[payload.ref] 63 if (lastSynced === payload.after) return { status: 'skipped', reason: 'already-synced' } 64 65 const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-push-')) 66 let sshCleanup: (() => void) | undefined 67 68 try { 69 // 1. Bare init. No working tree, no objects until we fetch. 70 await git(['init', '--bare', '-q'], { cwd: tmpDir }) 71 72 // 2. Install-token-authed clone URL. The `x-access-token` username is 73 // GitHub's convention for installation tokens. 74 const octokit = await installationOctokit(payload.installationId) 75 const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 76 const githubUrl = `https://x-access-token:${token}@github.com/${row.githubFullName}.git` 77 78 // 3. Fetch exactly the new ref. The `<sha>:<ref>` refspec asks git to 79 // fetch the object reachable from `after` and store it under our 80 // local refs/heads/... or refs/tags/... at the same name. 81 await git( 82 ['fetch', '--no-tags', '-q', githubUrl, `+${payload.after}:${payload.ref}`], 83 { cwd: tmpDir, timeout: 120_000 }, 84 ) 85 86 // 4. Push to the knot. `force-with-lease` means "only update the ref if 87 // its current tip on the knot still matches what we last saw". Without 88 // a lease value we fall back to plain `--force` because we have no 89 // way to know the knot's current tip otherwise (we don't `ls-remote`). 90 // The lease is `<our last synced sha>` when we have one; on first 91 // sync we use plain force. 92 const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId) 93 sshCleanup = cleanup 94 95 const knotUrl = `ssh://git@${sshHostForKnot(row.knot)}/${row.tangledRepoDid}` 96 const pushRefspec = lastSynced 97 ? `--force-with-lease=${payload.ref}:${lastSynced} ${payload.after}:${payload.ref}` 98 : `+${payload.after}:${payload.ref}` 99 100 try { 101 await git( 102 ['push', '-q', knotUrl, ...pushRefspec.split(' ')], 103 { 104 cwd: tmpDir, 105 env: { GIT_SSH_COMMAND: gitSshCommand }, 106 timeout: 120_000, 107 }, 108 ) 109 } 110 catch (err) { 111 const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : '' 112 const classified = classifyPushFailure(stderr) 113 if (classified?.reason === 'repo-gone') { 114 await markMappingError(row.id, 'knot reports repo no longer exists; stopping sync') 115 return { status: 'skipped', reason: 'repo-gone' } 116 } 117 throw classified ?? err 118 } 119 120 // 5. Update last-synced tip for this ref. Use jsonb_set to leave other 121 // refs untouched. 122 await db.update(repoMapping) 123 .set({ 124 lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(payload.ref)}}`}::text[], ${`"${payload.after}"`}::jsonb, true)`, 125 updatedAt: new Date(), 126 }) 127 .where(eq(repoMapping.id, row.id)) 128 129 return { status: 'synced' } 130 } 131 finally { 132 sshCleanup?.() 133 try { 134 rmSync(tmpDir, { recursive: true, force: true }) 135 } 136 catch { 137 // best-effort 138 } 139 } 140} 141 142/** jsonb_set path argument: `refs/heads/main` becomes a single text array element. */ 143function jsonbPath(ref: string): string { 144 // Escape any double-quotes inside the ref. We only support standard git ref 145 // names which never contain quotes, but be defensive. 146 return `"${ref.replaceAll('"', '\\"')}"` 147} 148 149async function markMappingError(mappingId: number, message: string): Promise<void> { 150 const db = useDb() 151 await db.update(repoMapping) 152 .set({ status: 'error', lastError: message, updatedAt: new Date() }) 153 .where(eq(repoMapping.id, mappingId)) 154} 155 156export { RemoteRejectedPushError }