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