ci/labels: run prettier

This is the result of:

  prettier --no-semi --single-quote
This commit is contained in:
Wolfgang Walther 2025-07-07 19:55:53 +02:00
parent 89ee8975ab
commit 9936e7d751
No known key found for this signature in database
GPG Key ID: B39893FA5F65CAE1

View File

@ -1,4 +1,4 @@
module.exports = async function({ github, context, core }) { module.exports = async function ({ github, context, core }) {
const Bottleneck = require('bottleneck') const Bottleneck = require('bottleneck')
const path = require('node:path') const path = require('node:path')
const { DefaultArtifactClient } = require('@actions/artifact') const { DefaultArtifactClient } = require('@actions/artifact')
@ -10,7 +10,7 @@ module.exports = async function({ github, context, core }) {
issues: 0, issues: 0,
prs: 0, prs: 0,
requests: 0, requests: 0,
artifacts: 0 artifacts: 0,
} }
// Rate-Limiting and Throttling, see for details: // Rate-Limiting and Throttling, see for details:
@ -20,7 +20,7 @@ module.exports = async function({ github, context, core }) {
// Avoid concurrent requests // Avoid concurrent requests
maxConcurrent: 1, maxConcurrent: 1,
// Will be updated with first `updateReservoir()` call below. // Will be updated with first `updateReservoir()` call below.
reservoir: 0 reservoir: 0,
}) })
// Pause between mutative requests // Pause between mutative requests
const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits) const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits)
@ -33,8 +33,7 @@ module.exports = async function({ github, context, core }) {
stats.requests++ stats.requests++
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method))
return writeLimits.schedule(request.bind(null, options)) return writeLimits.schedule(request.bind(null, options))
else else return allLimits.schedule(request.bind(null, options))
return allLimits.schedule(request.bind(null, options))
}) })
async function updateReservoir() { async function updateReservoir() {
@ -57,26 +56,28 @@ module.exports = async function({ github, context, core }) {
const reservoirUpdater = setInterval(updateReservoir, 60 * 1000) const reservoirUpdater = setInterval(updateReservoir, 60 * 1000)
async function handlePullRequest(item) { async function handlePullRequest(item) {
const log = (k,v) => core.info(`PR #${item.number} - ${k}: ${v}`) const log = (k, v) => core.info(`PR #${item.number} - ${k}: ${v}`)
const pull_number = item.number const pull_number = item.number
// This API request is important for the merge-conflict label, because it triggers the // 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. // 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({ const pull_request = (
...context.repo, await github.rest.pulls.get({
pull_number ...context.repo,
})).data pull_number,
})
).data
const reviews = await github.paginate(github.rest.pulls.listReviews, { const reviews = await github.paginate(github.rest.pulls.listReviews, {
...context.repo, ...context.repo,
pull_number pull_number,
}) })
const approvals = new Set( const approvals = new Set(
reviews reviews
.filter(review => review.state == 'APPROVED') .filter((review) => review.state == 'APPROVED')
.map(review => review.user?.id) .map((review) => review.user?.id),
) )
// After creation of a Pull Request, `merge_commit_sha` will be null initially: // After creation of a Pull Request, `merge_commit_sha` will be null initially:
@ -84,7 +85,8 @@ module.exports = async function({ github, context, core }) {
// To avoid labeling the PR as conflicted before that, we wait a few minutes. // 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 // 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. // running after Eval can indeed label the PR as conflicted if that is the case.
const merge_commit_sha_valid = new Date() - new Date(pull_request.created_at) > 3 * 60 * 1000 const merge_commit_sha_valid =
new Date() - new Date(pull_request.created_at) > 3 * 60 * 1000
const prLabels = { const prLabels = {
// We intentionally don't use the mergeable or mergeable_state attributes. // We intentionally don't use the mergeable or mergeable_state attributes.
@ -98,31 +100,41 @@ module.exports = async function({ github, context, core }) {
// On the first pass of the day, we just fetch the pull request, which triggers // 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 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. // 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 conflict':
merge_commit_sha_valid && !pull_request.merge_commit_sha,
'12.approvals: 1': approvals.size == 1, '12.approvals: 1': approvals.size == 1,
'12.approvals: 2': approvals.size == 2, '12.approvals: 2': approvals.size == 2,
'12.approvals: 3+': approvals.size >= 3, '12.approvals: 3+': approvals.size >= 3,
'12.first-time contribution': '12.first-time contribution': [
[ 'NONE', 'FIRST_TIMER', 'FIRST_TIME_CONTRIBUTOR' ].includes(pull_request.author_association), 'NONE',
'FIRST_TIMER',
'FIRST_TIME_CONTRIBUTOR',
].includes(pull_request.author_association),
} }
const { id: run_id, conclusion } = (await github.rest.actions.listWorkflowRuns({ const { id: run_id, conclusion } =
...context.repo, (
workflow_id: 'pr.yml', await github.rest.actions.listWorkflowRuns({
event: 'pull_request_target', ...context.repo,
exclude_pull_requests: true, workflow_id: 'pr.yml',
head_sha: pull_request.head.sha event: 'pull_request_target',
})).data.workflow_runs[0] ?? exclude_pull_requests: true,
head_sha: pull_request.head.sha,
})
).data.workflow_runs[0] ??
// TODO: Remove this after 2025-09-17, at which point all eval.yml artifacts will have expired. // TODO: Remove this after 2025-09-17, at which point all eval.yml artifacts will have expired.
(await github.rest.actions.listWorkflowRuns({ (
...context.repo, await github.rest.actions.listWorkflowRuns({
// In older PRs, we need eval.yml instead of pr.yml. ...context.repo,
workflow_id: 'eval.yml', // In older PRs, we need eval.yml instead of pr.yml.
event: 'pull_request_target', workflow_id: 'eval.yml',
status: 'success', event: 'pull_request_target',
exclude_pull_requests: true, status: 'success',
head_sha: pull_request.head.sha exclude_pull_requests: true,
})).data.workflow_runs[0] ?? {} head_sha: pull_request.head.sha,
})
).data.workflow_runs[0] ??
{}
// Newer PRs might not have run Eval to completion, yet. // Newer PRs might not have run Eval to completion, yet.
// Older PRs might not have an eval.yml workflow, yet. // Older PRs might not have an eval.yml workflow, yet.
@ -146,17 +158,24 @@ module.exports = async function({ github, context, core }) {
}) })
} }
const artifact = run_id && (await github.rest.actions.listWorkflowRunArtifacts({ const artifact =
...context.repo, run_id &&
run_id, (
name: 'comparison' await github.rest.actions.listWorkflowRunArtifacts({
})).data.artifacts[0] ...context.repo,
run_id,
name: 'comparison',
})
).data.artifacts[0]
// Instead of checking the boolean artifact.expired, we will give us a minute to // 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. // 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 // Older PRs, where the workflow run was already eval.yml, but the artifact was not
// called "comparison", yet, will skip the download. // called "comparison", yet, will skip the download.
const expired = !artifact || new Date(artifact?.expires_at ?? 0) < new Date(new Date().getTime() + 60 * 1000) const expired =
!artifact ||
new Date(artifact?.expires_at ?? 0) <
new Date(new Date().getTime() + 60 * 1000)
log('Artifact expires at', artifact?.expires_at ?? '<n/a>') log('Artifact expires at', artifact?.expires_at ?? '<n/a>')
if (!expired) { if (!expired) {
stats.artifacts++ stats.artifacts++
@ -165,17 +184,23 @@ module.exports = async function({ github, context, core }) {
findBy: { findBy: {
repositoryName: context.repo.repo, repositoryName: context.repo.repo,
repositoryOwner: context.repo.owner, repositoryOwner: context.repo.owner,
token: core.getInput('github-token') token: core.getInput('github-token'),
}, },
path: path.resolve(pull_number.toString()), path: path.resolve(pull_number.toString()),
expectedHash: artifact.digest expectedHash: artifact.digest,
}) })
const maintainers = new Set(Object.keys( const maintainers = new Set(
JSON.parse(await readFile(`${pull_number}/maintainers.json`, 'utf-8')) Object.keys(
).map(m => Number.parseInt(m, 10))) JSON.parse(
await readFile(`${pull_number}/maintainers.json`, 'utf-8'),
),
).map((m) => Number.parseInt(m, 10)),
)
const evalLabels = JSON.parse(await readFile(`${pull_number}/changed-paths.json`, 'utf-8')).labels const evalLabels = JSON.parse(
await readFile(`${pull_number}/changed-paths.json`, 'utf-8'),
).labels
Object.assign( Object.assign(
prLabels, prLabels,
@ -184,10 +209,12 @@ module.exports = async function({ github, context, core }) {
// The old eval labels would have been set by the eval run, // The old eval labels would have been set by the eval run,
// so now they'll be present in `before`. // so now they'll be present in `before`.
// TODO: Simplify once old eval results have expired (~2025-10) // TODO: Simplify once old eval results have expired (~2025-10)
(Array.isArray(evalLabels) ? undefined : evalLabels), Array.isArray(evalLabels) ? undefined : evalLabels,
{ {
'12.approved-by: package-maintainer': Array.from(maintainers).some(m => approvals.has(m)), '12.approved-by: package-maintainer': Array.from(maintainers).some(
} (m) => approvals.has(m),
),
},
) )
} }
@ -196,7 +223,7 @@ module.exports = async function({ github, context, core }) {
async function handle(item) { async function handle(item) {
try { try {
const log = (k,v,skip) => { const log = (k, v, skip) => {
core.info(`#${item.number} - ${k}: ${v}` + (skip ? ' (skipped)' : '')) core.info(`#${item.number} - ${k}: ${v}` + (skip ? ' (skipped)' : ''))
return skip return skip
} }
@ -216,38 +243,44 @@ module.exports = async function({ github, context, core }) {
} }
const latest_event_at = new Date( const latest_event_at = new Date(
(await github.paginate( (
github.rest.issues.listEventsForTimeline, await github.paginate(github.rest.issues.listEventsForTimeline, {
{
...context.repo, ...context.repo,
issue_number, issue_number,
per_page: 100 per_page: 100,
} })
)) )
.filter(({ event }) => [ .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 // These events are hand-picked from:
// Each of those causes a PR/issue to *not* be considered as stale anymore. // https://docs.github.com/en/rest/using-the-rest-api/issue-event-types?apiVersion=2022-11-28
// Most of these use created_at. // Each of those causes a PR/issue to *not* be considered as stale anymore.
'assigned', // Most of these use created_at.
'commented', // uses updated_at, because that could be > created_at 'assigned',
'committed', // uses committer.date 'commented', // uses updated_at, because that could be > created_at
'head_ref_force_pushed', 'committed', // uses committer.date
'milestoned', 'head_ref_force_pushed',
'pinned', 'milestoned',
'ready_for_review', 'pinned',
'renamed', 'ready_for_review',
'reopened', 'renamed',
'review_dismissed', 'reopened',
'review_requested', 'review_dismissed',
'reviewed', // uses submitted_at 'review_requested',
'unlocked', 'reviewed', // uses submitted_at
'unmarked_as_duplicate', 'unlocked',
].includes(event)) 'unmarked_as_duplicate',
.map(({ created_at, updated_at, committer, submitted_at }) => new Date(updated_at ?? created_at ?? submitted_at ?? committer.date)) ].includes(event),
// Reverse sort by date value. The default sort() sorts by string representation, which is bad for dates. )
.sort((a,b) => b-a) .map(
.at(0) ?? item.created_at ({ 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()) log('latest_event_at', latest_event_at.toISOString())
@ -256,33 +289,37 @@ module.exports = async function({ github, context, core }) {
// Create a map (Label -> Boolean) of all currently set labels. // Create a map (Label -> Boolean) of all currently set labels.
// Each label is set to True and can be disabled later. // Each label is set to True and can be disabled later.
const before = Object.fromEntries( const before = Object.fromEntries(
(await github.paginate(github.rest.issues.listLabelsOnIssue, { (
...context.repo, await github.paginate(github.rest.issues.listLabelsOnIssue, {
issue_number ...context.repo,
})) issue_number,
.map(({ name }) => [name, true]) })
).map(({ name }) => [name, true]),
) )
Object.assign(itemLabels, { Object.assign(itemLabels, {
'2.status: stale': !before['1.severity: security'] && latest_event_at < stale_at, '2.status: stale':
!before['1.severity: security'] && latest_event_at < stale_at,
}) })
const after = Object.assign({}, before, itemLabels) const after = Object.assign({}, before, itemLabels)
// No need for an API request, if all labels are the same. // No need for an API request, if all labels are the same.
const hasChanges = Object.keys(after).some(name => (before[name] ?? false) != after[name]) const hasChanges = Object.keys(after).some(
if (log('Has changes', hasChanges, !hasChanges)) (name) => (before[name] ?? false) != after[name],
return; )
if (log('Has changes', hasChanges, !hasChanges)) return
// Skipping labeling on a pull_request event, because we have no privileges. // Skipping labeling on a pull_request event, because we have no privileges.
const labels = Object.entries(after).filter(([,value]) => value).map(([name]) => name) const labels = Object.entries(after)
if (log('Set labels', labels, context.eventName == 'pull_request')) .filter(([, value]) => value)
return; .map(([name]) => name)
if (log('Set labels', labels, context.eventName == 'pull_request')) return
await github.rest.issues.setLabels({ await github.rest.issues.setLabels({
...context.repo, ...context.repo,
issue_number, issue_number,
labels labels,
}) })
} catch (cause) { } catch (cause) {
throw new Error(`Labeling #${item.number} failed.`, { cause }) throw new Error(`Labeling #${item.number} failed.`, { cause })
@ -293,19 +330,23 @@ module.exports = async function({ github, context, core }) {
if (context.payload.pull_request) { if (context.payload.pull_request) {
await handle(context.payload.pull_request) await handle(context.payload.pull_request)
} else { } else {
const lastRun = (await github.rest.actions.listWorkflowRuns({ const lastRun = (
...context.repo, await github.rest.actions.listWorkflowRuns({
workflow_id: 'labels.yml', ...context.repo,
event: 'schedule', workflow_id: 'labels.yml',
status: 'success', event: 'schedule',
exclude_pull_requests: true, status: 'success',
per_page: 1 exclude_pull_requests: true,
})).data.workflow_runs[0] per_page: 1,
})
).data.workflow_runs[0]
// Go back as far as the last successful run of this workflow to make sure // Go back as far as the last successful run of this workflow to make sure
// we are not leaving anyone behind on GHA failures. // we are not leaving anyone behind on GHA failures.
// Defaults to go back 1 hour on the first run. // Defaults to go back 1 hour on the first run.
const cutoff = new Date(lastRun?.created_at ?? new Date().getTime() - 1 * 60 * 60 * 1000) const cutoff = new Date(
lastRun?.created_at ?? new Date().getTime() - 1 * 60 * 60 * 1000,
)
core.info('cutoff timestamp: ' + cutoff.toISOString()) core.info('cutoff timestamp: ' + cutoff.toISOString())
const updatedItems = await github.paginate( const updatedItems = await github.paginate(
@ -314,11 +355,11 @@ module.exports = async function({ github, context, core }) {
q: [ q: [
`repo:"${context.repo.owner}/${context.repo.repo}"`, `repo:"${context.repo.owner}/${context.repo.repo}"`,
'is:open', 'is:open',
`updated:>=${cutoff.toISOString()}` `updated:>=${cutoff.toISOString()}`,
].join(' AND '), ].join(' AND '),
// TODO: Remove in 2025-10, when it becomes the default. // TODO: Remove in 2025-10, when it becomes the default.
advanced_search: true advanced_search: true,
} },
) )
let cursor let cursor
@ -327,24 +368,29 @@ module.exports = async function({ github, context, core }) {
if (lastRun) { if (lastRun) {
// The cursor to iterate through the full list of issues and pull requests // The cursor to iterate through the full list of issues and pull requests
// is passed between jobs as an artifact. // is passed between jobs as an artifact.
const artifact = (await github.rest.actions.listWorkflowRunArtifacts({ const artifact = (
...context.repo, await github.rest.actions.listWorkflowRunArtifacts({
run_id: lastRun.id, ...context.repo,
name: 'pagination-cursor' run_id: lastRun.id,
})).data.artifacts[0] name: 'pagination-cursor',
})
).data.artifacts[0]
// If the artifact is not available, the next iteration starts at the beginning. // If the artifact is not available, the next iteration starts at the beginning.
if (artifact) { if (artifact) {
stats.artifacts++ stats.artifacts++
const { downloadPath } = await artifactClient.downloadArtifact(artifact.id, { const { downloadPath } = await artifactClient.downloadArtifact(
findBy: { artifact.id,
repositoryName: context.repo.repo, {
repositoryOwner: context.repo.owner, findBy: {
token: core.getInput('github-token') repositoryName: context.repo.repo,
repositoryOwner: context.repo.owner,
token: core.getInput('github-token'),
},
expectedHash: artifact.digest,
}, },
expectedHash: artifact.digest )
})
cursor = await readFile(path.resolve(downloadPath, 'cursor'), 'utf-8') cursor = await readFile(path.resolve(downloadPath, 'cursor'), 'utf-8')
} }
@ -360,7 +406,7 @@ module.exports = async function({ github, context, core }) {
sort: 'created', sort: 'created',
direction: 'asc', direction: 'asc',
per_page: 100, per_page: 100,
after: cursor after: cursor,
}) })
// Regex taken and comment adjusted from: // Regex taken and comment adjusted from:
@ -369,7 +415,9 @@ module.exports = async function({ github, context, core }) {
// <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=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" // <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. // 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] const next = ((allItems.headers.link ?? '').match(
/<([^<>]+)>;\s*rel="next"/,
) ?? [])[1]
if (next) { if (next) {
cursor = new URL(next).searchParams.get('after') cursor = new URL(next).searchParams.get('after')
const uploadPath = path.resolve('cursor') const uploadPath = path.resolve('cursor')
@ -381,20 +429,29 @@ module.exports = async function({ github, context, core }) {
[uploadPath], [uploadPath],
path.resolve('.'), path.resolve('.'),
{ {
retentionDays: 1 retentionDays: 1,
} },
) )
} }
// Some items might be in both search results, so filtering out duplicates as well. // Some items might be in both search results, so filtering out duplicates as well.
const items = [].concat(updatedItems, allItems.data) const items = []
.filter((thisItem, idx, arr) => idx == arr.findIndex(firstItem => firstItem.number == thisItem.number)) .concat(updatedItems, allItems.data)
.filter(
(thisItem, idx, arr) =>
idx ==
arr.findIndex((firstItem) => firstItem.number == thisItem.number),
)
;(await Promise.allSettled(items.map(handle))) ;(await Promise.allSettled(items.map(handle)))
.filter(({ status }) => status == 'rejected') .filter(({ status }) => status == 'rejected')
.map(({ reason }) => core.setFailed(`${reason.message}\n${reason.cause.stack}`)) .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.`) core.notice(
`Processed ${stats.prs} PRs, ${stats.issues} Issues, made ${stats.requests + stats.artifacts} API requests and downloaded ${stats.artifacts} artifacts.`,
)
} }
} finally { } finally {
clearInterval(reservoirUpdater) clearInterval(reservoirUpdater)