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