mirror your GitHub repos to tangled.org automatically
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 }