Skip to content

Commit 9d7ea10

Browse files
committed
[CRCR] Implement on-call bot and allowlist functionality for downstream CI failures
1 parent 74a2874 commit 9d7ea10

11 files changed

Lines changed: 949 additions & 42 deletions

File tree

torchci/lib/bot/crcrOncallBot.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { Probot } from "probot";
2+
import { fetchCrcrAllowlist } from "../crcrAllowlist";
3+
import { isPyTorchbotSupportedOrg } from "./utils";
4+
5+
export const FAILURE_CONCLUSIONS: ReadonlySet<string> = new Set([
6+
"failure",
7+
"cancelled",
8+
"timed_out",
9+
]);
10+
11+
const MARKER = "<!-- crcr-oncall -->";
12+
13+
/**
14+
* Parse the downstream ``owner/repo`` out of a CRCR check run name.
15+
*
16+
* Check runs are named ``crcr/<owner>/<repo>/<workflow_name>/<job_name>``
17+
* (see the Python gh_helper.check_run_name), so the downstream repo is the
18+
* first two path segments after the ``crcr/`` prefix.
19+
*/
20+
export function downstreamRepoFromCheckRunName(name: string): string | null {
21+
const prefix = "crcr/";
22+
if (!name.startsWith(prefix)) {
23+
return null;
24+
}
25+
const parts = name.slice(prefix.length).split("/");
26+
if (parts.length < 4 || !parts[0] || !parts[1]) {
27+
return null;
28+
}
29+
return `${parts[0]}/${parts[1]}`;
30+
}
31+
32+
/**
33+
* Search existing PR comments for one with the CRCR on-call marker.
34+
* Returns its ID (or 0 if none found). Uses pagination so the marker
35+
* is found even when a PR has more than 30 comments.
36+
*/
37+
async function findExistingComment(
38+
octokit: any,
39+
owner: string,
40+
repo: string,
41+
prNumber: number
42+
): Promise<number> {
43+
const comments = await octokit.paginate(
44+
octokit.rest.issues.listComments,
45+
{ owner, repo, issue_number: prNumber }
46+
);
47+
for (const comment of comments) {
48+
if (comment.body?.includes(MARKER)) {
49+
return comment.id;
50+
}
51+
}
52+
return 0;
53+
}
54+
55+
export default function crcrOncallBot(app: Probot): void {
56+
app.on("check_run.completed", async (ctx) => {
57+
const owner = ctx.payload.repository.owner.login;
58+
if (!isPyTorchbotSupportedOrg(owner)) {
59+
return;
60+
}
61+
62+
const repo = ctx.payload.repository.name;
63+
const checkRun = ctx.payload.check_run;
64+
65+
// Only act on CRCR-created check runs
66+
const downstreamRepo = downstreamRepoFromCheckRunName(checkRun.name);
67+
if (!downstreamRepo) {
68+
return;
69+
}
70+
71+
// Only comment on failures
72+
const conclusion = checkRun.conclusion ?? "";
73+
if (!FAILURE_CONCLUSIONS.has(conclusion)) {
74+
return;
75+
}
76+
77+
// Get the PR this check run belongs to
78+
const prs = checkRun.pull_requests ?? [];
79+
if (prs.length === 0) {
80+
ctx.log(
81+
`crcrOncall: no PR associated with check run ${checkRun.name}, skipping`
82+
);
83+
return;
84+
}
85+
86+
const headSha = checkRun.head_sha;
87+
const checkRunUrl = checkRun.html_url ?? "";
88+
89+
// Load oncalls from the allowlist
90+
let oncalls: string[];
91+
try {
92+
const allowlist = await fetchCrcrAllowlist(ctx.octokit);
93+
oncalls = allowlist.getOncallsForRepo(downstreamRepo);
94+
} catch (err) {
95+
ctx.log({ err }, "crcrOncall: failed to load allowlist, skipping");
96+
return;
97+
}
98+
99+
if (oncalls.length === 0) {
100+
ctx.log(
101+
`crcrOncall: no oncalls configured for ${downstreamRepo}, skipping`
102+
);
103+
return;
104+
}
105+
106+
const mentions = oncalls.map((o) => `@${o}`).join(" ");
107+
108+
// Post a comment on each associated PR (typically just one)
109+
for (const pr of prs) {
110+
try {
111+
const prNumber = pr.number;
112+
113+
// Dedup by marker: only comment once per PR.
114+
// NOTE: There is a theoretical TOCTOU race here — two concurrent
115+
// check_run.completed events for the same PR could both pass the
116+
// marker check and post duplicate comments. In practice, checks
117+
// arrive sequentially, so this is unlikely to cause issues.
118+
const existingId = await findExistingComment(
119+
ctx.octokit,
120+
owner,
121+
repo,
122+
prNumber
123+
);
124+
if (existingId !== 0) {
125+
ctx.log(
126+
`crcrOncall: comment already exists on PR #${prNumber}, skipping`
127+
);
128+
continue;
129+
}
130+
131+
const commentBody = `${MARKER}
132+
## :x: CRCR downstream CI failure
133+
134+
The downstream CI workflow in **${downstreamRepo}** has failed on commit \`${headSha.slice(0, 7)}\`.
135+
136+
${mentions} please investigate.
137+
138+
### Details
139+
140+
| Field | Value |
141+
|---|---|
142+
| **Check Run** | [${checkRun.name}](${checkRunUrl}) |
143+
| **Commit** | \`${headSha}\` |
144+
| **Conclusion** | \`${conclusion}\` |
145+
`;
146+
147+
await ctx.octokit.issues.createComment({
148+
owner,
149+
repo,
150+
issue_number: prNumber,
151+
body: commentBody,
152+
});
153+
154+
ctx.log(
155+
`crcrOncall: commented on PR #${prNumber} for ${downstreamRepo} (${conclusion})`
156+
);
157+
} catch (err) {
158+
ctx.log({ err }, `crcrOncall: failed to comment on PR for ${downstreamRepo}`);
159+
}
160+
}
161+
});
162+
}

torchci/lib/bot/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import cancelWorkflowsOnCloseBot from "./cancelWorkflowsOnCloseBot";
66
import checkLabelsBot from "./checkLabelsBot";
77
import ciflowPushTrigger from "./ciflowPushTrigger";
88
import codevNoWritePerm from "./codevNoWritePermBot";
9+
import crcrOncallBot from "./crcrOncallBot";
910
import drciBot from "./drciBot";
1011
import nitpickBot from "./nitpickBot";
1112
import pytorchBot from "./pytorchBot";
@@ -22,6 +23,7 @@ export default function bot(app: Probot) {
2223
checkLabelsBot(app);
2324
ciflowPushTrigger(app);
2425
codevNoWritePerm(app);
26+
crcrOncallBot(app);
2527
drciBot(app);
2628
nitpickBot(app);
2729
pytorchBot(app);

torchci/lib/bot/pytorchBotHandler.ts

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import shlex from "shlex";
55
import { queryClickhouseSaved } from "../clickhouse";
66
import { getHelp, getParser } from "./cliParser";
77
import { cherryPickClassifications } from "./Constants";
8+
import { fetchCrcrAllowlist } from "../crcrAllowlist";
9+
import { downstreamRepoFromCheckRunName, FAILURE_CONCLUSIONS } from "./crcrOncallBot";
810
import PytorchBotLogger from "./pytorchbotLogger";
911
import {
1012
hasWritePermissions as _hasWP,
@@ -319,6 +321,30 @@ The explanation needs to be clear on why this is needed. Here are some good exam
319321
}
320322

321323
await this.logger.log("merge", extra_data);
324+
325+
// Check for L4 CRCR blocking failures (L3 failures are non-blocking).
326+
// Force merge (-f) bypasses this check, consistent with the existing
327+
// force-merge semantics for in-repo CI failures.
328+
// Only applies to pytorch/pytorch — the CRCR allowlist and check runs
329+
// are specific to that repo.
330+
if (!forceRequested && isPyTorchPyTorch(this.owner, this.repo)) {
331+
try {
332+
const blockingRepos = await this.getCrcrBlockingFailures();
333+
if (blockingRepos.length > 0) {
334+
const repoList = blockingRepos.join(", ");
335+
await this.addComment(
336+
`The following L4 downstream CI workflows have failed and are blocking this merge:\n\n` +
337+
blockingRepos.map((r) => `- \`${r}\``).join("\n") +
338+
`\n\nPlease investigate or use \`@pytorchbot merge -f\` to bypass.`
339+
);
340+
return;
341+
}
342+
} catch (err) {
343+
// If the blocking check itself fails, log and proceed (fail open).
344+
this.ctx.log({ err }, "CRCR blocking check failed, allowing merge");
345+
}
346+
}
347+
322348
if (!forceRequested && isPyTorchPyTorch(this.owner, this.repo)) {
323349
let labels: string[] = this.ctx.payload?.issue?.labels.map(
324350
(e: any) => e["name"]
@@ -401,10 +427,8 @@ The explanation needs to be clear on why this is needed. Here are some good exam
401427
);
402428
}
403429

404-
async hasWorkflowRunningPermissions(username: string): Promise<boolean> {
405-
if (await _hasWP(this.ctx, username)) {
406-
return true;
407-
}
430+
/** Lazy-load and cache the PR head SHA, then return it. */
431+
private async ensureHeadSha(): Promise<string> {
408432
if (this.headSha === undefined) {
409433
const pullRequest = await this.ctx.octokit.pulls.get({
410434
owner: this.owner,
@@ -413,12 +437,19 @@ The explanation needs to be clear on why this is needed. Here are some good exam
413437
});
414438
this.headSha = pullRequest.data.head.sha;
415439
}
440+
return this.headSha!;
441+
}
442+
443+
async hasWorkflowRunningPermissions(username: string): Promise<boolean> {
444+
if (await _hasWP(this.ctx, username)) {
445+
return true;
446+
}
416447

417448
return await hasApprovedPullRuns(
418449
this.ctx.octokit,
419450
this.ctx.payload.repository.owner.login,
420451
this.ctx.payload.repository.name,
421-
this.headSha!
452+
await this.ensureHeadSha()
422453
);
423454
}
424455

@@ -635,6 +666,66 @@ The explanation needs to be clear on why this is needed. Here are some good exam
635666
headSha: this.headSha,
636667
});
637668
}
669+
670+
/**
671+
* Return the list of L4 downstream repos whose CRCR check runs have failed
672+
* on this PR's head commit. L3 failures are intentionally omitted — they
673+
* are non-blocking.
674+
*/
675+
async getCrcrBlockingFailures(): Promise<string[]> {
676+
const headSha = await this.ensureHeadSha();
677+
678+
// Query GitHub Check Runs API for all check runs on this commit.
679+
// Use paginate to handle PRs with more than 100 check runs.
680+
let checkRuns: any[] = [];
681+
try {
682+
checkRuns = await this.ctx.octokit.paginate(
683+
this.ctx.octokit.checks.listForRef,
684+
{
685+
owner: this.owner,
686+
repo: this.repo,
687+
ref: headSha,
688+
filter: "latest",
689+
per_page: 100,
690+
}
691+
);
692+
} catch {
693+
// If we can't fetch check runs, fail open (don't block merge on
694+
// an infrastructure error)
695+
this.ctx.log("getCrcrBlockingFailures: failed to list check runs");
696+
return [];
697+
}
698+
699+
// Filter for CRCR check runs with a blocking conclusion
700+
const crcrFailures = checkRuns.filter(
701+
(cr: any) =>
702+
cr.name.startsWith("crcr/") &&
703+
FAILURE_CONCLUSIONS.has(cr.conclusion ?? "")
704+
);
705+
706+
if (crcrFailures.length === 0) {
707+
return [];
708+
}
709+
710+
// Load the allowlist to classify each failed repo as L3 or L4
711+
let allowlist;
712+
try {
713+
allowlist = await fetchCrcrAllowlist(this.ctx.octokit);
714+
} catch {
715+
this.ctx.log("getCrcrBlockingFailures: failed to load allowlist");
716+
return [];
717+
}
718+
719+
const blocking: string[] = [];
720+
for (const cr of crcrFailures) {
721+
const downstreamRepo = downstreamRepoFromCheckRunName(cr.name);
722+
if (downstreamRepo && allowlist.isBlocking(downstreamRepo)) {
723+
blocking.push(downstreamRepo);
724+
}
725+
}
726+
727+
return blocking;
728+
}
638729
}
639730

640731
export default PytorchBotHandler;

0 commit comments

Comments
 (0)