Description
Two related fixes to the Jira webhook triggers in apps/jira/triggers/, plus a bonus correctness fix to the OAuth2 webhook setup flow uncovered by code review.
Fix 1 — Honor project_key config (both triggers)
Both NewIssueTrigger (JIRA_NEW_ISSUE_TRIGGER) and UpdatedIssueTrigger (JIRA_UPDATED_ISSUE_TRIGGER) now override resolve_event_identity() to surface the inbound issue's project key as resolved_config={"project_key": ...}. The Mercury dispatcher uses resolved_config to route events only to trigger instances whose stored config matches — so a trigger configured for project ABC no longer fires for an issue in project XYZ. This is the canonical Mercury filtering pattern (same shape Slack channel triggers use via channel_id).
This is defense-in-depth on top of the JQL filter we already send to Jira at webhook setup time; multiple triggers from one OAuth account share a single Jira webhook URL, so the JQL filter alone isn't sufficient.
Fix 2 — Extend JIRA_UPDATED_ISSUE_TRIGGER to fire on comment events
UpdatedIssueTrigger now subscribes to comment_created, comment_updated, and comment_deleted in addition to jira:issue_updated. The webhook subscription, the match() selector, and the existing per-project JQL filter all pick up the additional events.
The payload schema gains two fields so consumers can tell what fired:
event_type: "issue_updated" | "comment_created" | "comment_updated" | "comment_deleted" (Literal-typed).
comment: an optional nested { id, body, author, created_at, updated_at } object, populated only on comment_* events.
Existing issue_updated consumers continue to see updated_fields populated from the changelog. Comment events get updated_fields={} and the new comment object filled in. Atlassian webhook payloads for comment events include the parent issue inline at body.issue, so no extra HTTP lookup is needed to populate issue_id / issue_key / project_key / summary / reporter.
Fix 3 (uncovered by code review) — Properly extend webhook subscriptions on duplicate-URL collision
Atlassian limits each OAuth user to a single webhook URL. The pre-existing _setup code treated the "Only a single URL per user is allowed" error as silent success — meaning users upgrading to receive comment events on an already-registered webhook would never see them delivered until they manually recreated it.
This PR now does the right thing on collision:
_list_oauth2_webhooks — paginated GET of all webhooks owned by this OAuth app.
_delete_oauth2_webhooks — bulk DELETE by id list.
_handle_duplicate_oauth2_webhook — when create returns the duplicate-URL error, list existing entries at this URL, partition by JQL filter so sibling triggers' filters (e.g. project='ABC' vs 'DEF') are preserved verbatim, union events for the matching-filter group, then DELETE + POST to recreate the full set in one atomic call. Optional Atlassian filter fields (fieldIdsFilter, issuePropertyKeysFilter) on existing entries are also preserved so we never silently broaden the subscription.
Failure modes handled:
- List returns no match for the URL → log loudly, return success (preserves prior behavior).
- DELETE fails → return without recreate so the user keeps their existing partial subscription rather than ending up with nothing.
- Recreate fails → raise so the caller knows the subscription is now broken (rather than silent success).
Mechanism
JiraBaseWebhook.match() now matches any event in cls._events() (the trigger's event plus an optional extra_events: ClassVar[Optional[List[str]]]).
_setup() sends the full event list to both REST API v3 (OAuth2) and REST 1.0 (Basic Auth) webhook endpoints.
NewIssueTrigger and NewProjectTrigger are unchanged in behavior (no extra_events set).
How did I test this PR
33 unit tests in tests/test_apps/test_jira/test_triggers.py:
match() multi-event behavior on both triggers.
resolve_event_identity() for matching, mismatched, and missing-project-key payloads on both triggers; comment-event project_key extraction.
transform() output for jira:issue_updated (existing behavior preserved including changelog → updated_fields) and all three comment events.
- Comment-author fallback to
accountId when displayName is missing.
_events() returning the correct subscription list.
- Duplicate-URL recreate flow: union of events when single existing entry; skip recreate when events already cover required; no-op + warning when list returns no match for our URL; preserve subscription when DELETE fails (no recreate); raise on recreate POST failure; preserve sibling entries with different JQL filters; preserve Atlassian optional filters (
fieldIdsFilter, issuePropertyKeysFilter) on recreate.
Commands run from /workspace/mercury:
$ pytest tests/test_apps/test_jira/ -v
... 33 passed in 2.32s
$ make fmt && make chk
... All checks passed! (full run: 31.8min, all 1200+ apps clean)
Codex review loop: 4 iterations completed. Each iteration found a distinct correctness issue in the recreate flow (stale-events, then sibling-JQL clobber, then fieldIdsFilter drop). All addressed with new tests in this PR.
End-to-end Jira webhook delivery against a live Atlassian instance was not run from the sandbox (Mercury changes are typically validated E2E via Cortex test infra). I attempted to dispatch a Cortex trigger-fixer workflow first, but Cortex's trigger registry returned Available triggers: [] for the jira app, so the workflow couldn't accept the task — falling back to direct edits with thorough unit coverage.
Triggered by: Pranai pranai@usefulagents.com | Source: slack
Session: https://zen-api-production-4c98.up.railway.app/dashboard/#/chat/zen-1fb16accc211