import { createHash } from 'node:crypto'; import type { PrSnapshot } from '../shared/types.js'; import type { AppConfig } from './config.js'; import type { Db } from './db.js'; import { categorize } from './categorize.js'; import { deriveReviewState, deriveVerdicts } from './reviewState.js'; import { graphql } from './github/client.js'; import { DETAIL_QUERY, LIST_QUERY, type DetailPr, type DetailResult, type ListPr, type ListResult, } from './github/queries.js'; const DETAIL_BATCH = 10; const ROLLING_REFRESH_MS = 15 * 60 * 1000; export interface PollResult { changedPrIds: string[]; newEventCount: number; prCount: number; } function signature(pr: ListPr): string { const resolved = pr.reviewThreads.nodes.filter((t) => t.isResolved).length; return createHash('sha1') .update( JSON.stringify([ pr.updatedAt, pr.headRefOid, pr.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null, pr.reviewThreads.totalCount, resolved, pr.comments.totalCount, pr.reviews.totalCount, pr.latestReviews.nodes.map((r) => `${r.author?.login}:${r.state}`), ]), ) .digest('hex'); } async function fetchList(config: AppConfig): Promise { const prs: ListPr[] = []; let cursor: string | null = null; do { const data: ListResult = await graphql(LIST_QUERY, { q: config.searchQuery, cursor, }); for (const node of data.search.nodes) { if ('id' in node) prs.push(node as ListPr); } cursor = data.search.pageInfo.hasNextPage ? data.search.pageInfo.endCursor : null; } while (cursor); return prs; } function buildSnapshot(config: AppConfig, pr: ListPr, detail: DetailPr | undefined, db: Db): PrSnapshot { const repo = pr.repository.nameWithOwner; let categories; if (detail) { const contexts = detail.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []; categories = categorize(config.rulesByRepo.get(repo), contexts); } else { // Detail node inaccessible this round — keep the previous check breakdown. categories = db.getSnapshot(pr.id)?.categories ?? categorize(config.rulesByRepo.get(repo), []); } return { id: pr.id, repo, number: pr.number, title: pr.title, url: pr.url, author: pr.author?.login ?? 'ghost', isDraft: pr.isDraft, branch: pr.headRefName, updatedAt: pr.updatedAt, ...deriveReviewState(pr), verdicts: deriveVerdicts(pr), categories, }; } export async function runPoll(config: AppConfig, db: Db): Promise { const now = new Date().toISOString(); const listPrs = await fetchList(config); const known = db.getPrSignatures(); const changed: ListPr[] = []; for (const pr of listPrs) { const prev = known.get(pr.id); const stale = !prev?.detailSyncedAt || Date.now() - Date.parse(prev.detailSyncedAt) > ROLLING_REFRESH_MS; if (!prev || prev.signature !== signature(pr) || stale) changed.push(pr); } const present = new Set(listPrs.map((p) => p.id)); db.markGone([...known.keys()].filter((id) => !present.has(id))); const detailById = new Map(); for (let i = 0; i < changed.length; i += DETAIL_BATCH) { const ids = changed.slice(i, i + DETAIL_BATCH).map((p) => p.id); const data = await graphql(DETAIL_QUERY, { ids }); for (const node of data.nodes) { if (node?.id) detailById.set(node.id, node); } } const backfillAsRead = db.getMeta('first_sync_done') !== '1' && process.env.BACKFILL_UNREAD !== '1'; let newEventCount = 0; for (const pr of changed) { const detail = detailById.get(pr.id); const snapshot = buildSnapshot(config, pr, detail, db); db.upsertPr(snapshot, signature(pr), detail ? now : null); if (detail) { newEventCount += ingest(db, detail, config.user, backfillAsRead); } } db.setMeta('first_sync_done', '1'); db.setMeta('last_sync_at', now); db.setMeta('last_sync_error', null); return { changedPrIds: changed.map((p) => p.id), newEventCount, prCount: listPrs.length }; } // Thin wrapper so poller stays the single import for index.ts / sync-once. import { ingestTimeline } from './timeline.js'; function ingest(db: Db, detail: DetailPr, user: string, backfillAsRead: boolean): number { return ingestTimeline(db, detail, { user, backfillAsRead }); }