Skip to content

v0.22.0 — integrations: webhook listen, mail-in-context, filters-as-code#27

Merged
wavyx merged 3 commits into
mainfrom
feat-integrations
Jul 9, 2026
Merged

v0.22.0 — integrations: webhook listen, mail-in-context, filters-as-code#27
wavyx merged 3 commits into
mainfrom
feat-integrations

Conversation

@wavyx

@wavyx wavyx commented Jul 9, 2026

Copy link
Copy Markdown
Owner

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).
  • Mail signaldeal context --mail (opt-in) summary + mail list --deal.
  • Filters as codefilter create/update (conditions via json/@file/stdin), filter helpers, filter export (portable, --all).

Quality gate

  • TDD. 1798 tests, 100% coverage, lint clean. 154 commands.
  • Adversarial review (8 findings, all fixed) + confirmed the warehouse-mail entity is genuinely infeasible (no watermark) so it was correctly not built. Key fixes: concurrent webhook listen no longer clobbers each other's webhook (unique per-run identity + stale-only scoped sweep); the receiver rejects forged POSTs (basic-auth 401); --synthetic --max-events no longer drops same-second events; mail paging is offset-based (the endpoint has no next_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.
  • Live-verify of the v1 webhook/mail/filters endpoints needs a sandbox account (added to the punch-list); unit + adversarial coverage stands in for now.

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 list with a deal-context mail summary signal, and filter 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 listen binds 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.
  • Mail signal (deal context --mail) is opt-in, degrades to null on permission errors, and uses a custom offset pager because the mail endpoint has no next_start field.
  • Filter commands implement create, update, export (single or --all), and helpers; 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's cleanup: if the Pipedrive DELETE call fails at shutdown, removeListenId is 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 (move removeListenId into a finally block). 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 cleanup closure inside runTunnel

Important Files Changed

Filename Overview
src/commands/webhook/listen.js New 614-line command implementing tunnel and synthetic webhook modes; has a bug where a failed DELETE at shutdown leaves the webhook ID permanently in the tracking set, preventing orphan sweeps from ever cleaning it up.
src/commands/mail/list.js New command implementing offset-based mail paging (correctly avoids the cursor pager that would loop); exports fetchDealMail and mailDirection for use by deal context.
src/commands/deal/context.js Adds opt-in mail summary slice via --mail flag; mail fetch errors degrade gracefully to null; concurrent Promise.all pattern for all slices is preserved correctly.
src/commands/filter/create.js New filter create command; --conditions is optional (stdin-compatible) but the fallback error message from resolveBody mentions --body rather than --conditions, which is confusing in interactive use.
src/commands/filter/export.js New filter export command; --all path uses a single non-paginated GET which is appropriate for the v1 filters list endpoint; single-filter export is straightforward.
src/commands/filter/helpers.js New filter helpers command; flattenOperators correctly handles both object and array-of-single-key-objects shapes for the operators API response.
src/commands/filter/update.js New filter update command; correctly validates that at least one field is being updated and uses PUT as expected by the Pipedrive v1 API.
test/commands/webhook/listen.test.js Comprehensive 1016-line test suite covering tunnel mode (auth, forwarding, orphan sweep, concurrent sessions, signals, max-events), synthetic mode (watermark, polling, same-second boundary), and all pure helper functions.
test/commands/mail/list.test.js Good coverage of offset paging, unwrapping, direction flag, and the deal context mail summarizer; pagination termination on empty page and more_items_in_collection=false are both tested.

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()
Loading
%%{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()
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "chore: release v0.22.0" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

wavyx added 3 commits July 9, 2026 14:40
- 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-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@wavyx wavyx merged commit 0710c12 into main Jul 9, 2026
12 checks passed
Comment on lines +373 to +385
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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)
}

Fix in Claude Code

Comment on lines +43 to +53
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,
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Misleading error when --conditions is omitted interactivelyresolveBody 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!

Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants