import { DateTime } from 'luxon'; import { z } from 'zod/v3'; import { logger } from '../../../logger/index.ts'; import * as memCache from '../../../util/cache/memory/index.ts'; import { getCache } from '../../../util/cache/repository/index.ts'; const GithubIssueBase = z.object({ number: z.number(), state: z.string().transform((val) => val.toLowerCase()), title: z.string(), body: z.string(), }); const GithubGraphqlIssue = GithubIssueBase.extend({ updatedAt: z.string(), }).transform((issue) => { const lastModified = issue.updatedAt; const { number, state, title, body } = issue; return { number, state, title, body, lastModified }; }); const GithubRestIssue = GithubIssueBase.extend({ updated_at: z.string(), }).transform((issue) => { const lastModified = issue.updated_at; const { number, state, title, body } = issue; return { number, state, title, body, lastModified }; }); export const GithubIssue = z.union([GithubGraphqlIssue, GithubRestIssue]); export type GithubIssue = z.infer; type CacheData = Record; export class GithubIssueCache { private static reset(cacheData: CacheData | null): void { memCache.set('github-issues-reconcile-queue', null); const repoCache = getCache(); repoCache.platform ??= {}; repoCache.platform.github ??= {}; if (cacheData) { repoCache.platform.github.issuesCache = cacheData; } else { delete repoCache.platform.github.issuesCache; } } private static get data(): CacheData | null { let cacheData: CacheData | undefined | null = getCache().platform?.github ?.issuesCache as CacheData | undefined; if (!cacheData) { return null; } cacheData = this.reconcile(cacheData); return cacheData; } static getIssues(): GithubIssue[] | null { const cacheData = this.data; if (!cacheData) { return null; } const sortedResult = Object.values(cacheData).sort( ({ lastModified: a }, { lastModified: b }) => DateTime.fromISO(b).toMillis() - DateTime.fromISO(a).toMillis(), ); return sortedResult; } static setIssues(issues: GithubIssue[]): void { const cacheData: CacheData = {}; for (const issue of issues) { cacheData[issue.number] = issue; } logger.debug(`Issues cache: Setting ${issues.length} issues in cache`); this.reset(cacheData); } static updateIssue(issue: GithubIssue): void { const cacheData = this.data; if (cacheData) { logger.debug(`Issues cache: Updating issue ${issue.number} in cache`); cacheData[issue.number] = issue; } } static deleteIssue(number: number): void { const cacheData = this.data; if (cacheData) { logger.debug(`Issues cache: Deleting issue ${number} from cache`); delete cacheData[number]; } } /** * At the moment of repo initialization, repository cache is not available. * What we can do is to store issues for later reconciliation. */ static addIssuesToReconcile(issues: GithubIssue[] | undefined): void { logger.debug( `Issues cache: Adding ${issues?.length} issues to reconcile queue`, ); memCache.set('github-issues-reconcile-queue', issues); } private static reconcile(cacheData: CacheData): CacheData | null { const issuesToReconcile = memCache.get( 'github-issues-reconcile-queue', ); if (!issuesToReconcile) { return cacheData; } let isReconciled = false; for (const issue of issuesToReconcile) { const cachedIssue = cacheData[issue.number]; // If we reached the item which is already in the cache, // it means sync is done. if ( cachedIssue?.number === issue.number && cachedIssue.lastModified === issue.lastModified ) { isReconciled = true; logger.debug(`Issues cache: Done reconciling at issue ${issue.number}`); break; } cacheData[issue.number] = issue; } // If we've just iterated over all the items in the cache, // it means sync is also done. if (issuesToReconcile.length >= Object.keys(cacheData).length) { logger.debug( `Issues cache: Done reconciling by iterating over all items`, ); isReconciled = true; } if (!isReconciled) { logger.debug('Issues cache: reset'); this.reset(null); return null; } logger.debug('Issues cache: synced'); this.reset(cacheData); return cacheData; } }