ci/github-script: move from ci/labels; allow single PR testing and non-dry mode (#424872)
This commit is contained in:
commit
13855a517b
4
.github/workflows/labels.yml
vendored
4
.github/workflows/labels.yml
vendored
@ -43,7 +43,7 @@ jobs:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
sparse-checkout: |
|
||||
ci/labels
|
||||
ci/github-script
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install @actions/artifact bottleneck
|
||||
@ -69,7 +69,7 @@ jobs:
|
||||
github-token: ${{ steps.app-token.outputs.token || github.token }}
|
||||
retries: 3
|
||||
script: |
|
||||
require('./ci/labels/labels.cjs')({
|
||||
require('./ci/github-script/labels.js')({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
|
||||
3
ci/github-script/.editorconfig
Normal file
3
ci/github-script/.editorconfig
Normal file
@ -0,0 +1,3 @@
|
||||
[run]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
13
ci/github-script/README.md
Normal file
13
ci/github-script/README.md
Normal file
@ -0,0 +1,13 @@
|
||||
# GitHub specific CI scripts
|
||||
|
||||
This folder contains [`actions/github-script`](https://github.com/actions/github-script)-based JavaScript code.
|
||||
It provides a `nix-shell` environment to run and test these actions locally.
|
||||
|
||||
To run any of the scripts locally:
|
||||
|
||||
- Enter `nix-shell` in `./ci/github-script`.
|
||||
- Ensure `gh` is authenticated.
|
||||
|
||||
## Labeler
|
||||
|
||||
Run `./run labels OWNER REPO`, where OWNER is your username or "NixOS" and REPO the name of your fork or "nixpkgs".
|
||||
@ -1,61 +1,12 @@
|
||||
module.exports = async function ({ github, context, core, dry }) {
|
||||
const Bottleneck = require('bottleneck')
|
||||
const path = require('node:path')
|
||||
const { DefaultArtifactClient } = require('@actions/artifact')
|
||||
const { readFile, writeFile } = require('node:fs/promises')
|
||||
const withRateLimit = require('./withRateLimit.js')
|
||||
|
||||
const artifactClient = new DefaultArtifactClient()
|
||||
|
||||
const stats = {
|
||||
issues: 0,
|
||||
prs: 0,
|
||||
requests: 0,
|
||||
artifacts: 0,
|
||||
}
|
||||
|
||||
// Rate-Limiting and Throttling, see for details:
|
||||
// https://github.com/octokit/octokit.js/issues/1069#throttling
|
||||
// https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api
|
||||
const allLimits = new Bottleneck({
|
||||
// Avoid concurrent requests
|
||||
maxConcurrent: 1,
|
||||
// Will be updated with first `updateReservoir()` call below.
|
||||
reservoir: 0,
|
||||
})
|
||||
// Pause between mutative requests
|
||||
const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits)
|
||||
github.hook.wrap('request', async (request, options) => {
|
||||
// Requests to the /rate_limit endpoint do not count against the rate limit.
|
||||
if (options.url == '/rate_limit') return request(options)
|
||||
// Search requests are in a different resource group, which allows 30 requests / minute.
|
||||
// We do less than a handful each run, so not implementing throttling for now.
|
||||
if (options.url.startsWith('/search/')) return request(options)
|
||||
stats.requests++
|
||||
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method))
|
||||
return writeLimits.schedule(request.bind(null, options))
|
||||
else return allLimits.schedule(request.bind(null, options))
|
||||
})
|
||||
|
||||
async function updateReservoir() {
|
||||
let response
|
||||
try {
|
||||
response = await github.rest.rateLimit.get()
|
||||
} catch (err) {
|
||||
core.error(`Failed updating reservoir:\n${err}`)
|
||||
// Keep retrying on failed rate limit requests instead of exiting the script early.
|
||||
return
|
||||
}
|
||||
// Always keep 1000 spare requests for other jobs to do their regular duty.
|
||||
// They normally use below 100, so 1000 is *plenty* of room to work with.
|
||||
const reservoir = Math.max(0, response.data.resources.core.remaining - 1000)
|
||||
core.info(`Updating reservoir to: ${reservoir}`)
|
||||
allLimits.updateSettings({ reservoir })
|
||||
}
|
||||
await updateReservoir()
|
||||
// Update remaining requests every minute to account for other jobs running in parallel.
|
||||
const reservoirUpdater = setInterval(updateReservoir, 60 * 1000)
|
||||
|
||||
async function handlePullRequest(item) {
|
||||
async function handlePullRequest({ item, stats }) {
|
||||
const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`)
|
||||
|
||||
const pull_number = item.number
|
||||
@ -221,7 +172,7 @@ module.exports = async function ({ github, context, core, dry }) {
|
||||
return prLabels
|
||||
}
|
||||
|
||||
async function handle(item) {
|
||||
async function handle({ item, stats }) {
|
||||
try {
|
||||
const log = (k, v, skip) => {
|
||||
core.info(`#${item.number} - ${k}: ${v}` + (skip ? ' (skipped)' : ''))
|
||||
@ -237,7 +188,7 @@ module.exports = async function ({ github, context, core, dry }) {
|
||||
|
||||
if (item.pull_request || context.payload.pull_request) {
|
||||
stats.prs++
|
||||
Object.assign(itemLabels, await handlePullRequest(item))
|
||||
Object.assign(itemLabels, await handlePullRequest({ item, stats }))
|
||||
} else {
|
||||
stats.issues++
|
||||
}
|
||||
@ -326,9 +277,9 @@ module.exports = async function ({ github, context, core, dry }) {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await withRateLimit({ github, core }, async (stats) => {
|
||||
if (context.payload.pull_request) {
|
||||
await handle(context.payload.pull_request)
|
||||
await handle({ item: context.payload.pull_request, stats })
|
||||
} else {
|
||||
const lastRun = (
|
||||
await github.rest.actions.listWorkflowRuns({
|
||||
@ -447,17 +398,11 @@ module.exports = async function ({ github, context, core, dry }) {
|
||||
arr.findIndex((firstItem) => firstItem.number == thisItem.number),
|
||||
)
|
||||
|
||||
;(await Promise.allSettled(items.map(handle)))
|
||||
;(await Promise.allSettled(items.map((item) => handle({ item, stats }))))
|
||||
.filter(({ status }) => status == 'rejected')
|
||||
.map(({ reason }) =>
|
||||
core.setFailed(`${reason.message}\n${reason.cause.stack}`),
|
||||
)
|
||||
|
||||
core.notice(
|
||||
`Processed ${stats.prs} PRs, ${stats.issues} Issues, made ${stats.requests + stats.artifacts} API requests and downloaded ${stats.artifacts} artifacts.`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
clearInterval(reservoirUpdater)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "labels",
|
||||
"name": "github-script",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@ -7,7 +7,8 @@
|
||||
"dependencies": {
|
||||
"@actions/artifact": "2.3.2",
|
||||
"@actions/github": "6.0.1",
|
||||
"bottleneck": "2.19.5"
|
||||
"bottleneck": "2.19.5",
|
||||
"commander": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/artifact": {
|
||||
@ -950,6 +951,15 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz",
|
||||
"integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/compress-commons": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
|
||||
@ -1,9 +1,9 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@actions/artifact": "2.3.2",
|
||||
"@actions/github": "6.0.1",
|
||||
"bottleneck": "2.19.5"
|
||||
"bottleneck": "2.19.5",
|
||||
"commander": "14.0.0"
|
||||
}
|
||||
}
|
||||
67
ci/github-script/run
Executable file
67
ci/github-script/run
Executable file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env -S node --import ./run
|
||||
import { execSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { program } from 'commander'
|
||||
import { getOctokit } from '@actions/github'
|
||||
|
||||
async function run(action, owner, repo, pull_number, dry) {
|
||||
const token = execSync('gh auth token', { encoding: 'utf-8' }).trim()
|
||||
|
||||
const github = getOctokit(token)
|
||||
|
||||
const payload = !pull_number ? {} : {
|
||||
pull_request: (await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
})).data
|
||||
}
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'github-script-'))
|
||||
try {
|
||||
process.env.GITHUB_WORKSPACE = tmp
|
||||
process.chdir(tmp)
|
||||
|
||||
await action({
|
||||
github,
|
||||
context: {
|
||||
payload,
|
||||
repo: {
|
||||
owner,
|
||||
repo,
|
||||
},
|
||||
},
|
||||
core: {
|
||||
getInput() {
|
||||
return token
|
||||
},
|
||||
error: console.error,
|
||||
info: console.log,
|
||||
notice: console.log,
|
||||
setFailed(msg) {
|
||||
console.error(msg)
|
||||
process.exitCode = 1
|
||||
},
|
||||
},
|
||||
dry,
|
||||
})
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command('labels')
|
||||
.description('Manage labels on pull requests.')
|
||||
.argument('<owner>', 'Owner of the GitHub repository to label (Example: NixOS)')
|
||||
.argument('<repo>', 'Name of the GitHub repository to label (Example: nixpkgs)')
|
||||
.argument('[pr]', 'Number of the Pull Request to label')
|
||||
.option('--no-dry', 'Make actual modifications')
|
||||
.action(async (owner, repo, pr, options) => {
|
||||
const labels = (await import('./labels.js')).default
|
||||
run(labels, owner, repo, pr, options.dry)
|
||||
})
|
||||
|
||||
await program.parse()
|
||||
@ -5,12 +5,14 @@
|
||||
|
||||
pkgs.callPackage (
|
||||
{
|
||||
mkShell,
|
||||
gh,
|
||||
importNpmLock,
|
||||
mkShell,
|
||||
nodejs,
|
||||
}:
|
||||
mkShell {
|
||||
packages = [
|
||||
gh
|
||||
importNpmLock.hooks.linkNodeModulesHook
|
||||
nodejs
|
||||
];
|
||||
61
ci/github-script/withRateLimit.js
Normal file
61
ci/github-script/withRateLimit.js
Normal file
@ -0,0 +1,61 @@
|
||||
module.exports = async function ({ github, core }, callback) {
|
||||
const Bottleneck = require('bottleneck')
|
||||
|
||||
const stats = {
|
||||
issues: 0,
|
||||
prs: 0,
|
||||
requests: 0,
|
||||
artifacts: 0,
|
||||
}
|
||||
|
||||
// Rate-Limiting and Throttling, see for details:
|
||||
// https://github.com/octokit/octokit.js/issues/1069#throttling
|
||||
// https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api
|
||||
const allLimits = new Bottleneck({
|
||||
// Avoid concurrent requests
|
||||
maxConcurrent: 1,
|
||||
// Will be updated with first `updateReservoir()` call below.
|
||||
reservoir: 0,
|
||||
})
|
||||
// Pause between mutative requests
|
||||
const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits)
|
||||
github.hook.wrap('request', async (request, options) => {
|
||||
// Requests to the /rate_limit endpoint do not count against the rate limit.
|
||||
if (options.url == '/rate_limit') return request(options)
|
||||
// Search requests are in a different resource group, which allows 30 requests / minute.
|
||||
// We do less than a handful each run, so not implementing throttling for now.
|
||||
if (options.url.startsWith('/search/')) return request(options)
|
||||
stats.requests++
|
||||
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method))
|
||||
return writeLimits.schedule(request.bind(null, options))
|
||||
else return allLimits.schedule(request.bind(null, options))
|
||||
})
|
||||
|
||||
async function updateReservoir() {
|
||||
let response
|
||||
try {
|
||||
response = await github.rest.rateLimit.get()
|
||||
} catch (err) {
|
||||
core.error(`Failed updating reservoir:\n${err}`)
|
||||
// Keep retrying on failed rate limit requests instead of exiting the script early.
|
||||
return
|
||||
}
|
||||
// Always keep 1000 spare requests for other jobs to do their regular duty.
|
||||
// They normally use below 100, so 1000 is *plenty* of room to work with.
|
||||
const reservoir = Math.max(0, response.data.resources.core.remaining - 1000)
|
||||
core.info(`Updating reservoir to: ${reservoir}`)
|
||||
allLimits.updateSettings({ reservoir })
|
||||
}
|
||||
await updateReservoir()
|
||||
// Update remaining requests every minute to account for other jobs running in parallel.
|
||||
const reservoirUpdater = setInterval(updateReservoir, 60 * 1000)
|
||||
|
||||
try {
|
||||
await callback(stats)
|
||||
} finally {
|
||||
clearInterval(reservoirUpdater)
|
||||
core.notice(
|
||||
`Processed ${stats.prs} PRs, ${stats.issues} Issues, made ${stats.requests + stats.artifacts} API requests and downloaded ${stats.artifacts} artifacts.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
# TODO: Move to <top-level>/.editorconfig, once ci/.editorconfig has made its way through staging.
|
||||
[*.cjs]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
@ -1,4 +0,0 @@
|
||||
To test the labeler locally:
|
||||
- Provide `gh` on `PATH` and make sure it's authenticated.
|
||||
- Enter `nix-shell` in `./ci/labels`.
|
||||
- Run `./run.js OWNER REPO`, where OWNER is your username or "NixOS" and REPO the name of your fork or "nixpkgs".
|
||||
@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { execSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { getOctokit } from '@actions/github'
|
||||
import labels from './labels.cjs'
|
||||
|
||||
if (process.argv.length !== 4)
|
||||
throw new Error('Call this with exactly two arguments: ./run.js OWNER REPO')
|
||||
const [, , owner, repo] = process.argv
|
||||
|
||||
const token = execSync('gh auth token', { encoding: 'utf-8' }).trim()
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'labels-'))
|
||||
try {
|
||||
process.env.GITHUB_WORKSPACE = tmp
|
||||
process.chdir(tmp)
|
||||
|
||||
await labels({
|
||||
github: getOctokit(token),
|
||||
context: {
|
||||
payload: {},
|
||||
repo: {
|
||||
owner,
|
||||
repo,
|
||||
},
|
||||
},
|
||||
core: {
|
||||
getInput() {
|
||||
return token
|
||||
},
|
||||
error: console.error,
|
||||
info: console.log,
|
||||
notice: console.log,
|
||||
setFailed(msg) {
|
||||
console.error(msg)
|
||||
process.exitCode = 1
|
||||
},
|
||||
},
|
||||
dry: true,
|
||||
})
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true })
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user