import { type ProcessRunner, runChecked, runProcess } from "./process.ts"; import type { ChangedFile, LinkedIssue, PullRequestSnapshot, PullRequestTarget, } from "./types.ts"; import { PACKAGE_VERSION } from "./version.ts"; const REVIEW_MARKER_PREFIX = "`; const match = [...extractBodies(comments), ...extractBodies(reviews)].find( (entry) => entry.body.includes(REVIEW_MARKER_PREFIX) && entry.body.includes(marker), ); return match ? (match.url ?? "existing GitHub review") : undefined; } async function resolveNameWithOwner( context: GitHubContext, ): Promise<{ owner: string; repo: string }> { const raw = await gh(context, ["repo", "view", "--json", "nameWithOwner"]); const data = parseJson(raw, "repository"); const nameWithOwner = stringField(data, "nameWithOwner"); const [owner, repo, extra] = nameWithOwner.split("/"); if (!owner || !repo || extra) throw new Error(`Invalid GitHub repository identity: ${nameWithOwner}`); return { owner, repo }; } export async function resolvePullRequest( target: PullRequestTarget, context: GitHubContext, ): Promise { await gh(context, ["auth", "status"]); const runner = context.runner ?? runProcess; const rootResult = await runChecked(runner, { command: "git", args: ["rev-parse", "--show-toplevel"], cwd: context.cwd, signal: context.signal, }); const repositoryRoot = rootResult.stdout.trim(); if (!repositoryRoot) throw new Error("Unable to resolve the repository root"); const localIdentity = await resolveNameWithOwner(context); if ( target.kind === "url" && (localIdentity.owner.toLowerCase() !== target.owner.toLowerCase() || localIdentity.repo.toLowerCase() !== target.repo.toLowerCase()) ) { throw new Error( `PR URL targets ${target.owner}/${target.repo}, but the current checkout is ${localIdentity.owner}/${localIdentity.repo}`, ); } const identity = localIdentity; const fields = [ "number", "url", "state", "isDraft", "author", "title", "body", "baseRefOid", "headRefOid", "files", "closingIssuesReferences", "comments", "reviews", ].join(","); const raw = await gh(context, [ "pr", "view", ...targetArgument(target), "--json", fields, ]); const data = parseJson(raw, "pull request"); const author = data.author; if (!isRecord(author)) throw new Error("gh response is missing pull request author"); const isDraft = data.isDraft; if (typeof isDraft !== "boolean") throw new Error("gh response is missing draft state"); const headSha = stringField(data, "headRefOid"); const state = stringField(data, "state"); const shouldLoadReviewContext = state === "OPEN" && !isDraft; const linkedIssues = shouldLoadReviewContext ? await hydrateLinkedIssues( parseIssueReferences(data.closingIssuesReferences), context, ) : []; const diff = shouldLoadReviewContext ? await gh(context, ["pr", "diff", ...targetArgument(target)]) : ""; return { repositoryRoot, snapshot: { owner: identity.owner, repo: identity.repo, number: numberField(data, "number"), url: stringField(data, "url"), state, isDraft, author: stringField(author, "login"), title: stringField(data, "title"), body: typeof data.body === "string" ? data.body : "", baseSha: stringField(data, "baseRefOid"), headSha, files: parseFiles(data.files), diff, linkedIssues, existingReviewUrl: findExistingReview( headSha, data.comments, data.reviews, ), }, }; } export async function verifyLocalCheckout( repositoryRoot: string, headSha: string, runner: ProcessRunner = runProcess, signal?: AbortSignal, ): Promise { const head = await runChecked(runner, { command: "git", args: ["rev-parse", "HEAD"], cwd: repositoryRoot, signal, }); if (head.stdout.trim() !== headSha) { throw new Error( `Local HEAD ${head.stdout.trim()} does not match PR head ${headSha}. Check out the PR head before review.`, ); } const status = await runChecked(runner, { command: "git", args: ["status", "--porcelain", "--untracked-files=no"], cwd: repositoryRoot, signal, }); if (status.stdout.trim()) { throw new Error( "Tracked worktree changes would make review context differ from the PR head", ); } } export interface ReviewCommentPayload { path: string; line: number; side: "LEFT" | "RIGHT"; start_line?: number; start_side?: "LEFT" | "RIGHT"; body: string; } export interface ReviewPayload { commit_id: string; event: "COMMENT"; body: string; comments: ReviewCommentPayload[]; } export async function publishReview( owner: string, repo: string, pullNumber: number, payload: ReviewPayload, context: GitHubContext, ): Promise { await gh( context, [ "api", "--method", "POST", `repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, "--input", "-", ], JSON.stringify(payload), ); } export function reviewMarker(headSha: string): string { return `${REVIEW_MARKER_PREFIX}${PACKAGE_VERSION} head:${headSha} -->`; }