forked from pytorch/test-infra
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcrcrOncallBot.ts
More file actions
198 lines (175 loc) · 5.58 KB
/
Copy pathcrcrOncallBot.ts
File metadata and controls
198 lines (175 loc) · 5.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
import { Probot } from "probot";
import { fetchCrcrAllowlist } from "../crcrAllowlist";
import { isPyTorchbotSupportedOrg } from "./utils";
export const FAILURE_CONCLUSIONS: ReadonlySet<string> = new Set([
"failure",
"cancelled",
"timed_out",
]);
function markerForRepo(downstreamRepo: string): string {
return `<!-- crcr-oncall:${downstreamRepo} -->`;
}
/**
* Parse the downstream ``owner/repo`` out of a CRCR check run name.
*
* Check runs are named ``crcr/<owner>/<repo>/<workflow_name>/<job_name>``
* (see the Python gh_helper.check_run_name), so the downstream repo is the
* first two path segments after the ``crcr/`` prefix.
*/
export function downstreamRepoFromCheckRunName(name: string): string | null {
const prefix = "crcr/";
if (!name.startsWith(prefix)) {
return null;
}
const parts = name.slice(prefix.length).split("/");
if (parts.length < 4 || !parts[0] || !parts[1]) {
return null;
}
return `${parts[0]}/${parts[1]}`;
}
/**
* Search existing PR comments for one with the CRCR on-call marker.
* Returns its ID (or 0 if none found). Uses pagination so the marker
* is found even when a PR has more than 30 comments.
*/
async function findExistingComment(
octokit: any,
owner: string,
repo: string,
prNumber: number,
downstreamRepo: string
): Promise<number> {
const marker = markerForRepo(downstreamRepo);
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
});
for (const comment of comments) {
if (comment.body?.includes(marker)) {
return comment.id;
}
}
return 0;
}
export default function crcrOncallBot(app: Probot): void {
app.on("check_run.completed", async (ctx) => {
const owner = ctx.payload.repository.owner.login;
if (!isPyTorchbotSupportedOrg(owner)) {
return;
}
const repo = ctx.payload.repository.name;
const checkRun = ctx.payload.check_run;
// Only act on CRCR-created check runs
const downstreamRepo = downstreamRepoFromCheckRunName(checkRun.name);
if (!downstreamRepo) {
return;
}
// Only comment on failures
const conclusion = checkRun.conclusion ?? "";
if (!FAILURE_CONCLUSIONS.has(conclusion)) {
return;
}
// Get the PRs this check run belongs to.
let prNumbers: number[] = [];
if (checkRun.output?.summary) {
const match = checkRun.output.summary.match(/for PR (\d+)/);
if (match) {
prNumbers = [parseInt(match[1], 10)];
}
} else if (checkRun.pull_requests && checkRun.pull_requests.length > 0) {
prNumbers = checkRun.pull_requests.map((pr) => pr.number);
}
// Fall back to Search API if still no PR found (e.g., pr_number was empty
// on the Lambda side).
if (prNumbers.length === 0 && checkRun.head_sha) {
try {
const result = await ctx.octokit.rest.search.issuesAndPullRequests({
q: `${checkRun.head_sha} type:pr repo:${owner}/${repo}`,
});
prNumbers = result.data.items.map((item: any) => item.number);
} catch (err) {
ctx.log(
{ err },
`crcrOncall: failed to resolve PRs for commit ${checkRun.head_sha}, skipping`
);
return;
}
}
if (prNumbers.length === 0) {
ctx.log(
`crcrOncall: no PR associated with check run ${checkRun.name}, skipping`
);
return;
}
const headSha = checkRun.head_sha;
const checkRunUrl = checkRun.html_url ?? "";
// Load oncalls from the allowlist
let oncalls: string[];
try {
const allowlist = await fetchCrcrAllowlist(ctx.octokit);
oncalls = allowlist.getOncallsForRepo(downstreamRepo);
} catch (err) {
ctx.log({ err }, "crcrOncall: failed to load allowlist, skipping");
return;
}
if (oncalls.length === 0) {
ctx.log(
`crcrOncall: no oncalls configured for ${downstreamRepo}, skipping`
);
return;
}
const mentions = oncalls.map((o) => `@${o}`).join(" ");
// Post a comment on each associated PR (typically just one)
for (const prNumber of prNumbers) {
try {
// Dedup by marker: only comment once per PR.
// NOTE: There is a theoretical TOCTOU race here — two concurrent
// check_run.completed events for the same PR could both pass the
// marker check and post duplicate comments. In practice, checks
// arrive sequentially, so this is unlikely to cause issues.
const existingId = await findExistingComment(
ctx.octokit,
owner,
repo,
prNumber,
downstreamRepo
);
if (existingId !== 0) {
ctx.log(
`crcrOncall: comment already exists on PR #${prNumber}, skipping`
);
continue;
}
const commentBody = `${markerForRepo(downstreamRepo)}
## :x: CRCR downstream CI failure
The downstream CI workflow in **${downstreamRepo}** has failed on commit \`${headSha.slice(
0,
7
)}\`.
${mentions} please investigate.
### Details
| Field | Value |
|---|---|
| **Check Run** | [${checkRun.name}](${checkRunUrl}) |
| **Commit** | \`${headSha}\` |
| **Conclusion** | \`${conclusion}\` |
`;
await ctx.octokit.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody,
});
ctx.log(
`crcrOncall: commented on PR #${prNumber} for ${downstreamRepo} (${conclusion})`
);
} catch (err) {
ctx.log(
{ err },
`crcrOncall: failed to comment on PR for ${downstreamRepo}`
);
}
}
});
}