diff --git a/src/commands/reviewapps/create.ts b/src/commands/reviewapps/create.ts new file mode 100644 index 0000000000..349fdc772b --- /dev/null +++ b/src/commands/reviewapps/create.ts @@ -0,0 +1,71 @@ +import {Command, flags} from '@heroku-cli/command' +import * as color from '@heroku/heroku-cli-util/color' +import {ux} from '@oclif/core/ux' +import tsheredoc from 'tsheredoc' + +import notify from '../../lib/notify.js' +import disambiguate from '../../lib/pipelines/disambiguate.js' +import createReviewApp from '../../lib/reviewapps/create-review-app.js' +import {parseWaitInterval} from '../../lib/reviewapps/wait-review-app.js' + +const heredoc = tsheredoc.default + +export default class ReviewappsCreate extends Command { + static description = 'create a review app from a pipeline\'s connected repository' + static examples = [ + color.command('heroku reviewapps:create -p my-pipeline -b my-branch --wait'), + ] + static flags = { + branch: flags.string({ + char: 'b', + description: 'branch to create the review app from', + required: true, + }), + pipeline: flags.string({ + char: 'p', + description: 'name of pipeline', + required: true, + }), + wait: flags.boolean({ + description: 'watch review app creation status and exit when complete', + }), + 'wait-interval': flags.string({ + description: 'how frequently to poll in seconds (use with --wait)', + }), + } + public static notifier: (subtitle: string, message: string, success?: boolean) => void = notify + + async run() { + const {flags} = await this.parse(ReviewappsCreate) + + const interval = parseWaitInterval(flags['wait-interval']) + + const pipeline = await disambiguate(this.heroku, flags.pipeline) + + ux.action.start(`Creating review app from ${color.cyan(flags.branch)}`) + + // Fail fast if the branch doesn't exist in the pipeline's connected repo. + try { + await this.heroku.get(`/pipelines/${pipeline.id}/repo/branches/${encodeURIComponent(flags.branch)}`, { + headers: {Accept: 'application/vnd.heroku+json; version=3.repositories-api'}, + }) + } catch (error: any) { + ux.action.stop(color.red('!')) + if (error?.http?.statusCode !== 404) throw error + throw new Error(`The branch ${color.cyan(flags.branch)} doesn't exist. Make sure the branch is correct and try again.`) + } + + try { + await createReviewApp(this.heroku, pipeline.id!, flags.branch, 'resolve', flags.wait, interval) + if (flags.wait) { + ReviewappsCreate.notifier(`heroku reviewapps:create ${flags.branch}`, 'Successfully created the review app.') + } + } catch (error) { + if (flags.wait) { + ReviewappsCreate.notifier(`heroku reviewapps:create ${flags.branch}`, 'Failed to create the review app.', false) + } + + throw error + } + } +} diff --git a/src/commands/reviewapps/wait.ts b/src/commands/reviewapps/wait.ts new file mode 100644 index 0000000000..31efa570c5 --- /dev/null +++ b/src/commands/reviewapps/wait.ts @@ -0,0 +1,46 @@ +import {Command, flags} from '@heroku-cli/command' +import {Args} from '@oclif/core' + +import notify from '../../lib/notify.js' +import {parseWaitInterval, REVIEW_APP_ACCEPT, waitForReviewApp} from '../../lib/reviewapps/wait-review-app.js' +import {ReviewApp} from '../../lib/types/fir.js' + +export default class Wait extends Command { + static args = { + app: Args.string({description: 'unique identifier or name of the app', required: true}), + } + static description = 'wait for a review app to finish creating' + static flags = { + 'wait-interval': flags.string({description: 'how frequently to poll in seconds'}), + } + public static notifier: (subtitle: string, message: string, success?: boolean) => void = notify + static topic = 'reviewapps' + + public async run(): Promise { + const {args, flags} = await this.parse(Wait) + + const {body: reviewApp} = await this.heroku.get(`/apps/${args.app}/review-app`, { + headers: {Accept: REVIEW_APP_ACCEPT}, + }) + + const interval = parseWaitInterval(flags['wait-interval']) + + const startTime = new Date() + // Only notify if we actually waited at least one poll interval; a review app + // in a terminal status returns immediately and needs no notification. + const waited = () => Date.now() - startTime.valueOf() >= interval * 1000 + try { + await waitForReviewApp(this.heroku, reviewApp, interval, args.app, 'Review app is building and will be ready when complete') + } catch (error) { + if (waited()) { + Wait.notifier(`heroku reviewapps:wait ${args.app}`, 'Failed to create the review app.', false) + } + + throw error + } + + if (waited()) { + Wait.notifier(`heroku reviewapps:wait ${args.app}`, 'Successfully created the review app.') + } + } +} diff --git a/src/lib/reviewapps/create-review-app.ts b/src/lib/reviewapps/create-review-app.ts new file mode 100644 index 0000000000..f72572c6ab --- /dev/null +++ b/src/lib/reviewapps/create-review-app.ts @@ -0,0 +1,85 @@ +import {APIClient} from '@heroku-cli/command' +import * as color from '@heroku/heroku-cli-util/color' +import {ux} from '@oclif/core/ux' + +import {App, ReviewApp} from '../types/fir.js' +import {REVIEW_APP_ACCEPT, waitForReviewApp} from './wait-review-app.js' + +// eslint-disable-next-line max-params +export default async function createReviewApp( + heroku: APIClient, + pipelineId: string, + branch: string, + sourceUrl: string, + wait: boolean, + interval: number, + options: {environment?: Record; prNumber?: number} = {}, +): Promise { + const body: Record = { + branch, + pipeline: pipelineId, + source_blob: {url: sourceUrl, version: null}, + } + if (options.prNumber !== undefined) body.pr_number = options.prNumber + if (options.environment) body.environment = options.environment + + let reviewApp: ReviewApp + try { + ux.action.start(`Creating review app from ${color.cyan(branch)}`) + const res = await heroku.post('/review-apps', { + body, + headers: {Accept: REVIEW_APP_ACCEPT}, + }) + reviewApp = res.body + ux.action.stop() + } catch (error) { + ux.action.stop(color.red('!')) + throw error + } + + if (reviewApp.message) ux.stdout(reviewApp.message) + + if (reviewApp.status === 'errored') { + throw new Error(reviewApp.error_status || reviewApp.message || 'Failed to create the review app.') + } + + if (wait) { + return waitForReviewApp(heroku, reviewApp, interval, undefined, 'Review app is building and will be ready when complete') + } + + ux.action.start('Review app is building and will be ready when complete') + const appName = await resolveAppName(heroku, reviewApp, interval) + ux.action.stop('') + + if (appName) { + ux.stdout(`Run ${color.code('heroku reviewapps:wait ' + appName)} to check progress.`) + } else { + ux.stdout(`Review app for ${color.cyan(branch)} is being created in the background.`) + } + + return reviewApp +} + +const APP_ID_STATUSES = new Set(['creating', 'pending']) + +// The POST response often lands before an app has been provisioned, so poll the +// review app until an app id appears (or it stops being in progress), then look +// up the app's name. +async function resolveAppName(heroku: APIClient, reviewApp: ReviewApp, interval: number): Promise { + let current = reviewApp + + while (!current.app?.id && APP_ID_STATUSES.has(current.status)) { + // eslint-disable-next-line no-promise-executor-return + await new Promise(resolve => setTimeout(resolve, interval * 1000)) + const {body} = await heroku.get(`/review-apps/${current.id}`, { + headers: {Accept: REVIEW_APP_ACCEPT}, + }) + current = body + } + + const appId = current.app?.id + if (!appId) return undefined + + const {body: app} = await heroku.get(`/apps/${appId}`) + return app.name ?? undefined +} diff --git a/src/lib/reviewapps/wait-review-app.ts b/src/lib/reviewapps/wait-review-app.ts new file mode 100644 index 0000000000..39be1163bd --- /dev/null +++ b/src/lib/reviewapps/wait-review-app.ts @@ -0,0 +1,63 @@ +import {APIClient} from '@heroku-cli/command' +import * as color from '@heroku/heroku-cli-util/color' +import {ux} from '@oclif/core/ux' + +import {ReviewApp} from '../types/fir.js' + +export const REVIEW_APP_ACCEPT = 'application/vnd.heroku+json; version=3.review-apps' +const IN_PROGRESS_STATUSES = new Set(['creating', 'deleting', 'pending']) +const DEFAULT_WAIT_INTERVAL = 5 + +// Parse a --wait-interval flag (seconds), falling back to the default for +// missing, non-numeric, or negative values. +export function parseWaitInterval(value?: string): number { + const interval = Number.parseInt(value || '', 10) + return !interval || interval < 0 ? DEFAULT_WAIT_INTERVAL : interval +} + +// eslint-disable-next-line max-params +export const waitForReviewApp = async function ( + api: APIClient, + reviewApp: ReviewApp, + interval: number, + appIdentifier?: string, + actionMessage?: string, +): Promise { + let body = {...reviewApp} + const inProgress = IN_PROGRESS_STATUSES.has(body.status) + + // Only pending/creating/deleting are in progress. Any other status is + // terminal, so skip polling and return immediately (surfacing an error below + // if it failed). + if (inProgress) { + ux.action.start(actionMessage || `Creating review app for ${color.app(body.branch)}`) + + // Poll the app-scoped endpoint when we have an app identifier, otherwise + // fall back to looking the review app up by its own id. + const statusPath = appIdentifier + ? `/apps/${appIdentifier}/review-app` + : `/review-apps/${body.id}` + + while (IN_PROGRESS_STATUSES.has(body.status)) { + const {body: refreshed} = await api.get(statusPath, { + headers: {Accept: REVIEW_APP_ACCEPT}, + }) + body = refreshed + + // Exit as soon as it's no longer in progress so we don't sleep an extra + // interval after the app is already done. + if (!IN_PROGRESS_STATUSES.has(body.status)) break + + // eslint-disable-next-line no-promise-executor-return + await new Promise(resolve => setTimeout(resolve, interval * 1000)) + } + } + + if (body.status === 'errored') { + if (inProgress) ux.action.stop(color.red('!')) + throw new Error(body.message || body.error_status || `Failed to create the review app. (Status: ${body.status})`) + } + + if (inProgress) ux.action.stop() + return body +} diff --git a/test/unit/commands/reviewapps/create.unit.test.ts b/test/unit/commands/reviewapps/create.unit.test.ts new file mode 100644 index 0000000000..0c408c93ea --- /dev/null +++ b/test/unit/commands/reviewapps/create.unit.test.ts @@ -0,0 +1,159 @@ +import {runCommand} from '@heroku-cli/test-utils' +import * as color from '@heroku/heroku-cli-util/color' +import {expect} from 'chai' +import lolex from 'lolex' +import nock from 'nock' +import {createSandbox} from 'sinon' + +import Cmd from '../../../../src/commands/reviewapps/create.js' +import {REVIEW_APP_ACCEPT} from '../../../../src/lib/reviewapps/wait-review-app.js' + +describe('reviewapps:create', function () { + const pipeline = {id: '123-pipeline', name: 'my-pipeline'} + + let api: nock.Scope + let clock: any + let sandbox: any + + beforeEach(function () { + api = nock('https://api.heroku.com') + api.get('/pipelines?eq[name]=my-pipeline').reply(200, [pipeline]) + api.get(`/pipelines/${pipeline.id}/repo/branches/my-branch`).reply(200, {name: 'my-branch'}) + sandbox = createSandbox() + clock = lolex.install() + clock.setTimeout = function (fn: any) { + process.nextTick(fn) + } + }) + + afterEach(function () { + api.done() + nock.cleanAll() + clock.uninstall() + sandbox.restore() + }) + + it('prints build progress with the review app name when --wait is not passed', async function () { + nock('https://api.heroku.com', {reqheaders: {Accept: REVIEW_APP_ACCEPT}}) + .post('/review-apps', { + branch: 'my-branch', + pipeline: pipeline.id, + source_blob: {url: 'resolve', version: null}, + }) + .reply(201, { + app: null, branch: 'my-branch', id: 'ra-1', message: null, pipeline: {id: pipeline.id}, status: 'pending', + }) + .get('/review-apps/ra-1') + .reply(200, { + app: {id: 'app-1'}, branch: 'my-branch', id: 'ra-1', pipeline: {id: pipeline.id}, status: 'creating', + }) + api.get('/apps/app-1').reply(200, {id: 'app-1', name: 'my-review-app'}) + + const {stderr, stdout} = await runCommand(Cmd, ['--pipeline=my-pipeline', '--branch=my-branch', '--wait-interval', '1']) + + expect(stderr).to.include('Review app is building and will be ready when complete') + expect(stdout).to.include('heroku reviewapps:wait my-review-app') + }) + + it('resolves the app name immediately when the POST already returns an app', async function () { + nock('https://api.heroku.com', {reqheaders: {Accept: REVIEW_APP_ACCEPT}}) + .post('/review-apps') + .reply(201, { + app: {id: 'app-1'}, branch: 'my-branch', id: 'ra-1', message: null, pipeline: {id: pipeline.id}, status: 'pending', + }) + api.get('/apps/app-1').reply(200, {id: 'app-1', name: 'my-review-app'}) + + const {stderr, stdout} = await runCommand(Cmd, ['--pipeline=my-pipeline', '--branch=my-branch']) + + expect(stderr).to.include('Review app is building and will be ready when complete') + expect(stdout).to.include('heroku reviewapps:wait my-review-app') + }) + + it('falls back to the background message when the app never appears', async function () { + nock('https://api.heroku.com', {reqheaders: {Accept: REVIEW_APP_ACCEPT}}) + .post('/review-apps') + .reply(201, { + app: null, branch: 'my-branch', id: 'ra-1', message: null, pipeline: {id: pipeline.id}, status: 'created', + }) + + const {stdout} = await runCommand(Cmd, ['--pipeline=my-pipeline', '--branch=my-branch']) + + expect(stdout).to.include('is being created in the background') + }) + + it('waits until the review app is created with --wait', async function () { + const notifySpy = sandbox.spy(Cmd, 'notifier') + nock('https://api.heroku.com', {reqheaders: {Accept: REVIEW_APP_ACCEPT}}) + .post('/review-apps') + .reply(201, { + branch: 'my-branch', id: 'ra-1', message: null, pipeline: {id: pipeline.id}, status: 'pending', + }) + .get('/review-apps/ra-1') + .reply(200, { + branch: 'my-branch', id: 'ra-1', pipeline: {id: pipeline.id}, status: 'creating', + }) + .get('/review-apps/ra-1') + .reply(200, { + branch: 'my-branch', id: 'ra-1', pipeline: {id: pipeline.id}, status: 'created', + }) + + const {stderr} = await runCommand(Cmd, ['--pipeline=my-pipeline', '--branch=my-branch', '--wait', '--wait-interval', '1']) + + expect(stderr).to.include('Review app is building and will be ready when complete') + expect(notifySpy.calledWith('heroku reviewapps:create my-branch', 'Successfully created the review app.')).to.be.true + }) + + it('throws and notifies with failure when the review app errors with --wait', async function () { + const notifySpy = sandbox.spy(Cmd, 'notifier') + api + .post('/review-apps') + .reply(201, { + branch: 'my-branch', id: 'ra-1', message: null, pipeline: {id: pipeline.id}, status: 'pending', + }) + .get('/review-apps/ra-1') + .reply(200, { + branch: 'my-branch', error_status: 'boom', id: 'ra-1', message: null, pipeline: {id: pipeline.id}, status: 'errored', + }) + + const {error} = await runCommand(Cmd, ['--pipeline=my-pipeline', '--branch=my-branch', '--wait', '--wait-interval', '1']) + + expect(error?.message).to.equal('boom') + expect(notifySpy.calledWith('heroku reviewapps:create my-branch', 'Failed to create the review app.', false)).to.be.true + }) + + it('does not poll and prints nothing when already created with --wait', async function () { + const notifySpy = sandbox.spy(Cmd, 'notifier') + api + .post('/review-apps') + .reply(201, { + branch: 'my-branch', id: 'ra-1', message: null, pipeline: {id: pipeline.id}, status: 'created', + }) + + const {stderr} = await runCommand(Cmd, ['--pipeline=my-pipeline', '--branch=my-branch', '--wait']) + + expect(stderr).to.not.include('Review app is building') + expect(notifySpy.calledWith('heroku reviewapps:create my-branch', 'Successfully created the review app.')).to.be.true + }) + + it('errors before creating when the branch does not exist', async function () { + nock.cleanAll() + api = nock('https://api.heroku.com') + api.get('/pipelines?eq[name]=my-pipeline').reply(200, [pipeline]) + api.get(`/pipelines/${pipeline.id}/repo/branches/missing-branch`).reply(404, {message: 'Not found'}) + + const {error} = await runCommand(Cmd, ['--pipeline=my-pipeline', '--branch=missing-branch']) + + expect(error?.message).to.equal(`The branch ${color.cyan('missing-branch')} doesn't exist. Make sure the branch is correct and try again.`) + }) + + it('rethrows non-404 errors from the branch check instead of reporting "Branch not found"', async function () { + nock.cleanAll() + api = nock('https://api.heroku.com') + api.get('/pipelines?eq[name]=my-pipeline').reply(200, [pipeline]) + api.get(`/pipelines/${pipeline.id}/repo/branches/my-branch`).reply(500, {message: 'Internal server error'}) + + const {error} = await runCommand(Cmd, ['--pipeline=my-pipeline', '--branch=my-branch']) + + expect(error?.message).to.not.equal('Branch not found') + }) +}) diff --git a/test/unit/commands/reviewapps/wait.unit.test.ts b/test/unit/commands/reviewapps/wait.unit.test.ts new file mode 100644 index 0000000000..4d658f7e43 --- /dev/null +++ b/test/unit/commands/reviewapps/wait.unit.test.ts @@ -0,0 +1,167 @@ +import {runCommand} from '@heroku-cli/test-utils' +import {expect} from 'chai' +import lolex from 'lolex' +import nock from 'nock' +import {createSandbox} from 'sinon' + +import Cmd from '../../../../src/commands/reviewapps/wait.js' +import {REVIEW_APP_ACCEPT} from '../../../../src/lib/reviewapps/wait-review-app.js' + +describe('reviewapps:wait', function () { + let api: nock.Scope + let clock: any + let sandbox: any + + beforeEach(function () { + api = nock('https://api.heroku.com') + sandbox = createSandbox() + clock = lolex.install() + clock.setTimeout = function (fn: any) { + process.nextTick(fn) + } + }) + + afterEach(function () { + api.done() + nock.cleanAll() + clock.uninstall() + sandbox.restore() + }) + + it('waits until the review app is created when given an app', async function () { + nock('https://api.heroku.com', {reqheaders: {Accept: REVIEW_APP_ACCEPT}}) + .get('/apps/my-app/review-app') + .reply(200, { + branch: 'b', id: 'ra-1', pipeline: {id: 'p'}, status: 'creating', + }) + .get('/apps/my-app/review-app') + .reply(200, { + branch: 'b', id: 'ra-1', pipeline: {id: 'p'}, status: 'created', + }) + + const {stderr} = await runCommand(Cmd, ['my-app', '--wait-interval', '1']) + + expect(stderr).to.include('Review app is building and will be ready when complete') + expect(stderr).to.include('done') + }) + + it('prints nothing when the review app is already created', async function () { + api + .get('/apps/my-app/review-app') + .reply(200, { + branch: 'b', id: 'ra-1', pipeline: {id: 'p'}, status: 'created', + }) + + const {stderr, stdout} = await runCommand(Cmd, ['my-app']) + + expect(stderr).to.equal('') + expect(stdout).to.equal('') + }) + + it('returns immediately for a terminal status that is not in progress', async function () { + api + .get('/apps/my-app/review-app') + .reply(200, { + branch: 'b', id: 'ra-1', pipeline: {id: 'p'}, status: 'deleted', + }) + + const {stderr, stdout} = await runCommand(Cmd, ['my-app']) + + expect(stderr).to.equal('') + expect(stdout).to.equal('') + }) + + it('polls until complete when the review app is deleting', async function () { + nock('https://api.heroku.com', {reqheaders: {Accept: REVIEW_APP_ACCEPT}}) + .get('/apps/my-app/review-app') + .reply(200, { + branch: 'b', id: 'ra-1', pipeline: {id: 'p'}, status: 'deleting', + }) + .get('/apps/my-app/review-app') + .reply(200, { + branch: 'b', id: 'ra-1', pipeline: {id: 'p'}, status: 'deleted', + }) + + const {stderr} = await runCommand(Cmd, ['my-app', '--wait-interval', '1']) + + expect(stderr).to.include('done') + }) + + it('surfaces the failure without notifying when the review app is already errored', async function () { + const notifySpy = sandbox.spy(Cmd, 'notifier') + api + .get('/apps/my-app/review-app') + .reply(200, { + branch: 'b', error_status: 'kaboom', id: 'ra-1', message: null, pipeline: {id: 'p'}, status: 'errored', + }) + + const {error} = await runCommand(Cmd, ['my-app']) + + expect(error?.message).to.equal('kaboom') + expect(notifySpy.called).to.be.false + }) + + it('throws and notifies with failure when the review app errors after polling', async function () { + const notifySpy = sandbox.spy(Cmd, 'notifier') + api + .get('/apps/my-app/review-app') + .reply(200, { + branch: 'b', id: 'ra-1', pipeline: {id: 'p'}, status: 'creating', + }) + .get('/apps/my-app/review-app') + .reply(200, () => { + clock.tick(5000) + return { + branch: 'b', error_status: 'creating', id: 'ra-1', message: 'Build failed.', pipeline: {id: 'p'}, status: 'errored', + } + }) + + const {error} = await runCommand(Cmd, ['my-app', '--wait-interval', '1']) + + expect(error?.message).to.equal('Build failed.') + expect(notifySpy.calledWith('heroku reviewapps:wait my-app', 'Failed to create the review app.', false)).to.be.true + }) + + it('notifies when creation takes at least one poll interval', async function () { + const notifySpy = sandbox.spy(Cmd, 'notifier') + api + .get('/apps/my-app/review-app') + .reply(200, { + branch: 'b', id: 'ra-1', pipeline: {id: 'p'}, status: 'creating', + }) + .get('/apps/my-app/review-app') + .reply(200, () => { + clock.tick(5000) + return { + branch: 'b', id: 'ra-1', pipeline: {id: 'p'}, status: 'created', + } + }) + + await runCommand(Cmd, ['my-app', '--wait-interval', '1']) + + expect(notifySpy.called).to.be.true + }) + + it('does NOT notify when creation takes less than one poll interval', async function () { + const notifySpy = sandbox.spy(Cmd, 'notifier') + api + .get('/apps/my-app/review-app') + .reply(200, { + branch: 'b', id: 'ra-1', pipeline: {id: 'p'}, status: 'creating', + }) + .get('/apps/my-app/review-app') + .reply(200, { + branch: 'b', id: 'ra-1', pipeline: {id: 'p'}, status: 'created', + }) + + await runCommand(Cmd, ['my-app', '--wait-interval', '1']) + + expect(notifySpy.called).to.be.false + }) + + it('errors when no app is provided', async function () { + const {error} = await runCommand(Cmd, []) + + expect(error?.message).to.include('Missing 1 required arg') + }) +})