Skip to content

src/github.ts

249 lines · 9.3 KB · TypeScript
Raw
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<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)
}