Skip to content

Commit 298e2da

Browse files
committed
feat(ng-dev): create pr takeover command
Create a command that allows for a user to checkout and takeover a pull request from github-robot. The process creates a local copy of the pull request and updates the commit messages to include its merge closing the original pull request.
1 parent 4b43307 commit 298e2da

File tree

7 files changed

+179
-0
lines changed

7 files changed

+179
-0
lines changed

ng-dev/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ esbuild_esm_bundle(
5858
# to launch these files/scripts dynamically (through e.g. `spawn` or `fork`).
5959
"//ng-dev/release/build:build-worker.ts",
6060
"//ng-dev/pr/merge:strategies/commit-message-filter.ts",
61+
"//ng-dev/pr/takeover-pr:commit-message-filter.ts",
6162
],
6263
external = NG_DEV_EXTERNALS,
6364
splitting = True,

ng-dev/pr/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ ts_library(
1010
"//ng-dev/pr/discover-new-conflicts",
1111
"//ng-dev/pr/merge",
1212
"//ng-dev/pr/rebase",
13+
"//ng-dev/pr/takeover-pr",
1314
"@npm//@types/yargs",
1415
],
1516
)

ng-dev/pr/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {CheckoutCommandModule} from './checkout/cli.js';
1313
import {DiscoverNewConflictsCommandModule} from './discover-new-conflicts/cli.js';
1414
import {MergeCommandModule} from './merge/cli.js';
1515
import {RebaseCommandModule} from './rebase/cli.js';
16+
import {TakeoverPrCommandModule} from './takeover-pr/cli.js';
1617

1718
/** Build the parser for pull request commands. */
1819
export function buildPrParser(localYargs: Argv) {
@@ -24,5 +25,6 @@ export function buildPrParser(localYargs: Argv) {
2425
.command(RebaseCommandModule)
2526
.command(MergeCommandModule)
2627
.command(CheckoutCommandModule)
28+
.command(TakeoverPrCommandModule)
2729
.command(CheckTargetBranchesModule);
2830
}

ng-dev/pr/takeover-pr/BUILD.bazel

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
load("//tools:defaults.bzl", "ts_library")
2+
3+
exports_files([
4+
"commit-message-filter.ts",
5+
])
6+
7+
ts_library(
8+
name = "takeover-pr",
9+
srcs = glob(["*.ts"]),
10+
visibility = ["//ng-dev:__subpackages__"],
11+
deps = [
12+
"//ng-dev/pr/common",
13+
"//ng-dev/pr/merge",
14+
"//ng-dev/utils",
15+
"@npm//@types/node",
16+
"@npm//@types/yargs",
17+
],
18+
)

ng-dev/pr/takeover-pr/cli.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Argv, Arguments, CommandModule} from 'yargs';
10+
11+
import {addGithubTokenOption} from '../../utils/git/github-yargs.js';
12+
import {takeoverPullRequest} from './takeover-pr.js';
13+
14+
export interface CheckoutOptions {
15+
pr: number;
16+
branch?: string;
17+
}
18+
19+
/** Builds the checkout pull request command. */
20+
function builder(yargs: Argv) {
21+
return addGithubTokenOption(yargs)
22+
.positional('pr', {type: 'number', demandOption: true})
23+
.option('branch', {
24+
type: 'string',
25+
describe: 'An optional branch name to use instead of a pull request number based name',
26+
demandOption: false,
27+
});
28+
}
29+
30+
/** Handles the checkout pull request command. */
31+
async function handler(options: Arguments<CheckoutOptions>) {
32+
await takeoverPullRequest(options);
33+
}
34+
35+
/** yargs command module for checking out a PR */
36+
export const TakeoverPrCommandModule: CommandModule<{}, CheckoutOptions> = {
37+
handler,
38+
builder,
39+
command: 'takeover <pr>',
40+
describe: 'Takeover a pull request from a stale upstream pull request',
41+
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* @license
5+
* Copyright Google LLC
6+
*
7+
* Use of this source code is governed by an MIT-style license that can be
8+
* found in the LICENSE file at https://angular.io/license
9+
*/
10+
11+
/**
12+
* Script that can be passed as commit message filter to `git filter-branch --msg-filter`.
13+
* The script rewrites commit messages to contain a Github instruction to close the
14+
* corresponding pull request. For more details. See: https://git.io/Jv64r.
15+
*/
16+
17+
main();
18+
19+
function main() {
20+
const [prNumber] = process.argv.slice(2);
21+
if (!prNumber) {
22+
console.error('No pull request number specified.');
23+
process.exit(1);
24+
}
25+
26+
let commitMessage = '';
27+
process.stdin.setEncoding('utf8');
28+
process.stdin.on('readable', () => {
29+
const chunk = process.stdin.read();
30+
if (chunk !== null) {
31+
commitMessage += chunk;
32+
}
33+
});
34+
35+
process.stdin.on('end', () => {
36+
console.info(rewriteCommitMessage(commitMessage, prNumber));
37+
});
38+
}
39+
40+
function rewriteCommitMessage(message: string, prNumber: string) {
41+
const lines = message.split(/\n/);
42+
lines.push(`Closes #${prNumber} as this was created using the \`ng-dev pr takeover\` tooling`);
43+
return lines.join('\n');
44+
}

ng-dev/pr/takeover-pr/takeover-pr.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {dirname, join} from 'path';
2+
import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client.js';
3+
import {Log, bold, green} from '../../utils/logging.js';
4+
import {checkOutPullRequestLocally} from '../common/checkout-pr.js';
5+
import {fileURLToPath} from 'url';
6+
7+
/** List of accounts that are supported for takeover. */
8+
const takeoverAccounts = ['angular-robot'];
9+
10+
export async function takeoverPullRequest({
11+
pr,
12+
branch,
13+
}: {
14+
pr: number;
15+
branch?: string;
16+
}): Promise<void> {
17+
/** An authenticated git client. */
18+
const git = await AuthenticatedGitClient.get();
19+
/** The branch name used for the takeover change. */
20+
const branchName = branch ?? `pr-${pr}`;
21+
22+
// Make sure the local repository is clean.
23+
if (git.hasUncommittedChanges()) {
24+
Log.error(
25+
` ✘ Local working repository not clean. Please make sure there are no uncommitted changes`,
26+
);
27+
return;
28+
}
29+
30+
// Verify that the expected branch name is available.
31+
if (git.runGraceful(['rev-parse', '-q', '--verify', branchName]).status === 0) {
32+
Log.error(` ✘ Expected branch name \`${branchName}\` already in use`);
33+
return;
34+
}
35+
36+
/** The pull request information from Github. */
37+
const {data: pullRequest} = await git.github.pulls.get({pull_number: pr, ...git.remoteParams});
38+
39+
// Confirm that the takeover request is being done on a valid pull request.
40+
if (!takeoverAccounts.includes(pullRequest.user.login)) {
41+
Log.error(` ✘ Unable to takeover pull request from: ${bold(pullRequest.user.login)}`);
42+
Log.error(` Supported accounts: ${bold(takeoverAccounts.join(', '))}`);
43+
return;
44+
}
45+
46+
Log.info(`Checking out \`${pullRequest.head.label}\` locally`);
47+
await checkOutPullRequestLocally(pr, {allowIfMaintainerCannotModify: true});
48+
49+
Log.info(`Setting local branch name based on the pull request`);
50+
git.run(['checkout', '-q', '-b', branchName]);
51+
52+
Log.info('Updating commit messages to close previous pull request');
53+
git.run([
54+
'filter-branch',
55+
'-f',
56+
'--msg-filter',
57+
`${getCommitMessageFilterScriptPath()} ${pr}`,
58+
`${pullRequest.base.sha}..HEAD`,
59+
]);
60+
61+
Log.info(` ${green('✔')} Checked out pull request #${pr} into branch: ${branchName}`);
62+
}
63+
64+
/** Gets the absolute file path to the commit-message filter script. */
65+
function getCommitMessageFilterScriptPath(): string {
66+
// This file is getting bundled and ends up in `<pkg-root>/bundles/<chunk>`. We also
67+
// bundle the commit-message-filter script as another entry-point and can reference
68+
// it relatively as the path is preserved inside `bundles/`.
69+
// *Note*: Relying on package resolution is problematic within ESM and with `local-dev.sh`
70+
const bundlesDir = dirname(fileURLToPath(import.meta.url));
71+
return join(bundlesDir, './pr/takeover-pr/commit-message-filter.mjs');
72+
}

0 commit comments

Comments
 (0)