v0.22.0 — integrations: webhook listen, mail-in-context, filters-as-code#27
Conversation
- webhook listen: a local webhook dev loop. Tunnel mode registers a temporary webhook against a user-supplied --url, prints deliveries (NDJSON under --output json) and optionally forwards them; --synthetic emits webhook-shaped events off the changes feed with no inbound network. Temp webhooks are deleted on exit/SIGINT, and orphaned ones from a crashed run are swept on the next start. - deal context grows a mail summary (message count, last message time and direction, participants, latest subject) that degrades to null when mail scope/sync is unavailable; new mail list --deal lists a deal's messages (bodies excluded by default). - filters-as-code: filter create/update take --conditions (json, @file, or stdin; passed through verbatim, field references are numeric field_id), filter helpers lists the valid fields/operators, filter export emits a round-trippable filter definition (single or --all). - MCP: filter create/update classified write; helpers/export/mail:list read; webhook:listen excluded (long-running, like watch). Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9
webhook listen:
- Each run registers a uniquely-named webhook (pdcli-listen:<uuid>) and
tracks a per-profile set of live ids, so the startup orphan-sweep only
deletes stale, untracked pdcli-listen webhooks — a concurrent listener
on the same account is never clobbered.
- The receiver requires the generated basic-auth the temp webhook was
registered with; a forged POST to the tunnel is rejected (401), not
printed or forwarded.
- --synthetic no longer drops events sharing the last-emitted second when
--max-events is hit (drains the boundary second first).
- A post-bind server error now runs full cleanup (delete webhook, clear
own tracking, remove signal handlers, close server) before exiting.
mail:
- Deal mail paging is offset-based; /deals/{id}/mailMessages returns no
next_start, so the shared cursor pager would have looped / duplicated.
- The deal-context mail summary is now opt-in (--mail): mail needs a scope
most token users lack, so it no longer costs a wasted call per context.
filters:
- filter export documents the round-trip: the exported {name,type,
conditions} is fed to filter create as separate flags, not one blob.
Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
| const cleanup = async () => { | ||
| server.close() | ||
| try { | ||
| await this.apiClient.del(`/api/v1/webhooks/${webhookId}`) | ||
| removeListenId(profile, webhookId) | ||
| } catch (err) { | ||
| process.stderr.write( | ||
| `Cleanup of webhook ${webhookId} failed: ${err.message}\n`, | ||
| ) | ||
| } | ||
| signals.off('SIGINT', onSigint) | ||
| signals.off('SIGTERM', onSigterm) | ||
| } |
There was a problem hiding this comment.
Tracking ID leaked on failed DELETE — when
apiClient.del throws, the catch block logs the error and exits the try block, so removeListenId is never called. The webhook ID remains in the profile's tracked set indefinitely. Because sweepOrphans explicitly excludes every tracked ID (if (tracked.has(w.id)) return false), no future run will ever clean up this webhook — it stays live in Pipedrive forwarding POST traffic to a dead tunnel URL. Moving removeListenId into a finally block ensures the tracking entry is always cleared, which lets a subsequent orphan sweep treat the stale webhook as a candidate for deletion.
| const cleanup = async () => { | |
| server.close() | |
| try { | |
| await this.apiClient.del(`/api/v1/webhooks/${webhookId}`) | |
| removeListenId(profile, webhookId) | |
| } catch (err) { | |
| process.stderr.write( | |
| `Cleanup of webhook ${webhookId} failed: ${err.message}\n`, | |
| ) | |
| } | |
| signals.off('SIGINT', onSigint) | |
| signals.off('SIGTERM', onSigterm) | |
| } | |
| const cleanup = async () => { | |
| server.close() | |
| try { | |
| await this.apiClient.del(`/api/v1/webhooks/${webhookId}`) | |
| } catch (err) { | |
| process.stderr.write( | |
| `Cleanup of webhook ${webhookId} failed: ${err.message}\n`, | |
| ) | |
| } finally { | |
| removeListenId(profile, webhookId) | |
| } | |
| signals.off('SIGINT', onSigint) | |
| signals.off('SIGTERM', onSigterm) | |
| } |
| const { flags } = await this.parse(FilterCreateCommand) | ||
|
|
||
| const raw = await resolveBody({ body: flags.conditions }) | ||
| let conditions | ||
| try { | ||
| conditions = JSON.parse(raw) | ||
| } catch (err) { | ||
| throw new CliError(`--conditions is not valid JSON: ${err.message}`, { | ||
| exitCode: 65, | ||
| }) | ||
| } |
There was a problem hiding this comment.
Misleading error when
--conditions is omitted interactively — resolveBody is a generic helper that throws --body is required (pass a value, @file, or pipe stdin) when invoked on a TTY without a body value. Users running filter create --name foo --type deals on a terminal will see this error mentioning --body, a flag that doesn't exist on this command. A pre-check with a --conditions-specific message before the resolveBody call would make the failure mode immediately clear.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Fourth and final pre-v1 release (integrations).
Added
webhook listen— stripe-listen-style dev loop: tunnel mode (temp webhook against--url, print/--forward-to, auto-delete + orphan sweep, basic-auth-gated receiver) and--synthetic(webhook-shaped events off the changes feed, no inbound network).deal context --mail(opt-in) summary +mail list --deal.filter create/update(conditions via json/@file/stdin),filter helpers,filter export(portable,--all).Quality gate
webhook listenno longer clobbers each other's webhook (unique per-run identity + stale-only scoped sweep); the receiver rejects forged POSTs (basic-auth 401);--synthetic --max-eventsno longer drops same-second events; mail paging is offset-based (the endpoint has nonext_start, so the shared cursor pager would have looped); mail made opt-in to avoid a wasted call per context for the majority without mail scope.Closes the four-release pre-v1 plan (v0.19-v0.22). Next: v1.0 contract kernel.
https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9
Greptile Summary
Adds three new integration surfaces:
webhook listen(tunnel mode with basic-auth-gated loopback receiver + synthetic changes-feed mode),mail listwith a deal-context mail summary signal, andfilter create/update/export/helpers. The implementation is well-structured with careful handling of edge cases (offset-based mail paging, same-second watermark boundary for--max-events, per-run webhook identity to avoid concurrent session clobbering).webhook listenbinds a loopback receiver, registers a uniquely-named temp webhook with generated basic-auth credentials, and guarantees cleanup on SIGINT/SIGTERM/--once/--max-events; synthetic mode polls the changes feed and emits the same envelope shape.deal context --mail) is opt-in, degrades tonullon permission errors, and uses a custom offset pager because the mail endpoint has nonext_startfield.create,update,export(single or--all), andhelpers; conditions are accepted as inline JSON,@file, or piped stdin.Confidence Score: 4/5
Safe to merge after fixing the webhook tracking ID leak on failed cleanup.
The core logic across all three new integrations is solid. The one real defect is in
webhook listen'scleanup: if the Pipedrive DELETE call fails at shutdown,removeListenIdis never reached, so the webhook ID stays in the profile's tracked set permanently. Future orphan sweeps explicitly skip tracked IDs, meaning that webhook can never be auto-cleaned and may remain live in Pipedrive indefinitely. The fix is a one-line change (moveremoveListenIdinto afinallyblock). Everything else — offset-based mail paging, same-second watermark boundary, per-run unique webhook identity, basic-auth gating — is implemented correctly and well-tested.src/commands/webhook/listen.js — specifically the
cleanupclosure insiderunTunnelImportant Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant CLI as webhook listen participant Config as Profile Config participant PD as Pipedrive API participant Tunnel as Tunnel (ngrok) participant Receiver as Local Receiver (:port) participant Fwd as --forward-to sink CLI->>Config: getListenIds (tracked set) CLI->>PD: GET /api/v1/webhooks (orphan sweep) PD-->>CLI: webhook list CLI->>PD: DELETE stale orphan webhooks (untracked + stale) CLI->>Receiver: server.listen(port, 127.0.0.1) CLI->>PD: "POST /api/v1/webhooks (subscription_url=tunnel, basic-auth creds)" PD-->>CLI: webhookId CLI->>Config: addListenId(webhookId) Tunnel->>Receiver: POST / (Authorization: Basic ...) Receiver->>Receiver: authMatches(header, expected) alt auth valid Receiver->>CLI: emitEvent(payload) CLI->>Fwd: POST payload (best-effort) Note over CLI: --once or max-events → shutdown else auth invalid Receiver-->>Tunnel: 401 end Note over CLI: SIGINT / SIGTERM CLI->>PD: "DELETE /api/v1/webhooks/{webhookId}" CLI->>Config: removeListenId(webhookId) CLI->>Receiver: server.close()%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant CLI as webhook listen participant Config as Profile Config participant PD as Pipedrive API participant Tunnel as Tunnel (ngrok) participant Receiver as Local Receiver (:port) participant Fwd as --forward-to sink CLI->>Config: getListenIds (tracked set) CLI->>PD: GET /api/v1/webhooks (orphan sweep) PD-->>CLI: webhook list CLI->>PD: DELETE stale orphan webhooks (untracked + stale) CLI->>Receiver: server.listen(port, 127.0.0.1) CLI->>PD: "POST /api/v1/webhooks (subscription_url=tunnel, basic-auth creds)" PD-->>CLI: webhookId CLI->>Config: addListenId(webhookId) Tunnel->>Receiver: POST / (Authorization: Basic ...) Receiver->>Receiver: authMatches(header, expected) alt auth valid Receiver->>CLI: emitEvent(payload) CLI->>Fwd: POST payload (best-effort) Note over CLI: --once or max-events → shutdown else auth invalid Receiver-->>Tunnel: 401 end Note over CLI: SIGINT / SIGTERM CLI->>PD: "DELETE /api/v1/webhooks/{webhookId}" CLI->>Config: removeListenId(webhookId) CLI->>Receiver: server.close()Reviews (1): Last reviewed commit: "chore: release v0.22.0" | Re-trigger Greptile