Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 163 additions & 101 deletions .github/workflows/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,23 @@
name: "Label PR"

on:
schedule:
- cron: '37 * * * *'
workflow_call:
workflow_run:
workflows:
- Review dismissed
- Review submitted
types: [completed]
workflow_dispatch:
inputs:
updatedWithin:
description: 'Updated within [hours]'
type: number
required: false
default: 0 # everything since last run

concurrency:
group: labels-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
# This explicitly avoids using `run_id` for the concurrency key to make sure that only
# *one* non-PR run can run at a time.
group: labels-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number }}
# PR- and manually-triggered runs will be cancelled, but scheduled runs will be queued.
cancel-in-progress: ${{ github.event_name != 'schedule' }}

permissions:
issues: write # needed to create *new* labels
Expand All @@ -31,114 +38,169 @@ jobs:
runs-on: ubuntu-24.04-arm
if: "!contains(github.event.pull_request.title, '[skip treewide]')"
steps:
- uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
id: eval
with:
script: |
const run_id = (await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'eval.yml',
event: 'pull_request_target',
head_sha: context.payload.pull_request?.head.sha ?? context.payload.workflow_run.head_sha
})).data.workflow_runs[0]?.id
core.setOutput('run-id', run_id)

- name: Download the comparison results
if: steps.eval.outputs.run-id
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
run-id: ${{ steps.eval.outputs.run-id }}
github-token: ${{ github.token }}
pattern: comparison
path: comparison
merge-multiple: true

- name: Labels from eval
if: steps.eval.outputs.run-id && github.event_name != 'pull_request'
- name: Install dependencies
run: npm install @actions/artifact

- name: Labels from API data and Eval results
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
UPDATED_WITHIN: ${{ inputs.updatedWithin }}
with:
script: |
const path = require('node:path')
const { DefaultArtifactClient } = require('@actions/artifact')
const { readFile } = require('node:fs/promises')

let pull_requests
if (context.payload.workflow_run) {
// PRs from forks don't have any PRs associated by default.
// Thus, we request the PR number with an API call *to* the fork's repo.
// Multiple pull requests can be open from the same head commit, either via
// different base branches or head branches.
const { head_repository, head_sha, repository } = context.payload.workflow_run
pull_requests = (await github.paginate(github.rest.repos.listPullRequestsAssociatedWithCommit, {
owner: head_repository.owner.login,
repo: head_repository.name,
commit_sha: head_sha
})).filter(pull_request => pull_request.base.repo.id == repository.id)
} else {
pull_requests = [ context.payload.pull_request ]
const artifactClient = new DefaultArtifactClient()

if (process.env.UPDATED_WITHIN && !/^\d+$/.test(process.env.UPDATED_WITHIN))
throw new Error('Please enter "updated within" as integer in hours.')

const cutoff = new Date(await (async () => {
// Always run for Pull Request triggers, no cutoff since there will be a single
// response only anyway. 0 is the Unix epoch, so always smaller.
if (context.payload.pull_request?.number) return 0

// Manually triggered via UI when updatedWithin is set. Will fallthrough to the last
// option if the updatedWithin parameter is set to 0, which is the default.
const updatedWithin = Number.parseInt(process.env.UPDATED_WITHIN, 10)
if (updatedWithin) return new Date().getTime() - updatedWithin * 60 * 60 * 1000

// Normally a scheduled run, but could be workflow_dispatch, see above. 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.
return (await github.rest.actions.listWorkflowRuns({
...context.repo,
workflow_id: 'labels.yml',
event: 'schedule',
status: 'success',
exclude_pull_requests: true
})).data.workflow_runs[0]?.created_at ?? new Date().getTime() - 1 * 60 * 60 * 1000
})())
core.info('cutoff timestamp: ' + cutoff.toISOString())

// To simplify this action's logic we fetch the pull_request data again below, even if
// we are already in a pull_request event's context and would have the data readily
// available. We do this by filtering the list of pull requests with head and base
// branch - there can only be a single open Pull Request for any such combination.
const prEventCondition = !context.payload.pull_request ? undefined : {
// "label" is in the format of `user:branch` or `org:branch`
head: context.payload.pull_request.head.label,
base: context.payload.pull_request.base.ref
}

await Promise.all(
pull_requests.map(async (pull_request) => {
const pr = {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull_request.number
}
await github.paginate(
github.rest.pulls.list,
{
...context.repo,
state: 'open',
sort: 'updated',
direction: 'desc',
...prEventCondition
},
async (response, done) => (await Promise.allSettled(response.data.map(async (pull_request) => {
try {
const log = (k,v) => core.info(`PR #${pull_request.number} - ${k}: ${v}`)

log('Last updated at', pull_request.updated_at)
if (new Date(pull_request.updated_at) < cutoff) return done()

const run_id = (await github.rest.actions.listWorkflowRuns({
...context.repo,
workflow_id: 'eval.yml',
event: 'pull_request_target',
// For PR events, the workflow run is still in progress with this job itself.
status: prEventCondition ? 'in_progress' : 'success',
exclude_pull_requests: true,
head_sha: pull_request.head.sha
})).data.workflow_runs[0]?.id

// Newer PRs might not have run Eval to completion, yet. We can skip them, because this
// job will be run as part of that Eval run anyway.
log('Last eval run', run_id)
if (!run_id) return;

const artifact = (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.
log('Artifact expires at', artifact.expires_at)
if (new Date(artifact.expires_at) < new Date(new Date().getTime() + 60 * 1000)) return;

await artifactClient.downloadArtifact(artifact.id, {
findBy: {
repositoryName: context.repo.repo,
repositoryOwner: context.repo.owner,
token: core.getInput('github-token')
},
path: path.resolve(pull_request.number.toString()),
expectedHash: artifact.digest
})

// Get all currently set labels that we manage
const before =
(await github.paginate(github.rest.issues.listLabelsOnIssue, pr))
.map(({ name }) => name)
.filter(name =>
name.startsWith('10.rebuild') ||
name == '11.by: package-maintainer' ||
name.startsWith('12.approvals:') ||
name == '12.approved-by: package-maintainer'
// Get all currently set labels that we manage
const before =
pull_request.labels.map(({ name }) => name)
.filter(name =>
name.startsWith('10.rebuild') ||
name == '11.by: package-maintainer' ||
name.startsWith('12.approvals:') ||
name == '12.approved-by: package-maintainer'
)

const approvals = new Set(
(await github.paginate(github.rest.pulls.listReviews, {
...context.repo,
pull_number: pull_request.number
}))
.filter(review => review.state == 'APPROVED')
.map(review => review.user.id)
)

const approvals = new Set(
(await github.paginate(github.rest.pulls.listReviews, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull_request.number
}))
.filter(review => review.state == 'APPROVED')
.map(review => review.user.id)
)

const maintainers = new Set(Object.keys(
JSON.parse(await readFile('comparison/maintainers.json', 'utf-8'))
))

// And the labels that should be there
const after = JSON.parse(await readFile('comparison/changed-paths.json', 'utf-8')).labels
if (approvals.size > 0) after.push(`12.approvals: ${approvals.size > 2 ? '3+' : approvals.size}`)
if (Array.from(maintainers).some(m => approvals.has(m))) after.push('12.approved-by: package-maintainer')

// Remove the ones not needed anymore
await Promise.all(
before.filter(name => !after.includes(name))
.map(name => github.rest.issues.removeLabel({
...pr,
name
}))
)

// And add the ones that aren't set already
const added = after.filter(name => !before.includes(name))
if (added.length > 0) {
await github.rest.issues.addLabels({
...pr,
labels: added
})
const maintainers = new Set(Object.keys(
JSON.parse(await readFile(`${pull_request.number}/maintainers.json`, 'utf-8'))
))

// And the labels that should be there
const after = JSON.parse(await readFile(`${pull_request.number}/changed-paths.json`, 'utf-8')).labels
if (approvals.size > 0) after.push(`12.approvals: ${approvals.size > 2 ? '3+' : approvals.size}`)
if (Array.from(maintainers).some(m => approvals.has(m))) after.push('12.approved-by: package-maintainer')

// Remove the ones not needed anymore
await Promise.all(
before.filter(name => !after.includes(name))
.map(name => github.rest.issues.removeLabel({
...context.repo,
issue_number: pull_request.number,
name
}))
)

// And add the ones that aren't set already
const added = after.filter(name => !before.includes(name))
if (added.length > 0) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: pull_request.number,
labels: added
})
}
} catch (cause) {
throw new Error(`Labeling PR #${pull_request.number} failed.`, { cause })
}
})
})))
.filter(({ status }) => status == 'rejected')
.map(({ reason }) => core.setFailed(`${reason.message}\n${reason.cause.stack}`))
)

- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
name: Labels from touched files
if: |
github.event_name != 'workflow_run' &&
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.owner.login != 'NixOS' || !(
github.head_ref == 'haskell-updates' ||
github.head_ref == 'python-updates' ||
Expand All @@ -153,7 +215,7 @@ jobs:
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
name: Labels from touched files (no sync)
if: |
github.event_name != 'workflow_run' &&
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.owner.login != 'NixOS' || !(
github.head_ref == 'haskell-updates' ||
github.head_ref == 'python-updates' ||
Expand All @@ -171,7 +233,7 @@ jobs:
# This is to avoid the mass of labels there, which is mostly useless - and really annoying for
# the backport labels.
if: |
github.event_name != 'workflow_run' &&
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.owner.login == 'NixOS' && (
github.head_ref == 'haskell-updates' ||
github.head_ref == 'python-updates' ||
Expand Down
17 changes: 0 additions & 17 deletions .github/workflows/review-submitted.yml

This file was deleted.