main
js 253 lines 7.76 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 'use strict';
9
10 /* eslint-disable no-for-of-loops/no-for-of-loops */
11
12 // The GitHub API half of sizebot, called from `actions/github-script` steps in
13 // `.github/workflows/runtime_sizebot_comment.yml`.
14 //
15 // `resolve` figures out which pull request a `workflow_run` event belongs to,
16 // finds any comment sizebot has already left on it, and decides whether this
17 // event should write at all. `post` creates or updates the comment from the body
18 // that `render-comment.js` produced in between.
19
20 const {readFileSync, writeFileSync} = require('fs');
21 const {
22 MARKER_PREFIX,
23 extractReport,
24 parseReportHead,
25 } = require('./render-comment');
26
27 const CONTEXT_PATH = 'sizebot-context.json';
28 const COMMENT_PATH = 'sizebot-comment.md';
29
30 const COMMENT_AUTHOR = 'github-actions[bot]';
31
32 // `pulls.listFiles` stops paginating here, so a pull request larger than this
33 // cannot be shown to touch only DevTools.
34 const MAX_LISTABLE_FILES = 3000;
35
36 const DEVTOOLS_PATH = 'packages/react-devtools';
37
38 async function findExistingComment(github, context, prNumber) {
39 const comments = await github.paginate(github.rest.issues.listComments, {
40 owner: context.repo.owner,
41 repo: context.repo.repo,
42 issue_number: prNumber,
43 per_page: 100,
44 });
45 for (const comment of comments) {
46 if (
47 comment.user.login === COMMENT_AUTHOR &&
48 comment.body.startsWith(MARKER_PREFIX)
49 ) {
50 return comment;
51 }
52 }
53 return null;
54 }
55
56 // `workflow_run.pull_requests` is empty for runs triggered by a fork, and
57 // neither `commits/{sha}/pulls` nor the search API index fork pull request head
58 // commits. Looking the branch up by `owner:ref` is what actually works for both
59 // fork and same-repo pull requests.
60 async function findPullRequestNumber(github, context, workflowRun) {
61 if (
62 workflowRun.pull_requests != null &&
63 workflowRun.pull_requests.length > 0
64 ) {
65 return workflowRun.pull_requests[0].number;
66 }
67
68 if (workflowRun.head_repository == null) {
69 return null;
70 }
71
72 const {data: pulls} = await github.rest.pulls.list({
73 owner: context.repo.owner,
74 repo: context.repo.repo,
75 head: `${workflowRun.head_repository.owner.login}:${workflowRun.head_branch}`,
76 state: 'open',
77 per_page: 100,
78 });
79 if (pulls.length === 0) {
80 return null;
81 }
82 return pulls[0].number;
83 }
84
85 async function findPullRequest(github, context, workflowRun) {
86 const number = await findPullRequestNumber(github, context, workflowRun);
87 if (number === null) {
88 return null;
89 }
90 // Always finish with `pulls.get`. The list endpoint omits `changed_files`,
91 // which `isDevToolsOnly` needs, and that is the endpoint the fork path uses.
92 const {data} = await github.rest.pulls.get({
93 owner: context.repo.owner,
94 repo: context.repo.repo,
95 pull_number: number,
96 });
97 return data;
98 }
99
100 async function isDevToolsOnly(github, context, pullRequest) {
101 if (
102 !Number.isInteger(pullRequest.changed_files) ||
103 // `listFiles` would silently truncate, and a truncated list can look
104 // DevTools-only when it is not.
105 pullRequest.changed_files > MAX_LISTABLE_FILES
106 ) {
107 return false;
108 }
109 const files = await github.paginate(github.rest.pulls.listFiles, {
110 owner: context.repo.owner,
111 repo: context.repo.repo,
112 pull_number: pullRequest.number,
113 per_page: 100,
114 });
115 if (files.length === 0) {
116 return false;
117 }
118 return files.every(file => file.filename.includes(DEVTOOLS_PATH));
119 }
120
121 async function resolve({github, context, core}) {
122 const workflowRun = context.payload.workflow_run;
123 const action = context.payload.action;
124
125 const pullRequest = await findPullRequest(github, context, workflowRun);
126 if (pullRequest === null) {
127 core.info('No open pull request for this run. Nothing to comment on.');
128 core.setOutput('action', 'skip');
129 return;
130 }
131
132 // A pull request number must never come from the build artifact, which the
133 // fork controls. Confirm the one we resolved really does belong to this run.
134 const runRepo = workflowRun.head_repository?.full_name ?? null;
135 const pullRequestRepo = pullRequest.head.repo?.full_name ?? null;
136 if (runRepo === null || pullRequestRepo === null) {
137 // A deleted fork leaves us no way to check, so don't write anything.
138 core.info('Head repository is unavailable. Nothing to comment on.');
139 core.setOutput('action', 'skip');
140 return;
141 }
142 if (pullRequestRepo !== runRepo) {
143 core.setFailed(
144 `Pull request #${pullRequest.number} has head repository ` +
145 `${pullRequestRepo}, but the run came from ${runRepo}.`
146 );
147 return;
148 }
149
150 // Mirrors the sizebot job's own condition in runtime_build_and_test.yml.
151 if (pullRequest.base.ref !== 'main') {
152 core.info(
153 `Pull request #${pullRequest.number} targets ${pullRequest.base.ref}, not main.`
154 );
155 core.setOutput('action', 'skip');
156 return;
157 }
158
159 const existing = await findExistingComment(
160 github,
161 context,
162 pullRequest.number
163 );
164 const existingReportHead =
165 existing === null ? null : parseReportHead(existing.body);
166
167 // The only event we ever drop: a comment already describes the current head,
168 // and this event is about an older commit. Without this, a run cancelled by a
169 // force push reports `cancelled` after the newer run's comment has landed and
170 // replaces good numbers with a cancellation notice.
171 if (
172 existingReportHead !== null &&
173 existingReportHead === pullRequest.head.sha &&
174 workflowRun.head_sha !== pullRequest.head.sha
175 ) {
176 core.info(
177 `Comment already reports on ${pullRequest.head.sha}; this run is for ` +
178 `${workflowRun.head_sha}. Leaving it alone.`
179 );
180 core.setOutput('action', 'skip');
181 return;
182 }
183
184 const devtoolsOnly =
185 action === 'completed'
186 ? await isDevToolsOnly(github, context, pullRequest)
187 : false;
188
189 writeFileSync(
190 CONTEXT_PATH,
191 JSON.stringify(
192 {
193 action,
194 prNumber: pullRequest.number,
195 prHeadSha: pullRequest.head.sha,
196 runHeadSha: workflowRun.head_sha,
197 runUrl: workflowRun.html_url,
198 runStatus: workflowRun.status,
199 runConclusion: workflowRun.conclusion,
200 devtoolsOnly,
201 existingCommentId: existing === null ? null : existing.id,
202 existingReportHead,
203 existingReport: existing === null ? null : extractReport(existing.body),
204 commentRunUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
205 },
206 null,
207 2
208 ) + '\n'
209 );
210
211 core.setOutput('action', 'continue');
212 // Only a completed run can have produced results to download.
213 core.setOutput('download_results', String(action === 'completed'));
214 }
215
216 async function post({github, context, core}) {
217 const sizebotContext = JSON.parse(readFileSync(CONTEXT_PATH, 'utf8'));
218 const body = readFileSync(COMMENT_PATH, 'utf8');
219
220 async function create() {
221 const {data} = await github.rest.issues.createComment({
222 owner: context.repo.owner,
223 repo: context.repo.repo,
224 issue_number: sizebotContext.prNumber,
225 body,
226 });
227 core.info(`Created ${data.html_url}`);
228 }
229
230 if (sizebotContext.existingCommentId === null) {
231 await create();
232 return;
233 }
234
235 try {
236 const {data} = await github.rest.issues.updateComment({
237 owner: context.repo.owner,
238 repo: context.repo.repo,
239 comment_id: sizebotContext.existingCommentId,
240 body,
241 });
242 core.info(`Updated ${data.html_url}`);
243 } catch (error) {
244 if (error.status !== 404) {
245 throw error;
246 }
247 // Someone deleted the comment between resolving it and writing to it.
248 core.info('Existing comment is gone, posting a new one.');
249 await create();
250 }
251 }
252
253 module.exports = {post, resolve};