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 } 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}