utils/outstandingThreads.ts
Rama main · réplica de lectura del repositorio original.
import { isPullfrog } from "./isPullfrog.ts";
/**
* lightweight paginated query for the approval gate: we only need each thread's
* resolved state and its ROOT author (oldest comment, hence `first: 1`) to count
* outstanding Pullfrog findings. distinct from REVIEW_THREADS_QUERY, which pages
* the same connection but carries full comment bodies — the invariant must hold
* on PRs with >100 threads, since a silent first-100 cap would let a finding
* beyond #100 slip an approval through.
*/
const OUTSTANDING_THREADS_QUERY = `
query ($owner: String!, $name: String!, $prNumber: Int!, $cursor: String) {
repository(owner: $owner, name: $name) {
pullRequest(number: $prNumber) {
reviewThreads(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
isResolved
comments(first: 1) { nodes { author { login } } }
}
}
}
}
}
`;
type OutstandingThreadsResponse = {
repository: {
pullRequest: {
reviewThreads: {
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes:
| ({
isResolved: boolean;
comments: { nodes: ({ author: { login: string } | null } | null)[] | null } | null;
} | null)[]
| null;
} | null;
} | null;
} | null;
};
/** the slice of a run or webhook context the query needs — shared by the action and the server. */
export type OutstandingThreadsCtx = {
octokit: { graphql: <T>(query: string, variables: Record<string, unknown>) => Promise<T> };
repo: { owner: string; name: string };
/** the server's, which knows the owner's own App bot; a run's `isPullfrog` already does. */
isPullfrog?: (login: string | null | undefined) => boolean;
};
/**
* count unresolved review threads on a PR whose root comment was authored by
* Pullfrog's bot. this is the full set of open Pullfrog findings on the PR —
* the basis for both the never-approve-with-outstanding-issues invariant (the
* approval gate in create_pull_request_review) and the `pullfrog-approval`
* status verdict. it is a function of all open findings, NOT just the latest
* commit's delta.
*
* outdated-but-unresolved threads still count — GitHub marks a thread
* `[OUTDATED]` when the anchor line moved (reformat, rename, force-push), which
* is not the same as the concern being addressed. human-reviewer threads are
* excluded: they belong to those reviewers to resolve. walks every page so a
* long-lived PR with >100 threads can't hide an outstanding finding.
*/
export async function countOutstandingPullfrogThreads(
ctx: OutstandingThreadsCtx,
pullNumber: number
): Promise<number> {
const isOwn = ctx.isPullfrog ?? isPullfrog;
let count = 0;
let cursor: string | null = null;
// bound the walk so a misbehaving cursor can't loop forever; 50 pages = 5000
// threads, orders of magnitude beyond any real PR.
for (let page = 0; page < 50; page += 1) {
const response: OutstandingThreadsResponse = await ctx.octokit.graphql(
OUTSTANDING_THREADS_QUERY,
{ owner: ctx.repo.owner, name: ctx.repo.name, prNumber: pullNumber, cursor }
);
const conn = response.repository?.pullRequest?.reviewThreads;
for (const thread of conn?.nodes ?? []) {
if (!thread || thread.isResolved) continue;
const root = thread.comments?.nodes?.find((c) => c != null);
if (root && isOwn(root.author?.login)) count += 1;
}
if (!conn?.pageInfo.hasNextPage) break;
cursor = conn.pageInfo.endCursor;
}
return count;
}