|
| 1 | +import { INSTALLATION_RECORD_PREFIX, createInstallationRecord } from './installation-analytics'; |
| 2 | +import { |
| 3 | + confirmGitHubInstallationIsInactive, |
| 4 | + deleteInstallationData, |
| 5 | + INSTALLATION_SWEEP_PAGE_SIZE, |
| 6 | +} from './installation-retention'; |
| 7 | +import { |
| 8 | + listActiveGitHubInstallations, |
| 9 | + type GitHubInstallationInventory, |
| 10 | +} from './github-installation-inventory'; |
| 11 | +import type { Env } from '../types'; |
| 12 | + |
| 13 | +const STORED_RECORD_PAGE_SIZE = 1000; |
| 14 | +const MAX_STORED_RECORD_PAGES = 20; |
| 15 | +const INSTALLATION_RECONCILIATION_BATCH_SIZE = 25; |
| 16 | +export const MAX_SCHEDULED_GITHUB_REQUESTS = 50; |
| 17 | +export const INSTALLATION_RECONCILIATION_AUDIT_KEY = |
| 18 | + 'operations:installation-reconciliation:last-success'; |
| 19 | + |
| 20 | +type ReconciliationMode = 'dry-run' | 'apply'; |
| 21 | + |
| 22 | +interface ReconciliationOptions { |
| 23 | + mode?: ReconciliationMode; |
| 24 | + now?: Date; |
| 25 | + inventory?: GitHubInstallationInventory; |
| 26 | + confirmInactive?: (env: Env, installationId: number) => Promise<boolean>; |
| 27 | + deleteInstallation?: (env: Env, installationId: number) => Promise<void>; |
| 28 | +} |
| 29 | + |
| 30 | +interface InstallationReconciliationAudit { |
| 31 | + schemaVersion: 1; |
| 32 | + mode: ReconciliationMode; |
| 33 | + completedAt: string; |
| 34 | + activeCount: number; |
| 35 | + eligibleCount: number; |
| 36 | + skippedCount: number; |
| 37 | + existingCount: number; |
| 38 | + missingCount: number; |
| 39 | + processedCount: number; |
| 40 | + createdCount: number; |
| 41 | + inactiveCount: number; |
| 42 | + remainingCount: number; |
| 43 | +} |
| 44 | + |
| 45 | +export async function reconcileInstallationRecords( |
| 46 | + env: Env, |
| 47 | + options: ReconciliationOptions = {} |
| 48 | +): Promise<InstallationReconciliationAudit> { |
| 49 | + const store = env.INSTALLATION_ANALYTICS; |
| 50 | + if (!store) throw new Error('INSTALLATION_ANALYTICS binding is required for reconciliation'); |
| 51 | + |
| 52 | + const mode = options.mode ?? 'dry-run'; |
| 53 | + const inventory = options.inventory ?? (await listActiveGitHubInstallations(env)); |
| 54 | + const storedIds = await listStoredInstallationIds(store); |
| 55 | + const missing = inventory.records.filter(record => !storedIds.has(record.installationId)); |
| 56 | + let createdCount = 0; |
| 57 | + let inactiveCount = 0; |
| 58 | + let processedCount = 0; |
| 59 | + |
| 60 | + if (mode === 'apply') { |
| 61 | + const confirmInactive = options.confirmInactive ?? confirmGitHubInstallationIsInactive; |
| 62 | + const deleteInstallation = options.deleteInstallation ?? deleteInstallationData; |
| 63 | + const batchSize = Math.max( |
| 64 | + 0, |
| 65 | + Math.min( |
| 66 | + INSTALLATION_RECONCILIATION_BATCH_SIZE, |
| 67 | + MAX_SCHEDULED_GITHUB_REQUESTS - inventory.pageCount - INSTALLATION_SWEEP_PAGE_SIZE |
| 68 | + ) |
| 69 | + ); |
| 70 | + for (const installation of missing.slice(0, batchSize)) { |
| 71 | + processedCount += 1; |
| 72 | + const created = await createInstallationRecord(store, installation); |
| 73 | + if (await confirmInactive(env, installation.installationId)) { |
| 74 | + await deleteInstallation(env, installation.installationId); |
| 75 | + inactiveCount += 1; |
| 76 | + } else if (created) { |
| 77 | + createdCount += 1; |
| 78 | + } |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + const audit: InstallationReconciliationAudit = { |
| 83 | + schemaVersion: 1, |
| 84 | + mode, |
| 85 | + completedAt: (options.now ?? new Date()).toISOString(), |
| 86 | + activeCount: inventory.installationIds.length, |
| 87 | + eligibleCount: inventory.records.length, |
| 88 | + skippedCount: inventory.skippedCount, |
| 89 | + existingCount: inventory.records.length - missing.length, |
| 90 | + missingCount: missing.length, |
| 91 | + processedCount, |
| 92 | + createdCount, |
| 93 | + inactiveCount, |
| 94 | + remainingCount: missing.length - processedCount, |
| 95 | + }; |
| 96 | + if (mode === 'apply') { |
| 97 | + await store.put(INSTALLATION_RECONCILIATION_AUDIT_KEY, JSON.stringify(audit)); |
| 98 | + } |
| 99 | + return audit; |
| 100 | +} |
| 101 | + |
| 102 | +async function listStoredInstallationIds(store: KVNamespace): Promise<Set<number>> { |
| 103 | + const ids = new Set<number>(); |
| 104 | + let cursor: string | undefined; |
| 105 | + for (let pageNumber = 0; pageNumber < MAX_STORED_RECORD_PAGES; pageNumber += 1) { |
| 106 | + const page = await store.list({ |
| 107 | + prefix: INSTALLATION_RECORD_PREFIX, |
| 108 | + limit: STORED_RECORD_PAGE_SIZE, |
| 109 | + ...(cursor ? { cursor } : {}), |
| 110 | + }); |
| 111 | + for (const key of page.keys) { |
| 112 | + const rawId = key.name.slice(INSTALLATION_RECORD_PREFIX.length); |
| 113 | + if (!/^\d+$/.test(rawId)) throw new Error('Malformed installation record key'); |
| 114 | + const id = Number(rawId); |
| 115 | + if (!Number.isSafeInteger(id) || id <= 0) { |
| 116 | + throw new Error('Malformed installation record key'); |
| 117 | + } |
| 118 | + ids.add(id); |
| 119 | + } |
| 120 | + if (page.list_complete) return ids; |
| 121 | + if (!page.cursor) throw new Error('Installation record pagination omitted its cursor'); |
| 122 | + cursor = page.cursor; |
| 123 | + } |
| 124 | + throw new Error('Installation record pagination exceeded the safety limit'); |
| 125 | +} |
0 commit comments