Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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.
Expand All @@ -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}`);
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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];
Expand Down
60 changes: 60 additions & 0 deletions src/utils/update-pr-branch.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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}\`.`,
}),
);
}
36 changes: 36 additions & 0 deletions tests/fixtures/issue_comment_update_branch.created.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
65 changes: 65 additions & 0 deletions tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof probot.receive>[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<typeof probot.receive>[0],
);

expect(updatePRBranch).not.toHaveBeenCalled();
expect(comment).toEqual(
'This PR was not created by roller and cannot be updated via this command.',
);
});
});
});