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 } from './git-wire/errors'
5
6export interface ActiveMapping {
7 id: number
8 githubFullName: string
9 tangledRepoDid: string
10 knot: string
11 lastSyncedRefs: Record<string, string>
12}
13
14export type SkipReason = 'no-mapping' | 'disabled'
15
16/**
17 * Load the `repo_mapping` row for `(installationId, githubRepoId)` and confirm
18 * it's ready to sync. Returns `{ skip }` when the row is missing, disabled, or
19 * hasn't completed enrolment (no `tangledRepoDid`/`knot` yet).
20 */
21export async function loadActiveMapping(
22 installationId: number,
23 githubRepoId: number,
24): Promise<{ mapping: ActiveMapping } | { skip: SkipReason }> {
25 const db = useDb()
26 const rows = await db.select().from(repoMapping).where(
27 and(
28 eq(repoMapping.installationId, installationId),
29 eq(repoMapping.githubRepoId, githubRepoId),
30 ),
31 ).limit(1)
32
33 if (rows.length === 0) return { skip: 'no-mapping' }
34 const row = rows[0]!
35
36 if (row.disabledAt) return { skip: 'disabled' }
37 if (!row.tangledRepoDid || !row.knot) return { skip: 'no-mapping' }
38
39 return {
40 mapping: {
41 id: row.id,
42 githubFullName: row.githubFullName,
43 tangledRepoDid: row.tangledRepoDid,
44 knot: row.knot,
45 // eslint-disable-next-line ts/no-unsafe-type-assertion -- jsonb column is typed `unknown`
46 lastSyncedRefs: (row.lastSyncedRefs ?? {}) as Record<string, string>,
47 },
48 }
49}
50
51/** Record the synced tip for one ref in the `lastSyncedRefs` jsonb map. */
52export async function setLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise<void> {
53 const db = useDb()
54 await db.update(repoMapping)
55 .set({
56 lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPathElement(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`,
57 updatedAt: new Date(),
58 })
59 .where(eq(repoMapping.id, mappingId))
60}
61
62/** Drop one ref from the `lastSyncedRefs` jsonb map. No-op if absent. */
63export async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise<void> {
64 const db = useDb()
65 await db.update(repoMapping)
66 .set({
67 lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`,
68 updatedAt: new Date(),
69 })
70 .where(eq(repoMapping.id, mappingId))
71}
72
73/** Mark a mapping `status='error'` so the worker stops retrying. */
74export async function markMappingError(mappingId: number, message: string): Promise<void> {
75 const db = useDb()
76 await db.update(repoMapping)
77 .set({ status: 'error', lastError: message, updatedAt: new Date() })
78 .where(eq(repoMapping.id, mappingId))
79}
80
81/** Human-readable `lastError` text for a terminal knot rejection. */
82export function terminalRejectionMessage(err: RemoteRejectedError): string {
83 if (err.reason === 'too-big') return `pack exceeded the configured size limit; stopping sync (${err.message})`
84 if (err.reason === 'auth-rejected') return 'knot rejected our ssh key; stopping sync'
85 return 'knot reports repo no longer exists; stopping sync'
86}
87
88/**
89 * True for knot rejections we treat as terminal: the repo is gone, our key is
90 * rejected, or the pack blew the size cap. Callers mark the mapping `error`
91 * and stop retrying; anything else re-throws for the queue's backoff.
92 */
93export function isTerminalRejection(err: unknown): err is RemoteRejectedError {
94 return err instanceof RemoteRejectedError
95 && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big')
96}
97
98/** jsonb path array element for a ref, escaping embedded quotes. */
99function jsonbPathElement(ref: string): string {
100 return `"${ref.replaceAll('"', '\\"')}"`
101}