Repository navigation
v0.21.0 — dev loop: quota, lookup, localhost mock #26
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| { | ||
| "name": "@wavyx/pdcli", | ||
| "version": "0.20.0", | ||
| "version": "0.21.0", | ||
| "publishConfig": { | ||
| "access": "public" | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { Args, Flags } from '@oclif/core' | ||
| import BaseCommand from '../base-command.js' | ||
| import { CliError } from '../lib/errors.js' | ||
| import { getFields, makeResolver } from '../lib/fields.js' | ||
|
|
||
| // User-facing entity -> itemSearch/field entity_type. Only entities that have | ||
| // both a fields endpoint (for name->key resolution) and a searchable type are | ||
| // supported; `project` is intentionally omitted (no fields endpoint). | ||
| const ENTITY_TYPES = { | ||
| deal: 'deal', | ||
| person: 'person', | ||
| org: 'organization', | ||
| product: 'product', | ||
| lead: 'lead', | ||
| } | ||
|
|
||
| const columns = { | ||
| id: { header: 'ID' }, | ||
| name: { header: 'Name / Title', get: (row) => row.title ?? row.name ?? '' }, | ||
| } | ||
|
|
||
| export default class LookupCommand extends BaseCommand { | ||
| static description = | ||
| 'Find item IDs by an exact field value — the create-vs-update primitive.\n' + | ||
| 'Matching is CASE-SENSITIVE, and the search index is eventually consistent ' + | ||
| '(a just-created record is not immediately findable), so a lookup-then-create ' + | ||
| 'loop can double-create in fast pipelines. Exit 3 means no match (branch to ' + | ||
| 'create); exit 0 with rows means it exists.' | ||
|
|
||
| static examples = [ | ||
| '<%= config.bin %> lookup deal --field "PO Number" --value PO-1234', | ||
| '<%= config.bin %> lookup person --field Email --value a@b.com --first --jq .id', | ||
| '<%= config.bin %> lookup org --field Name --value Acme --match beginning', | ||
| ] | ||
|
|
||
| static args = { | ||
| entity: Args.string({ | ||
| required: true, | ||
| description: 'Item type to search', | ||
| options: Object.keys(ENTITY_TYPES), | ||
| }), | ||
| } | ||
|
|
||
| static flags = { | ||
| ...BaseCommand.baseFlags, | ||
| field: Flags.string({ | ||
| required: true, | ||
| description: 'Field name (built-in or custom) to search on', | ||
| }), | ||
| value: Flags.string({ | ||
| required: true, | ||
| description: 'Value to match against the field', | ||
| }), | ||
| match: Flags.string({ | ||
| description: 'Match mode (case-sensitive)', | ||
| options: ['exact', 'beginning', 'middle'], | ||
| default: 'exact', | ||
| }), | ||
| first: Flags.boolean({ | ||
| description: 'Return only the first match (a single object)', | ||
| default: false, | ||
| }), | ||
| } | ||
|
|
||
| async run() { | ||
| const { args, flags } = await this.parse(LookupCommand) | ||
| const entityType = ENTITY_TYPES[args.entity] | ||
|
|
||
| const resolver = makeResolver(await getFields(this.apiClient, args.entity)) | ||
| const field = resolver.nameToKey(flags.field) | ||
| if (!field) { | ||
| throw new CliError(`Unknown field "${flags.field}" for ${args.entity}`, { | ||
| exitCode: 64, | ||
| }) | ||
| } | ||
|
|
||
| // exact match allows a 1-char term; the other modes need at least 2. | ||
| const minLength = flags.match === 'exact' ? 1 : 2 | ||
| if (flags.value.length < minLength) { | ||
| throw new CliError( | ||
| `--value must be at least ${minLength} character(s) for --match ${flags.match}`, | ||
| { exitCode: 64 }, | ||
| ) | ||
| } | ||
|
|
||
| // Search costs 20 rate-limit tokens; the field-search endpoint caps limit | ||
| // at 100 (single request, no auto-paging). | ||
| const body = await this.apiClient.get('/api/v2/itemSearch/field', { | ||
| query: { | ||
| term: flags.value, | ||
| entity_type: entityType, | ||
| field, | ||
| match: flags.match, | ||
| limit: flags.limit != null ? Math.min(flags.limit, 100) : undefined, | ||
| }, | ||
| }) | ||
|
|
||
| // Each result wraps the matched record under `item`; tolerate a flat shape | ||
| // (`{ id, ... }` directly) so the no-match check and output stay correct | ||
| // whichever the live endpoint returns. | ||
| const rows = (body.data ?? []).map((entry) => entry.item ?? entry) | ||
|
|
||
| if (rows.length === 0) { | ||
| throw new CliError( | ||
| `No ${args.entity} found where ${flags.field} = ${flags.value}`, | ||
| { exitCode: 3 }, | ||
| ) | ||
| } | ||
|
|
||
| await this.outputResults(flags.first ? rows[0] : rows, columns) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import { Flags } from '@oclif/core' | ||
| import BaseCommand from '../base-command.js' | ||
| import { CliError, RateLimitError } from '../lib/errors.js' | ||
|
|
||
| const columns = { | ||
| remaining: { | ||
| header: 'Remaining', | ||
| get: (r) => (r.daily.remaining == null ? 'n/a' : r.daily.remaining), | ||
| }, | ||
| limit: { | ||
| header: 'Limit', | ||
| get: (r) => (r.daily.limit == null ? 'n/a' : r.daily.limit), | ||
| }, | ||
| pct: { | ||
| header: '% Left', | ||
| get: (r) => (r.daily.pct == null ? 'n/a' : `${r.daily.pct}%`), | ||
| }, | ||
| reset: { | ||
| header: 'Burst reset (s)', | ||
| get: (r) => (r.reset == null ? 'n/a' : r.reset), | ||
| }, | ||
| } | ||
|
|
||
| export default class QuotaCommand extends BaseCommand { | ||
| static aliases = ['ratelimit'] | ||
|
|
||
| static description = | ||
| 'Show the remaining Pipedrive daily API token budget. Makes one cheap ' + | ||
| 'probe request purely to read the rate-limit headers. The budget is ' + | ||
| 'COMPANY-WIDE — shared across every integration and user on the account — ' + | ||
| 'so treat the reading as a hint, not a guarantee. --min / --threshold gate ' + | ||
| 'CI: they exit 75 (rate-limited) when the budget is too low.' | ||
|
|
||
| static examples = [ | ||
| '<%= config.bin %> quota', | ||
| '<%= config.bin %> quota --output json', | ||
| '<%= config.bin %> quota --min 5000', | ||
| '<%= config.bin %> quota --threshold 10', | ||
| ] | ||
|
|
||
| static flags = { | ||
| ...BaseCommand.baseFlags, | ||
| min: Flags.integer({ | ||
| description: | ||
| 'Exit 75 if the remaining daily token budget is below this many tokens', | ||
| min: 0, | ||
| }), | ||
| threshold: Flags.integer({ | ||
| description: | ||
| 'Exit 75 if the remaining daily token budget is below this percentage', | ||
| min: 0, | ||
| max: 100, | ||
| }), | ||
| } | ||
|
|
||
| async run() { | ||
| const { flags } = await this.parse(QuotaCommand) | ||
|
|
||
| // A GET-single-entity probe (~2 tokens) purely to populate the rate-limit | ||
| // headers. Users is v1-only (no v2 equivalent) and always exists. The | ||
| // probe goes through the shared transport, which THROWS a RateLimitError | ||
| // (exit 75) on a 429 or an exhausted daily budget — but that is precisely | ||
| // the reading a user runs `quota` to see. transport snapshots lastRateLimit | ||
| // off the 429 response BEFORE throwing, so swallow the rate-limit throw and | ||
| // fall through to print the reading; re-throw anything else (auth, 5xx, | ||
| // network) unchanged. | ||
| try { | ||
| await this.apiClient.get('/api/v1/users/me') | ||
| } catch (err) { | ||
| if (!(err instanceof RateLimitError)) throw err | ||
| } | ||
|
|
||
| // transport() populates lastRateLimit off every response (including the 429 | ||
| // above), so it is set whether the probe resolved or was rate-limited. | ||
| const rl = this.apiClient.lastRateLimit | ||
| const remaining = rl.dailyRemaining | ||
| const limit = rl.dailyLimit | ||
| const hasData = remaining != null | ||
| const pct = | ||
| hasData && limit ? Math.round((remaining / limit) * 100) : undefined | ||
|
|
||
| const data = { | ||
| daily: { | ||
| remaining: hasData ? remaining : null, | ||
| limit: limit ?? null, | ||
| pct: pct ?? null, | ||
| }, | ||
| reset: rl.reset ?? null, | ||
| } | ||
|
|
||
| await this.outputResults(data, columns) | ||
|
|
||
| // --min gates on the absolute remaining; skip silently when there is no | ||
| // remaining reading to compare (nothing to gate on — never fabricate one). | ||
| if (flags.min != null && hasData && remaining < flags.min) { | ||
| throw new CliError( | ||
| `Daily token budget ${remaining} is below --min ${flags.min}`, | ||
| { exitCode: 75 }, | ||
| ) | ||
| } | ||
|
|
||
| // --threshold gates on the percentage. If the caller asked for it but no | ||
| // pct could be computed (the daily-limit header was absent, or there was no | ||
| // reading at all), do NOT silently pass — a false green would let a CI job | ||
| // gating on `pdcli quota --threshold N` proceed blind. Fail closed. | ||
| if (flags.threshold != null) { | ||
| if (pct == null) { | ||
| throw new CliError( | ||
| 'cannot evaluate --threshold: no daily-limit header in the API ' + | ||
| 'response (x-daily-ratelimit-token-limit absent)', | ||
| { exitCode: 69 }, | ||
| ) | ||
| } | ||
|
Comment on lines
+107
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When |
||
| if (pct < flags.threshold) { | ||
| throw new CliError( | ||
| `Daily token budget ${pct}% remaining is below --threshold ` + | ||
| `${flags.threshold}%`, | ||
| { exitCode: 75 }, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lastRateLimitthis.apiClient.lastRateLimitis typed as{ ... } | undefineduntil the first HTTP response is received. The probe request is made just before this code and all non-RateLimitErrorexceptions are re-thrown, so in every currently reachable pathrlis an object. However, if a future code path (e.g. a quota invocation without the probe, or a refactor of the try/catch) ever allows control to reach here without a prior response,rl.dailyRemainingwill throw aTypeErrorwith no useful message rather than an actionable CLI error. A defensive early return keeps the invariant explicit.