From fb974a17174ccfeedf19fb14e5de37dd04d4b815 Mon Sep 17 00:00:00 2001 From: David Sanders Date: Tue, 23 Jun 2026 18:44:54 -0700 Subject: [PATCH] feat: /roller update-branch command Assisted-by: Claude Opus 4.8 --- src/index.ts | 36 ++++++++-- src/types.ts | 1 + src/utils/update-pr-branch.ts | 60 +++++++++++++++++ .../issue_comment_update_branch.created.json | 36 ++++++++++ tests/index.spec.ts | 65 +++++++++++++++++++ 5 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 src/utils/update-pr-branch.ts create mode 100644 tests/fixtures/issue_comment_update_branch.created.json diff --git a/src/index.ts b/src/index.ts index ebc72ea..c9a31f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,9 @@ import { handleNodeCheck } from './node-handler.js'; import { handleChromiumCheck } from './chromium-handler.js'; import { handleBuildImagesCheck } from './build-images-handler.js'; import { handleBuildImagesChromiumDepsCheck } from './build-images-chromium-deps-handler.js'; -import { REPOS, ROLL_TARGETS } from './constants.js'; +import { REPOS, ROLLER_BOT_LOGIN, ROLL_TARGETS } from './constants.js'; import { isAuthorizedElectronRepoUser } from './utils/is-authorized-user.js'; +import { updatePRBranch } from './utils/update-pr-branch.js'; const handler = (robot: Probot) => { robot.on('pull_request.closed', async (context) => { @@ -63,11 +64,6 @@ const handler = (robot: Probot) => { const d = debug('roller/github:issue_comment.created'); const { issue, comment } = context.payload; - const match = comment.body.match(/^\/roll (main|\d+-x-y)$/); - if (!match || !match[1]) { - return; - } - // Roll commands perform privileged actions against electron/electron, so only // honor them when issued from that repository. This prevents permissions on // unrelated repos where this app is installed from authorizing a roll. @@ -94,6 +90,34 @@ const handler = (robot: Probot) => { return; } + // Maintainer command to update the branch with the latest changes from its base branch + if (/^\/roller update-branch$/.test(comment.body)) { + const { data: pr } = await context.octokit.rest.pulls.get( + context.repo({ pull_number: issue.number }), + ); + + // Ensure the PR was actually created by roller before updating it. + if (pr.user?.login !== ROLLER_BOT_LOGIN) { + d(`#${issue.number} was not created by roller - skipping update-branch`); + await context.octokit.rest.issues.createComment( + context.repo({ + issue_number: issue.number, + body: 'This PR was not created by roller and cannot be updated via this command.', + }), + ); + return; + } + + d(`Updating the branch for #${issue.number}`); + await updatePRBranch(context, pr); + return; + } + + const match = comment.body.match(/^\/roll (main|\d+-x-y)$/); + if (!match || !match[1]) { + return; + } + const branch = match[1]; const isNodePR = issue.title.startsWith(`chore: bump ${ROLL_TARGETS.node.name}`); const isChromiumPR = issue.title.startsWith(`chore: bump ${ROLL_TARGETS.chromium.name}`); diff --git a/src/types.ts b/src/types.ts index 8059cbb..050c07f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import { RestEndpointMethodTypes } from '@octokit/rest'; +export type PullsGetResponseItem = RestEndpointMethodTypes['pulls']['get']['response']['data']; export type PullsListResponseItem = RestEndpointMethodTypes['pulls']['list']['response']['data'][0]; export type ReposListBranchesResponseItem = RestEndpointMethodTypes['repos']['listBranches']['response']['data'][0]; diff --git a/src/utils/update-pr-branch.ts b/src/utils/update-pr-branch.ts new file mode 100644 index 0000000..69006d0 --- /dev/null +++ b/src/utils/update-pr-branch.ts @@ -0,0 +1,60 @@ +import debug from 'debug'; +import type { Context } from 'probot'; + +import type { PullsGetResponseItem } from '../types.js'; + +/** + * Updates a pull request's branch by merging the latest changes from its base + * branch into it. This is the same operation as the "Update branch" button in + * the GitHub UI and is used to re-trigger CI on stale roller PRs. + */ +export async function updatePRBranch( + context: Context<'issue_comment.created'>, + pr: PullsGetResponseItem, +): Promise { + const d = debug('roller/github:updatePRBranch()'); + + d(`Updating #${pr.number} by merging the latest changes from "${pr.base.ref}"`); + + // Don't use the REBASE update method, as it would create unverified commits. + const mutation = `mutation UpdatePullRequestBranch($pullRequestId: ID!, $expectedHeadOid: GitObjectID!) { + updatePullRequestBranch(input: { + pullRequestId: $pullRequestId, + expectedHeadOid: $expectedHeadOid, + updateMethod: MERGE + }) { + pullRequest { + number + } + } + }`; + + try { + await context.octokit.graphql(mutation, { + pullRequestId: pr.node_id, + expectedHeadOid: pr.head.sha, + }); + } catch (error) { + d(`Failed to update branch for #${pr.number}`, error); + + const isConflict = (error as Error)?.message?.includes('merge conflict between base and head'); + + await context.octokit.rest.issues.createComment( + context.repo({ + issue_number: pr.number, + body: isConflict + ? `This branch could not be updated because there is a merge conflict with \`${pr.base.ref}\`. Please resolve the conflict manually.` + : `I was unable to update this branch with the latest changes from \`${pr.base.ref}\`. Please update it manually.`, + }), + ); + + return; + } + + await context.octokit.rest.issues.createComment( + context.repo({ + issue_number: pr.number, + body: `This branch has been updated with the latest changes from \`${pr.base.ref}\`.`, + }), + ); +} diff --git a/tests/fixtures/issue_comment_update_branch.created.json b/tests/fixtures/issue_comment_update_branch.created.json new file mode 100644 index 0000000..ba03451 --- /dev/null +++ b/tests/fixtures/issue_comment_update_branch.created.json @@ -0,0 +1,36 @@ +{ + "name": "issue_comment", + "payload": { + "action": "created", + "issue": { + "url": "https://api.github.com/repos/electron/electron/pull/0", + "html_url": "https://github.com/electron/electron/pull/0", + "number": 0, + "title": "chore: bump chromium to 1.2.3.4", + "user": { + "login": "dsanders11" + }, + "body": "Bump the version of Chromium to 1.2.3.4.", + "pull_request": {} + }, + "comment": { + "url": "https://api.github.com/repos/electron/electron/pulls/comments/0", + "html_url": "https://github.com/electron/electron/pulls/0#issuecomment-0", + "id": 0, + "user": { + "login": "dsanders11" + }, + "body": "/roller update-branch" + }, + "repository": { + "name": "electron", + "full_name": "electron/electron", + "owner": { + "login": "electron" + } + }, + "installation": { + "id": 12345 + } + } +} diff --git a/tests/index.spec.ts b/tests/index.spec.ts index 4015a57..952500e 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -4,17 +4,44 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import handler from '../src/index.js'; import { handleChromiumCheck } from '../src/chromium-handler.js'; +import { ROLLER_BOT_LOGIN } from '../src/constants.js'; import { handleNodeCheck } from '../src/node-handler.js'; import { isAuthorizedElectronRepoUser } from '../src/utils/is-authorized-user.js'; +import { updatePRBranch } from '../src/utils/update-pr-branch.js'; import issueCommentRollCreatedEvent from './fixtures/issue_comment_roll.created.json' with { type: 'json' }; +import issueCommentUpdateBranchCreatedEvent from './fixtures/issue_comment_update_branch.created.json' with { type: 'json' }; vi.mock('../src/chromium-handler.js'); vi.mock('../src/node-handler.js'); vi.mock('../src/utils/is-authorized-user.js'); +vi.mock('../src/utils/update-pr-branch.js'); const GH_API = 'https://api.github.com'; +const MOCK_PR = { + merged: true, + head: { + sha: '6dcb09b5b57875f334f61aebed695e2e4193db5e', + }, + base: { + ref: 'main', + repo: { + default_branch: 'main', + }, + }, + labels: [ + { + url: 'my_cool_url', + name: 'target/X-X-X', + color: 'fc2929', + }, + ], + user: { + login: ROLLER_BOT_LOGIN, + }, +}; + describe('roller', () => { describe('issue_comment.created event', () => { let probot: Probot; @@ -103,5 +130,43 @@ describe('roller', () => { expect(handleChromiumCheck).not.toHaveBeenCalled(); expect(handleNodeCheck).not.toHaveBeenCalled(); }); + + it('triggers a branch update on `/roller update-branch` comment', async () => { + vi.mocked(isAuthorizedElectronRepoUser).mockResolvedValue(true); + + nock(GH_API).persist().get('/repos/electron/electron/pulls/0').reply(200, MOCK_PR); + + await probot.receive( + issueCommentUpdateBranchCreatedEvent as Parameters[0], + ); + + expect(updatePRBranch).toHaveBeenCalled(); + }); + + it('does not trigger a branch update on `/roller update-branch` comment when roller is not the author', async () => { + vi.mocked(isAuthorizedElectronRepoUser).mockResolvedValue(true); + + nock(GH_API) + .persist() + .get('/repos/electron/electron/pulls/0') + .reply(200, { ...MOCK_PR, user: { login: 'someone-else' } }); + + let comment: string | undefined; + nock(GH_API) + .post('/repos/electron/electron/issues/0/comments', ({ body }) => { + comment = body; + return true; + }) + .reply(200); + + await probot.receive( + issueCommentUpdateBranchCreatedEvent as Parameters[0], + ); + + expect(updatePRBranch).not.toHaveBeenCalled(); + expect(comment).toEqual( + 'This PR was not created by roller and cannot be updated via this command.', + ); + }); }); });