@wavyx/pdcli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +8 -0
- package/oclif.manifest.json +2598 -925
- package/package.json +8 -1
- package/src/base-command.js +12 -2
- package/src/commands/alias/list.js +31 -0
- package/src/commands/alias/set.js +97 -0
- package/src/commands/alias/unset.js +26 -0
- package/src/commands/config/set.js +14 -0
- package/src/commands/config/unset.js +32 -0
- package/src/commands/file/remote-link.js +56 -0
- package/src/commands/funnel.js +97 -2
- package/src/commands/lead/label/list.js +27 -0
- package/src/commands/metrics/coverage.js +251 -0
- package/src/commands/org/merge.js +97 -0
- package/src/commands/person/merge.js +91 -0
- package/src/hooks/command-not-found.js +68 -0
- package/src/lib/aliases.js +35 -0
- package/src/lib/analytics.js +142 -0
- package/src/lib/audit.js +107 -6
- package/src/lib/client.js +30 -0
- package/src/lib/confirm.js +15 -2
- package/src/lib/entity-view.js +15 -9
- package/src/lib/fields.js +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wavyx/pdcli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -68,6 +68,9 @@
|
|
|
68
68
|
"field": {
|
|
69
69
|
"description": "Custom-field discovery and name/key resolution"
|
|
70
70
|
},
|
|
71
|
+
"metrics": {
|
|
72
|
+
"description": "Sales metrics and KPIs"
|
|
73
|
+
},
|
|
71
74
|
"user": {
|
|
72
75
|
"description": "Users"
|
|
73
76
|
},
|
|
@@ -76,6 +79,9 @@
|
|
|
76
79
|
},
|
|
77
80
|
"config": {
|
|
78
81
|
"description": "Configuration"
|
|
82
|
+
},
|
|
83
|
+
"alias": {
|
|
84
|
+
"description": "Command shortcuts (alias set/list/unset)"
|
|
79
85
|
}
|
|
80
86
|
},
|
|
81
87
|
"hooks": {
|
|
@@ -120,6 +126,7 @@
|
|
|
120
126
|
"scripts": {
|
|
121
127
|
"build": "oclif manifest",
|
|
122
128
|
"docs:commands": "node scripts/gen-commands.mjs",
|
|
129
|
+
"docs:demo": "node scripts/gen-demo.mjs",
|
|
123
130
|
"lint": "eslint . && prettier --check .",
|
|
124
131
|
"lint:fix": "eslint --fix . && prettier --write .",
|
|
125
132
|
"test": "vitest run",
|
package/src/base-command.js
CHANGED
|
@@ -22,6 +22,12 @@ export default class BaseCommand extends Command {
|
|
|
22
22
|
description: 'Comma-separated fields to display',
|
|
23
23
|
helpGroup: 'GLOBAL',
|
|
24
24
|
}),
|
|
25
|
+
'resolve-fields': Flags.boolean({
|
|
26
|
+
description:
|
|
27
|
+
'Resolve custom-field hash keys to names (and option ids to labels) in json/yaml/csv output of single-record get commands',
|
|
28
|
+
helpGroup: 'GLOBAL',
|
|
29
|
+
default: false,
|
|
30
|
+
}),
|
|
25
31
|
profile: Flags.string({
|
|
26
32
|
description: 'Named auth profile to use',
|
|
27
33
|
helpGroup: 'GLOBAL',
|
|
@@ -118,9 +124,13 @@ export default class BaseCommand extends Command {
|
|
|
118
124
|
})
|
|
119
125
|
}
|
|
120
126
|
|
|
121
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* The profile's `default_output` config value, when valid. Safe to call
|
|
129
|
+
* before parsing completes (handleError runs for parse failures too,
|
|
130
|
+
* when `this.flags` is still undefined).
|
|
131
|
+
*/
|
|
122
132
|
storedDefaultOutput() {
|
|
123
|
-
const stored = loadConfig(this.flags
|
|
133
|
+
const stored = loadConfig(this.flags?.profile).default_output
|
|
124
134
|
return ['table', 'json', 'yaml', 'csv'].includes(stored)
|
|
125
135
|
? stored
|
|
126
136
|
: undefined
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import BaseCommand from '../../base-command.js'
|
|
2
|
+
import { getAliases } from '../../lib/aliases.js'
|
|
3
|
+
|
|
4
|
+
const columns = {
|
|
5
|
+
name: { header: 'Alias' },
|
|
6
|
+
command: { header: 'Command' },
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default class AliasListCommand extends BaseCommand {
|
|
10
|
+
static skipAuth = true
|
|
11
|
+
|
|
12
|
+
static description = 'List all configured aliases'
|
|
13
|
+
|
|
14
|
+
static examples = ['<%= config.bin %> alias list']
|
|
15
|
+
|
|
16
|
+
async run() {
|
|
17
|
+
const aliases = getAliases()
|
|
18
|
+
const entries = Object.entries(aliases).map(([name, command]) => ({
|
|
19
|
+
name,
|
|
20
|
+
command,
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
if (entries.length === 0) {
|
|
24
|
+
this.log('No aliases configured.')
|
|
25
|
+
this.log('Create one: pdcli alias set <name> "<command>"')
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await this.outputResults(entries, columns)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Args } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import BaseCommand from '../../base-command.js'
|
|
4
|
+
import { setAlias, getAliases } from '../../lib/aliases.js'
|
|
5
|
+
import { CliError } from '../../lib/errors.js'
|
|
6
|
+
|
|
7
|
+
const MAX_ALIAS_HOPS = 10
|
|
8
|
+
|
|
9
|
+
export default class AliasSetCommand extends BaseCommand {
|
|
10
|
+
static skipAuth = true
|
|
11
|
+
|
|
12
|
+
static description = 'Create or update an alias'
|
|
13
|
+
|
|
14
|
+
static examples = [
|
|
15
|
+
'<%= config.bin %> alias set wd "deal list --status won"',
|
|
16
|
+
'<%= config.bin %> alias set open "deal list --status open --limit 50"',
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
static args = {
|
|
20
|
+
name: Args.string({ required: true, description: 'Alias name' }),
|
|
21
|
+
command: Args.string({ required: true, description: 'Command to alias' }),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async run() {
|
|
25
|
+
const { args } = await this.parse(AliasSetCommand)
|
|
26
|
+
const name = args.name
|
|
27
|
+
|
|
28
|
+
// A dotted name corrupts conf's dotted-path store/read (the value would
|
|
29
|
+
// be written nested and never round-trip back as a flat alias key).
|
|
30
|
+
if (name.includes('.')) {
|
|
31
|
+
throw new CliError(
|
|
32
|
+
`Cannot create alias ${chalk.cyan(name)}: alias names may not contain '.' (dots).`,
|
|
33
|
+
{ exitCode: 64 },
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (this.config.findCommand(name)) {
|
|
38
|
+
throw new CliError(
|
|
39
|
+
`Cannot create alias ${chalk.cyan(name)}: it shadows an existing pdcli command.`,
|
|
40
|
+
{ exitCode: 64 },
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A name matching a topic (e.g. `deal`) is reachable by oclif's dispatcher
|
|
45
|
+
// before command-not-found fires, so the alias would never run.
|
|
46
|
+
if (this.config.findTopic(name)) {
|
|
47
|
+
throw new CliError(
|
|
48
|
+
`Cannot create alias ${chalk.cyan(name)}: it shadows an existing pdcli topic.`,
|
|
49
|
+
{ exitCode: 64 },
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const firstToken = args.command.split(/\s+/).filter(Boolean)[0]
|
|
54
|
+
|
|
55
|
+
// Direct self-reference: `alias set x "x ..."` loops immediately.
|
|
56
|
+
if (firstToken === name) {
|
|
57
|
+
throw new CliError(
|
|
58
|
+
`Cannot create alias ${chalk.cyan(name)}: the command refers to itself.`,
|
|
59
|
+
{ exitCode: 64 },
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Transitive cycle: walk the existing alias graph from firstToken. If it
|
|
64
|
+
// leads back to `name` within MAX_ALIAS_HOPS, expanding this alias would
|
|
65
|
+
// re-enter forever.
|
|
66
|
+
const aliases = getAliases() ?? {}
|
|
67
|
+
const seen = new Set([name])
|
|
68
|
+
let token = firstToken
|
|
69
|
+
for (let hop = 0; hop < MAX_ALIAS_HOPS; hop++) {
|
|
70
|
+
if (token === name) {
|
|
71
|
+
const cycle = [...seen, name].join(' -> ')
|
|
72
|
+
throw new CliError(
|
|
73
|
+
`Cannot create alias ${chalk.cyan(name)}: it forms a cycle (${cycle}).`,
|
|
74
|
+
{ exitCode: 64 },
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
const next = aliases[token]
|
|
78
|
+
if (!next) break // token is not an alias — chain terminates safely
|
|
79
|
+
if (seen.has(token)) break // pre-existing cycle not involving `name`
|
|
80
|
+
seen.add(token)
|
|
81
|
+
token = next.split(/\s+/).filter(Boolean)[0]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Aliases run with full credentials — flag ones that hide destructive
|
|
85
|
+
// operations so a shared config can't smuggle in a silent delete.
|
|
86
|
+
if (/\b(DELETE|delete|merge)\b/.test(args.command)) {
|
|
87
|
+
process.stderr.write(
|
|
88
|
+
`${chalk.yellow('Warning:')} this alias wraps a destructive command — ` +
|
|
89
|
+
`it will run without additional confirmation prompts beyond the ` +
|
|
90
|
+
`command's own.\n`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setAlias(name, args.command)
|
|
95
|
+
this.log(chalk.green(`Alias set: ${chalk.cyan(name)} → ${args.command}`))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Args } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import BaseCommand from '../../base-command.js'
|
|
4
|
+
import { getAlias, unsetAlias } from '../../lib/aliases.js'
|
|
5
|
+
|
|
6
|
+
export default class AliasUnsetCommand extends BaseCommand {
|
|
7
|
+
static skipAuth = true
|
|
8
|
+
|
|
9
|
+
static description = 'Remove an alias'
|
|
10
|
+
|
|
11
|
+
static examples = ['<%= config.bin %> alias unset wd']
|
|
12
|
+
|
|
13
|
+
static args = {
|
|
14
|
+
name: Args.string({ required: true, description: 'Alias name' }),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async run() {
|
|
18
|
+
const { args } = await this.parse(AliasUnsetCommand)
|
|
19
|
+
if (!getAlias(args.name)) {
|
|
20
|
+
this.log(chalk.yellow(`Alias not found: ${args.name}`))
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
unsetAlias(args.name)
|
|
24
|
+
this.log(chalk.green(`Alias removed: ${args.name}`))
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -2,6 +2,12 @@ import { Args } from '@oclif/core'
|
|
|
2
2
|
import chalk from 'chalk'
|
|
3
3
|
import BaseCommand from '../../base-command.js'
|
|
4
4
|
import { setProfileConfig } from '../../lib/config.js'
|
|
5
|
+
import { CliError } from '../../lib/errors.js'
|
|
6
|
+
|
|
7
|
+
/** Allowed values for keys pdcli itself consumes; others are free-form. */
|
|
8
|
+
const VALIDATED_KEYS = {
|
|
9
|
+
default_output: ['table', 'json', 'yaml', 'csv'],
|
|
10
|
+
}
|
|
5
11
|
|
|
6
12
|
export default class ConfigSetCommand extends BaseCommand {
|
|
7
13
|
static skipAuth = true
|
|
@@ -21,6 +27,14 @@ export default class ConfigSetCommand extends BaseCommand {
|
|
|
21
27
|
async run() {
|
|
22
28
|
const { args } = await this.parse(ConfigSetCommand)
|
|
23
29
|
|
|
30
|
+
const allowed = VALIDATED_KEYS[args.key]
|
|
31
|
+
if (allowed && !allowed.includes(args.value)) {
|
|
32
|
+
throw new CliError(
|
|
33
|
+
`Invalid value "${args.value}" for ${args.key} — expected one of: ${allowed.join(', ')}`,
|
|
34
|
+
{ exitCode: 64 },
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
setProfileConfig(this.activeProfile, args.key, args.value)
|
|
25
39
|
this.log(
|
|
26
40
|
`Set ${chalk.cyan(args.key)} = ${chalk.green(args.value)} for profile ${chalk.cyan(this.activeProfile)}`,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Args } from '@oclif/core'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import BaseCommand from '../../base-command.js'
|
|
4
|
+
import { getProfileConfig, deleteProfileConfig } from '../../lib/config.js'
|
|
5
|
+
|
|
6
|
+
export default class ConfigUnsetCommand extends BaseCommand {
|
|
7
|
+
static skipAuth = true
|
|
8
|
+
|
|
9
|
+
static description = 'Remove a config key from the active profile'
|
|
10
|
+
|
|
11
|
+
static examples = ['<%= config.bin %> config unset default_output']
|
|
12
|
+
|
|
13
|
+
static args = {
|
|
14
|
+
key: Args.string({ required: true, description: 'Config key to remove' }),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async run() {
|
|
18
|
+
const { args } = await this.parse(ConfigUnsetCommand)
|
|
19
|
+
|
|
20
|
+
if (getProfileConfig(this.activeProfile, args.key) === undefined) {
|
|
21
|
+
this.log(
|
|
22
|
+
`${chalk.cyan(args.key)} is not set for profile ${chalk.cyan(this.activeProfile)}`,
|
|
23
|
+
)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
deleteProfileConfig(this.activeProfile, args.key)
|
|
28
|
+
this.log(
|
|
29
|
+
`Removed ${chalk.cyan(args.key)} from profile ${chalk.cyan(this.activeProfile)}`,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core'
|
|
2
|
+
import BaseCommand from '../../base-command.js'
|
|
3
|
+
import { outputRecord } from '../../lib/entity-view.js'
|
|
4
|
+
import { CliError } from '../../lib/errors.js'
|
|
5
|
+
|
|
6
|
+
export default class FileRemoteLinkCommand extends BaseCommand {
|
|
7
|
+
static description = 'Link an existing remote file (Google Drive) to an item'
|
|
8
|
+
|
|
9
|
+
static examples = [
|
|
10
|
+
'<%= config.bin %> file remote-link --deal 42 --remote-id 1AbC',
|
|
11
|
+
'<%= config.bin %> file remote-link --person 9 --remote-id 1AbC --output json',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
static flags = {
|
|
15
|
+
...BaseCommand.baseFlags,
|
|
16
|
+
deal: Flags.integer({ description: 'Link to a deal ID' }),
|
|
17
|
+
org: Flags.integer({ description: 'Link to an organization ID' }),
|
|
18
|
+
person: Flags.integer({ description: 'Link to a person ID' }),
|
|
19
|
+
'remote-id': Flags.string({
|
|
20
|
+
required: true,
|
|
21
|
+
description: 'ID of the remote file (e.g. Google Drive file ID)',
|
|
22
|
+
}),
|
|
23
|
+
'remote-location': Flags.string({
|
|
24
|
+
description: 'Remote storage location',
|
|
25
|
+
options: ['googledrive'],
|
|
26
|
+
default: 'googledrive',
|
|
27
|
+
}),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async run() {
|
|
31
|
+
const { flags } = await this.parse(FileRemoteLinkCommand)
|
|
32
|
+
|
|
33
|
+
const items = [
|
|
34
|
+
['deal', flags.deal],
|
|
35
|
+
['organization', flags.org],
|
|
36
|
+
['person', flags.person],
|
|
37
|
+
].filter(([, id]) => id != null)
|
|
38
|
+
|
|
39
|
+
if (items.length !== 1) {
|
|
40
|
+
throw new CliError('Pass exactly one of --deal, --org, or --person', {
|
|
41
|
+
exitCode: 64,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const [item_type, item_id] = items[0]
|
|
46
|
+
|
|
47
|
+
const res = await this.apiClient.postForm('/api/v1/files/remoteLink', {
|
|
48
|
+
item_type,
|
|
49
|
+
item_id,
|
|
50
|
+
remote_id: flags['remote-id'],
|
|
51
|
+
remote_location: flags['remote-location'],
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
await outputRecord(this, res.data)
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/commands/funnel.js
CHANGED
|
@@ -2,8 +2,13 @@ import { Flags } from '@oclif/core'
|
|
|
2
2
|
import BaseCommand from '../base-command.js'
|
|
3
3
|
import { collectPages } from '../lib/pagination.js'
|
|
4
4
|
import { parsePeriod, formatApiDatetime } from '../lib/period.js'
|
|
5
|
-
import { computeFunnel } from '../lib/analytics.js'
|
|
6
|
-
import { CliError } from '../lib/errors.js'
|
|
5
|
+
import { computeFunnel, computeExactFunnel } from '../lib/analytics.js'
|
|
6
|
+
import { CliError, ApiError } from '../lib/errors.js'
|
|
7
|
+
|
|
8
|
+
/** Token cost of one GET /deals/{id}/changelog request (rate-limit budget). */
|
|
9
|
+
const CHANGELOG_COST = 20
|
|
10
|
+
/** Above this deal count, mining gets expensive — warn before proceeding. */
|
|
11
|
+
const MINE_WARN_THRESHOLD = 100
|
|
7
12
|
|
|
8
13
|
export default class FunnelCommand extends BaseCommand {
|
|
9
14
|
static description =
|
|
@@ -23,6 +28,14 @@ export default class FunnelCommand extends BaseCommand {
|
|
|
23
28
|
pipeline: Flags.integer({
|
|
24
29
|
description: 'Pipeline ID (required when the account has several)',
|
|
25
30
|
}),
|
|
31
|
+
exact: Flags.boolean({
|
|
32
|
+
description:
|
|
33
|
+
'Mine real stage transitions from each deal’s changelog instead ' +
|
|
34
|
+
'of approximating from the final stage (one request per deal). ' +
|
|
35
|
+
'--period scopes only closed (won/lost) deals; open deals are ' +
|
|
36
|
+
'always included.',
|
|
37
|
+
default: false,
|
|
38
|
+
}),
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
async run() {
|
|
@@ -71,6 +84,36 @@ export default class FunnelCommand extends BaseCommand {
|
|
|
71
84
|
),
|
|
72
85
|
])
|
|
73
86
|
|
|
87
|
+
if (flags.exact) {
|
|
88
|
+
const exact = await this.mineExactFunnel(
|
|
89
|
+
[...open, ...won, ...lost],
|
|
90
|
+
stages,
|
|
91
|
+
pipelineId,
|
|
92
|
+
)
|
|
93
|
+
const columns = {
|
|
94
|
+
stage: { header: 'Stage' },
|
|
95
|
+
entered: { header: 'Entered (observed)' },
|
|
96
|
+
conversionFromPrev: {
|
|
97
|
+
// Not funnel conversion — exact entries are non-monotonic so this
|
|
98
|
+
// ratio can exceed 100%. Labelled to avoid that misreading.
|
|
99
|
+
header: 'Entered vs prev',
|
|
100
|
+
get: (row) =>
|
|
101
|
+
row.conversionFromPrev == null
|
|
102
|
+
? ''
|
|
103
|
+
: `${(row.conversionFromPrev * 100).toFixed(0)}%`,
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
// `won` is a single total: tables report it once under the rows;
|
|
107
|
+
// machine formats carry it as a top-level field next to the rows.
|
|
108
|
+
if (this.resolveFormat() === 'table') {
|
|
109
|
+
await this.outputResults(exact.rows, columns)
|
|
110
|
+
this.log(`Won: ${exact.won}`)
|
|
111
|
+
} else {
|
|
112
|
+
await this.outputResults(exact, columns)
|
|
113
|
+
}
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
74
117
|
const funnel = computeFunnel([...won, ...lost], open, stages, {
|
|
75
118
|
pipelineId,
|
|
76
119
|
})
|
|
@@ -89,4 +132,56 @@ export default class FunnelCommand extends BaseCommand {
|
|
|
89
132
|
openValue: { header: 'Open value' },
|
|
90
133
|
})
|
|
91
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Mine real stage transitions from each deal's v1 changelog. The changelog
|
|
138
|
+
* uses a flat v2-style cursor (additional_data.next_cursor on a v1 path), so
|
|
139
|
+
* the v2 pager works directly. Warns on stderr before mining a large set —
|
|
140
|
+
* each request costs 20 tokens — then lets the client's rate limiter pace it.
|
|
141
|
+
* @param {object[]} deals deals to mine (current stage_id needed per deal)
|
|
142
|
+
* @param {object[]} stages
|
|
143
|
+
* @param {number} pipelineId
|
|
144
|
+
*/
|
|
145
|
+
async mineExactFunnel(deals, stages, pipelineId) {
|
|
146
|
+
if (deals.length > MINE_WARN_THRESHOLD) {
|
|
147
|
+
process.stderr.write(
|
|
148
|
+
`Mining stage history for ${deals.length} deals ` +
|
|
149
|
+
`(~${deals.length} requests, ${CHANGELOG_COST} tokens each); ` +
|
|
150
|
+
`rate limiting may slow this down.\n`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const transitionsByDeal = []
|
|
155
|
+
let skipped = 0
|
|
156
|
+
for (const deal of deals) {
|
|
157
|
+
try {
|
|
158
|
+
const rows = await collectPages(
|
|
159
|
+
this.apiClient.pageV2(`/api/v1/deals/${deal.id}/changelog`, {
|
|
160
|
+
limit: 500,
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
transitionsByDeal.push({
|
|
164
|
+
dealId: deal.id,
|
|
165
|
+
stageId: deal.stage_id,
|
|
166
|
+
rows,
|
|
167
|
+
})
|
|
168
|
+
} catch (err) {
|
|
169
|
+
// One bad changelog request must not abort the whole mine: skip the
|
|
170
|
+
// deal, count it, and warn once after mining completes.
|
|
171
|
+
if (err instanceof ApiError) {
|
|
172
|
+
skipped++
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
throw err
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (skipped > 0) {
|
|
180
|
+
process.stderr.write(
|
|
181
|
+
`skipped ${skipped} deal(s) whose changelog could not be fetched\n`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return computeExactFunnel(transitionsByDeal, stages, { pipelineId })
|
|
186
|
+
}
|
|
92
187
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js'
|
|
2
|
+
|
|
3
|
+
const columns = {
|
|
4
|
+
id: { header: 'ID' },
|
|
5
|
+
name: { header: 'Name' },
|
|
6
|
+
color: { header: 'Color' },
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default class LeadLabelListCommand extends BaseCommand {
|
|
10
|
+
static description = 'List lead labels'
|
|
11
|
+
|
|
12
|
+
static examples = [
|
|
13
|
+
'<%= config.bin %> lead label list',
|
|
14
|
+
'<%= config.bin %> lead label list --output json',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
static flags = {
|
|
18
|
+
...BaseCommand.baseFlags,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async run() {
|
|
22
|
+
await this.parse(LeadLabelListCommand)
|
|
23
|
+
// leadLabels has no pagination — all labels are always returned.
|
|
24
|
+
const body = await this.apiClient.get('/api/v1/leadLabels')
|
|
25
|
+
await this.outputResults(body.data, columns)
|
|
26
|
+
}
|
|
27
|
+
}
|