nixpkgs mirror (for testing)
github.com/NixOS/nixpkgs
nix
1const { classify } = require('../supportedBranches.js')
2
3function runChecklist({
4 committers,
5 events,
6 files,
7 pull_request,
8 log,
9 maintainers,
10 user,
11 userIsMaintainer,
12}) {
13 const allByName = files.every(
14 ({ filename }) =>
15 filename.startsWith('pkgs/by-name/') && filename.split('/').length > 4,
16 )
17
18 const packages = files
19 .filter(({ filename }) => filename.startsWith('pkgs/by-name/'))
20 .map(({ filename }) => filename.split('/')[3])
21 .filter(Boolean)
22
23 const eligible = !packages.length
24 ? new Set()
25 : packages
26 .map((pkg) => new Set(maintainers[pkg]))
27 .reduce((acc, cur) => acc?.intersection(cur) ?? cur)
28
29 const approvals = new Set(
30 events
31 .filter(
32 ({ event, state, commit_id }) =>
33 event === 'reviewed' &&
34 state === 'approved' &&
35 // Only approvals for the current head SHA count, otherwise authors could push
36 // bad code between the approval and the merge.
37 commit_id === pull_request.head.sha,
38 )
39 .map(({ user }) => user?.id)
40 // Some users have been deleted, so filter these out.
41 .filter(Boolean),
42 )
43
44 const checklist = {
45 'PR targets a [development branch](https://github.com/NixOS/nixpkgs/blob/-/ci/README.md#branch-classification).':
46 classify(pull_request.base.ref).type.includes('development'),
47 'PR touches only files of packages in `pkgs/by-name/`.': allByName,
48 'PR is at least one of:': {
49 'Approved by a [committer](https://github.com/orgs/NixOS/teams/nixpkgs-committers).':
50 committers.intersection(approvals).size > 0,
51 'Backported via label.':
52 pull_request.user.login === 'nixpkgs-ci[bot]' &&
53 pull_request.head.ref.startsWith('backport-'),
54 'Opened by a [committer](https://github.com/orgs/NixOS/teams/nixpkgs-committers).':
55 committers.has(pull_request.user.id),
56 'Opened by [@r-ryantm](https://nix-community.github.io/nixpkgs-update/r-ryantm/).':
57 pull_request.user.login === 'r-ryantm',
58 },
59 'PR is not a draft': !pull_request.draft,
60 }
61
62 if (user) {
63 checklist[
64 `${user.login} is a member of [@NixOS/nixpkgs-maintainers](https://github.com/orgs/NixOS/teams/nixpkgs-maintainers).`
65 ] = userIsMaintainer
66 if (allByName) {
67 // We can only determine the below, if all packages are in by-name, since
68 // we can't reliably relate changed files to packages outside by-name.
69 checklist[`${user.login} is a maintainer of all touched packages.`] =
70 eligible.has(user.id)
71 }
72 } else {
73 // This is only used when no user is passed, i.e. for labeling.
74 checklist['PR has maintainers eligible to merge.'] = eligible.size > 0
75 }
76
77 const result = Object.values(checklist).every((v) =>
78 typeof v === 'boolean' ? v : Object.values(v).some(Boolean),
79 )
80
81 log('checklist', JSON.stringify(checklist))
82 log('eligible', JSON.stringify(Array.from(eligible)))
83 log('result', result)
84
85 return {
86 checklist,
87 eligible,
88 result,
89 }
90}
91
92// The merge command must be on a separate line and not within codeblocks or html comments.
93// Codeblocks can have any number of ` larger than 3 to open/close. We only look at code
94// blocks that are not indented, because the later regex wouldn't match those anyway.
95function hasMergeCommand(body) {
96 return (body ?? '')
97 .replace(/<!--.*?-->/gms, '')
98 .replace(/(^`{3,})[^`].*?\1/gms, '')
99 .match(/^@NixOS\/nixpkgs-merge-bot merge\s*$/m)
100}
101
102async function handleMergeComment({ github, body, node_id, reaction }) {
103 if (!hasMergeCommand(body)) return
104
105 await github.graphql(
106 `mutation($node_id: ID!, $reaction: ReactionContent!) {
107 addReaction(input: {
108 content: $reaction,
109 subjectId: $node_id
110 })
111 { clientMutationId }
112 }`,
113 { node_id, reaction },
114 )
115}
116
117async function handleMerge({
118 github,
119 context,
120 core,
121 log,
122 dry,
123 pull_request,
124 events,
125 maintainers,
126 getTeamMembers,
127 getUser,
128}) {
129 const pull_number = pull_request.number
130
131 const committers = new Set(
132 (await getTeamMembers('nixpkgs-committers')).map(({ id }) => id),
133 )
134
135 const files = (
136 await github.rest.pulls.listFiles({
137 ...context.repo,
138 pull_number,
139 per_page: 100,
140 })
141 ).data
142
143 // Early exit to prevent treewides from using up a lot of API requests (and time!) to list
144 // all the files in the pull request. For now, the merge-bot will not work when 100 or more
145 // files are touched in a PR - which should be more than fine.
146 // TODO: Find a more efficient way of downloading all the *names* of the touched files,
147 // including an early exit when the first non-by-name file is found.
148 if (files.length >= 100) return false
149
150 // Only look through comments *after* the latest (force) push.
151 const lastPush = events.findLastIndex(
152 ({ event, sha, commit_id }) =>
153 ['committed', 'head_ref_force_pushed'].includes(event) &&
154 (sha ?? commit_id) === pull_request.head.sha,
155 )
156
157 const comments = events.slice(lastPush + 1).filter(
158 ({ event, body, user, node_id }) =>
159 ['commented', 'reviewed'].includes(event) &&
160 hasMergeCommand(body) &&
161 // Ignore comments where the user has been deleted already.
162 user &&
163 // Ignore comments which had already been responded to by the bot.
164 (dry ||
165 !events.some(
166 ({ event, body }) =>
167 ['commented'].includes(event) &&
168 // We're only testing this hidden reference, but not the author of the comment.
169 // We'll just assume that nobody creates comments with this marker on purpose.
170 // Additionally checking the author is quite annoying for local debugging.
171 body.match(new RegExp(`^<!-- comment: ${node_id} -->$`, 'm')),
172 )),
173 )
174
175 async function merge() {
176 if (dry) {
177 core.info(`Merging #${pull_number}... (dry)`)
178 return ['Merge completed (dry)']
179 }
180
181 // Using GraphQL mutations instead of the REST /merge endpoint, because the latter
182 // doesn't work with Merge Queues. We now have merge queues enabled on all development
183 // branches, so we don't need a fallback for regular merges.
184 try {
185 const resp = await github.graphql(
186 `mutation($node_id: ID!, $sha: GitObjectID) {
187 enqueuePullRequest(input: {
188 expectedHeadOid: $sha,
189 pullRequestId: $node_id
190 })
191 {
192 clientMutationId,
193 mergeQueueEntry { mergeQueue { url } }
194 }
195 }`,
196 { node_id: pull_request.node_id, sha: pull_request.head.sha },
197 )
198 log('merge', 'Queued for merge')
199 return [
200 `:heavy_check_mark: [Queued](${resp.enqueuePullRequest.mergeQueueEntry.mergeQueue.url}) for merge (#306934)`,
201 ]
202 } catch (e) {
203 log('Enqueuing failed', e.response.errors[0].message)
204 }
205
206 // If required status checks are not satisfied, yet, the above will fail. In this case
207 // we can enable auto-merge. We could also only use auto-merge, but this often gets
208 // stuck for no apparent reason.
209 try {
210 await github.graphql(
211 `mutation($node_id: ID!, $sha: GitObjectID) {
212 enablePullRequestAutoMerge(input: {
213 expectedHeadOid: $sha,
214 pullRequestId: $node_id
215 })
216 { clientMutationId }
217 }`,
218 { node_id: pull_request.node_id, sha: pull_request.head.sha },
219 )
220 log('merge', 'Auto-merge enabled')
221 return [
222 `:heavy_check_mark: Enabled Auto Merge (#306934)`,
223 '',
224 '> [!TIP]',
225 '> Sometimes GitHub gets stuck after enabling Auto Merge. In this case, leaving another approval should trigger the merge.',
226 ]
227 } catch (e) {
228 log('Auto Merge failed', e.response.errors[0].message)
229 throw new Error(e.response.errors[0].message)
230 }
231 }
232
233 for (const comment of comments) {
234 log('comment', comment.node_id)
235
236 async function react(reaction) {
237 if (dry) {
238 core.info(`Reaction ${reaction} on ${comment.node_id} (dry)`)
239 return
240 }
241
242 await handleMergeComment({
243 github,
244 body: comment.body,
245 node_id: comment.node_id,
246 reaction,
247 })
248 }
249
250 async function isMaintainer(username) {
251 try {
252 return (
253 (
254 await github.rest.teams.getMembershipForUserInOrg({
255 org: context.repo.owner,
256 team_slug: 'nixpkgs-maintainers',
257 username,
258 })
259 ).data.state === 'active'
260 )
261 } catch (e) {
262 if (e.status === 404) return false
263 else throw e
264 }
265 }
266
267 const { result, eligible, checklist } = runChecklist({
268 committers,
269 events,
270 files,
271 pull_request,
272 log,
273 maintainers,
274 user: comment.user,
275 userIsMaintainer: await isMaintainer(comment.user.login),
276 })
277
278 const body = [
279 `<!-- comment: ${comment.node_id} -->`,
280 `@${comment.user.login} wants to merge this PR.`,
281 '',
282 'Requirements to merge this PR with `@NixOS/nixpkgs-merge-bot merge`:',
283 ...Object.entries(checklist).flatMap(([msg, res]) =>
284 typeof res === 'boolean'
285 ? `- :${res ? 'white_check_mark' : 'x'}: ${msg}`
286 : [
287 `- :${Object.values(res).some(Boolean) ? 'white_check_mark' : 'x'}: ${msg}`,
288 ...Object.entries(res).map(
289 ([msg, res]) =>
290 ` - ${res ? ':white_check_mark:' : ':white_large_square:'} ${msg}`,
291 ),
292 ],
293 ),
294 '',
295 ]
296
297 if (eligible.size > 0 && !eligible.has(comment.user.id)) {
298 const users = await Promise.all(
299 Array.from(eligible, async (id) => (await getUser(id)).login),
300 )
301 body.push(
302 '> [!TIP]',
303 '> Maintainers eligible to merge are:',
304 ...users.map((login) => `> - ${login}`),
305 '',
306 )
307 }
308
309 if (result) {
310 await react('ROCKET')
311 try {
312 body.push(...(await merge()))
313 } catch (e) {
314 // Remove the HTML comment with node_id reference to allow retrying this merge on the next run.
315 body.shift()
316 body.push(`:x: Merge failed with: ${e} (#371492)`)
317 }
318 } else {
319 await react('THUMBS_DOWN')
320 body.push(':x: Pull Request could not be merged (#305350)')
321 }
322
323 if (dry) {
324 core.info(body.join('\n'))
325 } else {
326 await github.rest.issues.createComment({
327 ...context.repo,
328 issue_number: pull_number,
329 body: body.join('\n'),
330 })
331 }
332
333 if (result) break
334 }
335
336 const { result } = runChecklist({
337 committers,
338 events,
339 files,
340 pull_request,
341 log,
342 maintainers,
343 })
344
345 // Returns a boolean, which indicates whether the PR is merge-bot eligible in principle.
346 // This is used to set the respective label in bot.js.
347 return result
348}
349
350module.exports = {
351 handleMerge,
352 handleMergeComment,
353}