1module.exports = async ({ 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
13
14 // This API request is important for the merge-conflict label, because it triggers the
15 // creation of a new test merge commit. This is needed to actually determine the state of a PR.
16 const pull_request = (
17 await github.rest.pulls.get({
18 ...context.repo,
19 pull_number,
20 })
21 ).data
22
23 const reviews = await github.paginate(github.rest.pulls.listReviews, {
24 ...context.repo,
25 pull_number,
26 })
27
28 const approvals = new Set(
29 reviews
30 .filter((review) => review.state === 'APPROVED')
31 .map((review) => review.user?.id),
32 )
33
34 // After creation of a Pull Request, `merge_commit_sha` will be null initially:
35 // The very first merge commit will only be calculated after a little while.
36 // To avoid labeling the PR as conflicted before that, we wait a few minutes.
37 // This is intentionally less than the time that Eval takes, so that the label job
38 // running after Eval can indeed label the PR as conflicted if that is the case.
39 const merge_commit_sha_valid =
40 Date.now() - new Date(pull_request.created_at) > 3 * 60 * 1000
41
42 const prLabels = {
43 // We intentionally don't use the mergeable or mergeable_state attributes.
44 // Those have an intermediate state while the test merge commit is created.
45 // This doesn't work well for us, because we might have just triggered another
46 // test merge commit creation by request the pull request via API at the start
47 // of this function.
48 // The attribute merge_commit_sha keeps the old value of null or the hash *until*
49 // the new test merge commit has either successfully been created or failed so.
50 // This essentially means we are updating the merge conflict label in two steps:
51 // On the first pass of the day, we just fetch the pull request, which triggers
52 // the creation. At this stage, the label is likely not updated, yet.
53 // The second pass will then read the result from the first pass and set the label.
54 '2.status: merge conflict':
55 merge_commit_sha_valid && !pull_request.merge_commit_sha,
56 '12.approvals: 1': approvals.size === 1,
57 '12.approvals: 2': approvals.size === 2,
58 '12.approvals: 3+': approvals.size >= 3,
59 '12.first-time contribution': [
60 'NONE',
61 'FIRST_TIMER',
62 'FIRST_TIME_CONTRIBUTOR',
63 ].includes(pull_request.author_association),
64 }
65
66 const { id: run_id, conclusion } =
67 (
68 await github.rest.actions.listWorkflowRuns({
69 ...context.repo,
70 workflow_id: 'pr.yml',
71 event: 'pull_request_target',
72 exclude_pull_requests: true,
73 head_sha: pull_request.head.sha,
74 })
75 ).data.workflow_runs[0] ??
76 // TODO: Remove this after 2025-09-17, at which point all eval.yml artifacts will have expired.
77 (
78 await github.rest.actions.listWorkflowRuns({
79 ...context.repo,
80 // In older PRs, we need eval.yml instead of pr.yml.
81 workflow_id: 'eval.yml',
82 event: 'pull_request_target',
83 status: 'success',
84 exclude_pull_requests: true,
85 head_sha: pull_request.head.sha,
86 })
87 ).data.workflow_runs[0] ??
88 {}
89
90 // Newer PRs might not have run Eval to completion, yet.
91 // Older PRs might not have an eval.yml workflow, yet.
92 // In either case we continue without fetching an artifact on a best-effort basis.
93 log('Last eval run', run_id ?? '<n/a>')
94
95 if (conclusion === 'success') {
96 // Check for any human reviews other than GitHub actions and other GitHub apps.
97 // Accounts could be deleted as well, so don't count them.
98 const humanReviews = reviews.filter(
99 (r) =>
100 r.user && !r.user.login.endsWith('[bot]') && r.user.type !== 'Bot',
101 )
102
103 Object.assign(prLabels, {
104 // We only set this label if the latest eval run was successful, because if it was not, it
105 // *could* have requested reviewers. We will let the PR author fix CI first, before "escalating"
106 // this PR to "needs: reviewer".
107 // Since the first Eval run on a PR always sets rebuild labels, the same PR will be "recently
108 // updated" for the next scheduled run. Thus, this label will still be set within a few minutes
109 // after a PR is created, if required.
110 // Note that a "requested reviewer" disappears once they have given a review, so we check
111 // existing reviews, too.
112 '9.needs: reviewer':
113 !pull_request.draft &&
114 pull_request.requested_reviewers.length === 0 &&
115 humanReviews.length === 0,
116 })
117 }
118
119 const artifact =
120 run_id &&
121 (
122 await github.rest.actions.listWorkflowRunArtifacts({
123 ...context.repo,
124 run_id,
125 name: 'comparison',
126 })
127 ).data.artifacts[0]
128
129 // Instead of checking the boolean artifact.expired, we will give us a minute to
130 // actually download the artifact in the next step and avoid that race condition.
131 // Older PRs, where the workflow run was already eval.yml, but the artifact was not
132 // called "comparison", yet, will skip the download.
133 const expired =
134 !artifact ||
135 new Date(artifact?.expires_at ?? 0) < new Date(Date.now() + 60 * 1000)
136 log('Artifact expires at', artifact?.expires_at ?? '<n/a>')
137 if (!expired) {
138 stats.artifacts++
139
140 await artifactClient.downloadArtifact(artifact.id, {
141 findBy: {
142 repositoryName: context.repo.repo,
143 repositoryOwner: context.repo.owner,
144 token: core.getInput('github-token'),
145 },
146 path: path.resolve(pull_number.toString()),
147 expectedHash: artifact.digest,
148 })
149
150 const maintainers = new Set(
151 Object.keys(
152 JSON.parse(
153 await readFile(`${pull_number}/maintainers.json`, 'utf-8'),
154 ),
155 ).map((m) => Number.parseInt(m, 10)),
156 )
157
158 const evalLabels = JSON.parse(
159 await readFile(`${pull_number}/changed-paths.json`, 'utf-8'),
160 ).labels
161
162 Object.assign(
163 prLabels,
164 // Ignore `evalLabels` if it's an array.
165 // This can happen for older eval runs, before we switched to objects.
166 // The old eval labels would have been set by the eval run,
167 // so now they'll be present in `before`.
168 // TODO: Simplify once old eval results have expired (~2025-10)
169 Array.isArray(evalLabels) ? undefined : evalLabels,
170 {
171 '12.approved-by: package-maintainer': Array.from(maintainers).some(
172 (m) => approvals.has(m),
173 ),
174 },
175 )
176 }
177
178 return prLabels
179 }
180
181 // Returns true if the issue was closed. In this case, the labeling does not need to
182 // continue for this issue. Returns false if no action was taken.
183 async function handleAutoClose(item) {
184 const issue_number = item.number
185
186 if (item.labels.some(({ name }) => name === '0.kind: packaging request')) {
187 const body = [
188 'Thank you for your interest in packaging new software in Nixpkgs. Unfortunately, to mitigate the unsustainable growth of unmaintained packages, **Nixpkgs is no longer accepting package requests** via Issues.',
189 '',
190 'As a [volunteer community][community], we are always open to new contributors. If you wish to see this package in Nixpkgs, **we encourage you to [contribute] it yourself**, via a Pull Request. Anyone can [become a package maintainer][maintainers]! You can find language-specific packaging information in the [Nixpkgs Manual][nixpkgs]. Should you need any help, please reach out to the community on [Matrix] or [Discourse].',
191 '',
192 '[community]: https://nixos.org/community',
193 '[contribute]: https://github.com/NixOS/nixpkgs/blob/master/pkgs/README.md#quick-start-to-adding-a-package',
194 '[maintainers]: https://github.com/NixOS/nixpkgs/blob/master/maintainers/README.md',
195 '[nixpkgs]: https://nixos.org/manual/nixpkgs/unstable/',
196 '[Matrix]: https://matrix.to/#/#dev:nixos.org',
197 '[Discourse]: https://discourse.nixos.org/c/dev/14',
198 ].join('\n')
199
200 core.info(`Issue #${item.number}: auto-closed`)
201
202 if (!dry) {
203 await github.rest.issues.createComment({
204 ...context.repo,
205 issue_number,
206 body,
207 })
208
209 await github.rest.issues.update({
210 ...context.repo,
211 issue_number,
212 state: 'closed',
213 state_reason: 'not_planned',
214 })
215 }
216
217 return true
218 }
219 return false
220 }
221
222 async function handle({ item, stats }) {
223 try {
224 const log = (k, v, skip) => {
225 core.info(`#${item.number} - ${k}: ${v}${skip ? ' (skipped)' : ''}`)
226 return skip
227 }
228
229 log('Last updated at', item.updated_at)
230 log('URL', item.html_url)
231
232 const issue_number = item.number
233
234 const itemLabels = {}
235
236 if (item.pull_request || context.payload.pull_request) {
237 stats.prs++
238 Object.assign(itemLabels, await handlePullRequest({ item, stats }))
239 } else {
240 stats.issues++
241 if (item.labels.some(({ name }) => name === '4.workflow: auto-close')) {
242 // If this returns true, the issue was closed. In this case we return, to not
243 // label the issue anymore. Most importantly this avoids unlabeling stale issues
244 // which are closed via auto-close.
245 if (await handleAutoClose(item)) return
246 }
247 }
248
249 const latest_event_at = new Date(
250 (
251 await github.paginate(github.rest.issues.listEventsForTimeline, {
252 ...context.repo,
253 issue_number,
254 per_page: 100,
255 })
256 )
257 .filter(({ event }) =>
258 [
259 // These events are hand-picked from:
260 // https://docs.github.com/en/rest/using-the-rest-api/issue-event-types?apiVersion=2022-11-28
261 // Each of those causes a PR/issue to *not* be considered as stale anymore.
262 // Most of these use created_at.
263 'assigned',
264 'commented', // uses updated_at, because that could be > created_at
265 'committed', // uses committer.date
266 ...(item.labels.some(({ name }) => name === '5.scope: tracking')
267 ? ['cross-referenced']
268 : []),
269 'head_ref_force_pushed',
270 'milestoned',
271 'pinned',
272 'ready_for_review',
273 'renamed',
274 'reopened',
275 'review_dismissed',
276 'review_requested',
277 'reviewed', // uses submitted_at
278 'unlocked',
279 'unmarked_as_duplicate',
280 ].includes(event),
281 )
282 .map(
283 ({ created_at, updated_at, committer, submitted_at }) =>
284 new Date(
285 updated_at ?? created_at ?? submitted_at ?? committer.date,
286 ),
287 )
288 // Reverse sort by date value. The default sort() sorts by string representation, which is bad for dates.
289 .sort((a, b) => b - a)
290 .at(0) ?? item.created_at,
291 )
292 log('latest_event_at', latest_event_at.toISOString())
293
294 const stale_at = new Date(new Date().setDate(new Date().getDate() - 180))
295
296 // Create a map (Label -> Boolean) of all currently set labels.
297 // Each label is set to True and can be disabled later.
298 const before = Object.fromEntries(
299 (
300 await github.paginate(github.rest.issues.listLabelsOnIssue, {
301 ...context.repo,
302 issue_number,
303 })
304 ).map(({ name }) => [name, true]),
305 )
306
307 Object.assign(itemLabels, {
308 '2.status: stale':
309 !before['1.severity: security'] && latest_event_at < stale_at,
310 })
311
312 const after = Object.assign({}, before, itemLabels)
313
314 // No need for an API request, if all labels are the same.
315 const hasChanges = Object.keys(after).some(
316 (name) => (before[name] ?? false) !== after[name],
317 )
318 if (log('Has changes', hasChanges, !hasChanges)) return
319
320 // Skipping labeling on a pull_request event, because we have no privileges.
321 const labels = Object.entries(after)
322 .filter(([, value]) => value)
323 .map(([name]) => name)
324 if (log('Set labels', labels, dry)) return
325
326 await github.rest.issues.setLabels({
327 ...context.repo,
328 issue_number,
329 labels,
330 })
331 } catch (cause) {
332 throw new Error(`Labeling #${item.number} failed.`, { cause })
333 }
334 }
335
336 await withRateLimit({ github, core }, async (stats) => {
337 if (context.payload.pull_request) {
338 await handle({ item: context.payload.pull_request, stats })
339 } else {
340 const lastRun = (
341 await github.rest.actions.listWorkflowRuns({
342 ...context.repo,
343 workflow_id: 'labels.yml',
344 event: 'schedule',
345 status: 'success',
346 exclude_pull_requests: true,
347 per_page: 1,
348 })
349 ).data.workflow_runs[0]
350
351 const cutoff = new Date(
352 Math.max(
353 // Go back as far as the last successful run of this workflow to make sure
354 // we are not leaving anyone behind on GHA failures.
355 // Defaults to go back 1 hour on the first run.
356 new Date(
357 lastRun?.created_at ?? Date.now() - 1 * 60 * 60 * 1000,
358 ).getTime(),
359 // Go back max. 1 day to prevent hitting all API rate limits immediately,
360 // when GH API returns a wrong workflow by accident.
361 Date.now() - 24 * 60 * 60 * 1000,
362 ),
363 )
364 core.info(`cutoff timestamp: ${cutoff.toISOString()}`)
365
366 const updatedItems = await github.paginate(
367 github.rest.search.issuesAndPullRequests,
368 {
369 q: [
370 `repo:"${context.repo.owner}/${context.repo.repo}"`,
371 'is:open',
372 `updated:>=${cutoff.toISOString()}`,
373 ].join(' AND '),
374 per_page: 100,
375 // TODO: Remove in 2025-10, when it becomes the default.
376 advanced_search: true,
377 },
378 )
379
380 let cursor
381
382 // No workflow run available the first time.
383 if (lastRun) {
384 // The cursor to iterate through the full list of issues and pull requests
385 // is passed between jobs as an artifact.
386 const artifact = (
387 await github.rest.actions.listWorkflowRunArtifacts({
388 ...context.repo,
389 run_id: lastRun.id,
390 name: 'pagination-cursor',
391 })
392 ).data.artifacts[0]
393
394 // If the artifact is not available, the next iteration starts at the beginning.
395 if (artifact) {
396 stats.artifacts++
397
398 const { downloadPath } = await artifactClient.downloadArtifact(
399 artifact.id,
400 {
401 findBy: {
402 repositoryName: context.repo.repo,
403 repositoryOwner: context.repo.owner,
404 token: core.getInput('github-token'),
405 },
406 expectedHash: artifact.digest,
407 },
408 )
409
410 cursor = await readFile(path.resolve(downloadPath, 'cursor'), 'utf-8')
411 }
412 }
413
414 // From GitHub's API docs:
415 // GitHub's REST API considers every pull request an issue, but not every issue is a pull request.
416 // For this reason, "Issues" endpoints may return both issues and pull requests in the response.
417 // You can identify pull requests by the pull_request key.
418 const allItems = await github.rest.issues.listForRepo({
419 ...context.repo,
420 state: 'open',
421 sort: 'created',
422 direction: 'asc',
423 per_page: 100,
424 after: cursor,
425 })
426
427 // Regex taken and comment adjusted from:
428 // https://github.com/octokit/plugin-paginate-rest.js/blob/8e5da25f975d2f31dda6b8b588d71f2c768a8df2/src/iterator.ts#L36-L41
429 // `allItems.headers.link` format:
430 // <https://api.github.com/repositories/4542716/issues?page=3&per_page=100&after=Y3Vyc29yOnYyOpLPAAABl8qNnYDOvnSJxA%3D%3D>; rel="next",
431 // <https://api.github.com/repositories/4542716/issues?page=1&per_page=100&before=Y3Vyc29yOnYyOpLPAAABl8xFV9DOvoouJg%3D%3D>; rel="prev"
432 // Sets `next` to undefined if "next" URL is not present or `link` header is not set.
433 const next = ((allItems.headers.link ?? '').match(
434 /<([^<>]+)>;\s*rel="next"/,
435 ) ?? [])[1]
436 if (next) {
437 cursor = new URL(next).searchParams.get('after')
438 const uploadPath = path.resolve('cursor')
439 await writeFile(uploadPath, cursor, 'utf-8')
440 if (dry) {
441 core.info(`pagination-cursor: ${cursor} (upload skipped)`)
442 } else {
443 // No stats.artifacts++, because this does not allow passing a custom token.
444 // Thus, the upload will not happen with the app token, but the default github.token.
445 await artifactClient.uploadArtifact(
446 'pagination-cursor',
447 [uploadPath],
448 path.resolve('.'),
449 {
450 retentionDays: 1,
451 },
452 )
453 }
454 }
455
456 // Some items might be in both search results, so filtering out duplicates as well.
457 const items = []
458 .concat(updatedItems, allItems.data)
459 .filter(
460 (thisItem, idx, arr) =>
461 idx ===
462 arr.findIndex((firstItem) => firstItem.number === thisItem.number),
463 )
464
465 ;(await Promise.allSettled(items.map((item) => handle({ item, stats }))))
466 .filter(({ status }) => status === 'rejected')
467 .map(({ reason }) =>
468 core.setFailed(`${reason.message}\n${reason.cause.stack}`),
469 )
470 }
471 })
472}