Skip to content

src/snapshot.ts

236 lines · 7.0 KB · TypeScript
Raw
/** 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<ThreadComment>
}

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<NormalizedCheck>
	duplicateChecksHidden: number
	/** GitHub Actions workflow runs for the head SHA (open PRs; empty otherwise). */
	headRuns: Array<WorkflowRun>
	threads: Array<ReviewThread>
	threadsTruncated: boolean
	reviews: Array<PrReview>
	issueComments: Array<PrIssueComment>
	deployRuns: Array<WorkflowRun> | 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<NormalizedCheck>): {
	checks: Array<NormalizedCheck>
	hidden: number
} {
	const byName = new Map<string, Array<NormalizedCheck>>()
	for (const check of checks) {
		const list = byName.get(check.name) ?? []
		list.push(check)
		byName.set(check.name, list)
	}
	const out: Array<NormalizedCheck> = []
	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<Record<string, unknown>>): Array<NormalizedCheck> {
	const checks: Array<NormalizedCheck> = []
	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 }
}