← Public packages
@kentcdodds/ship-pr
Progressive ship-pr loop for agents: PR status, Bugbot, review sort, 7-day decisions, merge, deploy, wrap-up.
src/github.ts
249 lines · 9.3 KB · TypeScriptimport githubGraphql from 'kody:@kentcdodds/github/graphql'
import githubRequest from 'kody:@kentcdodds/github/request'
import type { PrLocator } from './input.ts'
import {
actorLogin,
dedupeChecks,
normalizeContexts,
type PrSnapshot,
type ProductionInfo,
type WorkflowRun,
} from './snapshot.ts'
const PR_QUERY = `query ShipPr($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
title url state isDraft createdAt merged mergedAt mergeable mergeStateStatus reviewDecision
additions deletions baseRefName headRefName headRefOid
mergeCommit { oid }
timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) { nodes { ... on ReadyForReviewEvent { createdAt } } }
author { login __typename }
commits(last: 1) { nodes { commit { oid committedDate statusCheckRollup { state contexts(first: 100) { nodes {
__typename
... on CheckRun { name status conclusion detailsUrl startedAt completedAt checkSuite { app { slug } } }
... on StatusContext { context state targetUrl description createdAt }
} } } } } }
reviewThreads(first: 100) {
pageInfo { hasNextPage }
nodes { id isResolved isOutdated path line
comments(first: 30) { nodes { databaseId body url createdAt author { login __typename } replyTo { databaseId } } }
}
}
reviews(last: 30) { nodes { databaseId state body submittedAt url commit { oid } author { login __typename } } }
comments(last: 40) { nodes { databaseId body createdAt url author { login __typename } } }
}
}
}`
type Gql = Record<string, any>
/** Outbound fetches this module made since the last `takeFetchCount()`. */
let fetchCount = 0
/** Return and reset the outbound-fetch count (tick reports it as `cost`). */
export function takeFetchCount(): number {
const n = fetchCount
fetchCount = 0
return n
}
export async function fetchSnapshot(locator: PrLocator): Promise<PrSnapshot> {
fetchCount++
const result = (await githubGraphql({
account: 'bot',
query: PR_QUERY,
variables: { owner: locator.owner, repo: locator.repo, number: locator.prNumber },
})) as Gql
const pr = result?.data?.repository?.pullRequest as Gql | undefined
if (!pr) {
const errors = Array.isArray(result?.errors)
? result.errors.map((e: Gql) => e?.message).join('; ')
: ''
throw new Error(
`ship-pr: could not load ${locator.prUrl} as kody-bot${errors ? `: ${errors}` : ''}.`,
)
}
const commit = pr.commits?.nodes?.[0]?.commit as Gql | undefined
const contexts = (commit?.statusCheckRollup?.contexts?.nodes ?? []) as Array<Gql>
const deduped = dedupeChecks(normalizeContexts(contexts))
const mergeCommitSha = pr.mergeCommit?.oid ? String(pr.mergeCommit.oid) : null
const headSha = String(pr.headRefOid ?? commit?.oid ?? '')
let deployRuns: Array<WorkflowRun> | null = null
if (pr.merged && mergeCommitSha) {
deployRuns = await fetchWorkflowRuns(locator, mergeCommitSha)
}
const production = pr.merged && mergeCommitSha ? await fetchProduction(locator, mergeCommitSha) : null
// Workflow runs exist (queued) before their jobs report check runs, so CI
// gates on them too, not only on the status-check rollup.
const headRuns = pr.state === 'OPEN' && headSha ? await fetchWorkflowRuns(locator, headSha) : []
return {
locator,
title: String(pr.title ?? ''),
state: pr.state,
isDraft: Boolean(pr.isDraft),
merged: Boolean(pr.merged),
mergedAt: pr.mergedAt ?? null,
mergeCommitSha,
mergeable: String(pr.mergeable ?? 'UNKNOWN'),
mergeStateStatus: String(pr.mergeStateStatus ?? 'UNKNOWN'),
reviewDecision: pr.reviewDecision ?? null,
author: actorLogin(pr.author),
baseRef: String(pr.baseRefName ?? ''),
headRef: String(pr.headRefName ?? ''),
headSha,
headCommittedDate: commit?.committedDate ?? null,
createdAt: pr.createdAt ?? null,
readyForReviewAt: pr.timelineItems?.nodes?.[0]?.createdAt ?? null,
additions: Number(pr.additions ?? 0),
deletions: Number(pr.deletions ?? 0),
checks: deduped.checks,
duplicateChecksHidden: deduped.hidden,
headRuns,
threads: ((pr.reviewThreads?.nodes ?? []) as Array<Gql>).map((t) => ({
id: String(t.id),
isResolved: Boolean(t.isResolved),
isOutdated: Boolean(t.isOutdated),
path: t.path ?? null,
line: typeof t.line === 'number' ? t.line : null,
comments: ((t.comments?.nodes ?? []) as Array<Gql>).map((c) => ({
id: Number(c.databaseId),
author: actorLogin(c.author),
body: String(c.body ?? ''),
url: c.url ?? null,
createdAt: String(c.createdAt ?? ''),
replyToId: typeof c.replyTo?.databaseId === 'number' ? c.replyTo.databaseId : null,
})),
})),
threadsTruncated: Boolean(pr.reviewThreads?.pageInfo?.hasNextPage),
reviews: ((pr.reviews?.nodes ?? []) as Array<Gql>).map((r) => ({
id: Number(r.databaseId),
author: actorLogin(r.author),
state: String(r.state ?? ''),
body: String(r.body ?? ''),
submittedAt: r.submittedAt ?? null,
commitSha: r.commit?.oid ?? null,
url: r.url ?? null,
})),
issueComments: ((pr.comments?.nodes ?? []) as Array<Gql>).map((c) => ({
id: Number(c.databaseId),
author: actorLogin(c.author),
body: String(c.body ?? ''),
createdAt: String(c.createdAt ?? ''),
url: c.url ?? null,
})),
deployRuns,
production,
fetchedAt: new Date().toISOString(),
}
}
export async function fetchWorkflowRuns(locator: PrLocator, sha: string): Promise<Array<WorkflowRun>> {
fetchCount++
const response = (await githubRequest({
account: 'bot',
path: `/repos/${locator.owner}/${locator.repo}/actions/runs?head_sha=${sha}&per_page=100`,
})) as Gql
const runs = (response?.data?.workflow_runs ?? []) as Array<Gql>
return runs.map((w) => ({
id: Number(w.id),
name: String(w.name ?? ''),
event: String(w.event ?? ''),
status: String(w.status ?? ''),
conclusion: w.conclusion ?? null,
url: String(w.html_url ?? ''),
createdAt: String(w.created_at ?? ''),
}))
}
const HEALTH_URLS: Record<string, string> = {
'kentcdodds/kody': 'https://kody.codes/health',
}
/**
* For repos with a known production health endpoint, read the served
* `commitSha` and check (compare API) whether it is the merge SHA or a
* descendant. A newer push can cancel Validate/Deploy on the merge SHA via a
* concurrency group; production then ships the merge inside a later SHA.
*/
export async function fetchProduction(locator: PrLocator, mergeSha: string): Promise<ProductionInfo | null> {
const url = HEALTH_URLS[`${locator.owner}/${locator.repo}`.toLowerCase()]
if (!url) return null
const info: ProductionInfo = { url, commitSha: null, containsMerge: null, compareStatus: null, error: null }
try {
fetchCount++
const res = await fetch(url, { headers: { accept: 'application/json' } })
if (!res.ok) {
info.error = `health returned ${res.status}`
return info
}
const body = (await res.json()) as Gql
const sha = typeof body?.commitSha === 'string' ? body.commitSha : typeof body?.commit?.sha === 'string' ? body.commit.sha : null
info.commitSha = sha
if (!sha) {
info.error = 'health response has no commitSha'
return info
}
if (sha === mergeSha) {
info.containsMerge = true
info.compareStatus = 'identical'
return info
}
fetchCount++
const compare = (await githubRequest({
account: 'bot',
path: `/repos/${locator.owner}/${locator.repo}/compare/${mergeSha}...${sha}`,
})) as Gql
const status = typeof compare?.data?.status === 'string' ? compare.data.status : null
info.compareStatus = status
if (status) info.containsMerge = status === 'identical' || status === 'ahead'
else info.error = `compare failed (${compare?.status ?? 'unknown'})`
} catch (error) {
info.error = error instanceof Error ? error.message : String(error)
}
return info
}
/**
* Comment bodies are prose that may quote the `{{secret:<name>}}` placeholder syntax
* (common when replying to reviews of credential code). The gateway refuses a
* body placeholder unless the request opts into headers-only resolution; the
* auth header placeholder still resolves. Set this on every GitHub request that
* posts user-visible text.
*/
export const PROSE_BODY_HEADERS = { 'x-kody-secret-resolution': 'headers-only' } as const
/** Reply to a PR review comment thread as kody-bot. */
export async function replyToReviewComment(
locator: PrLocator,
commentId: number,
body: string,
): Promise<{ id: number | null; url: string | null }> {
const path = `/repos/${locator.owner}/${locator.repo}/pulls/${locator.prNumber}/comments/${commentId}/replies`
fetchCount++
const result = (await githubRequest({
account: 'bot',
path,
method: 'POST',
headers: { ...PROSE_BODY_HEADERS },
body: { body },
})) as Gql
if (result?.ok === false || (typeof result?.status === 'number' && result.status >= 400)) {
throw new Error(
`ship-pr: reply ${path} failed (${result?.status}): ${String(result?.text ?? '').slice(0, 200)}`,
)
}
return { id: result?.data?.id ?? null, url: result?.data?.html_url ?? null }
}
/** Resolve a review thread as kody-bot (GraphQL node id `PRRT_…`). */
export async function resolveReviewThread(threadId: string): Promise<boolean> {
fetchCount++
const result = (await githubGraphql({
account: 'bot',
query: `mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { id isResolved } } }`,
variables: { id: threadId },
})) as Gql
return Boolean(result?.data?.resolveReviewThread?.thread?.isResolved)
}