Skip to content

Commit 4bac884

Browse files
izaitsevfbIvan
andauthored
torchci: extract advisor dispatch into a shared lib + saved CH queries (refactor) (#8195)
Pure refactor of the manual "AI Analyze" dispatch endpoint — **no behavior change**. Split out from #8178 (per review) so it can land on its own ahead of the auto-dispatch feature. ## What changes - **New `lib/advisor/advisorDispatch.ts`** — the signal_pattern build + workflow dispatch (`dispatchAdvisorWorkflow`) and helpers (`signalKeyForJob`, `isValidSha`, the job-status fetchers), extracted from `dispatch-advisor.ts` so they can be reused by other callers. - **Saved ClickHouse queries** — the three inline queries move into `clickhouse_queries/` (`advisor_job_status_exact`, `advisor_job_status_pattern`, `advisor_trunk_shas_with_job`), called via `queryClickhouseSaved`. - **`dispatch-advisor.ts` becomes a thin handler** — same auth + write-permission gate, same verdict-based 10-minute dedup, same workflow file (`claude-autorevert-advisor.yml`) and 200/409/500 responses; it just delegates the dispatch to the shared lib. ## No functional change The manual button behaves exactly as before. The extracted code is byte-for-byte equivalent (the only transformation is inline SQL → saved queries). The retries-guard and the auto-dispatch feature live in the follow-up PR. ## Test plan - `tsc --noEmit` clean. - Local lintrunner clean (incl. SQLFLUFF on the new saved queries). - Manual "AI Analyze" button path is unchanged. Follow-up (new functionality, stacked on this): #8178 — auto-dispatch the advisor on Dr.CI new failures (per-repo config, dedup table, outage guard). Co-authored-by: Ivan <izaitsevfb@meta.com>
1 parent ac261fc commit 4bac884

8 files changed

Lines changed: 366 additions & 296 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+
"jobName": "String",
5+
"shas": "Array(String)"
6+
},
7+
"tests": []
8+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- Job events for specific SHAs, matched by exact "workflow / job" name.
2+
-- Used for the advisor's PR head commit, where the full job name is known.
3+
SELECT
4+
job.head_sha AS sha,
5+
job.conclusion_kg AS conclusion,
6+
CONCAT(job.workflow_name, ' / ', job.name) AS fullName,
7+
job.html_url AS htmlUrl,
8+
job.log_url AS logUrl,
9+
job.started_at AS startedAt,
10+
job.completed_at AS completedAt,
11+
job.torchci_classification_kg.'captures' AS failureCaptures,
12+
IF(
13+
job.torchci_classification_kg.'line' = '',
14+
[],
15+
[job.torchci_classification_kg.'line']
16+
) AS failureLines
17+
FROM default.workflow_job AS job FINAL
18+
WHERE
19+
job.id IN (
20+
SELECT id
21+
FROM materialized_views.workflow_job_by_head_sha
22+
WHERE head_sha IN {shas: Array(String)}
23+
)
24+
AND job.head_sha IN {shas: Array(String)}
25+
AND job.repository_full_name = {repo: String}
26+
AND CONCAT(job.workflow_name, ' / ', job.name) = {jobName: String}
27+
ORDER BY job.started_at DESC
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+
"jobPattern": "String",
5+
"shas": "Array(String)"
6+
},
7+
"tests": []
8+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- Job events for specific SHAs, matched by a "workflow / job" LIKE pattern.
2+
-- Used for the advisor's merge-base / trunk commits, where the job name may
3+
-- differ from the PR variant (e.g. -partial on the PR vs -all on trunk).
4+
SELECT
5+
job.head_sha AS sha,
6+
job.conclusion_kg AS conclusion,
7+
CONCAT(job.workflow_name, ' / ', job.name) AS fullName,
8+
job.html_url AS htmlUrl,
9+
job.log_url AS logUrl,
10+
job.started_at AS startedAt,
11+
job.completed_at AS completedAt,
12+
job.torchci_classification_kg.'captures' AS failureCaptures,
13+
IF(
14+
job.torchci_classification_kg.'line' = '',
15+
[],
16+
[job.torchci_classification_kg.'line']
17+
) AS failureLines
18+
FROM default.workflow_job AS job FINAL
19+
WHERE
20+
job.id IN (
21+
SELECT id
22+
FROM materialized_views.workflow_job_by_head_sha
23+
WHERE head_sha IN {shas: Array(String)}
24+
)
25+
AND job.head_sha IN {shas: Array(String)}
26+
AND job.repository_full_name = {repo: String}
27+
AND CONCAT(job.workflow_name, ' / ', job.name) LIKE {jobPattern: String}
28+
ORDER BY job.started_at DESC
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"params": {
3+
"repo": "String",
4+
"branch": "String",
5+
"jobPattern": "String",
6+
"limit": "UInt32"
7+
},
8+
"tests": []
9+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- Recent trunk SHAs (on the default branch) that have a finished run matching a
2+
-- "workflow / job" LIKE pattern. Gives the advisor recent trunk baselines for
3+
-- the failing job.
4+
SELECT DISTINCT job.head_sha AS head_sha
5+
FROM default.workflow_job AS job FINAL
6+
WHERE
7+
job.id IN (
8+
SELECT id
9+
FROM materialized_views.workflow_job_by_created_at
10+
WHERE created_at > now() - INTERVAL 3 DAY
11+
)
12+
AND job.repository_full_name = {repo: String}
13+
AND job.head_branch = {branch: String}
14+
AND CONCAT(job.workflow_name, ' / ', job.name) LIKE {jobPattern: String}
15+
AND job.conclusion_kg IN ('success', 'failure', 'cancelled', 'timed_out')
16+
ORDER BY job.started_at DESC
17+
LIMIT {limit: UInt32}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Shared AI advisor dispatch logic.
2+
//
3+
// Extracted from the manual "AI Analyze" endpoint (pull/dispatch-advisor.ts) so
4+
// the signal_pattern build + workflow dispatch can be reused by other callers.
5+
// Behavior-preserving: this is the same logic the manual endpoint already ran.
6+
7+
import { queryClickhouseSaved } from "lib/clickhouse";
8+
import { getOctokit } from "lib/github";
9+
10+
// The advisor workflow_dispatch file on the repo's default branch.
11+
const ADVISOR_WORKFLOW_FILE = "claude-autorevert-advisor.yml";
12+
13+
const SHA_REGEX = /^[0-9a-f]{7,40}$/i;
14+
15+
export function isValidSha(sha: string): boolean {
16+
return SHA_REGEX.test(sha);
17+
}
18+
19+
/**
20+
* The signal_key convention for HUD-originated advisor dispatches. The dr_ci_
21+
* prefix distinguishes these from autorevert-system dispatches. `fullJobName` is
22+
* the "Workflow / job" display string.
23+
*/
24+
export function signalKeyForJob(fullJobName: string): string {
25+
return `dr_ci_${fullJobName}`;
26+
}
27+
28+
/**
29+
* Derive a LIKE pattern from a HUD job name that matches both PR variants
30+
* (-partial) and trunk variants (-all), and strips shard parentheticals.
31+
* E.g. "Lint / lintrunner-pyrefly-partial / lint"
32+
* -> "Lint / lintrunner-pyrefly-% / lint"
33+
*/
34+
function jobNameToBasePattern(jobName: string): string {
35+
let pattern = jobName;
36+
pattern = pattern.replace(/-(partial|all)\b/g, "-%");
37+
pattern = pattern.replace(/\s*\([^)]*\)$/, "%");
38+
return pattern;
39+
}
40+
41+
interface JobEvent {
42+
conclusion: string;
43+
fullName: string;
44+
htmlUrl: string;
45+
logUrl: string;
46+
startedAt: string;
47+
completedAt: string;
48+
failureCaptures: string[];
49+
failureLines: string[];
50+
}
51+
52+
function groupJobRows(rows: any[]): Record<string, JobEvent[]> {
53+
const result: Record<string, JobEvent[]> = {};
54+
for (const row of rows) {
55+
const sha = row.sha as string;
56+
if (!result[sha]) result[sha] = [];
57+
result[sha].push({
58+
conclusion: (row.conclusion as string) || "pending",
59+
fullName: (row.fullName as string) || "",
60+
htmlUrl: row.htmlUrl as string,
61+
logUrl: row.logUrl as string,
62+
startedAt: (row.startedAt as string) || "",
63+
completedAt: (row.completedAt as string) || "",
64+
failureCaptures: (row.failureCaptures as string[]) || [],
65+
failureLines: (row.failureLines as string[]) || [],
66+
});
67+
}
68+
return result;
69+
}
70+
71+
/** Fetch job events for specific SHAs using exact name match (PR head). */
72+
async function fetchJobStatusExact(
73+
repo: string,
74+
jobName: string,
75+
shas: string[]
76+
): Promise<Record<string, JobEvent[]>> {
77+
if (shas.length === 0) return {};
78+
return groupJobRows(
79+
await queryClickhouseSaved("advisor_job_status_exact", {
80+
repo,
81+
jobName,
82+
shas,
83+
})
84+
);
85+
}
86+
87+
/** Fetch job events for specific SHAs using LIKE pattern (trunk/merge-base). */
88+
async function fetchJobStatusPattern(
89+
repo: string,
90+
jobPattern: string,
91+
shas: string[]
92+
): Promise<Record<string, JobEvent[]>> {
93+
if (shas.length === 0) return {};
94+
return groupJobRows(
95+
await queryClickhouseSaved("advisor_job_status_pattern", {
96+
repo,
97+
jobPattern,
98+
shas,
99+
})
100+
);
101+
}
102+
103+
/** Recent trunk SHAs with finished runs matching the job pattern. */
104+
async function fetchTrunkShasWithJob(
105+
repo: string,
106+
jobPattern: string,
107+
branch: string,
108+
limit: number = 5
109+
): Promise<string[]> {
110+
const rows = await queryClickhouseSaved("advisor_trunk_shas_with_job", {
111+
repo,
112+
jobPattern,
113+
branch,
114+
limit,
115+
});
116+
return rows.map((r: any) => r.head_sha as string);
117+
}
118+
119+
export interface DispatchParams {
120+
owner: string;
121+
repo: string;
122+
prNumber: number;
123+
headSha: string;
124+
// The full "Workflow / job" name. Used to build the signal_key and match
125+
// CH job rows.
126+
jobName: string;
127+
mergeBaseSha?: string;
128+
workflowName?: string;
129+
}
130+
131+
/**
132+
* Dispatch one advisor analysis as the bot. Builds the signal_pattern (PR head +
133+
* merge base + recent trunk runs of the same job) and triggers the advisor
134+
* workflow. Throws on failure so the caller can map it to an HTTP error.
135+
*/
136+
export async function dispatchAdvisorWorkflow(
137+
params: DispatchParams
138+
): Promise<void> {
139+
const { owner, repo, prNumber, headSha, jobName } = params;
140+
const repoFullName = `${owner}/${repo}`;
141+
const botOctokit = await getOctokit(owner, repo);
142+
const jobPattern = jobNameToBasePattern(jobName);
143+
144+
// Look up default branch (usually "main") instead of hardcoding
145+
const repoData = await botOctokit.rest.repos.get({ owner, repo });
146+
const defaultBranch = repoData.data.default_branch;
147+
148+
// Fetch merge base SHA from GitHub
149+
let resolvedMergeBase = params.mergeBaseSha || "";
150+
if (!resolvedMergeBase) {
151+
try {
152+
const compare = await botOctokit.rest.repos.compareCommits({
153+
owner,
154+
repo,
155+
base: defaultBranch,
156+
head: headSha,
157+
});
158+
resolvedMergeBase = compare.data.merge_base_commit.sha;
159+
} catch {
160+
// If compare fails (e.g. force-pushed branch), continue without merge base
161+
}
162+
}
163+
164+
// Fetch trunk SHAs that actually ran this job (using pattern match)
165+
const trunkShas = await fetchTrunkShasWithJob(
166+
repoFullName,
167+
jobPattern,
168+
defaultBranch,
169+
3
170+
);
171+
172+
// Fetch events: exact match for PR head, pattern match for trunk/merge-base
173+
const headStatus = await fetchJobStatusExact(repoFullName, jobName, [
174+
headSha,
175+
]);
176+
177+
const baseAndTrunkShas = [
178+
...(resolvedMergeBase ? [resolvedMergeBase] : []),
179+
...trunkShas,
180+
];
181+
const baseAndTrunkStatus = await fetchJobStatusPattern(
182+
repoFullName,
183+
jobPattern,
184+
baseAndTrunkShas
185+
);
186+
187+
const mkEvents = (status: Record<string, JobEvent[]>, sha: string) =>
188+
(status[sha] || []).map((e) => ({
189+
url: e.htmlUrl,
190+
log_url: e.logUrl,
191+
full_name: e.fullName,
192+
conclusion: e.conclusion,
193+
started_at: e.startedAt,
194+
completed_at: e.completedAt,
195+
failure_captures: e.failureCaptures,
196+
failure_lines: e.failureLines,
197+
}));
198+
199+
// Derive a commit-level timestamp from the earliest event started_at
200+
const commitTimestamp = (
201+
status: Record<string, JobEvent[]>,
202+
sha: string
203+
): string => {
204+
const events = status[sha] || [];
205+
const times = events
206+
.map((e) => e.startedAt)
207+
.filter(Boolean)
208+
.sort();
209+
return times[0] || "";
210+
};
211+
212+
const trunkCommits = trunkShas.map((sha) => ({
213+
sha,
214+
partition: "trunk: recent main commit with this job",
215+
timestamp: commitTimestamp(baseAndTrunkStatus, sha),
216+
events: mkEvents(baseAndTrunkStatus, sha),
217+
}));
218+
219+
// Use semantic partition names instead of failed/successful labels,
220+
// since the merge base or trunk commits may themselves be red.
221+
const signalPattern = {
222+
signal_key: signalKeyForJob(jobName),
223+
signal_source: "job",
224+
workflow_name: params.workflowName || "",
225+
pr_number: prNumber,
226+
head_sha: headSha,
227+
merge_base_sha: resolvedMergeBase,
228+
pr_head: {
229+
sha: headSha,
230+
is_suspect: true,
231+
timestamp: new Date().toISOString(),
232+
events: mkEvents(headStatus, headSha),
233+
},
234+
merge_base: resolvedMergeBase
235+
? {
236+
sha: resolvedMergeBase,
237+
timestamp: commitTimestamp(baseAndTrunkStatus, resolvedMergeBase),
238+
events: mkEvents(baseAndTrunkStatus, resolvedMergeBase),
239+
}
240+
: null,
241+
trunk: trunkCommits,
242+
};
243+
244+
await botOctokit.rest.actions.createWorkflowDispatch({
245+
owner,
246+
repo,
247+
workflow_id: ADVISOR_WORKFLOW_FILE,
248+
ref: defaultBranch,
249+
inputs: {
250+
suspect_commit: headSha,
251+
pr_number: String(prNumber),
252+
signal_pattern: JSON.stringify(signalPattern),
253+
},
254+
});
255+
}

0 commit comments

Comments
 (0)