mirror your GitHub repos to tangled.org automatically
1import { installationOctokit, installationToken } from './github-app'
2import { isTerminalRejection, loadActiveMapping, markMappingError, setLastSyncedRef, terminalRejectionMessage } from './repo-mapping'
3import { splicePush } from './splice'
4
5const ZERO_SHA = '0000000000000000000000000000000000000000'
6
7export interface PushPayload {
8 installationId: number
9 githubRepoId: number
10 ref: string
11 before: string
12 after: string
13}
14
15export interface PushResult {
16 status: 'synced' | 'skipped'
17 reason?: 'no-mapping' | 'disabled' | 'already-synced' | 'deletion' | 'repo-gone'
18}
19
20/**
21 * Mirror a single push from GitHub to the configured knot.
22 *
23 * 1. Look up the repo_mapping (installationId, githubRepoId). Skip if absent
24 * or disabled.
25 * 2. Ref-tip dedupe: if lastSyncedRefs[ref] === after, no-op. Guards against
26 * GitHub redeliveries. This is a cache only; correctness comes from the
27 * protocol-level compare-and-swap in the splice.
28 * 3. Skip ref deletions (after = 0000…). Handled by github.delete.
29 * 4. Splice: open receive-pack to the knot, fetch a thin pack of `after`
30 * from GitHub with the knot's tips as haves, pipe it straight through.
31 * Nothing touches disk.
32 * 5. Update lastSyncedRefs[ref] = after.
33 *
34 * On terminal failures (repo gone from knot, auth rejected, pack too big) we
35 * mark the mapping `status='error'` so the worker stops retrying. A lost
36 * compare-and-swap (`stale-old-sha`) and other transient failures re-throw so
37 * the queue retries with backoff; the retry re-reads the knot's tip.
38 */
39export async function syncPush(payload: PushPayload): Promise<PushResult> {
40 const loaded = await loadActiveMapping(payload.installationId, payload.githubRepoId)
41 if ('skip' in loaded) return { status: 'skipped', reason: loaded.skip }
42 const { mapping } = loaded
43
44 if (payload.after === ZERO_SHA) return { status: 'skipped', reason: 'deletion' }
45
46 if (mapping.lastSyncedRefs[payload.ref] === payload.after) {
47 return { status: 'skipped', reason: 'already-synced' }
48 }
49
50 const octokit = await installationOctokit(payload.installationId)
51 const token = await installationToken(octokit)
52
53 try {
54 const result = await splicePush({
55 installationId: payload.installationId,
56 repoFullName: mapping.githubFullName,
57 knot: mapping.knot,
58 repoDid: mapping.tangledRepoDid,
59 ref: payload.ref,
60 want: payload.after,
61 token,
62 })
63
64 await setLastSyncedRef(mapping.id, payload.ref, result.sha)
65 return { status: 'synced' }
66 }
67 catch (err) {
68 if (isTerminalRejection(err)) {
69 await markMappingError(mapping.id, terminalRejectionMessage(err))
70 return { status: 'skipped', reason: 'repo-gone' }
71 }
72 throw err
73 }
74}