ci/github-script: move from ci/labels; allow single PR testing and non-dry mode (#424872)

authored by Wolfgang Walther and committed by GitHub 13855a51 fef29b57

+171 -123
+2 -2
.github/workflows/labels.yml
··· 43 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 44 with: 45 sparse-checkout: | 46 - ci/labels 47 48 - name: Install dependencies 49 run: npm install @actions/artifact bottleneck ··· 69 github-token: ${{ steps.app-token.outputs.token || github.token }} 70 retries: 3 71 script: | 72 - require('./ci/labels/labels.cjs')({ 73 github, 74 context, 75 core,
··· 43 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 44 with: 45 sparse-checkout: | 46 + ci/github-script 47 48 - name: Install dependencies 49 run: npm install @actions/artifact bottleneck ··· 69 github-token: ${{ steps.app-token.outputs.token || github.token }} 70 retries: 3 71 script: | 72 + require('./ci/github-script/labels.js')({ 73 github, 74 context, 75 core,
+3
ci/github-script/.editorconfig
···
··· 1 + [run] 2 + indent_style = space 3 + indent_size = 2
+13
ci/github-script/README.md
···
··· 1 + # GitHub specific CI scripts 2 + 3 + This folder contains [`actions/github-script`](https://github.com/actions/github-script)-based JavaScript code. 4 + It provides a `nix-shell` environment to run and test these actions locally. 5 + 6 + To run any of the scripts locally: 7 + 8 + - Enter `nix-shell` in `./ci/github-script`. 9 + - Ensure `gh` is authenticated. 10 + 11 + ## Labeler 12 + 13 + Run `./run labels OWNER REPO`, where OWNER is your username or "NixOS" and REPO the name of your fork or "nixpkgs".
+67
ci/github-script/run
···
··· 1 + #!/usr/bin/env -S node --import ./run 2 + import { execSync } from 'node:child_process' 3 + import { mkdtempSync, rmSync } from 'node:fs' 4 + import { tmpdir } from 'node:os' 5 + import { join } from 'node:path' 6 + import { program } from 'commander' 7 + import { getOctokit } from '@actions/github' 8 + 9 + async function run(action, owner, repo, pull_number, dry) { 10 + const token = execSync('gh auth token', { encoding: 'utf-8' }).trim() 11 + 12 + const github = getOctokit(token) 13 + 14 + const payload = !pull_number ? {} : { 15 + pull_request: (await github.rest.pulls.get({ 16 + owner, 17 + repo, 18 + pull_number, 19 + })).data 20 + } 21 + 22 + const tmp = mkdtempSync(join(tmpdir(), 'github-script-')) 23 + try { 24 + process.env.GITHUB_WORKSPACE = tmp 25 + process.chdir(tmp) 26 + 27 + await action({ 28 + github, 29 + context: { 30 + payload, 31 + repo: { 32 + owner, 33 + repo, 34 + }, 35 + }, 36 + core: { 37 + getInput() { 38 + return token 39 + }, 40 + error: console.error, 41 + info: console.log, 42 + notice: console.log, 43 + setFailed(msg) { 44 + console.error(msg) 45 + process.exitCode = 1 46 + }, 47 + }, 48 + dry, 49 + }) 50 + } finally { 51 + rmSync(tmp, { recursive: true }) 52 + } 53 + } 54 + 55 + program 56 + .command('labels') 57 + .description('Manage labels on pull requests.') 58 + .argument('<owner>', 'Owner of the GitHub repository to label (Example: NixOS)') 59 + .argument('<repo>', 'Name of the GitHub repository to label (Example: nixpkgs)') 60 + .argument('[pr]', 'Number of the Pull Request to label') 61 + .option('--no-dry', 'Make actual modifications') 62 + .action(async (owner, repo, pr, options) => { 63 + const labels = (await import('./labels.js')).default 64 + run(labels, owner, repo, pr, options.dry) 65 + }) 66 + 67 + await program.parse()
+61
ci/github-script/withRateLimit.js
···
··· 1 + module.exports = async function ({ github, core }, callback) { 2 + const Bottleneck = require('bottleneck') 3 + 4 + const stats = { 5 + issues: 0, 6 + prs: 0, 7 + requests: 0, 8 + artifacts: 0, 9 + } 10 + 11 + // Rate-Limiting and Throttling, see for details: 12 + // https://github.com/octokit/octokit.js/issues/1069#throttling 13 + // https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api 14 + const allLimits = new Bottleneck({ 15 + // Avoid concurrent requests 16 + maxConcurrent: 1, 17 + // Will be updated with first `updateReservoir()` call below. 18 + reservoir: 0, 19 + }) 20 + // Pause between mutative requests 21 + const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits) 22 + github.hook.wrap('request', async (request, options) => { 23 + // Requests to the /rate_limit endpoint do not count against the rate limit. 24 + if (options.url == '/rate_limit') return request(options) 25 + // Search requests are in a different resource group, which allows 30 requests / minute. 26 + // We do less than a handful each run, so not implementing throttling for now. 27 + if (options.url.startsWith('/search/')) return request(options) 28 + stats.requests++ 29 + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) 30 + return writeLimits.schedule(request.bind(null, options)) 31 + else return allLimits.schedule(request.bind(null, options)) 32 + }) 33 + 34 + async function updateReservoir() { 35 + let response 36 + try { 37 + response = await github.rest.rateLimit.get() 38 + } catch (err) { 39 + core.error(`Failed updating reservoir:\n${err}`) 40 + // Keep retrying on failed rate limit requests instead of exiting the script early. 41 + return 42 + } 43 + // Always keep 1000 spare requests for other jobs to do their regular duty. 44 + // They normally use below 100, so 1000 is *plenty* of room to work with. 45 + const reservoir = Math.max(0, response.data.resources.core.remaining - 1000) 46 + core.info(`Updating reservoir to: ${reservoir}`) 47 + allLimits.updateSettings({ reservoir }) 48 + } 49 + await updateReservoir() 50 + // Update remaining requests every minute to account for other jobs running in parallel. 51 + const reservoirUpdater = setInterval(updateReservoir, 60 * 1000) 52 + 53 + try { 54 + await callback(stats) 55 + } finally { 56 + clearInterval(reservoirUpdater) 57 + core.notice( 58 + `Processed ${stats.prs} PRs, ${stats.issues} Issues, made ${stats.requests + stats.artifacts} API requests and downloaded ${stats.artifacts} artifacts.`, 59 + ) 60 + } 61 + }
-4
ci/labels/.editorconfig
··· 1 - # TODO: Move to <top-level>/.editorconfig, once ci/.editorconfig has made its way through staging. 2 - [*.cjs] 3 - indent_style = space 4 - indent_size = 2
···
ci/labels/.gitignore ci/github-script/.gitignore
ci/labels/.npmrc ci/github-script/.npmrc
-4
ci/labels/README.md
··· 1 - To test the labeler locally: 2 - - Provide `gh` on `PATH` and make sure it's authenticated. 3 - - Enter `nix-shell` in `./ci/labels`. 4 - - Run `./run.js OWNER REPO`, where OWNER is your username or "NixOS" and REPO the name of your fork or "nixpkgs".
···
+8 -63
ci/labels/labels.cjs ci/github-script/labels.js
··· 1 module.exports = async function ({ github, context, core, dry }) { 2 - const Bottleneck = require('bottleneck') 3 const path = require('node:path') 4 const { DefaultArtifactClient } = require('@actions/artifact') 5 const { readFile, writeFile } = require('node:fs/promises') 6 7 const artifactClient = new DefaultArtifactClient() 8 9 - const stats = { 10 - issues: 0, 11 - prs: 0, 12 - requests: 0, 13 - artifacts: 0, 14 - } 15 - 16 - // Rate-Limiting and Throttling, see for details: 17 - // https://github.com/octokit/octokit.js/issues/1069#throttling 18 - // https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api 19 - const allLimits = new Bottleneck({ 20 - // Avoid concurrent requests 21 - maxConcurrent: 1, 22 - // Will be updated with first `updateReservoir()` call below. 23 - reservoir: 0, 24 - }) 25 - // Pause between mutative requests 26 - const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits) 27 - github.hook.wrap('request', async (request, options) => { 28 - // Requests to the /rate_limit endpoint do not count against the rate limit. 29 - if (options.url == '/rate_limit') return request(options) 30 - // Search requests are in a different resource group, which allows 30 requests / minute. 31 - // We do less than a handful each run, so not implementing throttling for now. 32 - if (options.url.startsWith('/search/')) return request(options) 33 - stats.requests++ 34 - if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) 35 - return writeLimits.schedule(request.bind(null, options)) 36 - else return allLimits.schedule(request.bind(null, options)) 37 - }) 38 - 39 - async function updateReservoir() { 40 - let response 41 - try { 42 - response = await github.rest.rateLimit.get() 43 - } catch (err) { 44 - core.error(`Failed updating reservoir:\n${err}`) 45 - // Keep retrying on failed rate limit requests instead of exiting the script early. 46 - return 47 - } 48 - // Always keep 1000 spare requests for other jobs to do their regular duty. 49 - // They normally use below 100, so 1000 is *plenty* of room to work with. 50 - const reservoir = Math.max(0, response.data.resources.core.remaining - 1000) 51 - core.info(`Updating reservoir to: ${reservoir}`) 52 - allLimits.updateSettings({ reservoir }) 53 - } 54 - await updateReservoir() 55 - // Update remaining requests every minute to account for other jobs running in parallel. 56 - const reservoirUpdater = setInterval(updateReservoir, 60 * 1000) 57 - 58 - async function handlePullRequest(item) { 59 const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`) 60 61 const pull_number = item.number ··· 221 return prLabels 222 } 223 224 - async function handle(item) { 225 try { 226 const log = (k, v, skip) => { 227 core.info(`#${item.number} - ${k}: ${v}` + (skip ? ' (skipped)' : '')) ··· 237 238 if (item.pull_request || context.payload.pull_request) { 239 stats.prs++ 240 - Object.assign(itemLabels, await handlePullRequest(item)) 241 } else { 242 stats.issues++ 243 } ··· 326 } 327 } 328 329 - try { 330 if (context.payload.pull_request) { 331 - await handle(context.payload.pull_request) 332 } else { 333 const lastRun = ( 334 await github.rest.actions.listWorkflowRuns({ ··· 447 arr.findIndex((firstItem) => firstItem.number == thisItem.number), 448 ) 449 450 - ;(await Promise.allSettled(items.map(handle))) 451 .filter(({ status }) => status == 'rejected') 452 .map(({ reason }) => 453 core.setFailed(`${reason.message}\n${reason.cause.stack}`), 454 ) 455 - 456 - core.notice( 457 - `Processed ${stats.prs} PRs, ${stats.issues} Issues, made ${stats.requests + stats.artifacts} API requests and downloaded ${stats.artifacts} artifacts.`, 458 - ) 459 } 460 - } finally { 461 - clearInterval(reservoirUpdater) 462 - } 463 }
··· 1 module.exports = async function ({ github, context, core, dry }) { 2 const path = require('node:path') 3 const { DefaultArtifactClient } = require('@actions/artifact') 4 const { readFile, writeFile } = require('node:fs/promises') 5 + const withRateLimit = require('./withRateLimit.js') 6 7 const artifactClient = new DefaultArtifactClient() 8 9 + async function handlePullRequest({ item, stats }) { 10 const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`) 11 12 const pull_number = item.number ··· 172 return prLabels 173 } 174 175 + async function handle({ item, stats }) { 176 try { 177 const log = (k, v, skip) => { 178 core.info(`#${item.number} - ${k}: ${v}` + (skip ? ' (skipped)' : '')) ··· 188 189 if (item.pull_request || context.payload.pull_request) { 190 stats.prs++ 191 + Object.assign(itemLabels, await handlePullRequest({ item, stats })) 192 } else { 193 stats.issues++ 194 } ··· 277 } 278 } 279 280 + await withRateLimit({ github, core }, async (stats) => { 281 if (context.payload.pull_request) { 282 + await handle({ item: context.payload.pull_request, stats }) 283 } else { 284 const lastRun = ( 285 await github.rest.actions.listWorkflowRuns({ ··· 398 arr.findIndex((firstItem) => firstItem.number == thisItem.number), 399 ) 400 401 + ;(await Promise.allSettled(items.map((item) => handle({ item, stats })))) 402 .filter(({ status }) => status == 'rejected') 403 .map(({ reason }) => 404 core.setFailed(`${reason.message}\n${reason.cause.stack}`), 405 ) 406 } 407 + }) 408 }
+12 -2
ci/labels/package-lock.json ci/github-script/package-lock.json
··· 1 { 2 - "name": "labels", 3 "lockfileVersion": 3, 4 "requires": true, 5 "packages": { ··· 7 "dependencies": { 8 "@actions/artifact": "2.3.2", 9 "@actions/github": "6.0.1", 10 - "bottleneck": "2.19.5" 11 } 12 }, 13 "node_modules/@actions/artifact": { ··· 949 "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 950 "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 951 "license": "MIT" 952 }, 953 "node_modules/compress-commons": { 954 "version": "6.0.2",
··· 1 { 2 + "name": "github-script", 3 "lockfileVersion": 3, 4 "requires": true, 5 "packages": { ··· 7 "dependencies": { 8 "@actions/artifact": "2.3.2", 9 "@actions/github": "6.0.1", 10 + "bottleneck": "2.19.5", 11 + "commander": "14.0.0" 12 } 13 }, 14 "node_modules/@actions/artifact": { ··· 950 "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 951 "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 952 "license": "MIT" 953 + }, 954 + "node_modules/commander": { 955 + "version": "14.0.0", 956 + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", 957 + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", 958 + "license": "MIT", 959 + "engines": { 960 + "node": ">=20" 961 + } 962 }, 963 "node_modules/compress-commons": { 964 "version": "6.0.2",
+2 -2
ci/labels/package.json ci/github-script/package.json
··· 1 { 2 "private": true, 3 - "type": "module", 4 "dependencies": { 5 "@actions/artifact": "2.3.2", 6 "@actions/github": "6.0.1", 7 - "bottleneck": "2.19.5" 8 } 9 }
··· 1 { 2 "private": true, 3 "dependencies": { 4 "@actions/artifact": "2.3.2", 5 "@actions/github": "6.0.1", 6 + "bottleneck": "2.19.5", 7 + "commander": "14.0.0" 8 } 9 }
-45
ci/labels/run.js
··· 1 - #!/usr/bin/env node 2 - import { execSync } from 'node:child_process' 3 - import { mkdtempSync, rmSync } from 'node:fs' 4 - import { tmpdir } from 'node:os' 5 - import { join } from 'node:path' 6 - import { getOctokit } from '@actions/github' 7 - import labels from './labels.cjs' 8 - 9 - if (process.argv.length !== 4) 10 - throw new Error('Call this with exactly two arguments: ./run.js OWNER REPO') 11 - const [, , owner, repo] = process.argv 12 - 13 - const token = execSync('gh auth token', { encoding: 'utf-8' }).trim() 14 - 15 - const tmp = mkdtempSync(join(tmpdir(), 'labels-')) 16 - try { 17 - process.env.GITHUB_WORKSPACE = tmp 18 - process.chdir(tmp) 19 - 20 - await labels({ 21 - github: getOctokit(token), 22 - context: { 23 - payload: {}, 24 - repo: { 25 - owner, 26 - repo, 27 - }, 28 - }, 29 - core: { 30 - getInput() { 31 - return token 32 - }, 33 - error: console.error, 34 - info: console.log, 35 - notice: console.log, 36 - setFailed(msg) { 37 - console.error(msg) 38 - process.exitCode = 1 39 - }, 40 - }, 41 - dry: true, 42 - }) 43 - } finally { 44 - rmSync(tmp, { recursive: true }) 45 - }
···
+3 -1
ci/labels/shell.nix ci/github-script/shell.nix
··· 5 6 pkgs.callPackage ( 7 { 8 mkShell, 9 - importNpmLock, 10 nodejs, 11 }: 12 mkShell { 13 packages = [ 14 importNpmLock.hooks.linkNodeModulesHook 15 nodejs 16 ];
··· 5 6 pkgs.callPackage ( 7 { 8 + gh, 9 + importNpmLock, 10 mkShell, 11 nodejs, 12 }: 13 mkShell { 14 packages = [ 15 + gh 16 importNpmLock.hooks.linkNodeModulesHook 17 nodejs 18 ];