@wavyx/pdcli 0.8.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavyx/pdcli",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -82,6 +82,9 @@
82
82
  },
83
83
  "alias": {
84
84
  "description": "Command shortcuts (alias set/list/unset)"
85
+ },
86
+ "deal:product": {
87
+ "description": "Manage products (line items) on a deal"
85
88
  }
86
89
  },
87
90
  "hooks": {
@@ -24,7 +24,7 @@ export default class BaseCommand extends Command {
24
24
  }),
25
25
  'resolve-fields': Flags.boolean({
26
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',
27
+ 'Resolve custom-field hash keys to names (and option ids to labels) in json/yaml/csv output of get and core list commands',
28
28
  helpGroup: 'GLOBAL',
29
29
  default: false,
30
30
  }),
@@ -151,12 +151,31 @@ export default class BaseCommand extends Command {
151
151
  /**
152
152
  * @param {object | object[]} data
153
153
  * @param {Record<string, import('./lib/output/table.js').Column>} columns
154
+ * @param {{ entity?: string }} [options] entity context enables
155
+ * --resolve-fields custom-field resolution on machine-format lists
154
156
  */
155
- async outputResults(data, columns) {
157
+ async outputResults(data, columns, { entity } = {}) {
158
+ if (
159
+ entity &&
160
+ this.flags['resolve-fields'] &&
161
+ this.resolveFormat() !== 'table' &&
162
+ Array.isArray(data) &&
163
+ data.some((row) => row?.custom_fields)
164
+ ) {
165
+ const { getFields, makeResolver } = await import('./lib/fields.js')
166
+ // getFields is memoized per run — one defs fetch covers the whole list.
167
+ const resolver = makeResolver(await getFields(this.apiClient, entity))
168
+ data = data.map((row) =>
169
+ row?.custom_fields ? resolver.resolveCustomFields(row) : row,
170
+ )
171
+ }
172
+
156
173
  if (this.flags.jq) {
157
174
  // node-jq ships a native binary — load it only when actually used.
175
+ // Single records pass UNWRAPPED: `--jq .id` works on a get without
176
+ // the historical `.[0]` indirection (changed in 0.9.0).
158
177
  const { run } = await import('node-jq')
159
- const input = JSON.stringify(Array.isArray(data) ? data : [data])
178
+ const input = JSON.stringify(data)
160
179
  const result = await run(this.flags.jq, input, {
161
180
  input: 'string',
162
181
  output: 'pretty',
@@ -1,6 +1,7 @@
1
1
  import { Flags } from '@oclif/core'
2
2
  import BaseCommand from '../../base-command.js'
3
3
  import { collectPages } from '../../lib/pagination.js'
4
+ import { CliError } from '../../lib/errors.js'
4
5
 
5
6
  const columns = {
6
7
  id: { header: 'ID' },
@@ -27,7 +28,9 @@ export default class ActivityListCommand extends BaseCommand {
27
28
  deal: Flags.integer({ description: 'Filter by deal ID' }),
28
29
  person: Flags.integer({ description: 'Filter by person ID' }),
29
30
  org: Flags.integer({ description: 'Filter by organization ID' }),
30
- type: Flags.string({ description: 'Filter by activity type key' }),
31
+ type: Flags.string({
32
+ description: 'Filter by activity type key (applied client-side)',
33
+ }),
31
34
  done: Flags.boolean({
32
35
  description: 'Only completed activities',
33
36
  exclusive: ['todo'],
@@ -36,26 +39,65 @@ export default class ActivityListCommand extends BaseCommand {
36
39
  description: 'Only open (not done) activities',
37
40
  exclusive: ['done'],
38
41
  }),
42
+ filter: Flags.integer({ description: 'Filter by saved filter ID' }),
43
+ ids: Flags.string({
44
+ description: 'Comma-separated IDs to fetch (max 100)',
45
+ // The API silently drops `ids` when filter_id is present — refuse
46
+ // the combination instead (matches deal bulk-update).
47
+ exclusive: ['filter'],
48
+ }),
49
+ 'sort-by': Flags.string({
50
+ description: 'Sort field',
51
+ options: ['id', 'update_time', 'add_time', 'due_date'],
52
+ }),
53
+ 'sort-direction': Flags.string({
54
+ description: 'Sort direction',
55
+ options: ['asc', 'desc'],
56
+ }),
57
+ 'updated-since': Flags.string({
58
+ description:
59
+ 'Only items updated at/after this RFC3339 time (no fractional seconds)',
60
+ }),
61
+ 'updated-until': Flags.string({
62
+ description:
63
+ 'Only items updated before this RFC3339 time (no fractional seconds)',
64
+ }),
39
65
  }
40
66
 
41
67
  async run() {
42
68
  const { flags } = await this.parse(ActivityListCommand)
43
- const limit = flags.limit ?? 100
69
+ const limit = flags.limit ?? 500
70
+
71
+ const idList = flags.ids
72
+ ?.split(',')
73
+ .map((v) => v.trim())
74
+ .filter(Boolean)
75
+ if (idList && idList.length > 100) {
76
+ throw new CliError('--ids accepts at most 100 IDs', { exitCode: 64 })
77
+ }
44
78
 
45
79
  const query = {
46
80
  owner_id: flags.owner,
47
81
  deal_id: flags.deal,
48
82
  person_id: flags.person,
49
83
  org_id: flags.org,
50
- type: flags.type,
51
84
  done: flags.done ? true : flags.todo ? false : undefined,
52
- limit: Math.min(limit, 100),
85
+ filter_id: flags.filter,
86
+ ids: idList?.join(','),
87
+ sort_by: flags['sort-by'],
88
+ sort_direction: flags['sort-direction'],
89
+ updated_since: flags['updated-since'],
90
+ updated_until: flags['updated-until'],
91
+ limit: Math.min(limit, 500),
53
92
  }
54
93
 
55
- const items = await collectPages(
94
+ let items = await collectPages(
56
95
  this.apiClient.pageV2('/api/v2/activities', query),
57
96
  limit,
58
97
  )
59
- await this.outputResults(items, columns)
98
+ // The v2 activities endpoint has no `type` query param (it rejects
99
+ // unknown params with a 400) — filter client-side instead.
100
+ if (flags.type) items = items.filter((a) => a.type === flags.type)
101
+ await this.outputResults(items, columns, { entity: 'activity' })
60
102
  }
61
103
  }
@@ -0,0 +1,73 @@
1
+ import { Args, Flags } from '@oclif/core'
2
+ import BaseCommand from '../../base-command.js'
3
+ import { fetchChangelog } from '../../lib/changelog.js'
4
+ import { getFields, makeResolver } from '../../lib/fields.js'
5
+
6
+ const columns = {
7
+ time: { header: 'Time' },
8
+ field_key: { header: 'Field' },
9
+ old_value: { header: 'Old' },
10
+ new_value: { header: 'New' },
11
+ actor_user_id: { header: 'Actor' },
12
+ }
13
+
14
+ export default class DealHistoryCommand extends BaseCommand {
15
+ static description =
16
+ 'Field-change history for a deal, newest-first (the API’s native order)'
17
+
18
+ static examples = [
19
+ '<%= config.bin %> deal history 42',
20
+ '<%= config.bin %> deal history 42 --field stage_id',
21
+ '<%= config.bin %> deal history 42 --limit 20 --resolve-fields',
22
+ ]
23
+
24
+ static args = {
25
+ id: Args.integer({ required: true, description: 'Deal ID' }),
26
+ }
27
+
28
+ static flags = {
29
+ ...BaseCommand.baseFlags,
30
+ field: Flags.string({
31
+ description: 'Show only changes to this field key (e.g. stage_id)',
32
+ }),
33
+ }
34
+
35
+ async run() {
36
+ const { args, flags } = await this.parse(DealHistoryCommand)
37
+
38
+ // A field filter drops rows client-side, so the fetch can't be bounded
39
+ // by --limit in that case; otherwise cap the API page directly.
40
+ const fetchLimit = flags.field ? undefined : flags.limit
41
+ let rows = await fetchChangelog(this.apiClient, args.id, {
42
+ limit: fetchLimit,
43
+ })
44
+
45
+ if (flags.field) {
46
+ rows = rows.filter((r) => r.field_key === flags.field)
47
+ }
48
+ if (flags.limit != null) {
49
+ rows = rows.slice(0, flags.limit)
50
+ }
51
+
52
+ // The Field column is the one place hash keys appear as DATA — resolve
53
+ // them (and enum/set option ids) to names under --resolve-fields.
54
+ if (flags['resolve-fields'] && rows.length > 0) {
55
+ const resolver = makeResolver(await getFields(this.apiClient, 'deal'))
56
+ // changelog values are stringified — option ids resolve numerically
57
+ const label = (key, value) =>
58
+ resolver.optionIdToLabel(key, Number(value)) ?? value
59
+ rows = rows.map((r) => {
60
+ const name = resolver.keyToName(r.field_key)
61
+ if (!name) return r
62
+ return {
63
+ ...r,
64
+ field_key: name,
65
+ old_value: label(r.field_key, r.old_value),
66
+ new_value: label(r.field_key, r.new_value),
67
+ }
68
+ })
69
+ }
70
+
71
+ await this.outputResults(rows, columns)
72
+ }
73
+ }
@@ -1,6 +1,7 @@
1
1
  import { Flags } from '@oclif/core'
2
2
  import BaseCommand from '../../base-command.js'
3
3
  import { collectPages } from '../../lib/pagination.js'
4
+ import { CliError } from '../../lib/errors.js'
4
5
 
5
6
  const columns = {
6
7
  id: { header: 'ID' },
@@ -37,11 +38,42 @@ export default class DealListCommand extends BaseCommand {
37
38
  owner: Flags.integer({ description: 'Filter by owner (user) ID' }),
38
39
  person: Flags.integer({ description: 'Filter by person ID' }),
39
40
  org: Flags.integer({ description: 'Filter by organization ID' }),
41
+ filter: Flags.integer({ description: 'Filter by saved filter ID' }),
42
+ ids: Flags.string({
43
+ description: 'Comma-separated IDs to fetch (max 100)',
44
+ // The API silently drops `ids` when filter_id is present — refuse
45
+ // the combination instead (matches deal bulk-update).
46
+ exclusive: ['filter'],
47
+ }),
48
+ 'sort-by': Flags.string({
49
+ description: 'Sort field',
50
+ options: ['id', 'update_time', 'add_time'],
51
+ }),
52
+ 'sort-direction': Flags.string({
53
+ description: 'Sort direction',
54
+ options: ['asc', 'desc'],
55
+ }),
56
+ 'updated-since': Flags.string({
57
+ description:
58
+ 'Only items updated at/after this RFC3339 time (no fractional seconds)',
59
+ }),
60
+ 'updated-until': Flags.string({
61
+ description:
62
+ 'Only items updated before this RFC3339 time (no fractional seconds)',
63
+ }),
40
64
  }
41
65
 
42
66
  async run() {
43
67
  const { flags } = await this.parse(DealListCommand)
44
- const limit = flags.limit ?? 100
68
+ const limit = flags.limit ?? 500
69
+
70
+ const idList = flags.ids
71
+ ?.split(',')
72
+ .map((v) => v.trim())
73
+ .filter(Boolean)
74
+ if (idList && idList.length > 100) {
75
+ throw new CliError('--ids accepts at most 100 IDs', { exitCode: 64 })
76
+ }
45
77
 
46
78
  const query = {
47
79
  status: flags.status,
@@ -50,13 +82,19 @@ export default class DealListCommand extends BaseCommand {
50
82
  owner_id: flags.owner,
51
83
  person_id: flags.person,
52
84
  org_id: flags.org,
53
- limit: Math.min(limit, 100),
85
+ filter_id: flags.filter,
86
+ ids: idList?.join(','),
87
+ sort_by: flags['sort-by'],
88
+ sort_direction: flags['sort-direction'],
89
+ updated_since: flags['updated-since'],
90
+ updated_until: flags['updated-until'],
91
+ limit: Math.min(limit, 500),
54
92
  }
55
93
 
56
94
  const items = await collectPages(
57
95
  this.apiClient.pageV2('/api/v2/deals', query),
58
96
  limit,
59
97
  )
60
- await this.outputResults(items, columns)
98
+ await this.outputResults(items, columns, { entity: 'deal' })
61
99
  }
62
100
  }
@@ -0,0 +1,69 @@
1
+ import { Args, Flags } from '@oclif/core'
2
+ import BaseCommand from '../../../base-command.js'
3
+ import { CliError } from '../../../lib/errors.js'
4
+ import { buildWriteBody } from '../../../lib/input.js'
5
+ import { outputRecord } from '../../../lib/entity-view.js'
6
+
7
+ /** Coerce a numeric flag, failing with a clean input error on garbage. */
8
+ function num(name, value) {
9
+ if (value === undefined) return undefined
10
+ const n = Number(value)
11
+ if (!Number.isFinite(n)) {
12
+ throw new CliError(`Invalid number for --${name}: "${value}"`, {
13
+ exitCode: 64,
14
+ })
15
+ }
16
+ return n
17
+ }
18
+
19
+ export default class DealProductAddCommand extends BaseCommand {
20
+ static description = 'Attach a product to a deal'
21
+
22
+ static examples = [
23
+ '<%= config.bin %> deal product add 42 --product 10 --price 90',
24
+ '<%= config.bin %> deal product add 42 --product 10 --price 90 --quantity 3',
25
+ '<%= config.bin %> deal product add 42 --product 10 --price 90 --discount 10 --discount-type percentage',
26
+ ]
27
+
28
+ static args = {
29
+ id: Args.integer({ required: true, description: 'Deal ID' }),
30
+ }
31
+
32
+ static flags = {
33
+ ...BaseCommand.baseFlags,
34
+ product: Flags.integer({ required: true, description: 'Product ID' }),
35
+ price: Flags.string({
36
+ required: true,
37
+ description: 'Item price (per unit)',
38
+ }),
39
+ quantity: Flags.string({ description: 'Quantity', default: '1' }),
40
+ discount: Flags.string({ description: 'Discount value' }),
41
+ 'discount-type': Flags.string({
42
+ description: 'Discount type',
43
+ options: ['percentage', 'amount'],
44
+ }),
45
+ tax: Flags.string({ description: 'Product tax percentage' }),
46
+ comments: Flags.string({ description: 'Comments' }),
47
+ }
48
+
49
+ async run() {
50
+ const { args, flags } = await this.parse(DealProductAddCommand)
51
+
52
+ const body = buildWriteBody({
53
+ typed: {
54
+ product_id: flags.product,
55
+ item_price: num('price', flags.price),
56
+ quantity: num('quantity', flags.quantity),
57
+ discount: num('discount', flags.discount),
58
+ discount_type: flags['discount-type'],
59
+ tax: num('tax', flags.tax),
60
+ comments: flags.comments,
61
+ },
62
+ })
63
+
64
+ const res = await this.apiClient.post(`/api/v2/deals/${args.id}/products`, {
65
+ body,
66
+ })
67
+ await outputRecord(this, res.data)
68
+ }
69
+ }
@@ -0,0 +1,56 @@
1
+ import { Args, Flags } from '@oclif/core'
2
+ import BaseCommand from '../../../base-command.js'
3
+ import { collectPages } from '../../../lib/pagination.js'
4
+
5
+ const columns = {
6
+ id: { header: 'ID' },
7
+ product_id: { header: 'Product' },
8
+ name: { header: 'Name' },
9
+ item_price: { header: 'Item price' },
10
+ quantity: { header: 'Qty' },
11
+ discount: { header: 'Discount' },
12
+ sum: { header: 'Sum' },
13
+ }
14
+
15
+ export default class DealProductListCommand extends BaseCommand {
16
+ static description = 'List products attached to a deal'
17
+
18
+ static examples = [
19
+ '<%= config.bin %> deal product list 42',
20
+ '<%= config.bin %> deal product list 42 --sort-by add_time --sort-direction desc',
21
+ '<%= config.bin %> deal product list 42 --output json',
22
+ ]
23
+
24
+ static args = {
25
+ id: Args.integer({ required: true, description: 'Deal ID' }),
26
+ }
27
+
28
+ static flags = {
29
+ ...BaseCommand.baseFlags,
30
+ 'sort-by': Flags.string({
31
+ description: 'Field to sort by',
32
+ options: ['id', 'add_time', 'update_time', 'order_nr'],
33
+ }),
34
+ 'sort-direction': Flags.string({
35
+ description: 'Sort direction',
36
+ options: ['asc', 'desc'],
37
+ }),
38
+ }
39
+
40
+ async run() {
41
+ const { args, flags } = await this.parse(DealProductListCommand)
42
+ const limit = flags.limit ?? 500
43
+
44
+ const query = {
45
+ sort_by: flags['sort-by'],
46
+ sort_direction: flags['sort-direction'],
47
+ limit: Math.min(limit, 500),
48
+ }
49
+
50
+ const items = await collectPages(
51
+ this.apiClient.pageV2(`/api/v2/deals/${args.id}/products`, query),
52
+ limit,
53
+ )
54
+ await this.outputResults(items, columns)
55
+ }
56
+ }
@@ -0,0 +1,52 @@
1
+ import { Args, Flags } from '@oclif/core'
2
+ import chalk from 'chalk'
3
+ import BaseCommand from '../../../base-command.js'
4
+ import { confirmAction } from '../../../lib/confirm.js'
5
+ import { CliError } from '../../../lib/errors.js'
6
+
7
+ export default class DealProductRemoveCommand extends BaseCommand {
8
+ static description = 'Remove a product attached to a deal'
9
+
10
+ static examples = [
11
+ '<%= config.bin %> deal product remove 42 --attachment 3',
12
+ '<%= config.bin %> deal product remove 42 --attachment 3 --yes',
13
+ ]
14
+
15
+ static args = {
16
+ id: Args.integer({ required: true, description: 'Deal ID' }),
17
+ }
18
+
19
+ static flags = {
20
+ ...BaseCommand.baseFlags,
21
+ attachment: Flags.integer({
22
+ required: true,
23
+ description: 'Deal-product (attachment) ID',
24
+ }),
25
+ yes: Flags.boolean({
26
+ char: 'y',
27
+ description: 'Skip the confirmation prompt',
28
+ default: false,
29
+ }),
30
+ }
31
+
32
+ async run() {
33
+ const { args, flags } = await this.parse(DealProductRemoveCommand)
34
+
35
+ const ok = await confirmAction(
36
+ `Remove product attachment ${flags.attachment} from deal ${args.id}?`,
37
+ flags.yes,
38
+ )
39
+ if (!ok) {
40
+ throw new CliError('Aborted', { exitCode: 1 })
41
+ }
42
+
43
+ await this.apiClient.del(
44
+ `/api/v2/deals/${args.id}/products/${flags.attachment}`,
45
+ )
46
+ this.log(
47
+ chalk.green(
48
+ `Removed product attachment ${flags.attachment} from deal ${args.id}`,
49
+ ),
50
+ )
51
+ }
52
+ }
@@ -0,0 +1,78 @@
1
+ import { Args, Flags } from '@oclif/core'
2
+ import BaseCommand from '../../../base-command.js'
3
+ import { buildWriteBody } from '../../../lib/input.js'
4
+ import { outputRecord } from '../../../lib/entity-view.js'
5
+ import { CliError } from '../../../lib/errors.js'
6
+
7
+ /** Coerce a numeric flag, failing with a clean input error on garbage. */
8
+ function num(name, value) {
9
+ if (value === undefined) return undefined
10
+ const n = Number(value)
11
+ if (!Number.isFinite(n)) {
12
+ throw new CliError(`Invalid number for --${name}: "${value}"`, {
13
+ exitCode: 64,
14
+ })
15
+ }
16
+ return n
17
+ }
18
+
19
+ export default class DealProductUpdateCommand extends BaseCommand {
20
+ static description =
21
+ 'Update a product attached to a deal (v2 PATCH — only provided fields change)'
22
+
23
+ static examples = [
24
+ '<%= config.bin %> deal product update 42 --attachment 3 --quantity 5',
25
+ '<%= config.bin %> deal product update 42 --attachment 3 --price 120',
26
+ '<%= config.bin %> deal product update 42 --attachment 3 --discount 15 --discount-type amount',
27
+ ]
28
+
29
+ static args = {
30
+ id: Args.integer({ required: true, description: 'Deal ID' }),
31
+ }
32
+
33
+ static flags = {
34
+ ...BaseCommand.baseFlags,
35
+ attachment: Flags.integer({
36
+ required: true,
37
+ description: 'Deal-product (attachment) ID',
38
+ }),
39
+ product: Flags.integer({ description: 'Product ID' }),
40
+ price: Flags.string({ description: 'Item price (per unit)' }),
41
+ quantity: Flags.string({ description: 'Quantity' }),
42
+ discount: Flags.string({ description: 'Discount value' }),
43
+ 'discount-type': Flags.string({
44
+ description: 'Discount type',
45
+ options: ['percentage', 'amount'],
46
+ }),
47
+ tax: Flags.string({ description: 'Product tax percentage' }),
48
+ comments: Flags.string({ description: 'Comments' }),
49
+ }
50
+
51
+ async run() {
52
+ const { args, flags } = await this.parse(DealProductUpdateCommand)
53
+
54
+ const body = buildWriteBody({
55
+ typed: {
56
+ product_id: flags.product,
57
+ item_price: num('price', flags.price),
58
+ quantity: num('quantity', flags.quantity),
59
+ discount: num('discount', flags.discount),
60
+ discount_type: flags['discount-type'],
61
+ tax: num('tax', flags.tax),
62
+ comments: flags.comments,
63
+ },
64
+ })
65
+
66
+ if (Object.keys(body).length === 0) {
67
+ throw new CliError('Nothing to update — pass at least one field flag', {
68
+ exitCode: 64,
69
+ })
70
+ }
71
+
72
+ const res = await this.apiClient.patch(
73
+ `/api/v2/deals/${args.id}/products/${flags.attachment}`,
74
+ { body },
75
+ )
76
+ await outputRecord(this, res.data)
77
+ }
78
+ }
@@ -3,12 +3,8 @@ import BaseCommand from '../base-command.js'
3
3
  import { collectPages } from '../lib/pagination.js'
4
4
  import { parsePeriod, formatApiDatetime } from '../lib/period.js'
5
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
6
+ import { mineMany } from '../lib/changelog.js'
7
+ import { CliError } from '../lib/errors.js'
12
8
 
13
9
  export default class FunnelCommand extends BaseCommand {
14
10
  static description =
@@ -134,54 +130,16 @@ export default class FunnelCommand extends BaseCommand {
134
130
  }
135
131
 
136
132
  /**
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.
133
+ * Mine real stage transitions from each deal's v1 changelog (one request per
134
+ * deal, paced by the rate limiter), then compute the exact funnel. Mining,
135
+ * the large-set warning, and the skip-on-ApiError behavior live in the
136
+ * shared changelog lib so this command and `deal history` stay in step.
141
137
  * @param {object[]} deals deals to mine (current stage_id needed per deal)
142
138
  * @param {object[]} stages
143
139
  * @param {number} pipelineId
144
140
  */
145
141
  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
-
142
+ const transitionsByDeal = await mineMany(this.apiClient, deals)
185
143
  return computeExactFunnel(transitionsByDeal, stages, { pipelineId })
186
144
  }
187
145
  }