Skip to content

Commit 0876801

Browse files
izaitsevfbIvan
andauthored
torchci: don't auto-dispatch the AI advisor on closed/draft PRs (#8197)
Follow-up to #8178. The Dr.CI auto-dispatch path fired the AI advisor on **any** PR with recent NEW failures — including PRs that are **closed/merged** (won't be worked on) and **draft** (work-in-progress). Example: it dispatched on [pytorch/pytorch#187528](pytorch/pytorch#187528) right as it was closed. ## Fix Add a PR-state gate in `autoDispatchAdvisorForNewFailures`: - After dedup, once there's **fresh work** to dispatch, look up the PR and **skip if it's not open** (closed/merged) or — by default (`SKIP_DRAFT_PRS = true`) — **draft**. - The lookup runs **only when there's something to dispatch** (fully-deduped PRs are never looked up). - **Fails closed**: a lookup error skips the pass (the next 15-min pass retries) rather than dispatching on a possibly-closed PR. ## PR state from ClickHouse, not the GitHub API PR state comes from the **`default.pull_request` CH mirror** via a new `advisor_pr_state` saved query (`state` + `draft`), not `octokit.pulls.get`. This is consistent with the rest of the advisor path (all of its other reads are `queryClickhouseSaved`) and with `getPRsWithPendingJobInComment`, which already reads `pull_request.state` from CH — and it keeps the Dr.CI cron off the GitHub rate limit. The mirror lags GitHub by ~1 min (verified: #187528 closed at 22:04:54Z, CH `updated_at` 22:05:58Z), well within the 15-min cron cadence. A not-yet-mirrored PR (no row) is treated as **open** so brand-new PRs aren't dropped. ## Draft PRs `SKIP_DRAFT_PRS` defaults to **true** (skip drafts — WIP, tend to churn). One-line flip if advisor feedback on drafts is desirable. ## Test plan - `tsc --noEmit` clean; local lintrunner clean (incl. SQLFLUFF on the new saved query). - Verified the saved query live against CH: `{state: "closed", draft: false}` for #187528. - Unit tests: skips a closed PR (no dispatch, no marker write), skips a draft PR, and does **not** look up PR state when every failure is already deduped. Co-authored-by: Ivan <izaitsevfb@meta.com>
1 parent 77e179c commit 0876801

4 files changed

Lines changed: 145 additions & 3 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"params": {
3+
"prNumber": "Int64",
4+
"htmlUrl": "String"
5+
},
6+
"tests": []
7+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Latest PR state (open/closed) + draft flag for a single PR, used to gate AI
2+
-- advisor auto-dispatch (skip closed/merged or draft PRs). Reads the
3+
-- default.pull_request mirror instead of the GitHub API. Filters on `number`
4+
-- (the table's primary/sorting key) for an indexed lookup; html_url pins the
5+
-- repo since PR numbers are not unique across repos.
6+
SELECT
7+
state,
8+
draft
9+
FROM default.pull_request FINAL
10+
WHERE
11+
number = {prNumber: Int64}
12+
AND html_url = {htmlUrl: String}

torchci/lib/advisor/advisorDispatch.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -371,18 +371,56 @@ export async function recordDispatch(record: DispatchRecord): Promise<void> {
371371
});
372372
}
373373

374+
// Auto-dispatch is skipped on draft PRs (work-in-progress, not ready for
375+
// review). Flip to false to also analyze drafts.
376+
const SKIP_DRAFT_PRS = true;
377+
378+
/**
379+
* Fetch the minimal PR state needed to gate auto-dispatch, from the
380+
* default.pull_request ClickHouse mirror rather than the GitHub API. The rest
381+
* of the advisor path already reads from CH (and getPRsWithPendingJobInComment
382+
* in drci.ts already reads pull_request.state the same way), so this keeps the
383+
* Dr.CI cron off the GitHub rate limit. The mirror lags GitHub by ~1 minute,
384+
* well within the 15-minute cron cadence. A PR not yet mirrored (no row) is
385+
* treated as open so we don't drop dispatches on brand-new PRs.
386+
*/
387+
export async function getPullRequestMeta(
388+
owner: string,
389+
repo: string,
390+
prNumber: number
391+
): Promise<{ state: string; draft: boolean }> {
392+
const rows = await queryClickhouseSaved("advisor_pr_state", {
393+
prNumber,
394+
htmlUrl: `https://github.com/${owner}/${repo}/pull/${prNumber}`,
395+
});
396+
if (rows.length === 0) return { state: "open", draft: false };
397+
// Decode draft robustly: depending on the CH client/format a Bool can come
398+
// back as a JS boolean, a number, or a string ("0"/"1"/"true"/"false"). A
399+
// naive Boolean() would treat the string "0"/"false" as truthy and wrongly
400+
// mark every non-draft PR as draft (suppressing all open PRs).
401+
const draftRaw = rows[0].draft;
402+
const draft =
403+
draftRaw === true ||
404+
draftRaw === 1 ||
405+
draftRaw === "1" ||
406+
draftRaw === "true";
407+
return { state: rows[0].state as string, draft };
408+
}
409+
374410
// Injectable dependencies so the orchestration logic is unit-testable without
375411
// touching ClickHouse or GitHub.
376412
export interface AutoDispatchDeps {
377413
readDispatchStates: typeof readDispatchStates;
378414
recordDispatch: typeof recordDispatch;
379415
dispatchAdvisorWorkflow: typeof dispatchAdvisorWorkflow;
416+
getPullRequestMeta: typeof getPullRequestMeta;
380417
}
381418

382419
const defaultDeps: AutoDispatchDeps = {
383420
readDispatchStates,
384421
recordDispatch,
385422
dispatchAdvisorWorkflow,
423+
getPullRequestMeta,
386424
};
387425

388426
export interface AutoDispatchArgs {
@@ -417,7 +455,9 @@ export function autoDispatchEnabled(owner: string, repo: string): boolean {
417455
* - 'dispatched' is written on success; a 'failed' row supersedes the pre row
418456
* when the dispatch throws, re-enabling retry up to MAX_DISPATCH_RETRIES,
419457
* - bails entirely (dispatches nothing) if a PR has more NEW failures than the
420-
* per-repo max (outage guard).
458+
* per-repo max (outage guard),
459+
* - skips PRs that are not open (closed/merged) or, by default, draft -- the
460+
* PR state is only looked up once there is fresh work to dispatch.
421461
*/
422462
export async function autoDispatchAdvisorForNewFailures(
423463
args: AutoDispatchArgs,
@@ -471,8 +511,14 @@ export async function autoDispatchAdvisorForNewFailures(
471511
return;
472512
}
473513

474-
// The byKey set is already bounded by the outage guard above (<= the per-repo
475-
// max), so dispatch every fresh failure; dedup state skips repeats.
514+
// Which failures still need a dispatch (skip already dispatching/dispatched,
515+
// or failed-and-out-of-retries). The byKey set is already bounded by the
516+
// outage guard above.
517+
const toDispatch: {
518+
signalKey: string;
519+
job: RecentWorkflowsData;
520+
retryCount: number;
521+
}[] = [];
476522
for (const [signalKey, job] of byKey) {
477523
const prev = states.get(signalKey);
478524
if (prev) {
@@ -484,7 +530,35 @@ export async function autoDispatchAdvisorForNewFailures(
484530
}
485531
}
486532
const retryCount = prev?.state === "failed" ? prev.retryCount + 1 : 0;
533+
toDispatch.push({ signalKey, job, retryCount });
534+
}
535+
if (toDispatch.length === 0) return;
536+
537+
// Don't auto-dispatch on PRs that aren't open: a closed/merged PR won't be
538+
// worked on, and (by default) draft PRs are work-in-progress. Looked up only
539+
// now -- after dedup -- so the PR lookup is paid only when there is fresh
540+
// work. Fail closed: a lookup error skips this pass (the next pass retries).
541+
try {
542+
const pr = await deps.getPullRequestMeta(owner, repo, prNumber);
543+
if (pr.state !== "open") {
544+
console.log(
545+
`advisor auto-dispatch: PR ${prNumber} is ${pr.state}; skipping`
546+
);
547+
return;
548+
}
549+
if (pr.draft && SKIP_DRAFT_PRS) {
550+
console.log(`advisor auto-dispatch: PR ${prNumber} is a draft; skipping`);
551+
return;
552+
}
553+
} catch (e) {
554+
console.error(
555+
`advisor auto-dispatch: PR-state lookup failed for PR ${prNumber}, skipping pass`,
556+
e
557+
);
558+
return;
559+
}
487560

561+
for (const { signalKey, job, retryCount } of toDispatch) {
488562
const base = {
489563
owner,
490564
repo,

torchci/test/advisorDispatch.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ function makeDeps(overrides: Partial<AutoDispatchDeps> = {}): AutoDispatchDeps {
2626
readDispatchStates: jest.fn().mockResolvedValue(new Map()),
2727
recordDispatch: jest.fn().mockResolvedValue(undefined),
2828
dispatchAdvisorWorkflow: jest.fn().mockResolvedValue(undefined),
29+
getPullRequestMeta: jest
30+
.fn()
31+
.mockResolvedValue({ state: "open", draft: false }),
2932
...overrides,
3033
};
3134
}
@@ -242,4 +245,50 @@ describe("autoDispatchAdvisorForNewFailures", () => {
242245
DEFAULT_MAX_NEW_FAILURES
243246
);
244247
});
248+
249+
it("does not dispatch on a closed PR", async () => {
250+
const deps = makeDeps({
251+
getPullRequestMeta: jest
252+
.fn()
253+
.mockResolvedValue({ state: "closed", draft: false }),
254+
});
255+
await autoDispatchAdvisorForNewFailures(
256+
{ ...baseArgs, newFailures: [job("wf / a")] },
257+
deps
258+
);
259+
expect(deps.dispatchAdvisorWorkflow).not.toHaveBeenCalled();
260+
// Skipped after dedup but before any marker write.
261+
expect(deps.recordDispatch).not.toHaveBeenCalled();
262+
});
263+
264+
it("does not dispatch on a draft PR", async () => {
265+
const deps = makeDeps({
266+
getPullRequestMeta: jest
267+
.fn()
268+
.mockResolvedValue({ state: "open", draft: true }),
269+
});
270+
await autoDispatchAdvisorForNewFailures(
271+
{ ...baseArgs, newFailures: [job("wf / a")] },
272+
deps
273+
);
274+
expect(deps.dispatchAdvisorWorkflow).not.toHaveBeenCalled();
275+
});
276+
277+
it("does not look up PR state when every failure is already deduped", async () => {
278+
const states = new Map([
279+
[
280+
signalKeyForJob("wf / a"),
281+
{ state: "dispatched" as const, retryCount: 0 },
282+
],
283+
]);
284+
const deps = makeDeps({
285+
readDispatchStates: jest.fn().mockResolvedValue(states),
286+
});
287+
await autoDispatchAdvisorForNewFailures(
288+
{ ...baseArgs, newFailures: [job("wf / a")] },
289+
deps
290+
);
291+
expect(deps.getPullRequestMeta).not.toHaveBeenCalled();
292+
expect(deps.dispatchAdvisorWorkflow).not.toHaveBeenCalled();
293+
});
245294
});

0 commit comments

Comments
 (0)