module.exports = async ({ github, context, core, dry }) => { const path = require('node:path') const { DefaultArtifactClient } = require('@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() 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) 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. // For other branches, we select a suitable fallback below. if (branch === 'master') throw new Error('No maintainer map found.') 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 [] } 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) } return users[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 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, }) // When the same change has already been merged to the target branch, a PR will still be // open and display the same changes - but will not actually have any effect. This causes // strange CI behavior, because the diff of the merge-commit is empty, no rebuilds will // be detected, no maintainers pinged. // We can just check the temporary merge commit, and if it's empty the PR can safely be // closed - there are no further changes. // We only do this for PRs, which are non-empty to start with. This avoids closing PRs // which have been created with an empty commit for notification purposes, for example // the yearly election notification for voters. if (pull_request.merge_commit_sha && pull_request.changed_files > 0) { const commit = ( await github.rest.repos.getCommit({ ...context.repo, ref: pull_request.merge_commit_sha, }) ).data if (commit.files.length === 0) { const body = [ `The diff for the temporary merge commit ${pull_request.merge_commit_sha} is empty.`, 'The changes in this PR have almost certainly already been merged to the target branch.', ].join('\n') core.info(`PR #${item.number}: closed`) if (!dry) { await github.rest.issues.createComment({ ...context.repo, issue_number: pull_number, body, }) await github.rest.pulls.update({ ...context.repo, pull_number, state: 'closed', }) } return {} } } // Check for any human reviews other than GitHub actions and other GitHub apps. // Accounts could be deleted as well, so don't count them. const reviews = ( await github.paginate(github.rest.pulls.listReviews, { ...context.repo, pull_number, }) ).filter( (r) => r.user && !r.user.login.endsWith('[bot]') && r.user.type !== 'Bot', ) 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 ?? '') 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 ?? '') 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 evalLabels = JSON.parse( await readFile(`${pull_number}/changed-paths.json`, 'utf-8'), ).labels // 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 } // 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. maintainers: Object.keys( JSON.parse( await readFile(`${pull_number}/maintainers.json`, 'utf-8'), ), ).map((id) => parseInt(id)), owners, getTeamMembers, getUser, }) } } 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, }, ) if (item.pull_request || context.payload.pull_request) { 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 } } 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)) // 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'] && latest_event_at < stale_at, }) 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) { 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: // ; rel="next", // ; 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}`) } } }), ) } }) }