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