···
# This is used as fallback without app only.
# This happens when testing in forks without setting up that app.
30
-
# Labels will most likely not exist in forks, yet. For this case,
31
-
# we add the issues permission only here.
33
-
issues: write # needed to create *new* labels
···
app-id: ${{ vars.NIXPKGS_CI_APP_ID }}
private-key: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }}
55
-
# No issues: write permission here, because labels in Nixpkgs should
56
-
# be created explicitly via the UI with color and description.
53
+
permission-issues: write
permission-pull-requests: write
- name: Log current API rate limits
···
const artifactClient = new DefaultArtifactClient()
···
// Update remaining requests every minute to account for other jobs running in parallel.
const reservoirUpdater = setInterval(updateReservoir, 60 * 1000)
126
-
async function handle(item) {
128
-
const log = (k,v,skip) => {
129
-
core.info(`#${item.number} - ${k}: ${v}` + (skip ? ' (skipped)' : ''))
124
+
async function handlePullRequest(item) {
125
+
const log = (k,v) => core.info(`PR #${item.number} - ${k}: ${v}`)
133
-
log('Last updated at', item.updated_at)
135
-
log('URL', item.html_url)
127
+
const pull_number = item.number
137
-
const pull_number = item.number
138
-
const issue_number = item.number
129
+
// This API request is important for the merge-conflict label, because it triggers the
130
+
// creation of a new test merge commit. This is needed to actually determine the state of a PR.
131
+
const pull_request = (await github.rest.pulls.get({
140
-
// This API request is important for the merge-conflict label, because it triggers the
141
-
// creation of a new test merge commit. This is needed to actually determine the state of a PR.
142
-
const pull_request = (await github.rest.pulls.get({
136
+
const approvals = new Set(
137
+
(await github.paginate(github.rest.pulls.listReviews, {
141
+
.filter(review => review.state == 'APPROVED')
142
+
.map(review => review.user?.id)
147
-
const run_id = (await github.rest.actions.listWorkflowRuns({
145
+
// After creation of a Pull Request, `merge_commit_sha` will be null initially:
146
+
// The very first merge commit will only be calculated after a little while.
147
+
// To avoid labeling the PR as conflicted before that, we wait a few minutes.
148
+
// This is intentionally less than the time that Eval takes, so that the label job
149
+
// running after Eval can indeed label the PR as conflicted if that is the case.
150
+
const merge_commit_sha_valid = new Date() - new Date(pull_request.created_at) > 3 * 60 * 1000
153
+
// We intentionally don't use the mergeable or mergeable_state attributes.
154
+
// Those have an intermediate state while the test merge commit is created.
155
+
// This doesn't work well for us, because we might have just triggered another
156
+
// test merge commit creation by request the pull request via API at the start
157
+
// of this function.
158
+
// The attribute merge_commit_sha keeps the old value of null or the hash *until*
159
+
// the new test merge commit has either successfully been created or failed so.
160
+
// This essentially means we are updating the merge conflict label in two steps:
161
+
// On the first pass of the day, we just fetch the pull request, which triggers
162
+
// the creation. At this stage, the label is likely not updated, yet.
163
+
// The second pass will then read the result from the first pass and set the label.
164
+
'2.status: merge conflict': merge_commit_sha_valid && !pull_request.merge_commit_sha,
165
+
'12.approvals: 1': approvals.size == 1,
166
+
'12.approvals: 2': approvals.size == 2,
167
+
'12.approvals: 3+': approvals.size >= 3,
168
+
'12.first-time contribution':
169
+
[ 'NONE', 'FIRST_TIMER', 'FIRST_TIME_CONTRIBUTOR' ].includes(pull_request.author_association),
172
+
const run_id = (await github.rest.actions.listWorkflowRuns({
174
+
workflow_id: 'pr.yml',
175
+
event: 'pull_request_target',
176
+
exclude_pull_requests: true,
177
+
head_sha: pull_request.head.sha
178
+
})).data.workflow_runs[0]?.id ??
179
+
// TODO: Remove this after 2025-09-17, at which point all eval.yml artifacts will have expired.
180
+
(await github.rest.actions.listWorkflowRuns({
149
-
workflow_id: 'pr.yml',
182
+
// In older PRs, we need eval.yml instead of pr.yml.
183
+
workflow_id: 'eval.yml',
event: 'pull_request_target',
exclude_pull_requests: true,
head_sha: pull_request.head.sha
153
-
})).data.workflow_runs[0]?.id ??
154
-
// TODO: Remove this after 2025-09-17, at which point all eval.yml artifacts will have expired.
155
-
(await github.rest.actions.listWorkflowRuns({
157
-
// In older PRs, we need eval.yml instead of pr.yml.
158
-
workflow_id: 'eval.yml',
159
-
event: 'pull_request_target',
161
-
exclude_pull_requests: true,
162
-
head_sha: pull_request.head.sha
163
-
})).data.workflow_runs[0]?.id
188
+
})).data.workflow_runs[0]?.id
165
-
// Newer PRs might not have run Eval to completion, yet.
166
-
// Older PRs might not have an eval.yml workflow, yet.
167
-
// In either case we continue without fetching an artifact on a best-effort basis.
168
-
log('Last eval run', run_id ?? '<n/a>')
190
+
// Newer PRs might not have run Eval to completion, yet.
191
+
// Older PRs might not have an eval.yml workflow, yet.
192
+
// In either case we continue without fetching an artifact on a best-effort basis.
193
+
log('Last eval run', run_id ?? '<n/a>')
170
-
const artifact = run_id && (await github.rest.actions.listWorkflowRunArtifacts({
174
-
})).data.artifacts[0]
195
+
const artifact = run_id && (await github.rest.actions.listWorkflowRunArtifacts({
199
+
})).data.artifacts[0]
176
-
// Instead of checking the boolean artifact.expired, we will give us a minute to
177
-
// actually download the artifact in the next step and avoid that race condition.
178
-
// Older PRs, where the workflow run was already eval.yml, but the artifact was not
179
-
// called "comparison", yet, will skip the download.
180
-
const expired = !artifact || new Date(artifact?.expires_at ?? 0) < new Date(new Date().getTime() + 60 * 1000)
181
-
log('Artifact expires at', artifact?.expires_at ?? '<n/a>')
201
+
// Instead of checking the boolean artifact.expired, we will give us a minute to
202
+
// actually download the artifact in the next step and avoid that race condition.
203
+
// Older PRs, where the workflow run was already eval.yml, but the artifact was not
204
+
// called "comparison", yet, will skip the download.
205
+
const expired = !artifact || new Date(artifact?.expires_at ?? 0) < new Date(new Date().getTime() + 60 * 1000)
206
+
log('Artifact expires at', artifact?.expires_at ?? '<n/a>')
185
-
await artifactClient.downloadArtifact(artifact.id, {
187
-
repositoryName: context.repo.repo,
188
-
repositoryOwner: context.repo.owner,
189
-
token: core.getInput('github-token')
191
-
path: path.resolve(pull_number.toString()),
192
-
expectedHash: artifact.digest
210
+
await artifactClient.downloadArtifact(artifact.id, {
212
+
repositoryName: context.repo.repo,
213
+
repositoryOwner: context.repo.owner,
214
+
token: core.getInput('github-token')
216
+
path: path.resolve(pull_number.toString()),
217
+
expectedHash: artifact.digest
196
-
// Create a map (Label -> Boolean) of all currently set labels.
197
-
// Each label is set to True and can be disabled later.
198
-
const before = Object.fromEntries(
199
-
(await github.paginate(github.rest.issues.listLabelsOnIssue, {
203
-
.map(({ name }) => [name, true])
220
+
const maintainers = new Set(Object.keys(
221
+
JSON.parse(await readFile(`${pull_number}/maintainers.json`, 'utf-8'))
222
+
).map(m => Number.parseInt(m, 10)))
224
+
const evalLabels = JSON.parse(await readFile(`${pull_number}/changed-paths.json`, 'utf-8')).labels
228
+
// Ignore `evalLabels` if it's an array.
229
+
// This can happen for older eval runs, before we switched to objects.
230
+
// The old eval labels would have been set by the eval run,
231
+
// so now they'll be present in `before`.
232
+
// TODO: Simplify once old eval results have expired (~2025-10)
233
+
(Array.isArray(evalLabels) ? undefined : evalLabels),
235
+
'12.approved-by: package-maintainer': Array.from(maintainers).some(m => approvals.has(m)),
206
-
const approvals = new Set(
207
-
(await github.paginate(github.rest.pulls.listReviews, {
211
-
.filter(review => review.state == 'APPROVED')
212
-
.map(review => review.user?.id)
243
+
async function handle(item) {
245
+
const log = (k,v,skip) => {
246
+
core.info(`#${item.number} - ${k}: ${v}` + (skip ? ' (skipped)' : ''))
250
+
log('Last updated at', item.updated_at)
251
+
log('URL', item.html_url)
253
+
const issue_number = item.number
255
+
const itemLabels = {}
257
+
if (item.pull_request) {
259
+
Object.assign(itemLabels, await handlePullRequest(item))
const latest_event_at = new Date(
···
const stale_at = new Date(new Date().setDate(new Date().getDate() - 180))
253
-
// After creation of a Pull Request, `merge_commit_sha` will be null initially:
254
-
// The very first merge commit will only be calculated after a little while.
255
-
// To avoid labeling the PR as conflicted before that, we wait a few minutes.
256
-
// This is intentionally less than the time that Eval takes, so that the label job
257
-
// running after Eval can indeed label the PR as conflicted if that is the case.
258
-
const merge_commit_sha_valid = new Date() - new Date(pull_request.created_at) > 3 * 60 * 1000
260
-
// Manage most of the labels, without eval results
261
-
const after = Object.assign(
265
-
// We intentionally don't use the mergeable or mergeable_state attributes.
266
-
// Those have an intermediate state while the test merge commit is created.
267
-
// This doesn't work well for us, because we might have just triggered another
268
-
// test merge commit creation by request the pull request via API at the start
269
-
// of this function.
270
-
// The attribute merge_commit_sha keeps the old value of null or the hash *until*
271
-
// the new test merge commit has either successfully been created or failed so.
272
-
// This essentially means we are updating the merge conflict label in two steps:
273
-
// On the first pass of the day, we just fetch the pull request, which triggers
274
-
// the creation. At this stage, the label is likely not updated, yet.
275
-
// The second pass will then read the result from the first pass and set the label.
276
-
'2.status: merge conflict': merge_commit_sha_valid && !pull_request.merge_commit_sha,
277
-
'2.status: stale': !before['1.severity: security'] && latest_event_at < stale_at,
278
-
'12.approvals: 1': approvals.size == 1,
279
-
'12.approvals: 2': approvals.size == 2,
280
-
'12.approvals: 3+': approvals.size >= 3,
281
-
'12.first-time contribution':
282
-
[ 'NONE', 'FIRST_TIMER', 'FIRST_TIME_CONTRIBUTOR' ].includes(pull_request.author_association),
302
+
// Create a map (Label -> Boolean) of all currently set labels.
303
+
// Each label is set to True and can be disabled later.
304
+
const before = Object.fromEntries(
305
+
(await github.paginate(github.rest.issues.listLabelsOnIssue, {
309
+
.map(({ name }) => [name, true])
286
-
// Manage labels based on eval results
288
-
const maintainers = new Set(Object.keys(
289
-
JSON.parse(await readFile(`${pull_number}/maintainers.json`, 'utf-8'))
290
-
).map(m => Number.parseInt(m, 10)))
312
+
Object.assign(itemLabels, {
313
+
'2.status: stale': !before['1.severity: security'] && latest_event_at < stale_at,
292
-
const evalLabels = JSON.parse(await readFile(`${pull_number}/changed-paths.json`, 'utf-8')).labels
296
-
// Ignore `evalLabels` if it's an array.
297
-
// This can happen for older eval runs, before we switched to objects.
298
-
// The old eval labels would have been set by the eval run,
299
-
// so now they'll be present in `before`.
300
-
// TODO: Simplify once old eval results have expired (~2025-10)
301
-
(Array.isArray(evalLabels) ? undefined : evalLabels),
303
-
'12.approved-by: package-maintainer': Array.from(maintainers).some(m => approvals.has(m)),
316
+
const after = Object.assign({}, before, itemLabels)
// No need for an API request, if all labels are the same.
const hasChanges = Object.keys(after).some(name => (before[name] ?? false) != after[name])
···
`repo:"${process.env.GITHUB_REPOSITORY}"`,
`updated:>=${cutoff.toISOString()}`
···
// The search endpoint only allows fetching the first 1000 records, but the
362
-
// pull request list endpoint does not support counting the total number
364
-
// Thus, we use /search for counting and /pulls for reading the response.
365
-
const { total_count: total_pulls } = (await github.rest.search.issuesAndPullRequests({
371
+
// list endpoints do not support counting the total number of results.
372
+
// Thus, we use /search for counting and /issues for reading the response.
373
+
const { total_count: total_items } = (await github.rest.search.issuesAndPullRequests({
`repo:"${process.env.GITHUB_REPOSITORY}"`,
···
const { total_count: total_runs } = workflowData
379
-
const allPulls = (await github.rest.pulls.list({
386
+
// From GitHub's API docs:
387
+
// GitHub's REST API considers every pull request an issue, but not every issue is a pull request.
388
+
// For this reason, "Issues" endpoints may return both issues and pull requests in the response.
389
+
// You can identify pull requests by the pull_request key.
390
+
const allItems = (await github.rest.issues.listForRepo({
385
-
// We iterate through pages of 100 items across scheduled runs. With currently ~7000 open PRs and
386
-
// up to 6*24=144 scheduled runs per day, we hit every PR twice each day.
387
-
// We might not hit every PR on one iteration, because the pages will shift slightly when
388
-
// PRs are closed or merged. We assume this to be OK on the bigger scale, because a PR which was
396
+
// We iterate through pages of 100 items across scheduled runs. With currently ~7000 open PRs,
397
+
// 10000 open Issues and up to 6*24=144 scheduled runs per day, we hit every items a little less
398
+
// than once a day.
399
+
// We might not hit every item on one iteration, because the pages will shift slightly when
400
+
// items are closed or merged. We assume this to be OK on the bigger scale, because an item which was
// missed once, would have to move through the whole page to be missed again. This is very unlikely,
// so it should certainly be hit on the next iteration.
// TODO: Evaluate after a while, whether the above holds still true and potentially implement
// an overlap between runs.
393
-
page: (total_runs % Math.ceil(total_pulls / 100)) + 1
405
+
page: (total_runs % Math.ceil(total_items / 100)) + 1
// Some items might be in both search results, so filtering out duplicates as well.
397
-
const items = [].concat(updatedItems, allPulls)
409
+
const items = [].concat(updatedItems, allItems)
.filter((thisItem, idx, arr) => idx == arr.findIndex(firstItem => firstItem.number == thisItem.number))
;(await Promise.allSettled(items.map(handle)))
.filter(({ status }) => status == 'rejected')
.map(({ reason }) => core.setFailed(`${reason.message}\n${reason.cause.stack}`))
404
-
core.notice(`Processed ${stats.prs} PRs, made ${stats.requests + stats.artifacts} API requests and downloaded ${stats.artifacts} artifacts.`)
416
+
core.notice(`Processed ${stats.prs} PRs, ${stats.issues} Issues, made ${stats.requests + stats.artifacts} API requests and downloaded ${stats.artifacts} artifacts.`)
clearInterval(reservoirUpdater)