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'
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@${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
148async function markMappingError(mappingId: number, message: string): Promise<void> {
149 const db = useDb()
150 await db.update(repoMapping)
151 .set({ status: 'error', lastError: message, updatedAt: new Date() })
152 .where(eq(repoMapping.id, mappingId))
153}
154
155export { RemoteRejectedPushError }