at master 18 kB view raw
1module.exports = async ({ 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 Date.now() - 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 // Check for any human reviews other than GitHub actions and other GitHub apps. 97 // Accounts could be deleted as well, so don't count them. 98 const humanReviews = reviews.filter( 99 (r) => 100 r.user && !r.user.login.endsWith('[bot]') && r.user.type !== 'Bot', 101 ) 102 103 Object.assign(prLabels, { 104 // We only set this label if the latest eval run was successful, because if it was not, it 105 // *could* have requested reviewers. We will let the PR author fix CI first, before "escalating" 106 // this PR to "needs: reviewer". 107 // Since the first Eval run on a PR always sets rebuild labels, the same PR will be "recently 108 // updated" for the next scheduled run. Thus, this label will still be set within a few minutes 109 // after a PR is created, if required. 110 // Note that a "requested reviewer" disappears once they have given a review, so we check 111 // existing reviews, too. 112 '9.needs: reviewer': 113 !pull_request.draft && 114 pull_request.requested_reviewers.length === 0 && 115 humanReviews.length === 0, 116 }) 117 } 118 119 const artifact = 120 run_id && 121 ( 122 await github.rest.actions.listWorkflowRunArtifacts({ 123 ...context.repo, 124 run_id, 125 name: 'comparison', 126 }) 127 ).data.artifacts[0] 128 129 // Instead of checking the boolean artifact.expired, we will give us a minute to 130 // actually download the artifact in the next step and avoid that race condition. 131 // Older PRs, where the workflow run was already eval.yml, but the artifact was not 132 // called "comparison", yet, will skip the download. 133 const expired = 134 !artifact || 135 new Date(artifact?.expires_at ?? 0) < new Date(Date.now() + 60 * 1000) 136 log('Artifact expires at', artifact?.expires_at ?? '<n/a>') 137 if (!expired) { 138 stats.artifacts++ 139 140 await artifactClient.downloadArtifact(artifact.id, { 141 findBy: { 142 repositoryName: context.repo.repo, 143 repositoryOwner: context.repo.owner, 144 token: core.getInput('github-token'), 145 }, 146 path: path.resolve(pull_number.toString()), 147 expectedHash: artifact.digest, 148 }) 149 150 const maintainers = new Set( 151 Object.keys( 152 JSON.parse( 153 await readFile(`${pull_number}/maintainers.json`, 'utf-8'), 154 ), 155 ).map((m) => Number.parseInt(m, 10)), 156 ) 157 158 const evalLabels = JSON.parse( 159 await readFile(`${pull_number}/changed-paths.json`, 'utf-8'), 160 ).labels 161 162 Object.assign( 163 prLabels, 164 // Ignore `evalLabels` if it's an array. 165 // This can happen for older eval runs, before we switched to objects. 166 // The old eval labels would have been set by the eval run, 167 // so now they'll be present in `before`. 168 // TODO: Simplify once old eval results have expired (~2025-10) 169 Array.isArray(evalLabels) ? undefined : evalLabels, 170 { 171 '12.approved-by: package-maintainer': Array.from(maintainers).some( 172 (m) => approvals.has(m), 173 ), 174 }, 175 ) 176 } 177 178 return prLabels 179 } 180 181 // Returns true if the issue was closed. In this case, the labeling does not need to 182 // continue for this issue. Returns false if no action was taken. 183 async function handleAutoClose(item) { 184 const issue_number = item.number 185 186 if (item.labels.some(({ name }) => name === '0.kind: packaging request')) { 187 const body = [ 188 'Thank you for your interest in packaging new software in Nixpkgs. Unfortunately, to mitigate the unsustainable growth of unmaintained packages, **Nixpkgs is no longer accepting package requests** via Issues.', 189 '', 190 'As a [volunteer community][community], we are always open to new contributors. If you wish to see this package in Nixpkgs, **we encourage you to [contribute] it yourself**, via a Pull Request. Anyone can [become a package maintainer][maintainers]! You can find language-specific packaging information in the [Nixpkgs Manual][nixpkgs]. Should you need any help, please reach out to the community on [Matrix] or [Discourse].', 191 '', 192 '[community]: https://nixos.org/community', 193 '[contribute]: https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md#quick-start-to-adding-a-package', 194 '[maintainers]: https://github.com/NixOS/nixpkgs/blob/master/maintainers/README.md', 195 '[nixpkgs]: https://nixos.org/manual/nixpkgs/unstable/', 196 '[Matrix]: https://matrix.to/#/#dev:nixos.org', 197 '[Discourse]: https://discourse.nixos.org/c/dev/14', 198 ].join('\n') 199 200 core.info(`Issue #${item.number}: auto-closed`) 201 202 if (!dry) { 203 await github.rest.issues.createComment({ 204 ...context.repo, 205 issue_number, 206 body, 207 }) 208 209 await github.rest.issues.update({ 210 ...context.repo, 211 issue_number, 212 state: 'closed', 213 state_reason: 'not_planned', 214 }) 215 } 216 217 return true 218 } 219 return false 220 } 221 222 async function handle({ item, stats }) { 223 try { 224 const log = (k, v, skip) => { 225 core.info(`#${item.number} - ${k}: ${v}${skip ? ' (skipped)' : ''}`) 226 return skip 227 } 228 229 log('Last updated at', item.updated_at) 230 log('URL', item.html_url) 231 232 const issue_number = item.number 233 234 const itemLabels = {} 235 236 if (item.pull_request || context.payload.pull_request) { 237 stats.prs++ 238 Object.assign(itemLabels, await handlePullRequest({ item, stats })) 239 } else { 240 stats.issues++ 241 if (item.labels.some(({ name }) => name === '4.workflow: auto-close')) { 242 // If this returns true, the issue was closed. In this case we return, to not 243 // label the issue anymore. Most importantly this avoids unlabeling stale issues 244 // which are closed via auto-close. 245 if (await handleAutoClose(item)) return 246 } 247 } 248 249 const latest_event_at = new Date( 250 ( 251 await github.paginate(github.rest.issues.listEventsForTimeline, { 252 ...context.repo, 253 issue_number, 254 per_page: 100, 255 }) 256 ) 257 .filter(({ event }) => 258 [ 259 // These events are hand-picked from: 260 // https://docs.github.com/en/rest/using-the-rest-api/issue-event-types?apiVersion=2022-11-28 261 // Each of those causes a PR/issue to *not* be considered as stale anymore. 262 // Most of these use created_at. 263 'assigned', 264 'commented', // uses updated_at, because that could be > created_at 265 'committed', // uses committer.date 266 ...(item.labels.some(({ name }) => name === '5.scope: tracking') 267 ? ['cross-referenced'] 268 : []), 269 'head_ref_force_pushed', 270 'milestoned', 271 'pinned', 272 'ready_for_review', 273 'renamed', 274 'reopened', 275 'review_dismissed', 276 'review_requested', 277 'reviewed', // uses submitted_at 278 'unlocked', 279 'unmarked_as_duplicate', 280 ].includes(event), 281 ) 282 .map( 283 ({ created_at, updated_at, committer, submitted_at }) => 284 new Date( 285 updated_at ?? created_at ?? submitted_at ?? committer.date, 286 ), 287 ) 288 // Reverse sort by date value. The default sort() sorts by string representation, which is bad for dates. 289 .sort((a, b) => b - a) 290 .at(0) ?? item.created_at, 291 ) 292 log('latest_event_at', latest_event_at.toISOString()) 293 294 const stale_at = new Date(new Date().setDate(new Date().getDate() - 180)) 295 296 // Create a map (Label -> Boolean) of all currently set labels. 297 // Each label is set to True and can be disabled later. 298 const before = Object.fromEntries( 299 ( 300 await github.paginate(github.rest.issues.listLabelsOnIssue, { 301 ...context.repo, 302 issue_number, 303 }) 304 ).map(({ name }) => [name, true]), 305 ) 306 307 Object.assign(itemLabels, { 308 '2.status: stale': 309 !before['1.severity: security'] && latest_event_at < stale_at, 310 }) 311 312 const after = Object.assign({}, before, itemLabels) 313 314 // No need for an API request, if all labels are the same. 315 const hasChanges = Object.keys(after).some( 316 (name) => (before[name] ?? false) !== after[name], 317 ) 318 if (log('Has changes', hasChanges, !hasChanges)) return 319 320 // Skipping labeling on a pull_request event, because we have no privileges. 321 const labels = Object.entries(after) 322 .filter(([, value]) => value) 323 .map(([name]) => name) 324 if (log('Set labels', labels, dry)) return 325 326 await github.rest.issues.setLabels({ 327 ...context.repo, 328 issue_number, 329 labels, 330 }) 331 } catch (cause) { 332 throw new Error(`Labeling #${item.number} failed.`, { cause }) 333 } 334 } 335 336 await withRateLimit({ github, core }, async (stats) => { 337 if (context.payload.pull_request) { 338 await handle({ item: context.payload.pull_request, stats }) 339 } else { 340 const lastRun = ( 341 await github.rest.actions.listWorkflowRuns({ 342 ...context.repo, 343 workflow_id: 'labels.yml', 344 event: 'schedule', 345 status: 'success', 346 exclude_pull_requests: true, 347 per_page: 1, 348 }) 349 ).data.workflow_runs[0] 350 351 const cutoff = new Date( 352 Math.max( 353 // Go back as far as the last successful run of this workflow to make sure 354 // we are not leaving anyone behind on GHA failures. 355 // Defaults to go back 1 hour on the first run. 356 new Date( 357 lastRun?.created_at ?? Date.now() - 1 * 60 * 60 * 1000, 358 ).getTime(), 359 // Go back max. 1 day to prevent hitting all API rate limits immediately, 360 // when GH API returns a wrong workflow by accident. 361 Date.now() - 24 * 60 * 60 * 1000, 362 ), 363 ) 364 core.info(`cutoff timestamp: ${cutoff.toISOString()}`) 365 366 const updatedItems = await github.paginate( 367 github.rest.search.issuesAndPullRequests, 368 { 369 q: [ 370 `repo:"${context.repo.owner}/${context.repo.repo}"`, 371 'is:open', 372 `updated:>=${cutoff.toISOString()}`, 373 ].join(' AND '), 374 per_page: 100, 375 // TODO: Remove in 2025-10, when it becomes the default. 376 advanced_search: true, 377 }, 378 ) 379 380 let cursor 381 382 // No workflow run available the first time. 383 if (lastRun) { 384 // The cursor to iterate through the full list of issues and pull requests 385 // is passed between jobs as an artifact. 386 const artifact = ( 387 await github.rest.actions.listWorkflowRunArtifacts({ 388 ...context.repo, 389 run_id: lastRun.id, 390 name: 'pagination-cursor', 391 }) 392 ).data.artifacts[0] 393 394 // If the artifact is not available, the next iteration starts at the beginning. 395 if (artifact) { 396 stats.artifacts++ 397 398 const { downloadPath } = await artifactClient.downloadArtifact( 399 artifact.id, 400 { 401 findBy: { 402 repositoryName: context.repo.repo, 403 repositoryOwner: context.repo.owner, 404 token: core.getInput('github-token'), 405 }, 406 expectedHash: artifact.digest, 407 }, 408 ) 409 410 cursor = await readFile(path.resolve(downloadPath, 'cursor'), 'utf-8') 411 } 412 } 413 414 // From GitHub's API docs: 415 // GitHub's REST API considers every pull request an issue, but not every issue is a pull request. 416 // For this reason, "Issues" endpoints may return both issues and pull requests in the response. 417 // You can identify pull requests by the pull_request key. 418 const allItems = await github.rest.issues.listForRepo({ 419 ...context.repo, 420 state: 'open', 421 sort: 'created', 422 direction: 'asc', 423 per_page: 100, 424 after: cursor, 425 }) 426 427 // Regex taken and comment adjusted from: 428 // https://github.com/octokit/plugin-paginate-rest.js/blob/8e5da25f975d2f31dda6b8b588d71f2c768a8df2/src/iterator.ts#L36-L41 429 // `allItems.headers.link` format: 430 // <https://api.github.com/repositories/4542716/issues?page=3&per_page=100&after=Y3Vyc29yOnYyOpLPAAABl8qNnYDOvnSJxA%3D%3D>; rel="next", 431 // <https://api.github.com/repositories/4542716/issues?page=1&per_page=100&before=Y3Vyc29yOnYyOpLPAAABl8xFV9DOvoouJg%3D%3D>; rel="prev" 432 // Sets `next` to undefined if "next" URL is not present or `link` header is not set. 433 const next = ((allItems.headers.link ?? '').match( 434 /<([^<>]+)>;\s*rel="next"/, 435 ) ?? [])[1] 436 if (next) { 437 cursor = new URL(next).searchParams.get('after') 438 const uploadPath = path.resolve('cursor') 439 await writeFile(uploadPath, cursor, 'utf-8') 440 if (dry) { 441 core.info(`pagination-cursor: ${cursor} (upload skipped)`) 442 } else { 443 // No stats.artifacts++, because this does not allow passing a custom token. 444 // Thus, the upload will not happen with the app token, but the default github.token. 445 await artifactClient.uploadArtifact( 446 'pagination-cursor', 447 [uploadPath], 448 path.resolve('.'), 449 { 450 retentionDays: 1, 451 }, 452 ) 453 } 454 } 455 456 // Some items might be in both search results, so filtering out duplicates as well. 457 const items = [] 458 .concat(updatedItems, allItems.data) 459 .filter( 460 (thisItem, idx, arr) => 461 idx === 462 arr.findIndex((firstItem) => firstItem.number === thisItem.number), 463 ) 464 465 ;(await Promise.allSettled(items.map((item) => handle({ item, stats })))) 466 .filter(({ status }) => status === 'rejected') 467 .map(({ reason }) => 468 core.setFailed(`${reason.message}\n${reason.cause.stack}`), 469 ) 470 } 471 }) 472}