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
71 changes: 71 additions & 0 deletions src/commands/reviewapps/create.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
}
46 changes: 46 additions & 0 deletions src/commands/reviewapps/wait.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const {args, flags} = await this.parse(Wait)

const {body: reviewApp} = await this.heroku.get<ReviewApp>(`/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.')
}
}
}
85 changes: 85 additions & 0 deletions src/lib/reviewapps/create-review-app.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>; prNumber?: number} = {},
): Promise<ReviewApp> {
const body: Record<string, unknown> = {
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<ReviewApp>('/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<string | undefined> {
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<ReviewApp>(`/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<App>(`/apps/${appId}`)
return app.name ?? undefined
}
63 changes: 63 additions & 0 deletions src/lib/reviewapps/wait-review-app.ts
Original file line number Diff line number Diff line change
@@ -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<ReviewApp> {
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<ReviewApp>(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
}
Loading
Loading