/** Pure PR snapshot types + normalization (no kody imports; unit-testable). */ import type { PrLocator } from './input.ts' export type CheckState = 'pending' | 'success' | 'failure' | 'skipped' export type NormalizedCheck = { name: string source: 'check_run' | 'status' state: CheckState status: string conclusion: string | null url: string | null app: string | null description: string | null startedAt: string | null completedAt: string | null } export type ThreadComment = { id: number author: string body: string url: string | null createdAt: string replyToId: number | null } export type ReviewThread = { id: string isResolved: boolean isOutdated: boolean path: string | null line: number | null comments: Array } export type PrReview = { id: number author: string state: string body: string submittedAt: string | null commitSha: string | null url: string | null } export type PrIssueComment = { id: number author: string body: string createdAt: string url: string | null } export type WorkflowRun = { id: number name: string event: string status: string conclusion: string | null url: string createdAt: string } /** * What production serves, for repos with a known health endpoint * (kentcdodds/kody → https://kody.codes/health). `containsMerge` comes from the * GitHub compare API (merge SHA...served SHA: identical or ahead → true). */ export type ProductionInfo = { url: string commitSha: string | null containsMerge: boolean | null compareStatus: string | null error: string | null } export type PrSnapshot = { locator: PrLocator title: string state: 'OPEN' | 'CLOSED' | 'MERGED' isDraft: boolean merged: boolean mergedAt: string | null mergeCommitSha: string | null mergeable: string mergeStateStatus: string reviewDecision: string | null author: string baseRef: string headRef: string headSha: string headCommittedDate: string | null /** When the PR was opened (its `opened` event triggers CI). */ createdAt: string | null /** Latest ready-for-review transition (re-runs draft-gated workflows), or null. */ readyForReviewAt: string | null additions: number deletions: number checks: Array duplicateChecksHidden: number /** GitHub Actions workflow runs for the head SHA (open PRs; empty otherwise). */ headRuns: Array threads: Array threadsTruncated: boolean reviews: Array issueComments: Array deployRuns: Array | null /** Production health check after merge (known repos only; otherwise null/absent). */ production?: ProductionInfo | null fetchedAt: string } const pendingStates = new Set([ 'QUEUED', 'IN_PROGRESS', 'PENDING', 'WAITING', 'REQUESTED', 'EXPECTED', ]) const failureStates = new Set([ 'FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'STALE', 'ERROR', ]) export function checkStateOf(status: string, conclusion: string | null): CheckState { const s = String(status || '').toUpperCase() const c = conclusion ? String(conclusion).toUpperCase() : '' if (!c) return s === 'COMPLETED' ? 'success' : 'pending' if (pendingStates.has(c)) return 'pending' if (c === 'SKIPPED') return 'skipped' if (failureStates.has(c)) return 'failure' return 'success' } /** GraphQL logins drop `[bot]`; restore it for Bot actors so classifier rules match REST semantics. */ export function actorLogin(author: unknown): string { if (!author || typeof author !== 'object') return 'ghost' const a = author as { login?: unknown; __typename?: unknown } const login = typeof a.login === 'string' ? a.login : 'ghost' return a.__typename === 'Bot' && !/\[bot\]$/i.test(login) ? `${login}[bot]` : login } /** * Collapse duplicate check names (re-runs, skipped twin workflow runs). * Skipped entries lose to any non-skipped twin; otherwise the newest wins. */ export function dedupeChecks(checks: Array): { checks: Array hidden: number } { const byName = new Map>() for (const check of checks) { const list = byName.get(check.name) ?? [] list.push(check) byName.set(check.name, list) } const out: Array = [] for (const list of byName.values()) { const nonSkipped = list.filter((c) => c.state !== 'skipped') const pool = nonSkipped.length ? nonSkipped : list const newest = pool.slice().sort((a, b) => { const ta = a.state === 'pending' ? Infinity : Date.parse(a.startedAt || a.completedAt || '') || 0 const tb = b.state === 'pending' ? Infinity : Date.parse(b.startedAt || b.completedAt || '') || 0 return tb - ta })[0] out.push(newest) } return { checks: out, hidden: checks.length - out.length } } export function normalizeContexts(nodes: Array>): Array { const checks: Array = [] for (const node of nodes) { if (!node) continue if (node.__typename === 'CheckRun') { const status = String(node.status ?? '') const conclusion = node.conclusion == null ? null : String(node.conclusion) const suite = node.checkSuite as { app?: { slug?: string } } | undefined checks.push({ name: String(node.name ?? 'check'), source: 'check_run', state: checkStateOf(status, conclusion), status, conclusion, url: node.detailsUrl == null ? null : String(node.detailsUrl), app: suite?.app?.slug ?? null, description: null, startedAt: node.startedAt == null ? null : String(node.startedAt), completedAt: node.completedAt == null ? null : String(node.completedAt), }) } else if (node.__typename === 'StatusContext') { const state = String(node.state ?? '') checks.push({ name: String(node.context ?? 'status'), source: 'status', state: checkStateOf(state === 'PENDING' ? 'PENDING' : 'COMPLETED', state === 'PENDING' ? null : state), status: state, conclusion: state === 'PENDING' ? null : state, url: node.targetUrl == null ? null : String(node.targetUrl), app: null, description: node.description == null ? null : String(node.description), startedAt: node.createdAt == null ? null : String(node.createdAt), completedAt: null, }) } } return checks } export type AiReviewerKind = 'bugbot' | 'coderabbit' | 'devin' | 'seer' | 'copilot' /** AI reviewer checks are handled by the ai-review step, not CI. */ export function aiReviewerOfCheck(check: NormalizedCheck): AiReviewerKind | null { const name = check.name.toLowerCase() if (name === 'cursor bugbot' || name.includes('bugbot') || check.app === 'cursor') return 'bugbot' if (name.includes('coderabbit')) return 'coderabbit' if (name.includes('devin')) return 'devin' if (name.includes('seer')) return 'seer' if (name.includes('copilot')) return 'copilot' return null } /** Parse a GitHub Actions job URL into run/job ids for log hints. */ export function actionsIds(url: string | null): { runId: number; jobId: number | null } | null { if (!url) return null const match = url.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/) if (!match) return null return { runId: Number(match[1]), jobId: match[2] ? Number(match[2]) : null } }