| 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}; |