diff --git a/dev-infra/pr/BUILD.bazel b/dev-infra/pr/BUILD.bazel index b700e03f74..023bbd69ac 100644 --- a/dev-infra/pr/BUILD.bazel +++ b/dev-infra/pr/BUILD.bazel @@ -8,6 +8,7 @@ ts_library( deps = [ "//dev-infra/pr/discover-new-conflicts", "//dev-infra/pr/merge", + "//dev-infra/pr/rebase", "@npm//@types/yargs", ], ) diff --git a/dev-infra/pr/cli.ts b/dev-infra/pr/cli.ts index d5f4c7253f..981f19fd45 100644 --- a/dev-infra/pr/cli.ts +++ b/dev-infra/pr/cli.ts @@ -10,6 +10,7 @@ import * as yargs from 'yargs'; import {buildDiscoverNewConflictsCommand, handleDiscoverNewConflictsCommand} from './discover-new-conflicts/cli'; import {buildMergeCommand, handleMergeCommand} from './merge/cli'; +import {buildRebaseCommand, handleRebaseCommand} from './rebase/cli'; /** Build the parser for pull request commands. */ export function buildPrParser(localYargs: yargs.Argv) { @@ -21,6 +22,9 @@ export function buildPrParser(localYargs: yargs.Argv) { 'discover-new-conflicts ', 'Check if a pending PR causes new conflicts for other pending PRs', buildDiscoverNewConflictsCommand, handleDiscoverNewConflictsCommand) + .command( + 'rebase ', 'Rebase a pending PR and push the rebased commits back to Github', + buildRebaseCommand, handleRebaseCommand); } if (require.main === module) { diff --git a/dev-infra/pr/rebase/BUILD.bazel b/dev-infra/pr/rebase/BUILD.bazel new file mode 100644 index 0000000000..5ee5219d5d --- /dev/null +++ b/dev-infra/pr/rebase/BUILD.bazel @@ -0,0 +1,20 @@ +load("@npm_bazel_typescript//:index.bzl", "ts_library") + +ts_library( + name = "rebase", + srcs = [ + "cli.ts", + "index.ts", + ], + module_name = "@angular/dev-infra-private/pr/rebase", + visibility = ["//dev-infra:__subpackages__"], + deps = [ + "//dev-infra/utils", + "@npm//@types/inquirer", + "@npm//@types/node", + "@npm//@types/yargs", + "@npm//inquirer", + "@npm//typed-graphqlify", + "@npm//yargs", + ], +) diff --git a/dev-infra/pr/rebase/cli.ts b/dev-infra/pr/rebase/cli.ts new file mode 100644 index 0000000000..ac9f6e46fb --- /dev/null +++ b/dev-infra/pr/rebase/cli.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Arguments, Argv} from 'yargs'; + +import {rebasePr} from './index'; + +/** URL to the Github page where personal access tokens can be generated. */ +export const GITHUB_TOKEN_GENERATE_URL = `https://github.com/settings/tokens`; + +/** Builds the rebase pull request command. */ +export function buildRebaseCommand(yargs: Argv) { + return yargs.option('github-token', { + type: 'string', + description: 'Github token. If not set, token is retrieved from the environment variables.' + }); +} + +/** Handles the rebase pull request command. */ +export async function handleRebaseCommand(args: Arguments) { + const githubToken = args.githubToken || process.env.GITHUB_TOKEN || process.env.TOKEN; + if (!githubToken) { + console.error('No Github token set. Please set the `GITHUB_TOKEN` environment variable.'); + console.error('Alternatively, pass the `--github-token` command line flag.'); + console.error(`You can generate a token here: ${GITHUB_TOKEN_GENERATE_URL}`); + process.exit(1); + } + + await rebasePr(args.prNumber, githubToken); +} diff --git a/dev-infra/pr/rebase/index.ts b/dev-infra/pr/rebase/index.ts new file mode 100644 index 0000000000..7087422639 --- /dev/null +++ b/dev-infra/pr/rebase/index.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {prompt} from 'inquirer'; +import {types as graphQLTypes} from 'typed-graphqlify'; +import {URL} from 'url'; + +import {getConfig, NgDevConfig} from '../../utils/config'; +import {promptConfirm} from '../../utils/console'; +import {getCurrentBranch, hasLocalChanges} from '../../utils/git'; +import {getPr} from '../../utils/github'; +import {exec} from '../../utils/shelljs'; + +/* GraphQL schema for the response body for each pending PR. */ +const PR_SCHEMA = { + state: graphQLTypes.string, + maintainerCanModify: graphQLTypes.boolean, + viewerDidAuthor: graphQLTypes.boolean, + headRef: { + name: graphQLTypes.string, + repository: { + url: graphQLTypes.string, + nameWithOwner: graphQLTypes.string, + }, + }, + baseRef: { + name: graphQLTypes.string, + repository: { + url: graphQLTypes.string, + nameWithOwner: graphQLTypes.string, + }, + }, +}; + +/** + * Rebase the provided PR onto its merge target branch, and push up the resulting + * commit to the PRs repository. + */ +export async function rebasePr( + prNumber: number, githubToken: string, config: Pick = getConfig()) { + // TODO: Rely on a common assertNoLocalChanges function. + if (hasLocalChanges()) { + console.error('Cannot perform rebase of PR with local changes.'); + process.exit(1); + } + + /** + * The branch originally checked out before this method performs any Git + * operations that may change the working branch. + */ + const originalBranch = getCurrentBranch(); + /* Get the PR information from Github. */ + const pr = await getPr(PR_SCHEMA, prNumber, config.github); + + const fullHeadRef = `${pr.headRef.repository.nameWithOwner}:${pr.headRef.name}`; + const fullBaseRef = `${pr.baseRef.repository.nameWithOwner}:${pr.baseRef.name}`; + const headRefUrl = addAuthenticationToUrl(pr.headRef.repository.url, githubToken); + const baseRefUrl = addAuthenticationToUrl(pr.baseRef.repository.url, githubToken); + + // If the PR does not allow maintainers to modify it, exit as the rebased PR cannot + // be pushed up. + if (!pr.maintainerCanModify && !pr.viewerDidAuthor) { + console.error( + `Cannot rebase as you did not author the PR and the PR does not allow maintainers` + + `to modify the PR`); + process.exit(1); + } + + try { + // Fetch the branch at the commit of the PR, and check it out in a detached state. + console.info(`Checking out PR #${prNumber} from ${fullHeadRef}`); + exec(`git fetch ${headRefUrl} ${pr.headRef.name}`); + exec(`git checkout --detach FETCH_HEAD`); + + // Fetch the PRs target branch and rebase onto it. + console.info(`Fetching ${fullBaseRef} to rebase #${prNumber} on`); + exec(`git fetch ${baseRefUrl} ${pr.baseRef.name}`); + console.info(`Attempting to rebase PR #${prNumber} on ${fullBaseRef}`); + const rebaseResult = exec(`git rebase FETCH_HEAD`); + + // If the rebase was clean, push the rebased PR up to the authors fork. + if (rebaseResult.code === 0) { + console.info(`Rebase was able to complete automatically without conflicts`); + console.info(`Pushing rebased PR #${prNumber} to ${fullHeadRef}`); + exec(`git push ${baseRefUrl} HEAD:${pr.baseRef.name} --force-with-lease`); + console.info(`Rebased and updated PR #${prNumber}`); + cleanUpGitState(); + process.exit(0); + } + } catch (err) { + console.error(err.message); + cleanUpGitState(); + process.exit(1); + } + + // On automatic rebase failures, prompt to choose if the rebase should be continued + // manually or aborted now. + console.info(`Rebase was unable to complete automatically without conflicts.`); + // If the command is run in a non-CI environment, prompt to format the files immediately. + const continueRebase = + process.env['CI'] === undefined && await promptConfirm('Manually complete rebase?'); + + if (continueRebase) { + console.info( + `After manually completing rebase, run the following command to update PR #${prNumber}:`); + console.info( + ` $ git push ${pr.baseRef.repository.url} HEAD:${pr.baseRef.name} --force-with-lease`); + console.info(); + console.info( + `To abort the rebase and return to the state of the repository before this command`); + console.info(`run the following command:`); + console.info(` $ git rebase --abort && git reset --hard && git checkout ${originalBranch}`); + process.exit(1); + } else { + console.info(`Cleaning up git state, and restoring previous state.`); + } + + cleanUpGitState(); + process.exit(1); + + /** Reset git back to the original branch. */ + function cleanUpGitState() { + // Ensure that any outstanding rebases are aborted. + exec(`git rebase --abort`); + // Ensure that any changes in the current repo state are cleared. + exec(`git reset --hard`); + // Checkout the original branch from before the run began. + exec(`git checkout ${originalBranch}`); + } +} + +/** Adds the provided token as username to the provided url. */ +function addAuthenticationToUrl(urlString: string, token: string) { + const url = new URL(urlString); + url.username = token; + return url.toString(); +} diff --git a/dev-infra/utils/github.ts b/dev-infra/utils/github.ts index 127e9f14e3..34aa9cf184 100644 --- a/dev-infra/utils/github.ts +++ b/dev-infra/utils/github.ts @@ -26,6 +26,25 @@ const graphql = unauthenticatedGraphql.defaults({ } }); +/** Get a PR from github */ +export async function getPr( + prSchema: PrSchema, number: number, {owner, name}: GithubConfig) { + const PR_QUERY = params( + { + $number: 'Int!', // The PR number + $owner: 'String!', // The organization to query for + $name: 'String!', // The organization to query for + }, + { + repository: params({owner: '$owner', name: '$name'}, { + pullRequest: params({number: '$number'}, prSchema), + }) + }); + + const result = await graphql(graphqlQuery(PR_QUERY), {number, owner, name}) as typeof PR_QUERY; + return result.repository.pullRequest; +} + /** Get all pending PRs from github */ export async function getPendingPrs(prSchema: PrSchema, {owner, name}: GithubConfig) { // The GraphQL query object to get a page of pending PRs