mirror your GitHub repos to tangled.org automatically
1import { RemoteRejectedError, WireError } from './git-wire/errors'
2import { fetchAdvertisement } from './git-wire/upload-pack'
3import { installationOctokit, installationToken } from './github-app'
4import { clearLastSyncedRef, isTerminalRejection, loadActiveMapping, markMappingError, setLastSyncedRef } from './repo-mapping'
5import { spliceDelete, splicePush } from './splice'
6
7export type RefType = 'branch' | 'tag'
8
9export interface CreateRefPayload {
10 installationId: number
11 githubRepoId: number
12 refType: RefType
13 /** Short ref name as GitHub delivers it (e.g. `v1.0`, `feature-x`) — NOT
14 * the `refs/...` qualified form. */
15 ref: string
16}
17
18export interface DeleteRefPayload extends CreateRefPayload {}
19
20export interface RefResult {
21 status: 'synced' | 'skipped'
22 reason?: 'no-mapping' | 'disabled' | 'not-branch-or-tag' | 'repo-gone'
23}
24
25/**
26 * Mirror a branch or tag creation from GitHub to the configured knot.
27 *
28 * Triggered by GitHub's `create` webhook event. For branches, GitHub also
29 * sends a parallel `push` event (with `before = 0000…`), so the branch will
30 * usually have been created already by the time this fires — the splice is
31 * then a no-op via the knot tip already matching. For lightweight and
32 * annotated tags, no `push` event is sent, so this is the only path that
33 * creates them on the knot.
34 *
35 * We resolve the ref name to a SHA from GitHub's advertisement (annotated tags
36 * resolve to the tag object), then splice that SHA to the knot.
37 */
38export async function syncCreateRef(payload: CreateRefPayload): Promise<RefResult> {
39 if (payload.refType !== 'branch' && payload.refType !== 'tag') {
40 return { status: 'skipped', reason: 'not-branch-or-tag' }
41 }
42
43 const loaded = await loadActiveMapping(payload.installationId, payload.githubRepoId)
44 if ('skip' in loaded) return { status: 'skipped', reason: loaded.skip }
45 const { mapping } = loaded
46
47 const fullRef = qualifyRef(payload.refType, payload.ref)
48
49 const octokit = await installationOctokit(payload.installationId)
50 const token = await installationToken(octokit)
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 setLastSyncedRef(mapping.id, fullRef, result.sha)
71 return { status: 'synced' }
72 }
73 catch (err) {
74 if (isTerminalRejection(err)) {
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 loaded = await loadActiveMapping(payload.installationId, payload.githubRepoId)
99 if ('skip' in loaded) return { status: 'skipped', reason: loaded.skip }
100 const { mapping } = loaded
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}