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