Something went wrong. Try again.
Something went wrong. Try again.
30 kB · 825 lines
JavaScript
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826module.exports = async ({ github, context, core, dry }) => { const path = require('node:path') const { DefaultArtifactClient } = await import('@actions/artifact') const { readFile, writeFile } = require('node:fs/promises') const withRateLimit = require('./withRateLimit.js') const { classify } = require('../supportedBranches.js') const { handleMerge } = require('./merge.js') const { handleReviewers } = require('./reviewers.js')
const artifactClient = new DefaultArtifactClient()
// Detect if running in a fork (not NixOS/nixpkgs) const isFork = context.repo.owner !== 'NixOS'
const orgId = ( await github.rest.orgs.get({ org: context.repo.owner, }) ).data.id
async function downloadMaintainerMap(branch) { let run
const commits = ( await github.rest.repos.listCommits({ ...context.repo, sha: branch, // We look at 10 commits to find a maintainer map, but this is an arbitrary number. The // head commit might not have a map, if the queue was bypassed to merge it. This happens // frequently on staging-esque branches. The branch with the highest chance of getting // 10 consecutive bypassing commits is the stable staging-next branch. Luckily, this // also means that the number of PRs open towards that branch is very low, so falling // back to slightly imprecise maintainer data from master only has a marginal effect. per_page: 10, }) ).data
for (const commit of commits) { const run = ( await github.rest.actions.listWorkflowRuns({ ...context.repo, workflow_id: 'merge-group.yml', status: 'success', exclude_pull_requests: true, per_page: 1, head_sha: commit.sha, }) ).data.workflow_runs[0] if (!run) continue
const artifact = ( await github.rest.actions.listWorkflowRunArtifacts({ ...context.repo, run_id: run.id, name: 'maintainers', }) ).data.artifacts[0] if (!artifact || artifact.expired) continue
await artifactClient.downloadArtifact(artifact.id, { findBy: { repositoryName: context.repo.repo, repositoryOwner: context.repo.owner, token: core.getInput('github-token'), }, path: path.resolve(path.join('branches', branch)), expectedHash: artifact.digest, })
return JSON.parse( await readFile( path.resolve(path.join('branches', branch, 'maintainers.json')), 'utf-8', ), ) }
// We get here when none of the 10 commits we looked at contained a maintainer map. // For the master branch, we don't have any fallback options, so we error out. // In forks without merge-group history, return empty map to allow testing. if (branch === 'master') { if (isFork) { core.warning( 'No maintainer map found. Using empty map (expected in forks without merge-group history).', ) return {} } throw new Error('No maintainer map found.') }
// For other branches, we select a suitable fallback below. const { stable, version } = classify(branch)
const release = `release-${version}` if (stable && branch !== release) { // Only fallback to the release branch from *other* stable branches. // Explicitly avoids infinite recursion. return await getMaintainerMap(release) } else { // Falling back to master as last resort. // This can either be the case for unstable staging-esque or wip branches, // or for the primary stable branch (release-XX.YY). return await getMaintainerMap('master') } }
// Simple cache for maintainer maps to avoid downloading the same artifacts // over and over again. Ultimately returns a promise, so the result must be // awaited for. const maintainerMaps = {} function getMaintainerMap(branch) { if (!maintainerMaps[branch]) { maintainerMaps[branch] = downloadMaintainerMap(branch) } return maintainerMaps[branch] }
// Caching the list of team members saves API requests when running the bot on the schedule and // processing many PRs at once. const members = {} function getTeamMembers(team_slug) { if (context.eventName === 'pull_request') { // We have no chance of getting a token in the pull_request context with the right // permissions to access the members endpoint below. Thus, we're pretending to have // no members. This is OK; because this is only for the Test workflow, not for // real use. return [] }
// Forks don't have NixOS teams, return empty list if (isFork) { return [] }
if (!members[team_slug]) { members[team_slug] = github.paginate(github.rest.teams.listMembersInOrg, { org: context.repo.owner, team_slug, per_page: 100, }) }
return members[team_slug] }
// Caching users saves API requests when running the bot on the schedule and processing // many PRs at once. It also helps to encapsulate the special logic we need, because // actions/github doesn't support that endpoint fully, yet. const users = {} function getUser(id) { if (!users[id]) { users[id] = github .request({ method: 'GET', url: '/user/{id}', id, }) .then((resp) => resp.data) .catch((e) => { // User may have deleted their account if (e.status === 404) return null throw e }) }
return users[id] }
// Same for teams const teams = {} function getTeam(id) { if (!teams[id]) { teams[id] = github .request({ method: 'GET', url: '/organizations/{orgId}/team/{id}', orgId, id, }) .then((resp) => resp.data) .catch((e) => { // Team may have been deleted if (e.status === 404) return null throw e }) }
return teams[id] }
async function handlePullRequest({ item, stats, events }) { const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`)
const pull_number = item.number
// This API request is important for the merge-conflict label, because it triggers the // creation of a new test merge commit. This is needed to actually determine the state of a PR. const pull_request = ( await github.rest.pulls.get({ ...context.repo, pull_number, }) ).data
log('author', pull_request.user?.login)
const maintainers = await getMaintainerMap(pull_request.base.ref)
const merge_bot_eligible = await handleMerge({ github, context, core, log, dry, pull_request, events, maintainers, getTeamMembers, getUser, })
// Check for any human reviews other than the PR author, GitHub actions and other GitHub apps. const reviews = ( await github.graphql( `query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { # Unlikely that there's ever more than 100 reviews, so let's not bother, # but once https://github.com/actions/github-script/issues/309 is resolved, # it would be easy to enable pagination. reviews(first: 100) { nodes { state user: author { # Only get users, no bots ... on User { login # Set the id field in the resulting JSON to GraphQL's databaseId # databaseId in GraphQL-land is the same as id in REST-land id: databaseId } } onBehalfOf(first: 100) { nodes { slug } } } } } } }`, { owner: context.repo.owner, repo: context.repo.repo, pr: pull_number, }, ) ).repository.pullRequest.reviews.nodes.filter( (r) => // The `... on User` makes it such that .login only exists for users, // but we still need to filter the others out. // Accounts could be deleted as well, so don't count them. r.user?.login && // Also exclude author reviews, can't request their review in any case r.user.id !== pull_request.user?.id, )
const approvals = new Set( reviews .filter((review) => review.state === 'APPROVED') .map((review) => review.user?.id), )
// After creation of a Pull Request, `merge_commit_sha` will be null initially: // The very first merge commit will only be calculated after a little while. // To avoid labeling the PR as conflicted before that, we wait a few minutes. // This is intentionally less than the time that Eval takes, so that the label job // running after Eval can indeed label the PR as conflicted if that is the case. const merge_commit_sha_valid = Date.now() - new Date(pull_request.created_at) > 3 * 60 * 1000
const prLabels = { // We intentionally don't use the mergeable or mergeable_state attributes. // Those have an intermediate state while the test merge commit is created. // This doesn't work well for us, because we might have just triggered another // test merge commit creation by request the pull request via API at the start // of this function. // The attribute merge_commit_sha keeps the old value of null or the hash *until* // the new test merge commit has either successfully been created or failed so. // This essentially means we are updating the merge conflict label in two steps: // On the first pass of the day, we just fetch the pull request, which triggers // the creation. At this stage, the label is likely not updated, yet. // The second pass will then read the result from the first pass and set the label. '2.status: merge conflict': merge_commit_sha_valid && !pull_request.merge_commit_sha, '2.status: merge-bot eligible': merge_bot_eligible, '12.approvals: 1': approvals.size === 1, '12.approvals: 2': approvals.size === 2, '12.approvals: 3+': approvals.size >= 3, '12.first-time contribution': [ 'NONE', 'FIRST_TIMER', 'FIRST_TIME_CONTRIBUTOR', ].includes(pull_request.author_association), }
const { id: run_id, conclusion } = ( await github.rest.actions.listWorkflowRuns({ ...context.repo, workflow_id: 'pull-request-target.yml', event: 'pull_request_target', exclude_pull_requests: true, head_sha: pull_request.head.sha, }) ).data.workflow_runs[0] ?? // TODO: Remove this after 2026-02-01, at which point all pr.yml artifacts will have expired. ( await github.rest.actions.listWorkflowRuns({ ...context.repo, // In older PRs, we need pr.yml instead of pull-request-target.yml. workflow_id: 'pr.yml', event: 'pull_request_target', exclude_pull_requests: true, head_sha: pull_request.head.sha, }) ).data.workflow_runs[0] ?? {}
// Newer PRs might not have run Eval to completion, yet. // Older PRs might not have an eval.yml workflow, yet. // In either case we continue without fetching an artifact on a best-effort basis. log('Last eval run', run_id ?? '<n/a>')
if (conclusion === 'success') { Object.assign(prLabels, { // We only set this label if the latest eval run was successful, because if it was not, it // *could* have requested reviewers. We will let the PR author fix CI first, before "escalating" // this PR to "needs: reviewer". // Since the first Eval run on a PR always sets rebuild labels, the same PR will be "recently // updated" for the next scheduled run. Thus, this label will still be set within a few minutes // after a PR is created, if required. // Note that a "requested reviewer" disappears once they have given a review, so we check // existing reviews, too. '9.needs: reviewer': !pull_request.draft && pull_request.requested_reviewers.length === 0 && reviews.length === 0, }) }
const artifact = run_id && ( await github.rest.actions.listWorkflowRunArtifacts({ ...context.repo, run_id, name: 'comparison', }) ).data.artifacts[0]
// Instead of checking the boolean artifact.expired, we will give us a minute to // actually download the artifact in the next step and avoid that race condition. // Older PRs, where the workflow run was already eval.yml, but the artifact was not // called "comparison", yet, will skip the download. const expired = !artifact || new Date(artifact?.expires_at ?? 0) < new Date(Date.now() + 60 * 1000) log('Artifact expires at', artifact?.expires_at ?? '<n/a>') if (!expired) { stats.artifacts++
await artifactClient.downloadArtifact(artifact.id, { findBy: { repositoryName: context.repo.repo, repositoryOwner: context.repo.owner, token: core.getInput('github-token'), }, path: path.resolve(pull_number.toString()), expectedHash: artifact.digest, })
const changedPaths = JSON.parse( await readFile(`${pull_number}/changed-paths.json`, 'utf-8'), ) const evalLabels = changedPaths.labels
// Fetch all PR commits to check their messages for package patterns const prCommits = await github.paginate(github.rest.pulls.listCommits, { ...context.repo, pull_number, per_page: 100, }) const commitSubjects = prCommits.map( (c) => c.commit.message.split('\n')[0], )
// Label new package PRs: "packagename: init at X.Y.Z" // Exclude NixOS module commits like "nixos/timekpr: init at 0.5.8" const newPackagePattern = /^(?<!nixos\/)\S+: init at\b/ const hasNewPackages = changedPaths.attrdiff?.added?.length > 0 const commitsIndicateNewPackage = commitSubjects.some((msg) => newPackagePattern.test(msg), ) evalLabels['8.has: package (new)'] = hasNewPackages && commitsIndicateNewPackage
// Label package update PRs: "packagename: X.Y.Z -> A.B.C" // Matches versions like: 1.2.3, 0-unstable-2024-01-15, 1.3rc1, alpha, unstable // Exclude NixOS module commits like "nixos/ncps: types.str -> types.path" const updatePackagePattern = /^(?<!nixos\/)\S+: [\w.-]*\d[\w.-]* (->|→) [\w.-]*\d[\w.-]*$/ const commitsIndicateUpdate = commitSubjects.some((msg) => updatePackagePattern.test(msg), ) evalLabels['8.has: package (update)'] = commitsIndicateUpdate
// TODO: Get "changed packages" information from list of changed by-name files // in addition to just the Eval results, to make this work for these packages // when Eval results have expired as well. let packages try { packages = JSON.parse( await readFile(`${pull_number}/packages.json`, 'utf-8'), ) } catch (e) { if (e.code !== 'ENOENT') throw e // TODO: Remove this fallback code once all old artifacts without packages.json // have expired. This should be the case in ~ February 2026. packages = Array.from( new Set( Object.values( JSON.parse( await readFile(`${pull_number}/maintainers.json`, 'utf-8'), ), ).flat(1), ), ) }
Object.assign(prLabels, evalLabels, { '11.by: package-maintainer': Boolean(packages.length) && packages.every((pkg) => maintainers[pkg]?.includes(pull_request.user.id), ), '12.approved-by: package-maintainer': packages.some((pkg) => maintainers[pkg]?.some((m) => approvals.has(m)), ), })
if (!pull_request.draft) { let owners = [] try { // TODO: Create owner map similar to maintainer map. owners = (await readFile(`${pull_number}/owners.txt`, 'utf-8')).split( '\n', ) } catch (e) { // Older artifacts don't have the owners.txt, yet. if (e.code !== 'ENOENT') throw e }
let team_maintainers = [] try { team_maintainers = Object.keys( JSON.parse(await readFile(`${pull_number}/teams.json`, 'utf-8')), ).map((id) => parseInt(id)) } catch (e) { // Older artifacts don't have the teams.json, yet. if (e.code !== 'ENOENT') throw e }
// We set this label earlier already, but the current PR state can be very different // after handleReviewers has requested reviews, so update it in this case to prevent // this label from flip-flopping. prLabels['9.needs: reviewer'] = await handleReviewers({ github, context, core, log, dry, pull_request, reviews, // TODO: Use maintainer map instead of the artifact. user_maintainers: Object.keys( JSON.parse( await readFile(`${pull_number}/maintainers.json`, 'utf-8'), ), ).map((id) => parseInt(id)), team_maintainers, owners, getUser, getTeam, }) } }
return prLabels }
// Returns true if the issue was closed. In this case, the labeling does not need to // continue for this issue. Returns false if no action was taken. async function handleAutoClose(item) { const issue_number = item.number
if (item.labels.some(({ name }) => name === '0.kind: packaging request')) { const body = [ '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.', '', '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].', '', '[community]: https://nixos.org/community', '[contribute]: https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md#quick-start-to-adding-a-package', '[maintainers]: https://github.com/NixOS/nixpkgs/blob/master/maintainers/README.md', '[nixpkgs]: https://nixos.org/manual/nixpkgs/unstable/', '[Matrix]: https://matrix.to/#/#dev:nixos.org', '[Discourse]: https://discourse.nixos.org/c/dev/14', ].join('\n')
core.info(`Issue #${item.number}: auto-closed`)
if (!dry) { await github.rest.issues.createComment({ ...context.repo, issue_number, body, })
await github.rest.issues.update({ ...context.repo, issue_number, state: 'closed', state_reason: 'not_planned', }) }
return true } return false }
async function handle({ item, stats }) { try { const log = (k, v, skip) => { core.info(`#${item.number} - ${k}: ${v}${skip ? ' (skipped)' : ''}`) return skip }
log('Last updated at', item.updated_at) log('URL', item.html_url)
const issue_number = item.number
const itemLabels = {}
const events = await github.paginate( github.rest.issues.listEventsForTimeline, { ...context.repo, issue_number, per_page: 100, }, )
const latest_event_at = new Date( events .filter(({ event }) => [ // These events are hand-picked from: // https://docs.github.com/en/rest/using-the-rest-api/issue-event-types?apiVersion=2022-11-28 // Each of those causes a PR/issue to *not* be considered as stale anymore. // Most of these use created_at. 'assigned', 'commented', // uses updated_at, because that could be > created_at 'committed', // uses committer.date ...(item.labels.some(({ name }) => name === '5.scope: tracking') ? ['cross-referenced'] : []), 'head_ref_force_pushed', 'milestoned', 'pinned', 'ready_for_review', 'renamed', 'reopened', 'review_dismissed', 'review_requested', 'reviewed', // uses submitted_at 'unlocked', 'unmarked_as_duplicate', ].includes(event), ) .map( ({ created_at, updated_at, committer, submitted_at }) => new Date( updated_at ?? created_at ?? submitted_at ?? committer.date, ), ) // Reverse sort by date value. The default sort() sorts by string representation, which is bad for dates. .sort((a, b) => b - a) .at(0) ?? item.created_at, ) log('latest_event_at', latest_event_at.toISOString())
const stale_at = new Date(new Date().setDate(new Date().getDate() - 180)) const is_stale = latest_event_at < stale_at
if (item.pull_request || context.payload.pull_request) { // No need to compute merge commits for stale PRs over and over again. // This increases the repo size on GitHub's side unnecessarily and wastes // a lot of API requests, too. Any relevant change will result in the // stale status to change and thus pick up the PR again for labeling. if (!is_stale) { stats.prs++ Object.assign( itemLabels, await handlePullRequest({ item, stats, events }), ) } } else { stats.issues++ if (item.labels.some(({ name }) => name === '4.workflow: auto-close')) { // If this returns true, the issue was closed. In this case we return, to not // label the issue anymore. Most importantly this avoids unlabeling stale issues // which are closed via auto-close. if (await handleAutoClose(item)) return } }
// Create a map (Label -> Boolean) of all currently set labels. // Each label is set to True and can be disabled later. const before = Object.fromEntries( ( await github.paginate(github.rest.issues.listLabelsOnIssue, { ...context.repo, issue_number, }) ).map(({ name }) => [name, true]), )
Object.assign(itemLabels, { '2.status: stale': !before['1.severity: security'] && is_stale, })
const after = Object.assign({}, before, itemLabels)
// No need for an API request, if all labels are the same. const hasChanges = Object.keys(after).some( (name) => (before[name] ?? false) !== after[name], ) if (log('Has label changes', hasChanges, !hasChanges)) return
// Skipping labeling on a pull_request event, because we have no privileges. const labels = Object.entries(after) .filter(([, value]) => value) .map(([name]) => name) if (log('Set labels', labels, dry)) return
await github.rest.issues.setLabels({ ...context.repo, issue_number, labels, }) } catch (cause) { throw new Error(`Labeling #${item.number} failed.`, { cause }) } }
// Controls level of parallelism. Applies to both the number of concurrent requests // as well as the number of concurrent workers going through the list of PRs. // We'll only boost concurrency when we're running many PRs in parallel on a schedule, // but not for single PRs. This avoids things going wild, when we accidentally make // too many API requests on treewides. const maxConcurrent = context.payload.pull_request ? 1 : 20
await withRateLimit({ github, core, maxConcurrent }, async (stats) => { if (context.payload.pull_request) { await handle({ item: context.payload.pull_request, stats }) } else { const lastRun = ( await github.rest.actions.listWorkflowRuns({ ...context.repo, workflow_id: 'bot.yml', event: 'schedule', status: 'success', exclude_pull_requests: true, per_page: 1, }) ).data.workflow_runs[0]
const cutoff = new Date( Math.max( // Go back as far as the last successful run of this workflow to make sure // we are not leaving anyone behind on GHA failures. // Defaults to go back 1 hour on the first run. new Date( lastRun?.created_at ?? Date.now() - 1 * 60 * 60 * 1000, ).getTime(), // Go back max. 1 day to prevent hitting all API rate limits immediately, // when GH API returns a wrong workflow by accident. Date.now() - 24 * 60 * 60 * 1000, ), ) core.info(`cutoff timestamp: ${cutoff.toISOString()}`)
const updatedItems = await github.paginate( github.rest.search.issuesAndPullRequests, { q: [ `repo:"${context.repo.owner}/${context.repo.repo}"`, 'is:open', `updated:>=${cutoff.toISOString()}`, ].join(' AND '), per_page: 100, // TODO: Remove after 2025-11-04, when it becomes the default. advanced_search: true, }, )
let cursor
// No workflow run available the first time. if (lastRun) { // The cursor to iterate through the full list of issues and pull requests // is passed between jobs as an artifact. const artifact = ( await github.rest.actions.listWorkflowRunArtifacts({ ...context.repo, run_id: lastRun.id, name: 'pagination-cursor', }) ).data.artifacts[0]
// If the artifact is not available, the next iteration starts at the beginning. if (artifact && !artifact.expired) { stats.artifacts++
const { downloadPath } = await artifactClient.downloadArtifact( artifact.id, { findBy: { repositoryName: context.repo.repo, repositoryOwner: context.repo.owner, token: core.getInput('github-token'), }, expectedHash: artifact.digest, }, )
cursor = await readFile(path.resolve(downloadPath, 'cursor'), 'utf-8') } }
// From GitHub's API docs: // GitHub's REST API considers every pull request an issue, but not every issue is a pull request. // For this reason, "Issues" endpoints may return both issues and pull requests in the response. // You can identify pull requests by the pull_request key. const allItems = await github.rest.issues.listForRepo({ ...context.repo, state: 'open', sort: 'created', direction: 'asc', per_page: 100, after: cursor, })
// Regex taken and comment adjusted from: // https://github.com/octokit/plugin-paginate-rest.js/blob/8e5da25f975d2f31dda6b8b588d71f2c768a8df2/src/iterator.ts#L36-L41 // `allItems.headers.link` format: // <https://api.github.com/repositories/4542716/issues?page=3&per_page=100&after=Y3Vyc29yOnYyOpLPAAABl8qNnYDOvnSJxA%3D%3D>; rel="next", // <https://api.github.com/repositories/4542716/issues?page=1&per_page=100&before=Y3Vyc29yOnYyOpLPAAABl8xFV9DOvoouJg%3D%3D>; rel="prev" // Sets `next` to undefined if "next" URL is not present or `link` header is not set. const next = ((allItems.headers.link ?? '').match( /<([^<>]+)>;\s*rel="next"/, ) ?? [])[1] if (next) { cursor = new URL(next).searchParams.get('after') const uploadPath = path.resolve('cursor') await writeFile(uploadPath, cursor, 'utf-8') if (dry) { core.info(`pagination-cursor: ${cursor} (upload skipped)`) } else { // No stats.artifacts++, because this does not allow passing a custom token. // Thus, the upload will not happen with the app token, but the default github.token. await artifactClient.uploadArtifact( 'pagination-cursor', [uploadPath], path.resolve('.'), { retentionDays: 1, }, ) } }
// Some items might be in both search results, so filtering out duplicates as well. const items = [] .concat(updatedItems, allItems.data) .filter( (thisItem, idx, arr) => idx === arr.findIndex((firstItem) => firstItem.number === thisItem.number), )
// Instead of handling all items in parallel we set up some workers to handle the queue // with more controlled parallelism. This avoids problems with `pull_request` fetched at // the beginning getting out of date towards the end, because it took the whole job 20 // minutes or more to go through 100's of PRs. await Promise.all( Array.from({ length: maxConcurrent }, async () => { while (true) { const item = items.pop() if (!item) break try { await handle({ item, stats }) } catch (e) { core.setFailed(`${e.message}\n${e.cause.stack}`) } } }), ) } })}