diff --git a/packages/cli/src/commands/members/index.ts b/packages/cli/src/commands/members/index.ts new file mode 100644 index 0000000000..fd87e09c17 --- /dev/null +++ b/packages/cli/src/commands/members/index.ts @@ -0,0 +1,88 @@ +import color from '@heroku-cli/color' +import {Command, flags} from '@heroku-cli/command' +import {RoleCompletion} from '@heroku-cli/command/lib/completions' +import {ux} from '@oclif/core' +import * as Heroku from '@heroku-cli/schema' + +const _ = require('lodash') + +type MemberWithStatus = Heroku.TeamMember & { status?: string } + +const buildTableColumns = (teamInvites: Heroku.TeamInvitation[]) => { + const baseColumns = { + email: { + get: ({email}: any):string => color.cyan(email), + }, + role: { + get: ({role}: any):string => color.green(role), + }, + } + + if (teamInvites.length > 0) { + return { + ...baseColumns, + status: { + get: ({status}: any):string => color.green(status), + }, + } + } + + return baseColumns +} + +export default class MembersIndex extends Command { + static topic = 'members'; + static description = 'list members of a team'; + static flags = { + role: flags.string({char: 'r', description: 'filter by role', completion: RoleCompletion}), + pending: flags.boolean({description: 'filter by pending team invitations'}), + json: flags.boolean({description: 'output in json format'}), + team: flags.team({required: true}), + }; + + public async run(): Promise { + const {flags} = await this.parse(MembersIndex) + const {role, pending, json, team} = flags + const {body: teamInfo} = await this.heroku.get(`/teams/${team}`) + let teamInvites: Heroku.TeamInvitation[] = [] + if (teamInfo.type === 'team') { + const {body: orgFeatures} = await this.heroku.get(`/teams/${team}/features`) + if (orgFeatures.find((feature => feature.name === 'team-invite-acceptance' && feature.enabled))) { + const invitesResponse = await this.heroku.get( + `/teams/${team}/invitations`, + {headers: { + Accept: 'application/vnd.heroku+json; version=3.team-invitations', + }, + }) + teamInvites = _.map(invitesResponse.body, function (invite: Heroku.TeamInvitation) { + return {email: invite.user?.email, role: invite.role, status: 'pending'} + }) + } + } + + let {body: members} = await this.heroku.get(`/teams/${team}/members`) + // Set status '' to all existing members + _.map(members, (member: MemberWithStatus) => { + member.status = '' + }) + members = _.sortBy(_.union(members, teamInvites), 'email') + if (role) + members = members.filter(m => m.role === role) + if (pending) + members = members.filter(m => m.status === 'pending') + if (json) { + ux.log(JSON.stringify(members, null, 3)) + } else if (members.length === 0) { + let msg = `No members in ${color.magenta(team || '')}` + if (role) + msg += ` with role ${color.green(role)}` + ux.log(msg) + } else { + const tableColumns = buildTableColumns(teamInvites) + ux.table( + members, + tableColumns, + ) + } + } +} diff --git a/packages/cli/test/unit/commands/members/index.unit.test.ts b/packages/cli/test/unit/commands/members/index.unit.test.ts new file mode 100644 index 0000000000..e94b0f8ce3 --- /dev/null +++ b/packages/cli/test/unit/commands/members/index.unit.test.ts @@ -0,0 +1,140 @@ +import {stdout, stderr} from 'stdout-stderr' +import {expect} from 'chai' +import * as nock from 'nock' +import Cmd from '../../../../src/commands/members' +import runCommand from '../../../helpers/runCommand' +import { + teamInfo, + teamInvites, + teamFeatures, + teamMembers, +} from '../../../helpers/stubs/get' + +describe('heroku members', () => { + afterEach(() => nock.cleanAll()) + let apiGetOrgMembers: nock.Scope + const adminTeamMember = {email: 'admin@heroku.com', role: 'admin', user: {email: 'admin@heroku.com'}} + const collaboratorTeamMember = {email: 'collab@heroku.com', role: 'collaborator', user: {email: 'collab@heroku.com'}} + const memberTeamMember = {email: 'member@heroku.com', role: 'member', user: {email: 'member@heroku.com'}} + context('when it is an Enterprise team', () => { + beforeEach(() => { + teamInfo('enterprise') + }) + it('shows there are not team members if it is an orphan team', () => { + apiGetOrgMembers = teamMembers([]) + return runCommand(Cmd, [ + '--team', + 'myteam', + ]) + .then(() => expect('No members in myteam\n').to.eq(stdout.output)) + .then(() => expect('').to.eq(stderr.output)) + .then(() => apiGetOrgMembers.done()) + }) + it('shows all the team members', () => { + apiGetOrgMembers = teamMembers([adminTeamMember, collaboratorTeamMember]) + return runCommand(Cmd, [ + '--team', + 'myteam', + ]) + .then(() => expect(stdout.output).to.contain('admin@heroku.com admin \n collab@heroku.com collaborator')) + .then(() => expect('').to.eq(stderr.output)) + .then(() => apiGetOrgMembers.done()) + }) + it('filters members by role', () => { + apiGetOrgMembers = teamMembers([adminTeamMember, memberTeamMember]) + return runCommand(Cmd, [ + '--team', + 'myteam', + '--role', + 'member', + ]) + .then(() => expect(stdout.output).to.contain('member@heroku.com member')) + .then(() => expect('').to.eq(stderr.output)) + .then(() => apiGetOrgMembers.done()) + }) + it("shows the right message when filter doesn't return results", () => { + apiGetOrgMembers = teamMembers([adminTeamMember, memberTeamMember]) + return runCommand(Cmd, [ + '--team', + 'myteam', + '--role', + 'collaborator', + ]) + .then(() => expect('No members in myteam with role collaborator\n').to.eq(stdout.output)) + .then(() => expect('').to.eq(stderr.output)) + .then(() => apiGetOrgMembers.done()) + }) + it('filters members by role', () => { + apiGetOrgMembers = teamMembers([adminTeamMember, memberTeamMember]) + return runCommand(Cmd, [ + '--team', + 'myteam', + '--role', + 'member', + ]) + .then(() => expect(stdout.output).to.contain('member@heroku.com member')) + .then(() => expect('').to.eq(stderr.output)) + .then(() => apiGetOrgMembers.done()) + }) + }) + context('when it is a team', () => { + beforeEach(() => { + teamInfo('team') + }) + context('without the feature flag team-invite-acceptance', () => { + beforeEach(() => { + teamFeatures([]) + }) + it('does not show the status column', () => { + apiGetOrgMembers = teamMembers([adminTeamMember, memberTeamMember]) + return runCommand(Cmd, [ + '--team', + 'myteam', + ]) + .then(() => expect(stdout.output).to.not.contain('Status')) + .then(() => apiGetOrgMembers.done()) + }) + }) + context('with the feature flag team-invite-acceptance', () => { + beforeEach(() => { + teamFeatures([{name: 'team-invite-acceptance', enabled: true}]) + }) + it('shows all members including those with pending invites', () => { + const apiGetTeamInvites = teamInvites() + apiGetOrgMembers = teamMembers([adminTeamMember, collaboratorTeamMember]) + return runCommand(Cmd, [ + '--team', + 'myteam', + ]) + .then(() => expect(stdout.output).to.contain('admin@heroku.com admin \n collab@heroku.com collaborator \n invited-user@mail.com admin pending')) + .then(() => expect('').to.eq(stderr.output)) + .then(() => apiGetTeamInvites.done()) + .then(() => apiGetOrgMembers.done()) + }) + it('does not show the Status column when there are no pending invites', () => { + const apiGetTeamInvites = teamInvites([]) + apiGetOrgMembers = teamMembers([adminTeamMember, collaboratorTeamMember]) + return runCommand(Cmd, [ + '--team', + 'myteam', + ]) + .then(() => expect(stdout.output).to.not.contain('Status')) + .then(() => apiGetOrgMembers.done()) + .then(() => apiGetTeamInvites.done()) + }) + it('filters members by pending invites', () => { + const apiGetTeamInvites = teamInvites() + apiGetOrgMembers = teamMembers([adminTeamMember, collaboratorTeamMember]) + return runCommand(Cmd, [ + '--team', + 'myteam', + '--pending', + ]) + .then(() => expect(stdout.output).to.contain('invited-user@mail.com admin pending')) + .then(() => expect('').to.eq(stderr.output)) + .then(() => apiGetTeamInvites.done()) + .then(() => apiGetOrgMembers.done()) + }) + }) + }) +}) diff --git a/packages/orgs-v5/commands/members/index.js b/packages/orgs-v5/commands/members/index.js deleted file mode 100644 index 20501e11d7..0000000000 --- a/packages/orgs-v5/commands/members/index.js +++ /dev/null @@ -1,69 +0,0 @@ -'use strict' - -let _ = require('lodash') -let cli = require('heroku-cli-util') -let Utils = require('../../lib/utils') -const {flags} = require('@heroku-cli/command') -const {RoleCompletion} = require('@heroku-cli/command/lib/completions') - -async function run(context, heroku) { - let teamInfo = await Utils.teamInfo(context, heroku) - let groupName = context.flags.team - let teamInvites = [] - - if (teamInfo.type === 'team') { - let orgFeatures = await heroku.get(`/teams/${groupName}/features`) - - if (orgFeatures.find(feature => feature.name === 'team-invite-acceptance' && feature.enabled)) { - teamInvites = await heroku.request({ - headers: { - Accept: 'application/vnd.heroku+json; version=3.team-invitations', - }, - method: 'GET', - path: `/teams/${groupName}/invitations`, - }) - teamInvites = _.map(teamInvites, function (invite) { - return {email: invite.user.email, role: invite.role, status: 'pending'} - }) - } - } - - let members = await heroku.get(`/teams/${groupName}/members`) - // Set status '' to all existing members - _.map(members, member => { - member.status = '' - }) - members = _.sortBy(_.union(members, teamInvites), 'email') - if (context.flags.role) members = members.filter(m => m.role === context.flags.role) - if (context.flags.pending) members = members.filter(m => m.status === 'pending') - if (context.flags.json) { - cli.log(JSON.stringify(members, null, 3)) - } else if (members.length === 0) { - let msg = `No members in ${cli.color.magenta(groupName)}` - if (context.flags.role) msg += ` with role ${cli.color.green(context.flags.role)}` - cli.log(msg) - } else { - cli.table(members, { - printHeader: false, - columns: [ - {key: 'email', label: 'Email', format: e => cli.color.cyan(e)}, - {key: 'role', label: 'Role', format: r => cli.color.green(r)}, - {key: 'status', label: 'Status', format: r => cli.color.green(r)}, - ], - }) - } -} - -module.exports = { - topic: 'members', - description: 'list members of a team', - needsAuth: true, - wantsOrg: true, - flags: [ - {name: 'role', char: 'r', hasValue: true, description: 'filter by role', completion: RoleCompletion}, - {name: 'pending', hasValue: false, description: 'filter by pending team invitations'}, - {name: 'json', description: 'output in json format'}, - flags.team({name: 'team', hasValue: true, hidden: true}), - ], - run: cli.command(run), -} diff --git a/packages/orgs-v5/index.js b/packages/orgs-v5/index.js index 27617d49b3..ac580a71f2 100644 --- a/packages/orgs-v5/index.js +++ b/packages/orgs-v5/index.js @@ -23,7 +23,6 @@ exports.commands = flatten([ require('./commands/apps/lock'), require('./commands/apps/transfer'), require('./commands/apps/unlock'), - require('./commands/members'), require('./commands/members/add'), require('./commands/members/set'), require('./commands/members/remove'), diff --git a/packages/orgs-v5/test/unit/commands/members/index.unit.test.js b/packages/orgs-v5/test/unit/commands/members/index.unit.test.js deleted file mode 100644 index 35ebb4ab98..0000000000 --- a/packages/orgs-v5/test/unit/commands/members/index.unit.test.js +++ /dev/null @@ -1,129 +0,0 @@ -'use strict' -/* globals beforeEach afterEach cli nock expect context */ - -let cmd = require('../../../../commands/members') -let stubGet = require('../../stub/get') - -describe('heroku members', () => { - beforeEach(() => cli.mockConsole()) - afterEach(() => nock.cleanAll()) - - let apiGetOrgMembers - - it('is configured for an optional team flag', function () { - expect(cmd).to.have.own.property('wantsOrg', true) - }) - - context('when it is an Enterprise team', () => { - beforeEach(() => { - stubGet.teamInfo('enterprise') - }) - - it('shows there are not team members if it is an orphan team', () => { - apiGetOrgMembers = stubGet.teamMembers([]) - return cmd.run({flags: {team: 'myteam'}}) - .then(() => expect( - `No members in myteam -`).to.eq(cli.stdout)) - .then(() => expect('').to.eq(cli.stderr)) - .then(() => apiGetOrgMembers.done()) - }) - - it('shows all the team members', () => { - apiGetOrgMembers = stubGet.teamMembers([ - {email: 'a@heroku.com', role: 'admin'}, {email: 'b@heroku.com', role: 'collaborator'}, - ]) - return cmd.run({flags: {team: 'myteam'}}) - .then(() => expect( - `a@heroku.com admin -b@heroku.com collaborator -`).to.eq(cli.stdout)) - .then(() => expect('').to.eq(cli.stderr)) - .then(() => apiGetOrgMembers.done()) - }) - - let expectedOrgMembers = [{email: 'a@heroku.com', role: 'admin'}, {email: 'b@heroku.com', role: 'member'}] - - it('filters members by role', () => { - apiGetOrgMembers = stubGet.teamMembers(expectedOrgMembers) - return cmd.run({flags: {team: 'myteam', role: 'member'}}) - .then(() => expect( - `b@heroku.com member -`).to.eq(cli.stdout)) - .then(() => expect('').to.eq(cli.stderr)) - .then(() => apiGetOrgMembers.done()) - }) - - it("shows the right message when filter doesn't return results", () => { - apiGetOrgMembers = stubGet.teamMembers(expectedOrgMembers) - return cmd.run({flags: {team: 'myteam', role: 'collaborator'}}) - .then(() => expect( - `No members in myteam with role collaborator -`).to.eq(cli.stdout)) - .then(() => expect('').to.eq(cli.stderr)) - .then(() => apiGetOrgMembers.done()) - }) - - it('filters members by role', () => { - apiGetOrgMembers = stubGet.teamMembers(expectedOrgMembers) - return cmd.run({flags: {team: 'myteam', role: 'member'}}) - .then(() => expect( - `b@heroku.com member -`).to.eq(cli.stdout)) - .then(() => expect('').to.eq(cli.stderr)) - .then(() => apiGetOrgMembers.done()) - }) - }) - - context('when it is a team', () => { - beforeEach(() => { - stubGet.teamInfo('team') - }) - - context('without the feature flag team-invite-acceptance', () => { - beforeEach(() => { - stubGet.teamFeatures([]) - }) - }) - - context('with the feature flag team-invite-acceptance', () => { - beforeEach(() => { - stubGet.teamFeatures([{name: 'team-invite-acceptance', enabled: true}]) - }) - - it('shows all members including those with pending invites', () => { - let apiGetTeamInvites = stubGet.teamInvites() - - apiGetOrgMembers = stubGet.teamMembers([ - {email: 'a@heroku.com', role: 'admin'}, {email: 'b@heroku.com', role: 'collaborator'}, - ]) - - return cmd.run({flags: {team: 'myteam'}}) - .then(() => expect( - `a@heroku.com admin -b@heroku.com collaborator -invited-user@mail.com admin pending -`).to.eq(cli.stdout)) - .then(() => expect('').to.eq(cli.stderr)) - .then(() => apiGetTeamInvites.done()) - .then(() => apiGetOrgMembers.done()) - }) - - it('filters members by pending invites', () => { - let apiGetTeamInvites = stubGet.teamInvites() - - apiGetOrgMembers = stubGet.teamMembers([ - {email: 'a@heroku.com', role: 'admin'}, {email: 'b@heroku.com', role: 'collaborator'}, - ]) - - return cmd.run({flags: {team: 'myteam', pending: true}}) - .then(() => expect( - `invited-user@mail.com admin pending -`).to.eq(cli.stdout)) - .then(() => expect('').to.eq(cli.stderr)) - .then(() => apiGetTeamInvites.done()) - .then(() => apiGetOrgMembers.done()) - }) - }) - }) -})