import 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 /** 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 { 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 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 | 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).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).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).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).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> { 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 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 = { '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 { 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:}}` 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 { 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) }