@@ -5,6 +5,8 @@ import shlex from "shlex";
55import { queryClickhouseSaved } from "../clickhouse" ;
66import { getHelp , getParser } from "./cliParser" ;
77import { cherryPickClassifications } from "./Constants" ;
8+ import { fetchCrcrAllowlist } from "../crcrAllowlist" ;
9+ import { downstreamRepoFromCheckRunName , FAILURE_CONCLUSIONS } from "./crcrOncallBot" ;
810import PytorchBotLogger from "./pytorchbotLogger" ;
911import {
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
640731export default PytorchBotHandler ;
0 commit comments