diff --git a/scripts/update_prs.js b/scripts/update_prs.js new file mode 100644 index 000000000000..32ca032962c9 --- /dev/null +++ b/scripts/update_prs.js @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +require('../src/setup_node_env'); +require('../src/dev/prs/run_update_prs_cli'); diff --git a/src/dev/prs/github_api.ts b/src/dev/prs/github_api.ts new file mode 100644 index 000000000000..c0a4a0389777 --- /dev/null +++ b/src/dev/prs/github_api.ts @@ -0,0 +1,81 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import axios, { AxiosError, AxiosResponse } from 'axios'; + +import { createFailError } from '../run'; + +interface ResponseError extends AxiosError { + request: any; + response: AxiosResponse; +} +const isResponseError = (error: any): error is ResponseError => + error && error.response && error.response.status; + +const isRateLimitError = (error: any) => + isResponseError(error) && + error.response.status === 403 && + `${error.response.headers['X-RateLimit-Remaining']}` === '0'; + +export class GithubApi { + private api = axios.create({ + baseURL: 'https://api.github.com/', + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'kibana/update_prs_cli', + ...(this.accessToken ? { Authorization: `token ${this.accessToken} ` } : {}), + }, + }); + + constructor(private accessToken?: string) {} + + async getPrInfo(prNumber: number) { + try { + const resp = await this.api.get(`repos/elastic/kibana/pulls/${prNumber}`); + const targetRef: string = resp.data.base && resp.data.base.ref; + if (!targetRef) { + throw new Error('unable to read base ref from pr info'); + } + + const owner: string = resp.data.head && resp.data.head.user && resp.data.head.user.login; + if (!owner) { + throw new Error('unable to read owner info from pr info'); + } + + const sourceBranch: string = resp.data.head.ref; + if (!sourceBranch) { + throw new Error('unable to read source branch name from pr info'); + } + + return { + targetRef, + owner, + sourceBranch, + }; + } catch (error) { + if (!isRateLimitError(error)) { + throw error; + } + + throw createFailError( + 'github rate limit exceeded, please specify the `--access-token` command line flag and try again' + ); + } + } +} diff --git a/src/dev/prs/helpers.ts b/src/dev/prs/helpers.ts new file mode 100644 index 000000000000..d25db1a79a1b --- /dev/null +++ b/src/dev/prs/helpers.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Readable } from 'stream'; +import * as Rx from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +/** + * Convert a Readable stream to an observable of lines + */ +export const getLine$ = (stream: Readable) => { + return new Rx.Observable(subscriber => { + let buffer = ''; + return Rx.fromEvent(stream, 'data') + .pipe(takeUntil(Rx.fromEvent(stream, 'close'))) + .subscribe({ + next(chunk) { + buffer += chunk; + while (true) { + const i = buffer.indexOf('\n'); + if (i === -1) { + break; + } + + subscriber.next(buffer.slice(0, i)); + buffer = buffer.slice(i + 1); + } + }, + error(error) { + subscriber.error(error); + }, + complete() { + if (buffer.length) { + subscriber.next(buffer); + buffer = ''; + } + + subscriber.complete(); + }, + }); + }); +}; diff --git a/src/dev/prs/pr.ts b/src/dev/prs/pr.ts new file mode 100644 index 000000000000..7b86b1229e83 --- /dev/null +++ b/src/dev/prs/pr.ts @@ -0,0 +1,43 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createFlagError } from '../run'; + +const isNum = (input: string) => { + return /^\d+$/.test(input); +}; + +export class Pr { + static parseInput(input: string) { + if (!isNum(input)) { + throw createFlagError(`invalid pr number [${input}], expected a number`); + } + + return parseInt(input, 10); + } + + public readonly remoteRef = `pull/${this.number}/head`; + + constructor( + public readonly number: number, + public readonly targetRef: string, + public readonly owner: string, + public readonly sourceBranch: string + ) {} +} diff --git a/src/dev/prs/run_update_prs_cli.ts b/src/dev/prs/run_update_prs_cli.ts new file mode 100644 index 000000000000..e626dcee6d34 --- /dev/null +++ b/src/dev/prs/run_update_prs_cli.ts @@ -0,0 +1,179 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'path'; + +import * as Rx from 'rxjs'; +import execa from 'execa'; +import chalk from 'chalk'; +import { first, tap } from 'rxjs/operators'; +import dedent from 'dedent'; + +import { getLine$ } from './helpers'; +import { run, createFlagError } from '../run'; +import { Pr } from './pr'; +import { GithubApi } from './github_api'; + +const UPSTREAM_URL = 'git@github.com:elastic/kibana.git'; + +run( + async ({ flags, log }) => { + /** + * Start off by consuming the necessary flags so that errors from invalid + * flags can be thrown before anything serious is done + */ + const accessToken = flags['access-token']; + if (typeof accessToken !== 'string' && accessToken !== undefined) { + throw createFlagError('invalid --access-token, expected a single string'); + } + + const repoDir = flags['repo-dir']; + if (typeof repoDir !== 'string') { + throw createFlagError('invalid --repo-dir, expected a single string'); + } + + const prNumbers = flags._.map(arg => Pr.parseInput(arg)); + + /** + * Call the Gitub API once for each PR to get the targetRef so we know which branch to pull + * into that pr + */ + const api = new GithubApi(accessToken); + const prs = await Promise.all( + prNumbers.map(async prNumber => { + const { targetRef, owner, sourceBranch } = await api.getPrInfo(prNumber); + return new Pr(prNumber, targetRef, owner, sourceBranch); + }) + ); + + const execInDir = async (cmd: string, args: string[]) => { + log.debug(`$ ${cmd} ${args.join(' ')}`); + + const proc = execa(cmd, args, { + cwd: repoDir, + stdio: ['inherit', 'pipe', 'pipe'], + } as any); + + await Promise.all([ + proc.then(() => log.debug(` - ${cmd} exited with 0`)), + Rx.merge(getLine$(proc.stdout), getLine$(proc.stderr)) + .pipe(tap(line => log.debug(line))) + .toPromise(), + ]); + }; + + const init = async () => { + // ensure local repo is initialized + await execa('git', ['init', repoDir]); + + try { + // attempt to init upstream remote + await execInDir('git', ['remote', 'add', 'upstream', UPSTREAM_URL]); + } catch (error) { + if (error.code !== 128) { + throw error; + } + + // remote already exists, update its url + await execInDir('git', ['remote', 'set-url', 'upstream', UPSTREAM_URL]); + } + }; + + const updatePr = async (pr: Pr) => { + log.info('Fetching...'); + await execInDir('git', [ + 'fetch', + 'upstream', + '-fun', + `pull/${pr.number}/head:${pr.sourceBranch}`, + ]); + await execInDir('git', ['reset', '--hard']); + await execInDir('git', ['clean', '-fd']); + + log.info('Checking out %s:%s locally', pr.owner, pr.sourceBranch); + await execInDir('git', ['checkout', pr.sourceBranch]); + + try { + log.info('Pulling in changes from elastic:%s', pr.targetRef); + await execInDir('git', ['pull', 'upstream', pr.targetRef, '--no-edit']); + } catch (error) { + if (!error.stdout.includes('Automatic merge failed;')) { + throw error; + } + + const resolveConflicts = async () => { + log.error(chalk.red('Conflict resolution required')); + log.info( + dedent(chalk` + Please resolve the merge conflicts in ${repoDir} in another terminal window. + Once the conflicts are resolved run the following in the other window: + + git commit --no-edit + + {bold hit the enter key when complete} + `) + '\n' + ); + + await getLine$(process.stdin) + .pipe(first()) + .toPromise(); + + try { + await execInDir('git', ['diff-index', '--quiet', 'HEAD', '--']); + } catch (_) { + log.error(`Uncommitted changes in ${repoDir}`); + await resolveConflicts(); + } + }; + + await resolveConflicts(); + } + + log.info('Pushing changes to %s:%s', pr.owner, pr.sourceBranch); + await execInDir('git', [ + 'push', + `git@github.com:${pr.owner}/kibana.git`, + `HEAD:${pr.sourceBranch}`, + ]); + + log.success('updated'); + }; + + await init(); + for (const pr of prs) { + log.info('pr #%s', pr.number); + log.indent(4); + try { + await updatePr(pr); + } finally { + log.indent(-4); + } + } + }, + { + description: 'Update github PRs with the latest changes from their base branch', + usage: 'node scripts/update_prs number [...numbers]', + flags: { + string: ['repo-dir', 'access-token'], + default: { + 'repo-dir': resolve(__dirname, '../../../data/.update_prs'), + }, + }, + } +);