diff --git a/.github/workflows/retire.yml b/.github/workflows/retire.yml index 220e7de..273f6cb 100644 --- a/.github/workflows/retire.yml +++ b/.github/workflows/retire.yml @@ -10,10 +10,17 @@ on: permissions: {} +# Runs read the activity cache artifact of the previous run, so they must not overlap. +concurrency: + group: retire + jobs: retire: name: retire runs-on: ubuntu-latest + permissions: + # To download the activity cache artifact from the previous run + actions: read steps: - uses: actions/create-github-app-token@v2 id: app-token @@ -36,8 +43,42 @@ jobs: token: ${{ steps.app-token.outputs.token }} # Fetch full history to determine when committers were added fetch-depth: 0 + - name: Restore activity cache + uses: actions/github-script@v7 + with: + script: | + const restoreCache = require('./scripts/restore-cache.js'); + await restoreCache({ + github, + context, + core, + artifactName: 'retire-cache', + dir: '.cache', + }); - name: Run script - run: scripts/retire.sh NixOS nixpkgs nixpkgs-committers members "yesterday 1 month ago" "1 year ago" - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - PROD: "1" + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const retire = require('./scripts/retire.js'); + await retire({ + github, + options: { + org: 'NixOS', + activityRepo: 'nixpkgs', + memberRepo: 'nixpkgs-committers', + dir: 'members', + noticeCutoffSpec: 'yesterday 1 month ago', + closeCutoffSpec: '1 year ago', + confirmCutoffSpec: '7 days ago', + cacheFile: '.cache/retire-cache.json', + prod: true, + }, + }); + - name: Save activity cache + uses: actions/upload-artifact@v4 + with: + name: retire-cache + path: .cache/retire-cache.json + if-no-files-found: error + overwrite: true diff --git a/.gitignore b/.gitignore index d36977d..5c1dc9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .tmp +.cache diff --git a/scripts/README.md b/scripts/README.md index 6e3b118..d693d07 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -75,7 +75,10 @@ To test: PROD=1 scripts/nomination.sh members infinisil-test-org/nixpkgs-committers 33 30 <<< "added members/infinisil" ``` -## Testing `retire.sh` +## Testing `retire.js` + +In CI, `retire.js` runs via [`actions/github-script`](https://github.com/actions/github-script). +Locally it can be run with `scripts/retire-local.js`, which needs Node.js (`pkgs.nodejs`) and uses the `gh` CLI for API access instead. This script has external effects and as such needs a bit more care when testing. @@ -102,51 +105,69 @@ git push -f -u origin HEAD ### Test sequence -The following sequence tests all code paths: +The following sequence tests all code paths. + +The `CONFIRM_CUTOFF` argument is passed as `now` so that a single run is enough to open a retirement PR. +In CI it is set to `7 days ago` instead, which together with the `CACHE_FILE` activity cache (persisted between runs as a workflow artifact) requires a week of daily runs to agree before a PR is opened. 1. Run the script with the `active` repo argument to simulate CI running without inactive users: ```bash - scripts/retire.sh infinisil-test-org active nixpkgs-committers members-test 'yesterday 1 month ago' now + scripts/retire-local.js infinisil-test-org active nixpkgs-committers members-test 'yesterday 1 month ago' now now ``` Check that no PR would be opened. +1. Run the previous command again with `CACHE_FILE` set: + ```bash + CACHE_FILE=.tmp-cache.json scripts/retire-local.js infinisil-test-org active nixpkgs-committers members-test 'yesterday 1 month ago' now now + CACHE_FILE=.tmp-cache.json scripts/retire-local.js infinisil-test-org active nixpkgs-committers members-test 'yesterday 1 month ago' now now + rm .tmp-cache.json + ``` + + Check that the first run writes your activity to the cache file and that the second run skips the `/commits` query for your user because of it. 1. Run the script with the `empty` repo argument to simulate CI running with inactive users: ```bash - scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now + scripts/retire-local.js infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now now + ``` + + Check that it would only create a PR for your own user and not the "new-committer-1" or "new-committer-2" user. + Also check that with a `CONFIRM_CUTOFF` in the past (as used in CI), no PR would be opened yet: + + ```bash + scripts/retire-local.js infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now '7 days ago' ``` - Check that it would only create a PR for your own user and not the "new-committer-1" or "new-committer-2" user before running it again with `PROD=1` to actually do it: + Then run it again with `PROD=1` to actually do it: ```bash - PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now + PROD=1 scripts/retire-local.js infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now now ``` Check that it created the PR appropriately, including assigning the "retirement" label. You can undo this step by closing the PR. 1. Run it again to simulate CI running again later: ```bash - PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now + PROD=1 scripts/retire-local.js infinisil-test-org empty nixpkgs-committers members-test 'yesterday 1 month ago' now now ``` Check that no other PR is opened. 1. Run it again with `now` as the notice cutoff date to simulate the time interval passing: ```bash - PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test now now + PROD=1 scripts/retire-local.js infinisil-test-org empty nixpkgs-committers members-test now now now ``` Check that it undrafted the previous PR and posted an appropriate comment. 1. Run it again to simulate CI running again later: ```bash - PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test now now + PROD=1 scripts/retire-local.js infinisil-test-org empty nixpkgs-committers members-test now now now ``` Check that no other PR is opened. 1. Reset by marking the PR as a draft again, then run it again with the `active` repo argument to simulate activity during the time interval: ```bash - PROD=1 scripts/retire.sh infinisil-test-org active nixpkgs-committers members-test now now + PROD=1 scripts/retire-local.js infinisil-test-org active nixpkgs-committers members-test now now now ``` Check that it gets undrafted with a comment listing the new activity. 1. Close the PR, then run the script again with no activity and for an earlier close cutoff, simulating that the retirement was delayed: ```bash - PROD=1 scripts/retire.sh infinisil-test-org empty nixpkgs-committers members-test now '1 day ago' + PROD=1 scripts/retire-local.js infinisil-test-org empty nixpkgs-committers members-test now '1 day ago' now ``` Check that no other PR is opened. diff --git a/scripts/restore-cache.js b/scripts/restore-cache.js new file mode 100644 index 0000000..3096e69 --- /dev/null +++ b/scripts/restore-cache.js @@ -0,0 +1,51 @@ +// Restores the activity cache artifact uploaded by the previous successful +// run of the retire workflow, for use by scripts/retire.js. +// Runs in CI via actions/github-script (see .github/workflows/retire.yml). +// +// Any failure here only means starting without a cache, which is safe: +// a missing cache can delay retirements, but never cause one. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +module.exports = async function restoreCache({ github, context, core, artifactName, dir }) { + const { owner, repo } = context.repo; + try { + const runs = ( + await github.request(`GET /repos/${owner}/${repo}/actions/workflows/retire.yml/runs`, { + status: 'success', + per_page: 1, + }) + ).data.workflow_runs; + if (runs.length === 0) { + core.info('No previous successful run, starting fresh'); + return; + } + const runId = runs[0].id; + + const artifacts = ( + await github.request(`GET /repos/${owner}/${repo}/actions/runs/${runId}/artifacts`) + ).data.artifacts; + const artifact = artifacts.find((a) => a.name === artifactName && !a.expired); + if (!artifact) { + core.warning(`Run ${runId} has no ${artifactName} artifact, starting fresh`); + return; + } + + // The artifact download endpoint always serves a zip archive + const zip = ( + await github.request(`GET /repos/${owner}/${repo}/actions/artifacts/${artifact.id}/zip`) + ).data; + fs.mkdirSync(dir, { recursive: true }); + const zipPath = path.join(dir, `${artifactName}.zip`); + fs.writeFileSync(zipPath, Buffer.from(zip)); + execFileSync('unzip', ['-o', zipPath, '-d', dir], { stdio: 'inherit' }); + fs.rmSync(zipPath); + core.info(`Restored activity cache from run ${runId}`); + } catch (err) { + core.warning(`Could not restore activity cache, starting fresh: ${err.message}`); + } +}; diff --git a/scripts/retire-local.js b/scripts/retire-local.js new file mode 100755 index 0000000..bcdfe13 --- /dev/null +++ b/scripts/retire-local.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Local runner for scripts/retire.js, providing an Octokit-compatible shim +// backed by the `gh` CLI so no npm dependencies are needed. +// See scripts/README.md for the test sequence. + +'use strict'; + +const { execFileSync } = require('child_process'); +const retire = require('./retire.js'); + +function usage() { + console.error(`Usage: ${process.argv[1]} ORG ACTIVITY_REPO MEMBER_REPO DIR NOTICE_CUTOFF CLOSE_CUTOFF CONFIRM_CUTOFF`); + console.error(''); + console.error('Optionally set CACHE_FILE to a path for persisting activity observations between runs.'); + console.error('Set PROD=1 to actually perform side effects.'); + process.exit(1); +} + +const [org, activityRepo, memberRepo, dir, noticeCutoffSpec, closeCutoffSpec, confirmCutoffSpec] = + process.argv.slice(2); +if (!confirmCutoffSpec) { + usage(); +} + +function gh(args, input) { + return execFileSync('gh', args, { + input, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); +} + +const github = { + async request(route, params = {}) { + const [method, pathTemplate] = route.split(' '); + let args; + let input; + if (method === 'GET') { + const query = new URLSearchParams(params).toString(); + args = ['api', '-X', 'GET', query ? `${pathTemplate}?${query}` : pathTemplate]; + } else { + args = ['api', '-X', method, pathTemplate, '--input', '-']; + input = JSON.stringify(params); + } + const out = gh(args, input); + return { data: out.trim() === '' ? null : JSON.parse(out) }; + }, + async graphql(query, variables = {}) { + const out = gh(['api', 'graphql', '--input', '-'], JSON.stringify({ query, variables })); + const parsed = JSON.parse(out); + if (parsed.errors) { + throw new Error(JSON.stringify(parsed.errors)); + } + return parsed.data; + }, +}; + +retire({ + github, + options: { + org, + activityRepo, + memberRepo, + dir, + noticeCutoffSpec, + closeCutoffSpec, + confirmCutoffSpec, + cacheFile: process.env.CACHE_FILE, + prod: Boolean(process.env.PROD), + }, +}).catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/retire.js b/scripts/retire.js new file mode 100644 index 0000000..c7d4f8f --- /dev/null +++ b/scripts/retire.js @@ -0,0 +1,435 @@ +// Automatic retirement of inactive committers. +// +// In CI this runs via actions/github-script (see .github/workflows/retire.yml), +// which provides an authenticated Octokit instance as `github`. +// Locally it runs via scripts/retire-local.js, which provides a compatible +// shim backed by the `gh` CLI. See scripts/README.md for the test sequence. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +// Parse a small subset of GNU `date --date` expressions (in UTC), enough for +// the specs this script is invoked with: "now", "yesterday", " ago" +// (combinable, e.g. "yesterday 1 month ago"), and absolute dates like +// "2024-10-07". Returns a unix epoch in seconds. +function parseDate(spec) { + let s = spec.trim(); + + const absolute = Date.parse(/^\d{4}-\d{2}-\d{2}/.test(s) ? s : ''); + if (!Number.isNaN(absolute)) { + return Math.floor(absolute / 1000); + } + + const d = new Date(); + const setters = { + second: (n) => d.setUTCSeconds(d.getUTCSeconds() - n), + minute: (n) => d.setUTCMinutes(d.getUTCMinutes() - n), + hour: (n) => d.setUTCHours(d.getUTCHours() - n), + day: (n) => d.setUTCDate(d.getUTCDate() - n), + week: (n) => d.setUTCDate(d.getUTCDate() - 7 * n), + month: (n) => d.setUTCMonth(d.getUTCMonth() - n), + year: (n) => d.setUTCFullYear(d.getUTCFullYear() - n), + }; + + while (s !== '') { + let m; + if ((m = s.match(/^now\b/))) { + // nothing to do + } else if ((m = s.match(/^yesterday\b/))) { + setters.day(1); + } else if ((m = s.match(/^(\d+) (second|minute|hour|day|week|month|year)s? ago\b/))) { + setters[m[2]](Number(m[1])); + } else { + throw new Error(`Cannot parse date spec ${JSON.stringify(spec)}`); + } + s = s.slice(m[0].length).trim(); + } + return Math.floor(d.getTime() / 1000); +} + +function isoSeconds(epoch) { + return new Date(epoch * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +module.exports = async function retire({ github, options }) { + const { + org, + activityRepo, + memberRepo, + dir, + noticeCutoffSpec, + closeCutoffSpec, + confirmCutoffSpec, + cacheFile, + prod, + } = options; + + const log = (msg = '') => console.error(msg); + + const git = (...args) => + execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trimEnd(); + + const trace = (fn, description) => { + log(`Running: ${description}`); + return fn(); + }; + + // Skip side effects unless running in production, but log what would happen + const effect = (fn, description) => { + if (prod) { + return trace(fn, description); + } + log(`\x1b[33mSkipping effect: ${description}\x1b[0m`); + return undefined; + }; + + const mainBranch = git('branch', '--show-current'); + const nowEpoch = Math.floor(Date.now() / 1000); + + const noticeCutoff = parseDate(noticeCutoffSpec); + + // People that received the commit bit after this date won't be retired + const newCutoff = parseDate('1 year ago'); + + // Users whose retirement PRs were closed after this date won't be retired + const closeCutoff = parseDate(closeCutoffSpec); + + // Users first observed as inactive after this date won't be retired yet; + // they keep being checked until the observation is old enough. + // This prevents a single spurious empty response from the /commits endpoint + // from causing an accidental retirement PR (see issue #100). + const confirmCutoff = parseDate(confirmCutoffSpec); + + // People are considered active if they merged a PR after this date + const yearAgo = parseDate('1 year ago'); + + // Cached merges newer than this are trusted without querying GitHub again. + // The gap until the 1-year mark means the /commits endpoint gets queried + // for about a month of daily runs before a retirement PR can be opened, + // so a retirement is backed by many independent queries agreeing. + const queryCutoff = parseDate('11 months ago'); + + // We need to know when people received their commit bit to avoid retiring + // them within the first year. For now this is done either with the git + // creation date of the file, or its contents: + // + // | commit bit reception date | file creation date | file contents | + // | -------------------------- | ------------------ | -------------- | + // | A) -∞ - 2024-10-06 | 2025-07-16 | empty | + // | B) 2024-10-07 - 2025-04-22 | 2025-07-16 | reception date | + // | C) 2025-08-13 - ∞ | reception date | empty | + // + // After 2026-04-23 (one year after C started), the file creation date + // for all first-year committers will match the reception date, + // while everybody else will have been a committer for more than one year. + // This means the code can then be simplified to just + // check if the file creation date is in the last year. + // + // For now however, the code needs to check if the file creation date + // is before 2025-07-17 to distinguish between periods A and C, + // so we hardcode that date for the code to use. + const createdOnReceptionEpoch = parseDate('2025-07-17'); + + let tmp; + if (!prod) { + tmp = path.join(git('rev-parse', '--show-toplevel'), '.tmp'); + fs.rmSync(tmp, { recursive: true, force: true }); + fs.mkdirSync(tmp); + log(`\x1b[33mprod is not set, skipping effects and keeping activity files in ${tmp} until the next run\x1b[0m`); + } + + // Activity observed by previous runs, so that the unreliable /commits + // endpoint doesn't need to be trusted on a single response (see issue #100). + // The cache only holds positive evidence (merges the API actually returned) + // plus the time somebody was first observed as inactive, + // so a spurious empty response can never make a cached-active user look inactive. + // Bump this version to invalidate all previously cached data. + const cacheVersion = 1; + let cache = {}; + if (cacheFile) { + try { + const parsed = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); + if (parsed.version === cacheVersion) { + cache = parsed.users ?? {}; + log(`Loaded activity cache with ${Object.keys(cache).length} entries from ${cacheFile}`); + } else { + log(`Activity cache at ${cacheFile} has wrong version, starting fresh`); + } + } catch { + log(`No usable activity cache at ${cacheFile}, starting fresh`); + } + } + const newCache = {}; + + const logins = fs.readdirSync(dir).sort(); + for (const login of logins) { + // Figure out when this person received the commit bit + // Get the unix epoch of the last commit that added this file + // --first-parent is important to get the time of when the main branch was changed + const fileCommitEpoch = Number( + git( + 'log', + '--first-parent', + '--no-follow', + '--diff-filter=A', + '--max-count=1', + '--format=%cd', + '--date=unix', + '--', + login, + ), + ); + let receptionEpoch; + if (fileCommitEpoch < createdOnReceptionEpoch) { + // If it was created before creation actually matched the reception date + // This branch can be removed after 2026-04-23 + const contents = fs.readFileSync(path.join(dir, login), 'utf8').trim(); + if (contents !== '') { + // If the file is non-empty it indicates an explicit reception date + receptionEpoch = parseDate(contents); + } else { + // Otherwise they received the commit bit more than a year ago (start of unix epoch, 1970) + receptionEpoch = 0; + } + } else { + // Otherwise creation matches reception + receptionEpoch = fileCommitEpoch; + } + + // Latest retirement PR, whether draft, open or closed + const branchName = `retire-${login}`; + const prInfo = ( + await trace( + () => + github.request(`GET /repos/${org}/${memberRepo}/pulls`, { + state: 'all', + head: `${org}:${branchName}`, + per_page: 1, + }), + `GET /repos/${org}/${memberRepo}/pulls?head=${org}:${branchName}`, + ) + ).data[0]; + const prState = prInfo ? prInfo.state : 'none'; + + if (prState === 'closed' && Date.parse(prInfo.closed_at) / 1000 > closeCutoff) { + log(`${login} had a retirement PR that was closed recently, skipping retirement check`); + // Keep the cached observations so the #100 protection survives the skip + if (cache[login]) { + newCache[login] = cache[login]; + } + continue; + } + + // If the commit bit was received after the cutoff date, don't retire in any case + if (receptionEpoch > newCutoff) { + log(`${login} became a committer less than 1 year ago, skipping retirement check`); + if (cache[login]) { + newCache[login] = cache[login]; + } + continue; + } + + // What previous runs observed about this person's merge activity + let { mergeEpoch = 0, mergePr = '', firstInactive = 0 } = cache[login] ?? {}; + + let activityLines = []; + let queried = false; + if (prState !== 'open' && mergeEpoch > queryCutoff) { + // Recent cached activity already rules out retirement, + // no need to bother the unreliable /commits endpoint + } else { + queried = true; + // For an open PR the comment lists activity from the whole past year; + // otherwise only merges newer than the cached one are of interest + const sinceEpoch = + prState === 'open' ? yearAgo : Math.max(yearAgo, mergeEpoch + 1); + const commits = ( + await trace( + () => + github.request(`GET /repos/${org}/${activityRepo}/commits`, { + since: isoSeconds(sinceEpoch), + author: login, + committer: 'web-flow', + per_page: 100, + }), + `GET /repos/${org}/${activityRepo}/commits?author=${login}&since=${isoSeconds(sinceEpoch)}`, + ) + ).data; + for (const commit of commits) { + // PR merge commits have two parents. We also check it’s an + // authentic GitHub commit, because… why not? + if (commit.parents.length !== 2 || !commit.commit.verification.verified) { + continue; + } + const m = commit.commit.message.match(/ \((#[0-9]+)\)$/m); + if (!m) { + continue; + } + activityLines.push({ + epoch: Math.floor(Date.parse(commit.commit.committer.date) / 1000), + pr: m[1], + line: `- \`${commit.commit.committer.date}\` – ${org}/${activityRepo}${m[1]}`, + }); + } + } + + if (activityLines.length > 0) { + // Cache the newest merge (not trusting the endpoint's result ordering) + const newest = activityLines.reduce((a, b) => (a.epoch >= b.epoch ? a : b)); + if (newest.epoch > mergeEpoch) { + mergeEpoch = newest.epoch; + mergePr = newest.pr; + } + firstInactive = 0; + } else if (queried && firstInactive === 0) { + // An empty response might just be a timeout of the endpoint (issue #100), + // so this only starts the inactivity clock; retirement additionally needs + // all queries until the confirmation cutoff to keep coming back empty + firstInactive = nowEpoch; + } + + // Remember the observations for the cache written at the end of the run + newCache[login] = { mergeEpoch, mergePr, firstInactive }; + + if (!prod) { + fs.writeFileSync( + path.join(tmp, login), + activityLines.map((a) => a.line + '\n').join(''), + ); + } + + if (prState === 'open') { + if (activityLines.length === 0 && mergeEpoch > yearAgo) { + // The query came back empty even though the cache proves activity + // (issue #100), so reconstruct the activity list from the cache + activityLines = [ + { line: `- \`${isoSeconds(mergeEpoch)}\` – ${org}/${activityRepo}${mergePr}` }, + ]; + } + // If there is an open PR already + const prNumber = prInfo.number; + const epochCreatedAt = Date.parse(prInfo.created_at) / 1000; + if (prInfo.draft && epochCreatedAt < noticeCutoff) { + log(`${login} has a retirement PR due, unmarking PR as draft and commenting with next steps`); + await effect( + () => + github.graphql( + `mutation($id: ID!) { + markPullRequestReadyForReview(input: { pullRequestId: $id }) { + pullRequest { number } + } + }`, + { id: prInfo.node_id }, + ), + `markPullRequestReadyForReview ${org}/${memberRepo}#${prNumber}`, + ); + const bodyLines = []; + if (activityLines.length > 0) { + bodyLines.push( + `One month has passed, and @${login} has been active again:`, + ...activityLines.map((a) => a.line), + '', + 'If still appropriate, this PR may be merged and implemented by:', + ); + } else { + bodyLines.push('One month has passed, so this PR should now be merged and implemented by:'); + } + bodyLines.push( + `- Adding @${login} to the [Retired Nixpkgs Contributors team](https://github.com/orgs/NixOS/teams/retired-nixpkgs-contributors)`, + ' ```sh', + ' gh api \\', + ' --method PUT \\', + ` '/orgs/NixOS/teams/retired-nixpkgs-contributors/memberships/${login}' \\`, + ' -f role=member', + ' ```', + `- Removing @${login} from the [Nixpkgs Committers team](https://github.com/orgs/NixOS/teams/nixpkgs-committers)`, + ' ```sh', + ' gh api \\', + ' --method DELETE \\', + ` '/orgs/NixOS/teams/nixpkgs-committers/memberships/${login}'`, + ' ```', + ); + await effect( + () => + github.request(`POST /repos/${org}/${memberRepo}/issues/${prNumber}/comments`, { + body: bodyLines.join('\n'), + }), + `POST comment on ${org}/${memberRepo}#${prNumber}:\n${bodyLines.join('\n')}`, + ); + } else { + log(`${login} has a retirement PR pending`); + } + } else if (activityLines.length > 0 || mergeEpoch > yearAgo) { + // Merges returned by the query are always newer than yearAgo (the since parameter), + // so activityLines being non-empty implies mergeEpoch > yearAgo. + if (activityLines.length > 0) { + log(`${login} is active with ${activityLines.length} new merges`); + } else { + log(`${login} is active, last known merge ${mergePr} on ${isoSeconds(mergeEpoch)} (cached)`); + } + } else if (firstInactive > confirmCutoff) { + log(`${login} appears inactive since ${isoSeconds(firstInactive)}, continuing to check before opening a PR`); + } else { + log(`${login} has become inactive, opening a PR`); + // If there is no PR yet, but they have become inactive + try { + trace(() => git('switch', '-C', branchName), `git switch -C ${branchName}`); + trace(() => git('rm', login), `git rm ${login}`); + trace( + () => git('commit', '-m', `Automatic retirement of @${login}`), + `git commit -m 'Automatic retirement of @${login}'`, + ); + effect( + () => git('push', '-f', '-u', 'origin', branchName), + `git push -f -u origin ${branchName}`, + ); + const body = [ + `This is an automated PR to retire @${login} as a Nixpkgs committer because they have not used their commit access in the past year.`, + '', + `@${login}: You can make a comment stating why you believe your commit access should be kept. Otherwise, this PR will be merged and implemented in one month.`, + '', + '> [!NOTE]', + '> Commit access is not required for most forms of contributing, including being a maintainer and reviewing PRs.' + + ' It is only needed for things that require `write` permissions to Nixpkgs, such as merging PRs.', + ].join('\n'); + const pr = await effect( + () => + github.request(`POST /repos/${org}/${memberRepo}/pulls`, { + title: `Automatic retirement of @${login}`, + body, + head: `${org}:${branchName}`, + base: mainBranch, + draft: true, + }), + `POST /repos/${org}/${memberRepo}/pulls (retirement PR for @${login}):\n${body}`, + ); + if (pr) { + await effect( + () => + github.request(`POST /repos/${org}/${memberRepo}/issues/${pr.data.number}/labels`, { + labels: ['retirement'], + }), + `POST retirement label on ${org}/${memberRepo}#${pr.data.number}`, + ); + } + } finally { + trace(() => git('checkout', mainBranch), `git checkout ${mainBranch}`); + trace(() => git('branch', '-D', branchName), `git branch -D ${branchName}`); + } + } + log(); + } + + if (cacheFile) { + fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); + fs.writeFileSync( + cacheFile + '.tmp', + JSON.stringify({ version: cacheVersion, users: newCache }, null, 2) + '\n', + ); + fs.renameSync(cacheFile + '.tmp', cacheFile); + log(`Wrote activity cache to ${cacheFile}`); + } +}; diff --git a/scripts/retire.sh b/scripts/retire.sh deleted file mode 100755 index cf144b5..0000000 --- a/scripts/retire.sh +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env bash - -source "$(dirname -- "${BASH_SOURCE[0]}")"/common.sh - -shopt -s nullglob - -usage() { - log "Usage: $0 ORG ACTIVITY_REPO MEMBER_REPO DIR NOTICE_CUTOFF CLOSE_CUTOFF" - exit 1 -} - -ORG=${1:-$(usage)} -ACTIVITY_REPO=${2:-$(usage)} -MEMBER_REPO=${3:-$(usage)} -DIR=${4:-$(usage)} -NOTICE_CUTOFF=${5:-$(usage)} -CLOSE_CUTOFF=${6:-$(usage)} - -mainBranch=$(git branch --show-current) -noticeCutoff=$(date --date="$NOTICE_CUTOFF" +%s) - -# People that received the commit bit after this date won't be retired -newCutoff=$(date --date="1 year ago" +%s) -# Users whose retirement PRs were closed after this date won't be retired -closeCutoff=$(date --date="$CLOSE_CUTOFF" +%s) - -# We need to know when people received their commit bit to avoid retiring them within the first year. -# For now this is done either with the git creation date of the file, or its contents: -# -# | commit bit reception date | file creation date | file contents | -# | -------------------------- | ------------------ | -------------- | -# | A) -∞ - 2024-10-06 | 2025-07-16 | empty | -# | B) 2024-10-07 - 2025-04-22 | 2025-07-16 | reception date | -# | C) 2025-08-13 - ∞ | reception date | empty | -# -# After 2026-04-23 (one year after C started), the file creation date -# for all first-year committers will match the reception date, -# while everybody else will have been a committer for more than one year. -# This means the code can then be simplified to just -# check if the file creation date is in the last year. -# -# For now however, the code needs to check if the file creation date -# is before 2025-07-17 to distinguish between periods A and C, -# so we hardcode that date for the code to use. -createdOnReceptionEpoch=$(date --date=2025-07-17 +%s) - -if [[ -z "${PROD:-}" ]]; then - tmp=$(git rev-parse --show-toplevel)/.tmp - rm -rf "$tmp" - mkdir "$tmp" - log -e "\e[33mPROD=1 is not set, skipping effects and keeping temporary files in $tmp until the next run\e[0m" -else - tmp=$(mktemp -d) - trap 'rm -rf "$tmp"' exit -fi - -mkdir -p "$DIR" -cd "$DIR" -for login in *; do - - # Figure out when this person received the commit bit - # Get the unix epoch of the last commit that added this file - # --first-parent is important to get the time of when the main branch was changed - fileCommitEpoch=$(git log \ - --first-parent \ - --no-follow \ - --diff-filter=A \ - --max-count=1 \ - --format=%cd \ - --date=unix \ - -- "$login") - if (( fileCommitEpoch < createdOnReceptionEpoch )); then - # If it was created before creation actually matched the reception date - # This branch can be removed after 2026-04-23 - - if [[ -s "$login" ]]; then - # If the file is non-empty it indicates an explicit reception date - receptionEpoch=$(date --date="$(<"$login")" +%s) - else - # Otherwise they received the commit bit more than a year ago (start of unix epoch, 1970) - receptionEpoch=0 - fi - else - # Otherwise creation matches reception - receptionEpoch=$fileCommitEpoch - fi - - # Latest retirement PR, whether draft, open or closed - branchName=retire-$login - prInfo=$(trace gh api -X GET /repos/"$ORG"/"$MEMBER_REPO"/pulls \ - -f state=all \ - -f head="$ORG":"$branchName" \ - --jq '.[0]') - if [[ -n "$prInfo" ]]; then - prState=$(jq -r .state <<< "$prInfo") - else - prState=none - fi - - if [[ "$prState" == closed ]] && resetEpoch=$(jq '.closed_at | fromdateiso8601' <<< "$prInfo") && (( closeCutoff < resetEpoch )); then - log "$login had a retirement PR that was closed recently, skipping retirement check" - continue - fi - - # If the commit bit was received after the cutoff date, don't retire in any case - if (( newCutoff < receptionEpoch )); then - log "$login became a committer less than 1 year ago, skipping retirement check" - continue - fi - - PR_PREFIX="$ORG/$ACTIVITY_REPO" \ - trace gh api -X GET /repos/"$ORG"/"$ACTIVITY_REPO"/commits \ - -f since="$(date --date='1 year ago' --iso-8601=seconds)" \ - -f author="$login" \ - -f committer=web-flow \ - -f per_page=100 \ - --jq '.[] | - # PR merge commits have two parents. We also check it’s an - # authentic GitHub commit, because… why not? - select((.parents | length) == 2 and .commit.verification.verified) | - (.commit.message | capture(" \\((?#[0-9]+)\\)$").pr) as $pr | - "- `\(.commit.committer.date)` – \(env.PR_PREFIX)\($pr)"' \ - > "$tmp/$login" - mergeCount=$(wc -l <"$tmp/$login") - - if [[ "$prState" == open ]]; then - # If there is an open PR already - prNumber=$(jq .number <<< "$prInfo") - epochCreatedAt=$(jq '.created_at | fromdateiso8601' <<< "$prInfo") - if jq -e .draft <<< "$prInfo" >/dev/null && (( epochCreatedAt < noticeCutoff )); then - log "$login has a retirement PR due, unmarking PR as draft and commenting with next steps" - effect gh pr ready --repo "$ORG/$MEMBER_REPO" "$prNumber" - { - if (( mergeCount > 0 )); then - echo "One month has passed, and @$login has been active again:" - cat "$tmp/$login" - echo "" - echo "If still appropriate, this PR may be merged and implemented by:" - else - echo "One month has passed, so this PR should now be merged and implemented by:" - fi - echo "- Adding @$login to the [Retired Nixpkgs Contributors team](https://github.com/orgs/NixOS/teams/retired-nixpkgs-contributors)" - echo ' ```sh' - echo ' gh api \' - echo ' --method PUT \' - echo " '/orgs/NixOS/teams/retired-nixpkgs-contributors/memberships/$login' \\" - echo ' -f role=member' - echo ' ```' - echo "- Removing @$login from the [Nixpkgs Committers team](https://github.com/orgs/NixOS/teams/nixpkgs-committers)" - echo ' ```sh' - echo ' gh api \' - echo ' --method DELETE \' - echo " '/orgs/NixOS/teams/nixpkgs-committers/memberships/$login'" - echo ' ```' - } | effect gh api --method POST /repos/"$ORG"/"$MEMBER_REPO"/issues/"$prNumber"/comments -F "body=@-" >/dev/null - else - log "$login has a retirement PR pending" - fi - elif (( mergeCount <= 0 )); then - log "$login has become inactive, opening a PR" - # If there is no PR yet, but they have become inactive - ( - trace git switch -C "$branchName" - trap 'trace git checkout "$mainBranch" && trace git branch -D "$branchName"' exit - trace git rm "$login" - trace git commit -m "Automatic retirement of @$login" - effect git push -f -u origin "$branchName" - prNumber=$({ - echo "This is an automated PR to retire @$login as a Nixpkgs committer because they have not used their commit access in the past year." - echo "" - echo "@$login: You can make a comment stating why you believe your commit access should be kept. Otherwise, this PR will be merged and implemented in one month." - echo "" - echo "> [!NOTE]" - echo -n "> Commit access is not required for most forms of contributing, including being a maintainer and reviewing PRs." - echo ' It is only needed for things that require `write` permissions to Nixpkgs, such as merging PRs.' - } | effect gh api \ - --method POST \ - /repos/"$ORG"/"$MEMBER_REPO"/pulls \ - -f "title=Automatic retirement of @$login" \ - -F "body=@-" \ - -f "head=$ORG:$branchName" \ - -f "base=$mainBranch" \ - -F "draft=true" \ - --jq .number - ) - - effect gh api \ - --method POST \ - /repos/"$ORG"/"$MEMBER_REPO"/issues/"$prNumber"/labels \ - -f "labels[]=retirement" >/dev/null - ) - else - log "$login is active with $mergeCount merges" - fi - log "" -done