Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: migrate apps:transfer to oclif/core #2767

Merged
merged 20 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
998e380
refactor: initial migration of apps:transfer to oclif/core
k80bowman Mar 13, 2024
f262148
refactor: move access utils since they are used by apps command
k80bowman Mar 15, 2024
daec2e8
refactor: add isValidEmail to teamUtils
k80bowman Mar 15, 2024
f0c9303
refactor: migrate AppTransfer lib class
k80bowman Mar 15, 2024
6dc469a
refactor: fix types and update getAppsToTransfer
k80bowman Mar 15, 2024
0209fd5
refactor: add confirm flag and construct app name
k80bowman Mar 15, 2024
e5bff00
refactor: print error message and update lock.run call
k80bowman Mar 15, 2024
f9ed17c
refactor: initial refactor of apps:transfer tests
k80bowman Apr 2, 2024
2f93e3a
refactor: replace AppTransfer class with function and fix message output
k80bowman Apr 2, 2024
4f914e4
refactor: test fixes
k80bowman Apr 3, 2024
c4f9311
refactor: remove .only
k80bowman Apr 3, 2024
3086ba3
refactor: add await to getConfig for test setup
k80bowman Apr 3, 2024
dc0556f
fix: add app flag to AppsLock.run call and fix test
k80bowman Apr 3, 2024
683d4af
fix: add async await for commands that need the config
k80bowman Apr 4, 2024
7ff0371
fix: add nock for addons:wait test api call
k80bowman Apr 4, 2024
8ef5709
fix: fix apps:transfer test accounting for mac and windows formatting
k80bowman Apr 4, 2024
3eb5dbe
fix: one more windows test fix for apps:transfer
k80bowman Apr 4, 2024
b573e24
refactor: remove orgs-v5 package directory
k80bowman Apr 4, 2024
d6169f9
fix: update yarn.lock
k80bowman Apr 4, 2024
b273ffb
refactor: remove import of orgs-v5 plugin from cli
k80bowman Apr 8, 2024
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
2 changes: 0 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"@heroku-cli/notifications": "^1.2.4",
"@heroku-cli/plugin-certs-v5": "^9.0.0-alpha.0",
"@heroku-cli/plugin-ci-v5": "^9.0.0-alpha.0",
"@heroku-cli/plugin-orgs-v5": "^9.0.0-alpha.0",
"@heroku-cli/plugin-pg-v5": "^9.0.0-alpha.0",
"@heroku-cli/plugin-ps": "^8.1.7",
"@heroku-cli/plugin-ps-exec": "^2.4.0",
Expand Down Expand Up @@ -182,7 +181,6 @@
"plugins": [
"@oclif/plugin-legacy",
"@heroku-cli/plugin-certs-v5",
"@heroku-cli/plugin-orgs-v5",
"@heroku-cli/plugin-pg-v5",
"@heroku-cli/plugin-ps-exec",
"@heroku-cli/plugin-spaces",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/access/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import color from '@heroku-cli/color'
import {Command, flags} from '@heroku-cli/command'
import {Args, ux} from '@oclif/core'
import * as Heroku from '@heroku-cli/schema'
import {isTeamApp, getOwner} from '../../lib/access/utils'
import {isTeamApp, getOwner} from '../../lib/teamUtils'
import * as _ from 'lodash'
export default class AccessAdd extends Command {
static description = 'add new users to your app'
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/access/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {Command, flags} from '@heroku-cli/command'
import {ux} from '@oclif/core'
import * as Heroku from '@heroku-cli/schema'
import * as _ from 'lodash'
import {isTeamApp, getOwner} from '../../lib/access/utils'
import {isTeamApp, getOwner} from '../../lib/teamUtils'

type MemberData = {
email: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/access/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import color from '@heroku-cli/color'
import {Command, flags} from '@heroku-cli/command'
import {Args, ux} from '@oclif/core'
import * as Heroku from '@heroku-cli/schema'
import {isTeamApp} from '../../lib/access/utils'
import {isTeamApp} from '../../lib/teamUtils'

export default class Update extends Command {
static topic = 'access';
Expand Down
91 changes: 91 additions & 0 deletions packages/cli/src/commands/apps/transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import color from '@heroku-cli/color'
import {Command, flags} from '@heroku-cli/command'
import {Args, ux} from '@oclif/core'
import * as Heroku from '@heroku-cli/schema'
import {sortBy} from 'lodash'
import * as inquirer from 'inquirer'
import {getOwner, isTeamApp, isValidEmail} from '../../lib/teamUtils'
import AppsLock from './lock'
import {appTransfer} from '../../lib/apps/app-transfer'
import confirmApp from '../../lib/apps/confirm-app'

function getAppsToTransfer(apps: Heroku.App[]) {
return inquirer.prompt([{
type: 'checkbox',
name: 'choices',
pageSize: 20,
message: 'Select applications you would like to transfer',
choices: apps.map(function (app) {
return {
name: `${app.name} (${getOwner(app.owner?.email)})`, value: {name: app.name, owner: app.owner?.email},
}
}),
}])
}

export default class AppsTransfer extends Command {
static topic = 'apps';
static description = 'transfer applications to another user or team';
static flags = {
locked: flags.boolean({char: 'l', required: false, description: 'lock the app upon transfer'}),
bulk: flags.boolean({required: false, description: 'transfer applications in bulk'}),
app: flags.app(),
remote: flags.remote({char: 'r'}),
confirm: flags.string({char: 'c', hidden: true}),
};

static args = {
recipient: Args.string({description: 'user or team to transfer applications to', required: true}),
};

static examples = [`$ heroku apps:transfer collaborator@example.com
Transferring example to collaborator@example.com... done

$ heroku apps:transfer acme-widgets
Transferring example to acme-widgets... done

$ heroku apps:transfer --bulk acme-widgets
...`]

public async run() {
const {flags, args} = await this.parse(AppsTransfer)
const {app, bulk, locked, confirm} = flags
const recipient = args.recipient
if (bulk) {
const {body: allApps} = await this.heroku.get<Heroku.App[]>('/apps')
const selectedApps = await getAppsToTransfer(sortBy(allApps, 'name'))
ux.warn(`Transferring applications to ${color.magenta(recipient)}...\n`)
for (const app of selectedApps.choices) {
try {
await appTransfer({
heroku: this.heroku,
appName: app.name,
recipient: recipient,
personalToPersonal: isValidEmail(recipient) && !isTeamApp(app.owner),
bulk: true,
})
} catch (error) {
const {message} = error as {message: string}
ux.error(message)
}
}
} else {
const {body: appInfo} = await this.heroku.get<Heroku.App>(`/apps/${app}`)
const appName = appInfo.name ?? app ?? ''
if (isValidEmail(recipient) && isTeamApp(appInfo.owner?.email)) {
await confirmApp(appName, confirm, 'All collaborators will be removed from this app')
}

await appTransfer({
heroku: this.heroku,
appName,
recipient,
personalToPersonal: isValidEmail(recipient) && !isTeamApp(appInfo.owner?.email),
bulk,
})
if (locked) {
await AppsLock.run(['--app', appName], this.config)
}
}
}
}
45 changes: 45 additions & 0 deletions packages/cli/src/lib/apps/app-transfer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {APIClient} from '@heroku-cli/command'
import {color} from '@heroku-cli/color'
import * as Heroku from '@heroku-cli/schema'
import {ux} from '@oclif/core'

type Options = {
heroku: APIClient,
appName: string,
recipient: string,
personalToPersonal: boolean,
bulk: boolean,
}

const getRequestOpts = (options: Options) => {
const {appName, bulk, recipient, personalToPersonal} = options
const isPersonalToPersonal = personalToPersonal || personalToPersonal === undefined
const requestOpts = isPersonalToPersonal ?
{
body: {app: appName, recipient},
transferMsg: `Initiating transfer of ${color.app(appName)}`,
path: '/account/app-transfers',
method: 'POST',
} : {
body: {owner: recipient},
transferMsg: `Transferring ${color.app(appName)}`,
path: `/teams/apps/${appName}`,
method: 'PATCH',
}
if (!bulk) requestOpts.transferMsg += ` to ${color.magenta(recipient)}`
return requestOpts
}

export const appTransfer = async (options: Options) => {
const {body, transferMsg, path, method} = getRequestOpts(options)
ux.action.start(transferMsg)
const {body: request} = await options.heroku.request<Heroku.TeamApp>(
path,
{
method,
body,
},
)
const message = request.state === 'pending' ? 'email sent' : undefined
ux.action.stop(message)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ export const getOwner = function (owner: string | undefined) {

return owner
}

export const isValidEmail = function (email: string) {
return /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(email)
}
5 changes: 3 additions & 2 deletions packages/cli/test/helpers/runCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ const stopMock = () => {
stderr.stop()
}

const runCommand = (Cmd: GenericCmd, args: string[] = [], printStd = false) => {
const instance = new Cmd(args, getConfig())
const runCommand = async (Cmd: GenericCmd, args: string[] = [], printStd = false) => {
const conf = await getConfig()
const instance = new Cmd(args, conf)
if (printStd) {
stdout.print = true
stderr.print = true
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/test/helpers/testInstances.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import {APIClient} from '@heroku-cli/command'
import {Config} from '@oclif/core'

export const getConfig = () => new Config({root: '../../package.json'})
export const getConfig = async () => {
const pjsonPath = require.resolve('../../package.json')
const conf = new Config({root: pjsonPath})
await conf.load()
return conf
}

export const getHerokuAPI = () => new APIClient(getConfig())
export const getHerokuAPI = async () => {
const conf = await getConfig()
return new APIClient(conf)
}
2 changes: 2 additions & 0 deletions packages/cli/test/unit/commands/addons/wait.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ Created www-redis as REDIS_URL
})
it('shows that it failed to provision', function () {
nock('https://api.heroku.com')
.post('/actions/addons/resolve', {app: null, addon: 'www-redis'})
.reply(200, [fixtures.addons['www-redis']])
.get('/addons/www-redis')
.reply(200, fixtures.addons['www-redis'])
const deprovisionedAddon = _.clone(fixtures.addons['www-redis'])
Expand Down
146 changes: 146 additions & 0 deletions packages/cli/test/unit/commands/apps/transfer.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {stdout, stderr} from 'stdout-stderr'
import * as nock from 'nock'
import * as proxyquire from 'proxyquire'
import {expect} from 'chai'
import runCommand, {GenericCmd} from '../../../helpers/runCommand'
import {apps, personalApp, teamApp} from '../../../helpers/stubs/get'
import {teamAppTransfer} from '../../../helpers/stubs/patch'
import {personalToPersonal} from '../../../helpers/stubs/post'

let Cmd: GenericCmd
let inquirer: {prompt?: (prompts: { choices: any }[]) => void} = {}

describe('heroku apps:transfer', () => {
beforeEach(() => {
inquirer = {}
const {default: proxyCmd} = proxyquire('../../../../src/commands/apps/transfer', {
inquirer,
'@noCallThru': true,
})
Cmd = proxyCmd
})
afterEach(() => nock.cleanAll())
context('when transferring in bulk', () => {
beforeEach(() => {
apps()
})
it('transfers selected apps to a team', async () => {
inquirer.prompt = (prompts: { choices: any }[]) => {
const choices = prompts[0].choices
expect(choices).to.eql([
{
name: 'my-team-app (team)', value: {name: 'my-team-app', owner: 'team@herokumanager.com'},
}, {
name: 'myapp (foo@foo.com)', value: {name: 'myapp', owner: 'foo@foo.com'},
},
])
return Promise.resolve({choices: [{name: 'myapp', owner: 'foo@foo.com'}]})
}

const api = teamAppTransfer()
await runCommand(Cmd, [
'--bulk',
'team',
])
api.done()
expect(stderr.output).to.include('Warning: Transferring applications to team...\n')
expect(stderr.output).to.include('\nTransferring ⬢ myapp...\nTransferring ⬢ myapp... done\n')
})
it('transfers selected apps to a personal account', async () => {
inquirer.prompt = (prompts: { choices: any }[]) => {
const choices = prompts[0].choices
expect(choices).to.eql([
{
name: 'my-team-app (team)', value: {name: 'my-team-app', owner: 'team@herokumanager.com'},
}, {
name: 'myapp (foo@foo.com)', value: {name: 'myapp', owner: 'foo@foo.com'},
},
])
return Promise.resolve({choices: [{name: 'myapp', owner: 'foo@foo.com'}]})
}

const api = personalToPersonal()
await runCommand(Cmd, [
'--bulk',
'raulb@heroku.com',
])
api.done()
expect(stderr.output).to.include('Warning: Transferring applications to raulb@heroku.com...\n')
expect(stderr.output).to.include('\nInitiating transfer of ⬢ myapp...\nInitiating transfer of ⬢ myapp... email sent\n')
})
})
context('when it is a personal app', () => {
beforeEach(() => {
personalApp()
})
it('transfers the app to a personal account', async () => {
const api = personalToPersonal()
await runCommand(Cmd, [
'--app',
'myapp',
'raulb@heroku.com',
])
expect('').to.eq(stdout.output)
expect('Initiating transfer of ⬢ myapp to raulb@heroku.com...\nInitiating transfer of ⬢ myapp to raulb@heroku.com... email sent\n').to.eq(stderr.output)
api.done()
})
it('transfers the app to a team', async () => {
const api = teamAppTransfer()
await runCommand(Cmd, [
'--app',
'myapp',
'team',
])
expect('').to.eq(stdout.output)
expect('Transferring ⬢ myapp to team...\nTransferring ⬢ myapp to team... done\n').to.eq(stderr.output)
api.done()
})
})
context('when it is an org app', () => {
beforeEach(() => {
teamApp()
})
it('transfers the app to a personal account confirming app name', async () => {
const api = teamAppTransfer()
await runCommand(Cmd, [
'--app',
'myapp',
'--confirm',
'myapp',
'team',
])
expect('').to.eq(stdout.output)
expect('Transferring ⬢ myapp to team...\nTransferring ⬢ myapp to team... done\n').to.eq(stderr.output)
api.done()
})
it('transfers the app to a team', async () => {
const api = teamAppTransfer()
await runCommand(Cmd, [
'--app',
'myapp',
'team',
])
expect('').to.eq(stdout.output)
expect('Transferring ⬢ myapp to team...\nTransferring ⬢ myapp to team... done\n').to.eq(stderr.output)
api.done()
})
it('transfers and locks the app if --locked is passed', async () => {
const api = teamAppTransfer()
const lockedAPI = nock('https://api.heroku.com:443')
.get('/teams/apps/myapp')
.reply(200, {name: 'myapp', locked: false})
.patch('/teams/apps/myapp', {locked: true})
.reply(200)
await runCommand(Cmd, [
'--app',
'myapp',
'--locked',
'team',
])
expect('').to.eq(stdout.output)
expect('Transferring ⬢ myapp to team...\nTransferring ⬢ myapp to team... done\nLocking myapp...\nLocking myapp... done\n').to.eq(stderr.output)
api.done()
lockedAPI.done()
})
})
})
Loading
Loading