nixpkgs mirror (for testing) github.com/NixOS/nixpkgs
nix
at r-updates 353 lines 11 kB view raw
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}