mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

1import { and, eq, sql } from 'drizzle-orm' 2import { repoMapping } from '../db/schema' 3import { useDb } from './db' 4import { RemoteRejectedError, WireError } from './git-wire/errors' 5import { fetchAdvertisement } from './git-wire/upload-pack' 6import { installationOctokit } from './github-app' 7import { spliceDelete, splicePush } from './splice' 8 9export type RefType = 'branch' | 'tag' 10 11export interface CreateRefPayload { 12 installationId: number 13 githubRepoId: number 14 refType: RefType 15 /** Short ref name as GitHub delivers it (e.g. `v1.0`, `feature-x`) — NOT 16 * the `refs/...` qualified form. */ 17 ref: string 18} 19 20export interface DeleteRefPayload extends CreateRefPayload {} 21 22export interface RefResult { 23 status: 'synced' | 'skipped' 24 reason?: 'no-mapping' | 'disabled' | 'not-branch-or-tag' | 'repo-gone' 25} 26 27/** 28 * Mirror a branch or tag creation from GitHub to the configured knot. 29 * 30 * Triggered by GitHub's `create` webhook event. For branches, GitHub also 31 * sends a parallel `push` event (with `before = 0000…`), so the branch will 32 * usually have been created already by the time this fires — the splice is 33 * then a no-op via the knot tip already matching. For lightweight and 34 * annotated tags, no `push` event is sent, so this is the only path that 35 * creates them on the knot. 36 * 37 * We resolve the ref name to a SHA from GitHub's advertisement (annotated tags 38 * resolve to the tag object), then splice that SHA to 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 50 const octokit = await installationOctokit(payload.installationId) 51 const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 52 53 const adv = await fetchAdvertisement(mapping.githubFullName, token) 54 const want = adv.refs.get(fullRef) 55 if (!want) { 56 // GitHub can deliver the create webhook before its replicas advertise the 57 // ref. Transient: re-throw so the queue retries with backoff. 58 throw new WireError(`github does not yet advertise ${fullRef} for ${mapping.githubFullName}`) 59 } 60 61 try { 62 const result = await splicePush({ 63 installationId: payload.installationId, 64 repoFullName: mapping.githubFullName, 65 knot: mapping.knot, 66 repoDid: mapping.tangledRepoDid, 67 ref: fullRef, 68 want, 69 token, 70 }) 71 await updateLastSyncedRef(mapping.id, fullRef, result.sha) 72 return { status: 'synced' } 73 } 74 catch (err) { 75 if (err instanceof RemoteRejectedError && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big')) { 76 await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync') 77 return { status: 'skipped', reason: 'repo-gone' } 78 } 79 throw err 80 } 81} 82 83/** 84 * Mirror a branch or tag deletion from GitHub to the configured knot. 85 * 86 * Triggered by GitHub's `delete` webhook event. For branches, GitHub also 87 * sends a parallel `push` event with `after = 0000…`, which `syncPush` 88 * currently skips (`reason: 'deletion'`) — this is the path that actually 89 * removes the ref on the knot. Tag deletion arrives only via this event. 90 * 91 * Deletion is idempotent: if the ref is already absent on the knot we treat it 92 * as success. We wanted it gone, it's gone. 93 */ 94export async function syncDeleteRef(payload: DeleteRefPayload): Promise<RefResult> { 95 if (payload.refType !== 'branch' && payload.refType !== 'tag') { 96 return { status: 'skipped', reason: 'not-branch-or-tag' } 97 } 98 99 const mapping = await loadActiveMapping(payload.installationId, payload.githubRepoId) 100 if ('skip' in mapping) return mapping.skip 101 102 const fullRef = qualifyRef(payload.refType, payload.ref) 103 104 try { 105 await spliceDelete({ 106 installationId: payload.installationId, 107 knot: mapping.knot, 108 repoDid: mapping.tangledRepoDid, 109 ref: fullRef, 110 }) 111 await clearLastSyncedRef(mapping.id, fullRef) 112 return { status: 'synced' } 113 } 114 catch (err) { 115 if (err instanceof RemoteRejectedError && err.reason === 'repo-gone') { 116 await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync') 117 return { status: 'skipped', reason: 'repo-gone' } 118 } 119 throw err 120 } 121} 122 123function qualifyRef(refType: RefType, ref: string): string { 124 return refType === 'tag' ? `refs/tags/${ref}` : `refs/heads/${ref}` 125} 126 127type ActiveMapping = { 128 id: number 129 githubFullName: string 130 tangledRepoDid: string 131 knot: string 132} | { skip: RefResult } 133 134async function loadActiveMapping(installationId: number, githubRepoId: number): Promise<ActiveMapping> { 135 const db = useDb() 136 const rows = await db.select().from(repoMapping).where( 137 and( 138 eq(repoMapping.installationId, installationId), 139 eq(repoMapping.githubRepoId, githubRepoId), 140 ), 141 ).limit(1) 142 143 if (rows.length === 0) return { skip: { status: 'skipped', reason: 'no-mapping' } } 144 const row = rows[0]! 145 146 if (row.disabledAt) return { skip: { status: 'skipped', reason: 'disabled' } } 147 if (!row.tangledRepoDid || !row.knot) return { skip: { status: 'skipped', reason: 'no-mapping' } } 148 149 return { 150 id: row.id, 151 githubFullName: row.githubFullName, 152 tangledRepoDid: row.tangledRepoDid, 153 knot: row.knot, 154 } 155} 156 157async function updateLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise<void> { 158 const db = useDb() 159 await db.update(repoMapping) 160 .set({ 161 lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`, 162 updatedAt: new Date(), 163 }) 164 .where(eq(repoMapping.id, mappingId)) 165} 166 167async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise<void> { 168 const db = useDb() 169 // jsonb minus text removes a top-level key. Safe no-op if absent. 170 await db.update(repoMapping) 171 .set({ 172 lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`, 173 updatedAt: new Date(), 174 }) 175 .where(eq(repoMapping.id, mappingId)) 176} 177 178async function markMappingError(mappingId: number, message: string): Promise<void> { 179 const db = useDb() 180 await db.update(repoMapping) 181 .set({ status: 'error', lastError: message, updatedAt: new Date() }) 182 .where(eq(repoMapping.id, mappingId)) 183} 184 185function jsonbPath(ref: string): string { 186 return `"${ref.replaceAll('"', '\\"')}"` 187}