Files
gstack/lib/pr-wait-github.ts
T
Garry Tanandcapy-ai[bot] 6bbd0a536f v1.91.266.0 feat(pr-wait): gstack-pr-wait event watcher (tstack pr-wait design port) and /land-and-deploy Step 3 wiring (#3495)
* feat(pr-wait): gstack-pr-wait event watcher (tstack pr-wait design port) and /land-and-deploy Step 3 wiring

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pr-wait): keep the result codes in lib/result-codes-release.ts so fixtures that copy the module list keep working

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pr-wait): function declarations for the fake-gh helpers so the pregate TSX import scanner maps the lib files

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pr-wait): /ship review pass 1 — every check gates green, --timeout, bot identity, signal disposition

Red Team and specialist findings on #3495, all fixed in the diff:

- green gates on every check on the head, as gstack-ci-gate does, so
  land-and-deploy Step 2 and Step 3 can no longer disagree while a
  non-required check pends; required checks still gate before they appear
- --timeout MINUTES (default 60, 0 = none) ends a watch that met nothing as
  PR_WAIT_BLOCKED naming the still-pending checks; restores Step 3's cap
- a bot owns logins that start with its name (greptile-apps[bot]), not any
  login containing it; a failed gating-bot check is a failed check, never a
  completed review
- ready: green alone when no gating bot is configured (pr_wait_gating_bots
  none no longer polls forever); held while merge state is UNKNOWN
- BEHIND and DIRTY announced once per head, with the short head in the line
- the bin installs no signal handlers: the synchronous loop never ran them
  and they made the process ignore SIGTERM/SIGINT; default disposition kills
  it at once and the next watcher takes over the dead pid's lock
- a gh spawn timeout (ETIMEDOUT) is retried like any transient failure
- a local fault (unwritable state root) is PR_WAIT_REFUSED exit 2, so exit 1
  always carries a PR_WAIT_BLOCKED line
- control characters in GitHub text never start a second event line

Tests: 21 new cases including a real SIGTERM against the bin, gstack-config
set-time validation and default parity, and the 14 state-matrix cells the
state-machines specialist listed (gstack-state-matrix check: ok, 33/33).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pr-wait): /ship review pass 2 — advisory checks gate too, BEHIND keyed on the head, verdict survives a failed save

- an advisory bot's review is never waited for, but its check gates like any
  other, so gstack-ci-gate (Step 2) and gstack-pr-wait --until green (Step 3)
  read the same check set with no exception
- BEHIND and DIRTY are keyed on the head alone; the pass-1 key also held the
  base oid, so a base that kept moving re-announced BEHIND to a restarted
  watcher on every commit
- finish() keeps the printed verdict's exit code when the state file cannot
  be written; the save error goes to stderr
- PR_WAIT_BLOCKED and PR_WAIT_REFUSED summaries name every exit-1 and exit-2
  path (--timeout, a local error); emitBots docstring matches the gating rule
- CHANGELOG says what a timeout means at land-and-deploy Step 3: a STOP with
  the pending checks named, no wait-more question

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-27 04:45:33 +00:00

234 lines
10 KiB
TypeScript

/**
* pr-wait-github — the GitHub read side of gstack-pr-wait: a gh runner that
* retries transient failures, the one GraphQL read of a pull request, its
* checks from `gh pr checks`, the required-check names from rulesets and
* classic branch protection, and the base-branch comparisons. The event logic
* is lib/pr-wait.ts; the two are split so each stays under the module-size
* ratchet.
*
* Design ported from tstack's `pr-wait` (t3dotgg/tstack,
* skills/babysit-pr/scripts/pr-wait at 7de017b, MIT, Copyright (c) 2026 Theo
* Browne; NOTICE.md and licenses/MIT-tstack.txt), rewritten in TypeScript on
* gstack's gh runner shape (lib/ci-gate.ts).
*/
import type { GhResult, GhRunner } from './ci-gate';
export class GhError extends Error {
constructor(message: string, readonly transient: boolean) {
super(message);
this.name = 'GhError';
}
}
export interface GhEnv {
gh: GhRunner;
sleep(ms: number): void;
log(message: string): void;
}
export type BucketClass = 'pass' | 'pending' | 'fail';
export interface Actor { login: string; bot: boolean }
export interface Comment { id: string; url: string; body: string; author: Actor }
export interface Review { id: string; url: string; state: string; body: string; commitOid: string | null; author: Actor }
export interface Thread { isResolved: boolean; path: string; line: number | null; comments: Comment[] }
export interface PullRequest {
url: string;
state: string;
headRefOid: string;
baseRefName: string;
mergeStateStatus: string;
baseOid: string | null;
author: string;
viewer: string;
comments: Comment[];
reviews: Review[];
threads: Thread[];
}
export interface Check { name: string; bucket: string; link: string; description: string }
const TRANSIENT = /HTTP (5\d\d|429)|rate limit|timed? ?out|connection (reset|refused)|EOF|temporar|could not resolve host|TLS handshake|Something went wrong/i;
const RETRIES = 5;
const PASS_BUCKETS = new Set(['pass', 'skipping', 'neutral']);
/** Worst bucket first, for collapsing duplicate check names (push and pull_request runs of one job). */
const RANK: Record<BucketClass, number> = { fail: 2, pending: 1, pass: 0 };
export const PR_QUERY = `
query($owner: String!, $name: String!, $number: Int!) {
viewer { login }
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
url state headRefOid baseRefName mergeStateStatus
author { login }
baseRef { target { oid } }
comments(last: 100) { nodes { id url body author { login __typename } } }
reviews(last: 100) {
nodes { id url state body commit { oid } author { login __typename } }
}
reviewThreads(last: 100) {
nodes {
isResolved path line
comments(first: 100) { nodes { id url body author { login __typename } } }
}
}
}
}
}
`;
export function bucketClass(bucket: string): BucketClass {
if (PASS_BUCKETS.has(bucket)) return 'pass';
return bucket === 'pending' ? 'pending' : 'fail';
}
function lastLine(text: string): string {
return text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? '';
}
/** Run gh. A transient failure (HTTP 5xx or 429, network) is retried with doubling backoff. */
export function runGh(env: GhEnv, args: string[], allow: number[] = [0]): GhResult {
let delay = 2_000;
for (let attempt = 1; ; attempt++) {
const result = env.gh(args);
// gh missing is fatal; any other spawn failure (its 60 s timeout, a signal) is a slow API, so retry.
if (result.error && /not installed/.test(result.error)) throw new GhError(result.error, false);
const status = result.status ?? -1;
const transient = status !== 0 && (result.error !== undefined || result.status === null || TRANSIENT.test(result.stderr));
if (!transient && allow.includes(status)) return result;
const message = result.error ?? (lastLine(result.stderr) || `gh exited ${status}`);
if (!transient || attempt === RETRIES) throw new GhError(message, transient);
env.log(`gh ${args.slice(0, 2).join(' ')}: ${message}; retry in ${delay / 1000}s`);
env.sleep(delay);
delay *= 2;
}
}
function ghLines(env: GhEnv, args: string[]): string[] {
return runGh(env, args).stdout.split(/\r?\n/).filter(Boolean);
}
type Json = Record<string, unknown>;
const obj = (value: unknown): Json => (value && typeof value === 'object' ? (value as Json) : {});
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
const nodes = (value: unknown): Json[] => {
const list = obj(value).nodes;
return Array.isArray(list) ? list.map(obj) : [];
};
const actor = (node: Json): Actor => {
const author = obj(node.author);
return { login: str(author.login) || 'ghost', bot: author.__typename === 'Bot' };
};
const comment = (node: Json): Comment => ({ id: str(node.id), url: str(node.url), body: str(node.body), author: actor(node) });
/** The current owner/name of the checkout's GitHub repository. */
export function currentRepo(env: GhEnv): string {
return runGh(env, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']).stdout.trim();
}
/** Confirms the PR exists and is readable; throws GhError otherwise. */
export function confirmPullRequest(env: GhEnv, repo: string, pr: number): void {
runGh(env, ['pr', 'view', String(pr), '-R', repo, '--json', 'number']);
}
export function fetchPullRequest(env: GhEnv, repo: string, pr: number): PullRequest {
const [owner, name] = repo.split('/');
const result = runGh(env, ['api', 'graphql', '-f', `query=${PR_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${pr}`]);
let parsed: Json;
try { parsed = obj(JSON.parse(result.stdout)); } catch {
throw new GhError('gh api graphql printed output that is not JSON', false);
}
const node = obj(obj(obj(parsed.data).repository).pullRequest);
if (!str(node.headRefOid)) {
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(e => str(obj(e).message)).filter(Boolean).join('; ') : '';
throw new GhError(errors || `${repo}#${pr} is not a pull request GitHub returned`, false);
}
return {
url: str(node.url),
state: str(node.state),
headRefOid: str(node.headRefOid),
baseRefName: str(node.baseRefName),
mergeStateStatus: str(node.mergeStateStatus) || 'UNKNOWN',
baseOid: str(obj(obj(node.baseRef).target).oid) || null,
author: str(obj(node.author).login) || 'ghost',
viewer: str(obj(obj(parsed.data).viewer).login),
comments: nodes(node.comments).map(comment),
reviews: nodes(node.reviews).map(r => ({ ...comment(r), state: str(r.state), commitOid: str(obj(r.commit).oid) || null })),
threads: nodes(node.reviewThreads).map(t => ({
isResolved: t.isResolved === true,
path: str(t.path),
line: typeof t.line === 'number' ? t.line : null,
comments: nodes(t.comments).map(comment),
})),
};
}
/**
* Every check on the PR head by name, duplicates collapsed to the worst
* bucket. `gh pr checks` exits 8 while checks run and 1 when one failed:
* those are states, so the JSON is read whatever the exit status.
*/
export function fetchChecks(env: GhEnv, repo: string, pr: number): Map<string, Check> {
const result = runGh(env, ['pr', 'checks', String(pr), '-R', repo, '--json', 'name,bucket,link,description'], [0, 1, 8]);
const checks = new Map<string, Check>();
if (!result.stdout.trim()) {
if (/no checks reported/.test(result.stderr)) return checks;
throw new GhError(lastLine(result.stderr) || `gh pr checks exited ${result.status}`, false);
}
let rows: unknown;
try { rows = JSON.parse(result.stdout); } catch {
throw new GhError('gh pr checks printed output that is not JSON', false);
}
if (!Array.isArray(rows)) throw new GhError('gh pr checks JSON is not a list', false);
for (const raw of rows.map(obj)) {
const name = str(raw.name);
if (!name) continue;
const row: Check = { name, bucket: str(raw.bucket) || 'pending', link: str(raw.link), description: str(raw.description) };
const current = checks.get(name);
if (!current || RANK[bucketClass(row.bucket)] > RANK[bucketClass(current.bucket)]) checks.set(name, row);
}
return checks;
}
/**
* Required check names for the base branch, from rulesets and classic branch
* protection. Reading them up front means a required check that has not
* started yet counts as pending instead of green by omission. A 404 means no
* protection of that kind, which is normal; any other failure is logged and
* that source contributes nothing.
*/
export function requiredChecks(env: GhEnv, repo: string, base: string): Set<string> {
const sources: Array<[string, string]> = [
[`repos/${repo}/rules/branches/${base}`, '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context'],
[`repos/${repo}/branches/${base}/protection/required_status_checks`, '.contexts[]?, .checks[]?.context'],
];
const names = new Set<string>();
for (const [path, jq] of sources) {
try {
for (const name of ghLines(env, ['api', path, '--jq', jq])) names.add(name);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/HTTP 404/.test(message)) env.log(`could not read required checks from ${path}: ${message}`);
}
}
return names;
}
/** Files the PR changes, from `gh pr diff --name-only`. */
export function pullRequestFiles(env: GhEnv, repo: string, pr: number): Set<string> {
return new Set(ghLines(env, ['pr', 'diff', String(pr), '-R', repo, '--name-only']));
}
/** Files the commits between two base-branch heads changed. */
export function compareFiles(env: GhEnv, repo: string, from: string, to: string): string[] {
return ghLines(env, ['api', `repos/${repo}/compare/${from}...${to}`, '--jq', '.files[].filename']);
}
/** How many commits the head is behind the base head, or null when unreadable. */
export function behindBy(env: GhEnv, repo: string, baseOid: string, head: string): string | null {
try {
return runGh(env, ['api', `repos/${repo}/compare/${baseOid}...${head}`, '--jq', '.behind_by']).stdout.trim() || null;
} catch {
return null;
}
}