← Public packages
@kentcdodds/ship-pr
Progressive ship-pr loop for agents: PR status, Bugbot, review sort, 7-day decisions, merge, deploy, wrap-up.
src/snapshot.ts
236 lines · 7.0 KB · TypeScript/** 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 }
}