Clone of https://github.com/NixOS/nixpkgs.git (to stress-test knotserver)
1module.exports = async function ({ github, context, core, dry }) { 2 const path = require('node:path') 3 const { DefaultArtifactClient } = require('@actions/artifact') 4 const { readFile, writeFile } = require('node:fs/promises') 5 const withRateLimit = require('./withRateLimit.js') 6 7 const artifactClient = new DefaultArtifactClient() 8 9 async function handlePullRequest({ item, stats }) { 10 const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`) 11 12 const pull_number = item.number 13 14 // This API request is important for the merge-conflict label, because it triggers the 15 // creation of a new test merge commit. This is needed to actually determine the state of a PR. 16 const pull_request = ( 17 await github.rest.pulls.get({ 18 ...context.repo, 19 pull_number, 20 }) 21 ).data 22 23 const reviews = await github.paginate(github.rest.pulls.listReviews, { 24 ...context.repo, 25 pull_number, 26 }) 27 28 const approvals = new Set( 29 reviews 30 .filter((review) => review.state == 'APPROVED') 31 .map((review) => review.user?.id), 32 ) 33 34 // After creation of a Pull Request, `merge_commit_sha` will be null initially: 35 // The very first merge commit will only be calculated after a little while. 36 // To avoid labeling the PR as conflicted before that, we wait a few minutes. 37 // This is intentionally less than the time that Eval takes, so that the label job 38 // running after Eval can indeed label the PR as conflicted if that is the case. 39 const merge_commit_sha_valid = 40 new Date() - new Date(pull_request.created_at) > 3 * 60 * 1000 41 42 const prLabels = { 43 // We intentionally don't use the mergeable or mergeable_state attributes. 44 // Those have an intermediate state while the test merge commit is created. 45 // This doesn't work well for us, because we might have just triggered another 46 // test merge commit creation by request the pull request via API at the start 47 // of this function. 48 // The attribute merge_commit_sha keeps the old value of null or the hash *until* 49 // the new test merge commit has either successfully been created or failed so. 50 // This essentially means we are updating the merge conflict label in two steps: 51 // On the first pass of the day, we just fetch the pull request, which triggers 52 // the creation. At this stage, the label is likely not updated, yet. 53 // The second pass will then read the result from the first pass and set the label. 54 '2.status: merge conflict': 55 merge_commit_sha_valid && !pull_request.merge_commit_sha, 56 '12.approvals: 1': approvals.size == 1, 57 '12.approvals: 2': approvals.size == 2, 58 '12.approvals: 3+': approvals.size >= 3, 59 '12.first-time contribution': [ 60 'NONE', 61 'FIRST_TIMER', 62 'FIRST_TIME_CONTRIBUTOR', 63 ].includes(pull_request.author_association), 64 } 65 66 const { id: run_id, conclusion } = 67 ( 68 await github.rest.actions.listWorkflowRuns({ 69 ...context.repo, 70 workflow_id: 'pr.yml', 71 event: 'pull_request_target', 72 exclude_pull_requests: true, 73 head_sha: pull_request.head.sha, 74 }) 75 ).data.workflow_runs[0] ?? 76 // TODO: Remove this after 2025-09-17, at which point all eval.yml artifacts will have expired. 77 ( 78 await github.rest.actions.listWorkflowRuns({ 79 ...context.repo, 80 // In older PRs, we need eval.yml instead of pr.yml. 81 workflow_id: 'eval.yml', 82 event: 'pull_request_target', 83 status: 'success', 84 exclude_pull_requests: true, 85 head_sha: pull_request.head.sha, 86 }) 87 ).data.workflow_runs[0] ?? 88 {} 89 90 // Newer PRs might not have run Eval to completion, yet. 91 // Older PRs might not have an eval.yml workflow, yet. 92 // In either case we continue without fetching an artifact on a best-effort basis. 93 log('Last eval run', run_id ?? '<n/a>') 94 95 if (conclusion === 'success') { 96 Object.assign(prLabels, { 97 // We only set this label if the latest eval run was successful, because if it was not, it 98 // *could* have requested reviewers. We will let the PR author fix CI first, before "escalating" 99 // this PR to "needs: reviewer". 100 // Since the first Eval run on a PR always sets rebuild labels, the same PR will be "recently 101 // updated" for the next scheduled run. Thus, this label will still be set within a few minutes 102 // after a PR is created, if required. 103 // Note that a "requested reviewer" disappears once they have given a review, so we check 104 // existing reviews, too. 105 '9.needs: reviewer': 106 !pull_request.draft && 107 pull_request.requested_reviewers.length == 0 && 108 reviews.length == 0, 109 }) 110 } 111 112 const artifact = 113 run_id && 114 ( 115 await github.rest.actions.listWorkflowRunArtifacts({ 116 ...context.repo, 117 run_id, 118 name: 'comparison', 119 }) 120 ).data.artifacts[0] 121 122 // Instead of checking the boolean artifact.expired, we will give us a minute to 123 // actually download the artifact in the next step and avoid that race condition. 124 // Older PRs, where the workflow run was already eval.yml, but the artifact was not 125 // called "comparison", yet, will skip the download. 126 const expired = 127 !artifact || 128 new Date(artifact?.expires_at ?? 0) < 129 new Date(new Date().getTime() + 60 * 1000) 130 log('Artifact expires at', artifact?.expires_at ?? '<n/a>') 131 if (!expired) { 132 stats.artifacts++ 133 134 await artifactClient.downloadArtifact(artifact.id, { 135 findBy: { 136 repositoryName: context.repo.repo, 137 repositoryOwner: context.repo.owner, 138 token: core.getInput('github-token'), 139 }, 140 path: path.resolve(pull_number.toString()), 141 expectedHash: artifact.digest, 142 }) 143 144 const maintainers = new Set( 145 Object.keys( 146 JSON.parse( 147 await readFile(`${pull_number}/maintainers.json`, 'utf-8'), 148 ), 149 ).map((m) => Number.parseInt(m, 10)), 150 ) 151 152 const evalLabels = JSON.parse( 153 await readFile(`${pull_number}/changed-paths.json`, 'utf-8'), 154 ).labels 155 156 Object.assign( 157 prLabels, 158 // Ignore `evalLabels` if it's an array. 159 // This can happen for older eval runs, before we switched to objects. 160 // The old eval labels would have been set by the eval run, 161 // so now they'll be present in `before`. 162 // TODO: Simplify once old eval results have expired (~2025-10) 163 Array.isArray(evalLabels) ? undefined : evalLabels, 164 { 165 '12.approved-by: package-maintainer': Array.from(maintainers).some( 166 (m) => approvals.has(m), 167 ), 168 }, 169 ) 170 } 171 172 return prLabels 173 } 174 175 async function handle({ item, stats }) { 176 try { 177 const log = (k, v, skip) => { 178 core.info(`#${item.number} - ${k}: ${v}` + (skip ? ' (skipped)' : '')) 179 return skip 180 } 181 182 log('Last updated at', item.updated_at) 183 log('URL', item.html_url) 184 185 const issue_number = item.number 186 187 const itemLabels = {} 188 189 if (item.pull_request || context.payload.pull_request) { 190 stats.prs++ 191 Object.assign(itemLabels, await handlePullRequest({ item, stats })) 192 } else { 193 stats.issues++ 194 } 195 196 const latest_event_at = new Date( 197 ( 198 await github.paginate(github.rest.issues.listEventsForTimeline, { 199 ...context.repo, 200 issue_number, 201 per_page: 100, 202 }) 203 ) 204 .filter(({ event }) => 205 [ 206 // These events are hand-picked from: 207 // https://docs.github.com/en/rest/using-the-rest-api/issue-event-types?apiVersion=2022-11-28 208 // Each of those causes a PR/issue to *not* be considered as stale anymore. 209 // Most of these use created_at. 210 'assigned', 211 'commented', // uses updated_at, because that could be > created_at 212 'committed', // uses committer.date 213 'head_ref_force_pushed', 214 'milestoned', 215 'pinned', 216 'ready_for_review', 217 'renamed', 218 'reopened', 219 'review_dismissed', 220 'review_requested', 221 'reviewed', // uses submitted_at 222 'unlocked', 223 'unmarked_as_duplicate', 224 ].includes(event), 225 ) 226 .map( 227 ({ created_at, updated_at, committer, submitted_at }) => 228 new Date( 229 updated_at ?? created_at ?? submitted_at ?? committer.date, 230 ), 231 ) 232 // Reverse sort by date value. The default sort() sorts by string representation, which is bad for dates. 233 .sort((a, b) => b - a) 234 .at(0) ?? item.created_at, 235 ) 236 log('latest_event_at', latest_event_at.toISOString()) 237 238 const stale_at = new Date(new Date().setDate(new Date().getDate() - 180)) 239 240 // Create a map (Label -> Boolean) of all currently set labels. 241 // Each label is set to True and can be disabled later. 242 const before = Object.fromEntries( 243 ( 244 await github.paginate(github.rest.issues.listLabelsOnIssue, { 245 ...context.repo, 246 issue_number, 247 }) 248 ).map(({ name }) => [name, true]), 249 ) 250 251 Object.assign(itemLabels, { 252 '2.status: stale': 253 !before['1.severity: security'] && latest_event_at < stale_at, 254 }) 255 256 const after = Object.assign({}, before, itemLabels) 257 258 // No need for an API request, if all labels are the same. 259 const hasChanges = Object.keys(after).some( 260 (name) => (before[name] ?? false) != after[name], 261 ) 262 if (log('Has changes', hasChanges, !hasChanges)) return 263 264 // Skipping labeling on a pull_request event, because we have no privileges. 265 const labels = Object.entries(after) 266 .filter(([, value]) => value) 267 .map(([name]) => name) 268 if (log('Set labels', labels, dry)) return 269 270 await github.rest.issues.setLabels({ 271 ...context.repo, 272 issue_number, 273 labels, 274 }) 275 } catch (cause) { 276 throw new Error(`Labeling #${item.number} failed.`, { cause }) 277 } 278 } 279 280 await withRateLimit({ github, core }, async (stats) => { 281 if (context.payload.pull_request) { 282 await handle({ item: context.payload.pull_request, stats }) 283 } else { 284 const lastRun = ( 285 await github.rest.actions.listWorkflowRuns({ 286 ...context.repo, 287 workflow_id: 'labels.yml', 288 event: 'schedule', 289 status: 'success', 290 exclude_pull_requests: true, 291 per_page: 1, 292 }) 293 ).data.workflow_runs[0] 294 295 const cutoff = new Date( 296 Math.max( 297 // Go back as far as the last successful run of this workflow to make sure 298 // we are not leaving anyone behind on GHA failures. 299 // Defaults to go back 1 hour on the first run. 300 new Date(lastRun?.created_at ?? new Date().getTime() - 1 * 60 * 60 * 1000).getTime(), 301 // Go back max. 1 day to prevent hitting all API rate limits immediately, 302 // when GH API returns a wrong workflow by accident. 303 new Date().getTime() - 24 * 60 * 60 * 1000, 304 ), 305 ) 306 core.info('cutoff timestamp: ' + cutoff.toISOString()) 307 308 const updatedItems = await github.paginate( 309 github.rest.search.issuesAndPullRequests, 310 { 311 q: [ 312 `repo:"${context.repo.owner}/${context.repo.repo}"`, 313 'is:open', 314 `updated:>=${cutoff.toISOString()}`, 315 ].join(' AND '), 316 per_page: 100, 317 // TODO: Remove in 2025-10, when it becomes the default. 318 advanced_search: true, 319 }, 320 ) 321 322 let cursor 323 324 // No workflow run available the first time. 325 if (lastRun) { 326 // The cursor to iterate through the full list of issues and pull requests 327 // is passed between jobs as an artifact. 328 const artifact = ( 329 await github.rest.actions.listWorkflowRunArtifacts({ 330 ...context.repo, 331 run_id: lastRun.id, 332 name: 'pagination-cursor', 333 }) 334 ).data.artifacts[0] 335 336 // If the artifact is not available, the next iteration starts at the beginning. 337 if (artifact) { 338 stats.artifacts++ 339 340 const { downloadPath } = await artifactClient.downloadArtifact( 341 artifact.id, 342 { 343 findBy: { 344 repositoryName: context.repo.repo, 345 repositoryOwner: context.repo.owner, 346 token: core.getInput('github-token'), 347 }, 348 expectedHash: artifact.digest, 349 }, 350 ) 351 352 cursor = await readFile(path.resolve(downloadPath, 'cursor'), 'utf-8') 353 } 354 } 355 356 // From GitHub's API docs: 357 // GitHub's REST API considers every pull request an issue, but not every issue is a pull request. 358 // For this reason, "Issues" endpoints may return both issues and pull requests in the response. 359 // You can identify pull requests by the pull_request key. 360 const allItems = await github.rest.issues.listForRepo({ 361 ...context.repo, 362 state: 'open', 363 sort: 'created', 364 direction: 'asc', 365 per_page: 100, 366 after: cursor, 367 }) 368 369 // Regex taken and comment adjusted from: 370 // https://github.com/octokit/plugin-paginate-rest.js/blob/8e5da25f975d2f31dda6b8b588d71f2c768a8df2/src/iterator.ts#L36-L41 371 // `allItems.headers.link` format: 372 // <https://api.github.com/repositories/4542716/issues?page=3&per_page=100&after=Y3Vyc29yOnYyOpLPAAABl8qNnYDOvnSJxA%3D%3D>; rel="next", 373 // <https://api.github.com/repositories/4542716/issues?page=1&per_page=100&before=Y3Vyc29yOnYyOpLPAAABl8xFV9DOvoouJg%3D%3D>; rel="prev" 374 // Sets `next` to undefined if "next" URL is not present or `link` header is not set. 375 const next = ((allItems.headers.link ?? '').match( 376 /<([^<>]+)>;\s*rel="next"/, 377 ) ?? [])[1] 378 if (next) { 379 cursor = new URL(next).searchParams.get('after') 380 const uploadPath = path.resolve('cursor') 381 await writeFile(uploadPath, cursor, 'utf-8') 382 if (dry) { 383 core.info(`pagination-cursor: ${cursor} (upload skipped)`) 384 } else { 385 // No stats.artifacts++, because this does not allow passing a custom token. 386 // Thus, the upload will not happen with the app token, but the default github.token. 387 await artifactClient.uploadArtifact( 388 'pagination-cursor', 389 [uploadPath], 390 path.resolve('.'), 391 { 392 retentionDays: 1, 393 }, 394 ) 395 } 396 } 397 398 // Some items might be in both search results, so filtering out duplicates as well. 399 const items = [] 400 .concat(updatedItems, allItems.data) 401 .filter( 402 (thisItem, idx, arr) => 403 idx == 404 arr.findIndex((firstItem) => firstItem.number == thisItem.number), 405 ) 406 407 ;(await Promise.allSettled(items.map((item) => handle({ item, stats })))) 408 .filter(({ status }) => status == 'rejected') 409 .map(({ reason }) => 410 core.setFailed(`${reason.message}\n${reason.cause.stack}`), 411 ) 412 } 413 }) 414}