mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

at main 4.7 kB View raw
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}