Skip to content

Commit a6273d8

Browse files
izaitsevfbIvan
andauthored
torchci: render AI advisor verdict inline in the Dr.CI comment (#8202)
Surfaces the AI CI Advisor verdict directly in the Dr.CI PR comment (task #132 part 3). Under each **NEW FAILURE** / **UNCLASSIFIED FAILURE**, an `AI verdict:` line shows a colored status pill; once the verdict is finalized, the pill becomes a collapsible expand with the reasoning. ### What it looks like `AI verdict:` (plain text — toggles the expand) followed by a pill linking to HUD: - **In-progress** (dispatched, no verdict yet): just the pill (`analyzing`), no expand. - **Concluded**: the `AI verdict:` text toggles a `<details>` whose summary is the pill and whose body is the advisor's reasoning + a HUD link. Confidence is encoded **in the wording** (never a number) and the pill color rides a green → yellow → red gradient, with low confidence pulling toward yellow: | confidence | not-related pole | related pole | |---|---|---| | high (≥0.89) | `not related` (green) | `related` (red) | | med (0.70–0.89) | `probably not related` | `probably related` | | low (≤0.70) | `not related (uncertain)` (yellow) | `related (uncertain)` (yellow) | `garbage` → gray; `unsure`/unknown → `inconclusive`. Rendering mock: pytorch/ciforge#517 (final = the "Option C" comment). ### How it works - **New SVG endpoint** `pages/api/drci/advisorBadge.ts`, keyed on `(owner, repo, sha, job)`. The comment's `<img>` points here, so the pill can flip *analyzing → verdict* server-side **without rewriting the comment** (which only happens on the ~15-min Dr.CI cron). **State-dependent caching**: short TTL (`max-age=60`) while in-progress so the proxy (GitHub camo) re-fetches and the badge updates soon after the verdict lands; **immutable** long TTL once final (a verdict never changes for a fixed `sha`+`job`) → near-zero load afterward. - `lib/advisor/advisorBadge.ts` (pure): verdict+confidence → label/color, flat SVG render, badge-URL + line-HTML builders, and the per-job line selector. Fully unit-tested. - `lib/advisor/advisorComment.ts`: reads finalized verdicts (`advisor_verdicts_for_pr`) + in-progress dispatch state (`advisor_dispatch_states`) and builds the per-job lines. Verdicts match jobs by **exact signal-key equality** (`dr_ci_${jobName}`, the same key auto-dispatch wrote) — no fuzzy matching. - `drci.ts`: minimal additive hook — build the map (wrapped in try/catch so an advisor/ClickHouse error can never break the comment), thread it into the NEW FAILURE / UNCLASSIFIED sections. - New saved query `advisor_verdict_for_job` (indexed point lookup on the verdict table's ORDER BY prefix). ### Rollout Gated behind a **new `DRCI_ADVISOR_COMMENT_ENABLED` flag (default off)** so it ships dark — flip it in Vercel prod to enable. **Display-only**: never edits merge behavior. (Note: GitHub's comment sanitizer strips `target=_blank`, so the pill link opens in the same tab — unavoidable without JS.) ### Test plan - `tsc --noEmit` clean; `prettier`/`sqlfluff` clean. - New `test/advisorBadge.test.ts` (jest, node ≥18 — runs in CI) covers the confidence buckets, gradient mapping, SVG render, badge-URL encoding, in-progress vs concluded line rendering, and the verdict/in-progress/none line selection. - Manual: enable the flag on a deployment and confirm the line renders on a real pytorch/pytorch PR with a known verdict. --------- Co-authored-by: Ivan <izaitsevfb@meta.com>
1 parent b48344a commit a6273d8

7 files changed

Lines changed: 705 additions & 5 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"params": {
3+
"repo": "String",
4+
"sha": "String",
5+
"signalKey": "String"
6+
},
7+
"tests": []
8+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Latest AI advisor verdict for one (repo, suspect commit, signal_key). Backs
2+
-- the Dr.CI advisor badge endpoint. Filters the verdict table's ORDER BY prefix
3+
-- (repo, suspect_commit, signal_key, timestamp), so it reads only the relevant
4+
-- granules.
5+
SELECT
6+
verdict,
7+
confidence
8+
FROM
9+
misc.autorevert_advisor_verdicts
10+
WHERE
11+
repo = {repo: String}
12+
AND suspect_commit = {sha: String}
13+
AND signal_key = {signalKey: String}
14+
ORDER BY
15+
timestamp DESC
16+
LIMIT 1
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
// Pure rendering helpers for the AI CI Advisor verdict surfaced in the Dr.CI
2+
// comment (no server-only imports, so this is unit-testable and importable
3+
// anywhere). Two outputs:
4+
// - renderBadgeSvg: the colored status pill served by the advisorBadge API
5+
// route (the <img> in the comment points at that route so it can flip
6+
// analyzing -> verdict server-side without rewriting the comment).
7+
// - selectAdvisorLines / renderVerdictLine / renderInProgressLine: the
8+
// "AI verdict:" line emitted under each new failure in the comment.
9+
10+
import _ from "lodash";
11+
12+
// The signal_key prefix for HUD-originated (Dr.CI) advisor dispatches. Mirrors
13+
// signalKeyForJob() in advisorDispatch.ts; duplicated here to keep this module
14+
// free of any server-only imports.
15+
export const DRCI_SIGNAL_KEY_PREFIX = "dr_ci_";
16+
17+
export function drciSignalKeyForJob(fullJobName: string): string {
18+
return `${DRCI_SIGNAL_KEY_PREFIX}${fullJobName}`;
19+
}
20+
21+
export interface AdvisorBadge {
22+
label: string;
23+
// Hex fill, e.g. "#2da44e".
24+
color: string;
25+
// Use dark text for light (yellow-ish) fills that wash out white text.
26+
darkText: boolean;
27+
}
28+
29+
// Dispatched but no verdict yet.
30+
export const ANALYZING_BADGE: AdvisorBadge = {
31+
label: "analyzing",
32+
color: "#9f9f9f",
33+
darkText: false,
34+
};
35+
36+
// No verdict and no in-progress dispatch found (race / stale comment).
37+
export const PENDING_BADGE: AdvisorBadge = {
38+
label: "pending",
39+
color: "#9f9f9f",
40+
darkText: false,
41+
};
42+
43+
export type ConfidenceBucket = "high" | "med" | "low";
44+
45+
// Confidence is shown as a word baked into the label, never a number:
46+
// high >= 0.89, med (0.70, 0.89), low <= 0.70.
47+
export function confidenceBucket(confidence: number): ConfidenceBucket {
48+
if (confidence >= 0.89) return "high";
49+
if (confidence > 0.7) return "med";
50+
return "low";
51+
}
52+
53+
// Map a verdict + confidence to a label and a color on a green -> yellow -> red
54+
// gradient, with "uncertain" (low confidence) pulling toward yellow. The "AI
55+
// verdict:" prefix lives in the comment line, so the badge carries only the
56+
// status text.
57+
export function verdictBadge(
58+
verdict: string,
59+
confidence: number
60+
): AdvisorBadge {
61+
const v = (verdict || "").toLowerCase();
62+
63+
if (v === "garbage") {
64+
return { label: "garbage", color: "#6e7781", darkText: false };
65+
}
66+
if (v === "unsure") {
67+
return { label: "inconclusive", color: "#8b949e", darkText: false };
68+
}
69+
70+
const bucket = confidenceBucket(confidence);
71+
72+
// `related` is the context-neutral successor to `revert`; treat both as the
73+
// "related to this PR" pole.
74+
if (v === "related" || v === "revert") {
75+
if (bucket === "high") {
76+
return { label: "related", color: "#d1242f", darkText: false };
77+
}
78+
if (bucket === "med") {
79+
return { label: "probably related", color: "#e8702a", darkText: false };
80+
}
81+
return { label: "related (uncertain)", color: "#e0a82e", darkText: true };
82+
}
83+
84+
if (v === "not_related") {
85+
if (bucket === "high") {
86+
return { label: "not related", color: "#2da44e", darkText: false };
87+
}
88+
if (bucket === "med") {
89+
return {
90+
label: "probably not related",
91+
color: "#94c11f",
92+
darkText: true,
93+
};
94+
}
95+
return {
96+
label: "not related (uncertain)",
97+
color: "#c9b81a",
98+
darkText: true,
99+
};
100+
}
101+
102+
// Unknown verdict value -> treat as inconclusive rather than guessing a pole.
103+
return { label: "inconclusive", color: "#8b949e", darkText: false };
104+
}
105+
106+
// A minimal flat single-segment SVG pill (shields "flat" look, no left label).
107+
export function renderBadgeSvg(badge: AdvisorBadge): string {
108+
const text = badge.label;
109+
// Approximate text width; Verdana ~6.5px/char at 11px, plus horizontal pad.
110+
const width = Math.max(46, Math.round(text.length * 6.5) + 16);
111+
const textColor = badge.darkText ? "#33333a" : "#ffffff";
112+
const safe = _.escape(text);
113+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${safe}">
114+
<title>${safe}</title>
115+
<rect width="${width}" height="20" rx="3" fill="${badge.color}"/>
116+
<text x="${(width / 2).toFixed(
117+
1
118+
)}" y="14" fill="${textColor}" font-family="Verdana,DejaVu Sans,Geneva,sans-serif" font-size="11" text-anchor="middle">${safe}</text>
119+
</svg>`;
120+
}
121+
122+
// Absolute URL of the badge image for one (repo, sha, job). Camo fetches it
123+
// server-side; the route resolves the live state, so the same URL flips from
124+
// analyzing to the verdict without the comment changing.
125+
export function advisorBadgeUrl(
126+
hudBaseUrl: string,
127+
owner: string,
128+
repo: string,
129+
sha: string,
130+
jobName: string
131+
): string {
132+
const qs = new URLSearchParams({ owner, repo, sha, job: jobName });
133+
return `${hudBaseUrl}/api/drci/advisorBadge?${qs.toString()}`;
134+
}
135+
136+
function hudPrUrl(
137+
hudBaseUrl: string,
138+
owner: string,
139+
repo: string,
140+
prNumber: number,
141+
jobId: number
142+
): string {
143+
return `${hudBaseUrl}/pr/${owner}/${repo}/${prNumber}#${jobId}`;
144+
}
145+
146+
// In-progress line: just the badge (no expand), linked to HUD. The pill flips
147+
// to the verdict in place once the advisor finishes.
148+
export function renderInProgressLine(
149+
hudBaseUrl: string,
150+
owner: string,
151+
repo: string,
152+
prNumber: number,
153+
sha: string,
154+
jobName: string,
155+
jobId: number
156+
): string {
157+
const badge = advisorBadgeUrl(hudBaseUrl, owner, repo, sha, jobName);
158+
const link = hudPrUrl(hudBaseUrl, owner, repo, prNumber, jobId);
159+
return ` AI verdict: <a href="${link}"><img src="${badge}"></a>\n`;
160+
}
161+
162+
// Concluded line: "AI verdict:" plain text toggles the expand; the badge links
163+
// to HUD; the reasoning lives inside the expand.
164+
export function renderVerdictLine(
165+
hudBaseUrl: string,
166+
owner: string,
167+
repo: string,
168+
prNumber: number,
169+
sha: string,
170+
jobName: string,
171+
jobId: number,
172+
summary: string
173+
): string {
174+
const badge = advisorBadgeUrl(hudBaseUrl, owner, repo, sha, jobName);
175+
const link = hudPrUrl(hudBaseUrl, owner, repo, prNumber, jobId);
176+
// The advisor summary is model-generated from (attacker-influenceable) PR
177+
// content, so HTML-escape it before embedding in the comment: collapse
178+
// newlines (can't break the blockquote) and neutralize markup so it can't
179+
// close the <details>/<blockquote> or inject tags.
180+
const oneLine = _.escape((summary || "").replace(/\s*\n\s*/g, " ").trim());
181+
return (
182+
` <details><summary>AI verdict: <a href="${link}"><img src="${badge}"></a></summary><blockquote>\n\n` +
183+
` ${oneLine}\n\n` +
184+
` <a href="${link}">Full reasoning on HUD &rarr;</a>\n` +
185+
` </blockquote></details>\n`
186+
);
187+
}
188+
189+
// Minimal shapes the pure selector needs (subset of RecentWorkflowsData /
190+
// AdvisorVerdict) so this module stays import-light.
191+
export interface AdvisorLineJob {
192+
id: number;
193+
name: string;
194+
}
195+
export interface AdvisorLineVerdict {
196+
summary: string;
197+
}
198+
199+
// Decide the per-job line: a finalized verdict wins; otherwise an in-progress
200+
// dispatch ('dispatching'/'dispatched') shows the analyzing badge; otherwise no
201+
// line. Pure so it is unit-testable without ClickHouse. Returns job.id -> HTML.
202+
export function selectAdvisorLines(
203+
hudBaseUrl: string,
204+
owner: string,
205+
repo: string,
206+
prNumber: number,
207+
headSha: string,
208+
jobs: AdvisorLineJob[],
209+
verdictByKey: Map<string, AdvisorLineVerdict>,
210+
inProgressKeys: Set<string>
211+
): Map<number, string> {
212+
const out = new Map<number, string>();
213+
for (const job of jobs) {
214+
if (!job.name) continue;
215+
const key = drciSignalKeyForJob(job.name);
216+
const verdict = verdictByKey.get(key);
217+
if (verdict) {
218+
out.set(
219+
job.id,
220+
renderVerdictLine(
221+
hudBaseUrl,
222+
owner,
223+
repo,
224+
prNumber,
225+
headSha,
226+
job.name,
227+
job.id,
228+
verdict.summary
229+
)
230+
);
231+
continue;
232+
}
233+
if (inProgressKeys.has(key)) {
234+
out.set(
235+
job.id,
236+
renderInProgressLine(
237+
hudBaseUrl,
238+
owner,
239+
repo,
240+
prNumber,
241+
headSha,
242+
job.name,
243+
job.id
244+
)
245+
);
246+
}
247+
}
248+
return out;
249+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Server-side glue for the inline AI advisor verdict lines in the Dr.CI comment.
2+
// Reads finalized verdicts + in-progress dispatch state from ClickHouse, then
3+
// delegates the (pure) line selection/rendering to lib/advisor/advisorBadge.
4+
//
5+
// The verdict's signal_key is exactly `dr_ci_${jobName}` (what auto-dispatch
6+
// wrote), so verdicts match jobs by exact signal-key equality -- no fuzzy
7+
// matchVerdictToJob needed for the PR side.
8+
9+
import {
10+
AdvisorLineVerdict,
11+
selectAdvisorLines,
12+
} from "lib/advisor/advisorBadge";
13+
import { isAdvisorEnabled } from "lib/advisor/advisorConfig";
14+
import {
15+
readDispatchStates,
16+
signalKeyForJob,
17+
} from "lib/advisor/advisorDispatch";
18+
import {
19+
AdvisorVerdictRow,
20+
deduplicateVerdicts,
21+
} from "lib/advisorVerdictUtils";
22+
import { queryClickhouseSaved } from "lib/clickhouse";
23+
import { RecentWorkflowsData } from "lib/types";
24+
25+
// Gate the inline verdict rendering behind its own flag so it ships dark and
26+
// can be enabled per deployment (Vercel env var), independently of the
27+
// auto-dispatch flag. Display-only, so it doesn't also require VERCEL_ENV
28+
// (unlike auto-dispatch, which fires real workflow_dispatches).
29+
export function advisorCommentEnabled(owner: string, repo: string): boolean {
30+
return (
31+
process.env.DRCI_ADVISOR_COMMENT_ENABLED === "true" &&
32+
isAdvisorEnabled(owner, repo)
33+
);
34+
}
35+
36+
/**
37+
* Build the per-job "AI verdict:" line for a PR's new/unclassified failures.
38+
* Returns job.id -> rendered HTML (empty map when the comment flag is off, the
39+
* repo isn't advisor-enabled, or there are no jobs). The caller wraps this so a
40+
* ClickHouse error can never break the Dr.CI comment.
41+
*/
42+
export async function buildAdvisorVerdictLines(
43+
hudBaseUrl: string,
44+
owner: string,
45+
repo: string,
46+
prNumber: number,
47+
headSha: string,
48+
jobs: RecentWorkflowsData[]
49+
): Promise<Map<number, string>> {
50+
if (!advisorCommentEnabled(owner, repo) || jobs.length === 0) {
51+
return new Map();
52+
}
53+
54+
// Finalized verdicts for this PR, keyed by signal_key for the head commit.
55+
const verdictRows = (await queryClickhouseSaved("advisor_verdicts_for_pr", {
56+
repo: `${owner}/${repo}`,
57+
prNumber,
58+
})) as AdvisorVerdictRow[];
59+
const verdictByKey = new Map<string, AdvisorLineVerdict>();
60+
for (const v of deduplicateVerdicts(verdictRows)) {
61+
if (v.sha === headSha) {
62+
verdictByKey.set(v.signalKey, { summary: v.summary });
63+
}
64+
}
65+
66+
// In-progress dispatches (dispatching/dispatched) for the head commit.
67+
const signalKeys = jobs
68+
.filter((j) => j.name)
69+
.map((j) => signalKeyForJob(j.name));
70+
const states = await readDispatchStates(owner, repo, headSha, signalKeys);
71+
const inProgressKeys = new Set<string>();
72+
for (const [key, st] of states) {
73+
if (st.state === "dispatching" || st.state === "dispatched") {
74+
inProgressKeys.add(key);
75+
}
76+
}
77+
78+
return selectAdvisorLines(
79+
hudBaseUrl,
80+
owner,
81+
repo,
82+
prNumber,
83+
headSha,
84+
jobs.map((j) => ({ id: j.id, name: j.name })),
85+
verdictByKey,
86+
inProgressKeys
87+
);
88+
}

0 commit comments

Comments
 (0)