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 } from './git'
8import { installationOctokit } from './github-app'
9import { loadSshCommandForInstall } from './ssh-cmd'
10import { sshHostForKnot } from './sync-push-host'
11
12export type RefType = 'branch' | 'tag'
13
14export interface CreateRefPayload {
15 installationId: number
16 githubRepoId: number
17 refType: RefType
18 /** Short ref name as GitHub delivers it (e.g. `v1.0`, `feature-x`) \u2014 NOT
19 * the `refs/...` qualified form. */
20 ref: string
21}
22
23export interface DeleteRefPayload extends CreateRefPayload {}
24
25export interface RefResult {
26 status: 'synced' | 'skipped'
27 reason?: 'no-mapping' | 'disabled' | 'not-branch-or-tag' | 'repo-gone'
28}
29
30/**
31 * Mirror a branch or tag creation from GitHub to the configured knot.
32 *
33 * Triggered by GitHub's `create` webhook event. For branches, GitHub also
34 * sends a parallel `push` event (with `before = 0000…`), so the branch will
35 * usually have been created already by the time this fires \u2014 the push to
36 * knot is then a no-op via ref-tip dedupe. For lightweight and annotated
37 * tags, no `push` event is sent, so this is the only path that creates them
38 * on the knot.
39 */
40export async function syncCreateRef(payload: CreateRefPayload): Promise<RefResult> {
41 if (payload.refType !== 'branch' && payload.refType !== 'tag') {
42 return { status: 'skipped', reason: 'not-branch-or-tag' }
43 }
44
45 const mapping = await loadActiveMapping(payload.installationId, payload.githubRepoId)
46 if ('skip' in mapping) return mapping.skip
47
48 const fullRef = qualifyRef(payload.refType, payload.ref)
49 const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-create-'))
50 let sshCleanup: (() => void) | undefined
51
52 try {
53 await git(['init', '--bare', '-q'], { cwd: tmpDir })
54
55 const octokit = await installationOctokit(payload.installationId)
56 const { token } = (await octokit.auth({ type: 'installation' })) as { token: string }
57 const githubUrl = `https://x-access-token:${token}@github.com/${mapping.githubFullName}.git`
58
59 // Fetch the ref by name. Tags carry whatever object git stores at the
60 // ref (commit for lightweight; tag object for annotated); fetch gives
61 // us all the reachable objects either way.
62 await git(
63 ['fetch', '--no-tags', '-q', githubUrl, `+${fullRef}:${fullRef}`],
64 { cwd: tmpDir, timeout: 120_000 },
65 )
66
67 const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId)
68 sshCleanup = cleanup
69 const knotUrl = `ssh://git@${sshHostForKnot(mapping.knot)}/${mapping.tangledRepoDid}`
70
71 try {
72 await git(
73 ['push', '-q', knotUrl, `+${fullRef}:${fullRef}`],
74 { cwd: tmpDir, env: { GIT_SSH_COMMAND: gitSshCommand }, timeout: 120_000 },
75 )
76 }
77 catch (err) {
78 const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : ''
79 const classified = classifyPushFailure(stderr)
80 if (classified?.reason === 'repo-gone') {
81 await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync')
82 return { status: 'skipped', reason: 'repo-gone' }
83 }
84 throw classified ?? err
85 }
86
87 // For branches we get the SHA from the local ref after fetch; for tags
88 // we still update lastSyncedRefs so a subsequent push event with the
89 // same SHA short-circuits via ref-tip dedupe.
90 const { stdout: sha } = await git(['rev-parse', fullRef], { cwd: tmpDir })
91 await updateLastSyncedRef(mapping.id, fullRef, sha.trim())
92
93 return { status: 'synced' }
94 }
95 finally {
96 sshCleanup?.()
97 try {
98 rmSync(tmpDir, { recursive: true, force: true })
99 }
100 catch {
101 // best-effort
102 }
103 }
104}
105
106/**
107 * Mirror a branch or tag deletion from GitHub to the configured knot.
108 *
109 * Triggered by GitHub's `delete` webhook event. For branches, GitHub also
110 * sends a parallel `push` event with `after = 0000\u2026`, which `syncPush`
111 * currently skips (`reason: 'deletion'`) \u2014 this is the path that actually
112 * removes the ref on the knot. Tag deletion arrives only via this event.
113 *
114 * Deletion is idempotent: if the ref doesn't exist on the knot we treat it
115 * as success. We wanted it gone, it's gone.
116 */
117export async function syncDeleteRef(payload: DeleteRefPayload): Promise<RefResult> {
118 if (payload.refType !== 'branch' && payload.refType !== 'tag') {
119 return { status: 'skipped', reason: 'not-branch-or-tag' }
120 }
121
122 const mapping = await loadActiveMapping(payload.installationId, payload.githubRepoId)
123 if ('skip' in mapping) return mapping.skip
124
125 const fullRef = qualifyRef(payload.refType, payload.ref)
126 const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-delete-'))
127 let sshCleanup: (() => void) | undefined
128
129 try {
130 // No fetch needed; we're only telling the remote to drop a ref.
131 await git(['init', '--bare', '-q'], { cwd: tmpDir })
132
133 const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId)
134 sshCleanup = cleanup
135 const knotUrl = `ssh://git@${sshHostForKnot(mapping.knot)}/${mapping.tangledRepoDid}`
136
137 // The `:<ref>` (empty source) refspec means "delete <ref> on the remote".
138 try {
139 await git(
140 ['push', '-q', knotUrl, `:${fullRef}`],
141 { cwd: tmpDir, env: { GIT_SSH_COMMAND: gitSshCommand }, timeout: 60_000 },
142 )
143 }
144 catch (err) {
145 const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : ''
146 // "remote ref does not exist" is success for our purposes \u2014 the ref is
147 // gone, which is what we wanted.
148 if (/remote ref does not exist|unable to delete.*does not exist/i.test(stderr)) {
149 await clearLastSyncedRef(mapping.id, fullRef)
150 return { status: 'synced' }
151 }
152 const classified = classifyPushFailure(stderr)
153 if (classified?.reason === 'repo-gone') {
154 await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync')
155 return { status: 'skipped', reason: 'repo-gone' }
156 }
157 throw classified ?? err
158 }
159
160 await clearLastSyncedRef(mapping.id, fullRef)
161 return { status: 'synced' }
162 }
163 finally {
164 sshCleanup?.()
165 try {
166 rmSync(tmpDir, { recursive: true, force: true })
167 }
168 catch {
169 // best-effort
170 }
171 }
172}
173
174function qualifyRef(refType: RefType, ref: string): string {
175 return refType === 'tag' ? `refs/tags/${ref}` : `refs/heads/${ref}`
176}
177
178type ActiveMapping = {
179 id: number
180 githubFullName: string
181 tangledRepoDid: string
182 knot: string
183} | { skip: RefResult }
184
185async function loadActiveMapping(installationId: number, githubRepoId: number): Promise<ActiveMapping> {
186 const db = useDb()
187 const rows = await db.select().from(repoMapping).where(
188 and(
189 eq(repoMapping.installationId, installationId),
190 eq(repoMapping.githubRepoId, githubRepoId),
191 ),
192 ).limit(1)
193
194 if (rows.length === 0) return { skip: { status: 'skipped', reason: 'no-mapping' } }
195 const row = rows[0]!
196
197 if (row.disabledAt) return { skip: { status: 'skipped', reason: 'disabled' } }
198 if (!row.tangledRepoDid || !row.knot) return { skip: { status: 'skipped', reason: 'no-mapping' } }
199
200 return {
201 id: row.id,
202 githubFullName: row.githubFullName,
203 tangledRepoDid: row.tangledRepoDid,
204 knot: row.knot,
205 }
206}
207
208async function updateLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise<void> {
209 const db = useDb()
210 await db.update(repoMapping)
211 .set({
212 lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`,
213 updatedAt: new Date(),
214 })
215 .where(eq(repoMapping.id, mappingId))
216}
217
218async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise<void> {
219 const db = useDb()
220 // jsonb minus text removes a top-level key. Safe no-op if absent.
221 await db.update(repoMapping)
222 .set({
223 lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`,
224 updatedAt: new Date(),
225 })
226 .where(eq(repoMapping.id, mappingId))
227}
228
229async function markMappingError(mappingId: number, message: string): Promise<void> {
230 const db = useDb()
231 await db.update(repoMapping)
232 .set({ status: 'error', lastError: message, updatedAt: new Date() })
233 .where(eq(repoMapping.id, mappingId))
234}
235
236function jsonbPath(ref: string): string {
237 return `"${ref.replaceAll('"', '\\"')}"`
238}