Skip to content

Commit f9219b7

Browse files
committed
[CRCR] Implement on-call bot and allowlist functionality for downstream CI failures
1 parent a6273d8 commit f9219b7

9 files changed

Lines changed: 931 additions & 1 deletion

File tree

torchci/lib/bot/crcrOncallBot.ts

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

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: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ 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";
89
import PytorchBotLogger from "./pytorchbotLogger";
910
import {
1011
hasWritePermissions as _hasWP,
@@ -319,6 +320,28 @@ The explanation needs to be clear on why this is needed. Here are some good exam
319320
}
320321

321322
await this.logger.log("merge", extra_data);
323+
324+
// Check for L4 CRCR blocking failures (L3 failures are non-blocking).
325+
// Force merge (-f) bypasses this check, consistent with the existing
326+
// force-merge semantics for in-repo CI failures.
327+
if (!forceRequested) {
328+
try {
329+
const blockingRepos = await this.getCrcrBlockingFailures();
330+
if (blockingRepos.length > 0) {
331+
const repoList = blockingRepos.join(", ");
332+
await this.addComment(
333+
`The following L4 downstream CI workflows have failed and are blocking this merge:\n\n` +
334+
blockingRepos.map((r) => `- \`${r}\``).join("\n") +
335+
`\n\nPlease investigate or use \`@pytorchbot merge -f\` to bypass.`
336+
);
337+
return;
338+
}
339+
} catch (err) {
340+
// If the blocking check itself fails, log and proceed (fail open).
341+
this.ctx.log({ err }, "CRCR blocking check failed, allowing merge");
342+
}
343+
}
344+
322345
if (!forceRequested && isPyTorchPyTorch(this.owner, this.repo)) {
323346
let labels: string[] = this.ctx.payload?.issue?.labels.map(
324347
(e: any) => e["name"]
@@ -635,6 +658,82 @@ The explanation needs to be clear on why this is needed. Here are some good exam
635658
headSha: this.headSha,
636659
});
637660
}
661+
662+
/**
663+
* Return the list of L4 downstream repos whose CRCR check runs have failed
664+
* on this PR's head commit. L3 failures are intentionally omitted — they
665+
* are non-blocking.
666+
*/
667+
async getCrcrBlockingFailures(): Promise<string[]> {
668+
// Lazy-load the head SHA if not already cached
669+
if (this.headSha === undefined) {
670+
const pullRequest = await this.ctx.octokit.pulls.get({
671+
owner: this.owner,
672+
repo: this.repo,
673+
pull_number: this.prNum,
674+
});
675+
this.headSha = pullRequest.data.head.sha;
676+
}
677+
678+
// Query GitHub Check Runs API for all check runs on this commit
679+
let checkRuns: any[] = [];
680+
try {
681+
const res = await this.ctx.octokit.checks.listForRef({
682+
owner: this.owner,
683+
repo: this.repo,
684+
ref: this.headSha!,
685+
filter: "latest",
686+
per_page: 100,
687+
});
688+
checkRuns = res.data.check_runs;
689+
} catch {
690+
// If we can't fetch check runs, fail open (don't block merge on
691+
// an infrastructure error)
692+
this.ctx.log("getCrcrBlockingFailures: failed to list check runs");
693+
return [];
694+
}
695+
696+
// Filter for CRCR check runs with a blocking conclusion
697+
const FAILURE_CONCLUSIONS = new Set([
698+
"failure",
699+
"cancelled",
700+
"timed_out",
701+
"action_required",
702+
]);
703+
704+
const crcrFailures = checkRuns.filter(
705+
(cr: any) =>
706+
cr.name.startsWith("crcr/") &&
707+
FAILURE_CONCLUSIONS.has(cr.conclusion ?? "")
708+
);
709+
710+
if (crcrFailures.length === 0) {
711+
return [];
712+
}
713+
714+
// Load the allowlist to classify each failed repo as L3 or L4
715+
let allowlist;
716+
try {
717+
allowlist = await fetchCrcrAllowlist(this.ctx.octokit);
718+
} catch {
719+
this.ctx.log("getCrcrBlockingFailures: failed to load allowlist");
720+
return [];
721+
}
722+
723+
const blocking: string[] = [];
724+
for (const cr of crcrFailures) {
725+
// Parse downstream repo from check run name: crcr/<owner>/<repo>/...
726+
const nameParts = cr.name.slice(5).split("/"); // remove "crcr/"
727+
if (nameParts.length >= 2) {
728+
const downstreamRepo = `${nameParts[0]}/${nameParts[1]}`;
729+
if (allowlist.isBlocking(downstreamRepo)) {
730+
blocking.push(downstreamRepo);
731+
}
732+
}
733+
}
734+
735+
return blocking;
736+
}
638737
}
639738

640739
export default PytorchBotHandler;

0 commit comments

Comments
 (0)