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, WireError } from './git-wire/errors'
5import { installationOctokit } from './github-app'
6import { 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}