Skip to content

fix(jira): match status by locale-invariant ID with name fallback (MNG-1768) - #1528

Merged
aaight merged 3 commits into
devfrom
fix/mng-1768-jira-locale-status-matching
Aug 4, 2026
Merged

fix(jira): match status by locale-invariant ID with name fallback (MNG-1768)#1528
aaight merged 3 commits into
devfrom
fix/mng-1768-jira-locale-status-matching

Conversation

@aaight

@aaight aaight commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes MNG-1768 — JIRA status matching was name-based on both ends, so status moves silently no-op'd when the credential account's language differed from the webhook (site) language. This PR makes matching locale-invariant by keying on the JIRA status ID, with name matching kept as a fallback (zero forced migration), and makes the silent miss loud via Sentry.

🔗 https://linear.app/issue/MNG-1768

What changed

Locale-invariant ID matching on both ends

  • src/triggers/shared/pm-status.ts — new resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions({ statusId, statusName, configuredStatuses }). Reuses the existing workflow-definition iterator via a closure matcher: matches when the stored config value exactly equals statusId or case-insensitively equals statusName (ID checked first). Custom workflow statuses and the null-agentType guard keep working unchanged.
  • src/triggers/jira/types.ts — widened the webhook payload types: issue.fields.status.id?, and changelog.items[].from? / .to? (the status IDs) alongside the existing fromString/toString names.
  • src/triggers/jira/status-changed.tsresolveNewStatus now returns { id?, name? } (create path → issue.fields.status.{id,name}; update path → changelog {to, toString}). handle() resolves via the new id-or-name resolver; matches() accepts an id or name on the create path. Logs toStatusId next to toStatus.
  • src/pm/jira/adapter.tsmoveWorkItem matcher now prefers transitions[].to.id === destination (the target status ID, distinct from the transition t.id), keeping the name/t.id branches as fallbacks. JiraTransition.to widened to { id?, name? }. A genuine no-transition-found miss now emits a Sentry captureException tagged jira_transition_not_found in addition to the existing WARN. Documented that JQL listWorkItems accepts a quoted status ID unchanged.

Self-healing wizard migration

  • web/.../jira/wizard.ts — the status-mapping select now persists { id: s.id, name: s.name } (status ID as the value, name displayed) instead of { id: s.name, name: s.name }.
  • web/.../jira/state.ts — new normalizeJiraStatusMappingsToIds(mappings, statuses), invoked in the SET_JIRA_PROJECT_DETAILS reducer. When project details load, legacy name-valued mappings auto-upgrade to IDs (case-insensitive); already-ID and unrecognized/custom values are left untouched. Re-saving any project backfills IDs.

Docs

  • src/integrations/README.md — JIRA row in the custom-workflow-status table updated to note IDs (name as legacy fallback); new "JIRA status matching is ID-based" subsection.
  • CLAUDE.md / AGENTS.md (symlinked, stay byte-identical) — new "JIRA status matching (locale-invariant)" section.

Testing

  • resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions: id-match / name-match / id-preferred / no-match / custom-status / null-agent.
  • Trigger: locale-mismatch repro (IDs in config + foreign-language toString dispatches), name-based back-compat, create path via status.id, toStatusId logging.
  • Adapter: moveWorkItem matches by to.id (foreign-language to.name), Sentry jira_transition_not_found capture on a genuine miss, name-based back-compat retained (no capture).
  • Wizard: mapping persists the status ID, legacy name→id normalization on details load, already-id / unknown-custom values untouched.
  • npm run typecheck, biome lint on changed files, PM conformance harness, and the docs-drift guard all pass.

🤖 Generated with Claude Code

🕵️ claude-code · claude-opus-4-8 · run details

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.48936% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/pm/jira/adapter.ts 78.12% 7 Missing ⚠️
src/triggers/jira/status-changed.ts 94.11% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@nhopeatall nhopeatall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Correctly implements MNG-1768 — JIRA status matching is now locale-invariant (status ID first, case-insensitive name fallback) on both the dispatch (resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions) and move (moveWorkItemto.id) sides, plus a self-healing wizard migration (normalizeJiraStatusMappingsToIds on SET_JIRA_PROJECT_DETAILS). The change matches all 7 implementation steps in the work item, keeps name-based configs working (verified via retained back-compat tests and the built-in todo → implementation resolution), and CI is green (7/7). No blocking issues — a few considerations below.

Code Issues

Should Fix

  • src/pm/jira/adapter.tsmoveWorkItem Sentry capture on benign no-ops. The new captureException({ jira_transition_not_found }) fires on any miss, but moveWorkItem is called best-effort from createWorkItem (the backlog transition, adapter.ts:214) and from lifecycle safeMove (moveOnPrepare / moveOnSuccess, src/pm/lifecycle.ts:187). Neither checks whether the issue is already in destination. A freshly-created issue that JIRA already placed in the configured backlog status has no self-transition, so this will emit a Sentry event for a benign no-op — potentially on every issue creation — diluting the exact genuine locale/misconfig signal the ticket wants to make loud. Consider skipping the capture when the current status already equals destination (or when invoked from the best-effort create path).

Documentation

  • src/integrations/README.md:298 and docs/architecture/04-agent-system.md:144 still state the JIRA trigger resolves via resolvePMStatusAgentByNameFromWorkflowDefinitions. This PR replaced that call with resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions, so README.md now contradicts its own newly-added subsection (~L273). Worth updating both for consistency given the repo's docs-drift discipline (04-agent-system.md is outside this diff).

Nitpicks (optional)

  • src/pm/jira/adapter.ts — the legacy t.id === destination fallback (transition ID) can now collide with a numeric status-ID destination (e.g. a small system status id equal to some transition's id). The to.id-first ordering makes this very unlikely, but the branch appears vestigial (no caller passes a transition ID) — consider dropping it.
  • tests/unit/triggers/shared/pm-status.test.ts — the "prefers the ID match when both id and name are supplied" case passes because planning is the first object entry (and matches by id), not because of a global id-over-name preference; reversing entry order would let the name match on todo win. The test is valid, but the comment overstates the guarantee.

🕵️ claude-code · claude-opus-4-8 · run details

Comment thread src/pm/jira/adapter.ts
// account whose transitions never match `destination` is otherwise
// invisible — this capture surfaces it on the first run. No-op when
// SENTRY_DSN is unset.
captureException(new Error('No JIRA transition found for destination'), {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

moveWorkItem unconditionally captures jira_transition_not_found on a miss, but it is called best-effort from createWorkItem (the backlog transition at adapter.ts:214) and from lifecycle safeMove (moveOnPrepare / moveOnSuccess). Neither checks whether the issue is already in destination. A newly-created issue that JIRA already placed in the configured backlog status has no self-transition, so this fires a Sentry capture for a benign no-op — potentially on every issue creation — diluting the genuine locale/misconfig signal this tag is meant to surface. Consider skipping the capture when the current status already equals destination (or when called from the best-effort create path).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in c40449d. moveWorkItem now checks whether the issue is already in destination before capturing on the miss path, so the jira_transition_not_found signal stays meaningful.

Rather than plumb a "best-effort" flag through createWorkItem and lifecycle safeMove (moveOnPrepare/moveOnSuccess) separately, I added a small isAlreadyInStatus(id, destination) helper that fetches the current status and matches it locale-invariantly (status ID first, case-insensitive name fallback — mirroring the transition matcher). When the issue is already there, we logger.debug a no-op and return without capturing; any read failure returns false so a genuine miss is never suppressed. This covers all best-effort callers uniformly (the freshly-created-issue-already-in-backlog case included) at the cost of one extra getIssue only on the already-rare miss path.

While here, I also dropped the vestigial t.id === destination (transition-ID) fallback branch you flagged in the nitpick — no caller passes a transition ID and it could collide with a numeric status-ID destination.

New coverage in tests/unit/pm/jira/adapter.test.ts: benign no-op by ID, benign no-op by name (back-compat), and an explicit-current-status genuine-miss case that still captures.

Address review feedback on PR #1528 (MNG-1768):

- moveWorkItem no longer fires the jira_transition_not_found Sentry
  capture when the issue is already in the destination status. Best-effort
  callers (createWorkItem's backlog move, lifecycle moveOnPrepare/
  moveOnSuccess) move unconditionally and JIRA offers no self-transition,
  so an already-there issue legitimately reaches the miss path — capturing
  there diluted the genuine locale/misconfig signal. Added isAlreadyInStatus
  helper (ID-first, name fallback) so only a real miss is surfaced loudly.
- Dropped the vestigial `t.id === destination` (transition-ID) matcher
  branch that could collide with a numeric status-ID destination; no caller
  passes a transition ID.
- Docs: src/integrations/README.md and docs/architecture/04-agent-system.md
  now reference resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions for the
  JIRA status-changed trigger.
- Clarified the overstated pm-status "prefers the ID match" test comment
  (per-entry ID-before-name, first matching entry wins during iteration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@nhopeatall nhopeatall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Requesting changes. The core MNG-1768 fix — locale-invariant ID-or-name matching for JIRA status-changed dispatch (resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions) and moveWorkItem (to.id first), plus the self-healing wizard migration and the jira_transition_not_found Sentry signal — is correct, cleanly implemented, and well-tested. But the PR changes the semantics of every jira.statuses config value from name → ID and only updates two of the four consumers of that map. Two other JIRA triggers still match the issue's status.name by name only and will silently stop firing once a project's config holds IDs.

Architecture & Design

  • [BLOCKING] Incomplete read-side migration of jira.statuses (name → ID). The write side now persists status IDs (jira/wizard.ts:510, providerStates → { id: s.id }) and auto-migrates legacy name configs to IDs (jira/state.ts normalizeJiraStatusMappingsToIds on SET_JIRA_PROJECT_DETAILS). status-changed dispatch and moveWorkItem were updated to match ID-or-name — but two other readers of the same jira.statuses map were not:

    • src/triggers/jira/label-added.tsJiraReadyToProcessLabelTrigger reads payload.issue.fields.status.name (L77) and resolves the agent via resolvePMLabelAgentByStatusNameFromWorkflowDefinitions({ statusName, configuredStatuses: jiraConfig.statuses }) (L91–94), which matches by name only (caseInsensitiveStatusMatcher).
    • src/triggers/jira/comment-mention.tsisInPlanningStatus compares payload.issue.fields.status.name (L181) against jiraConfig.statuses.planning by name (L89). planning is one of the wizard's JIRA_STATUS_SLOTS, so it is migrated to an ID.

    Failing scenario (new JIRA project, fully silent): operator maps planning → "Planning" in the updated wizard → config persists statuses.planning = "10005"; enables pm:comment-mention for respond-to-planning-comment. A user @mentions the bot on an issue currently in Planning. isInPlanningStatus compares status.name "Planning" against statuses.planning "10005" → no match → handle() returns null at a debug log → the agent never runs. The cascade-ready label flow breaks identically ("To Do" vs "10010"). This is the exact silent-no-op class MNG-1768 set out to eliminate, reintroduced on a different surface. Existing JIRA projects are safe only until their config is re-saved; new projects are affected immediately.

    Why CI is green: the existing jira-label-added / jira-comment-mention suites use name-based statuses ({ planning: 'Planning', todo: 'To Do', ... } matched against status: { name: 'Planning' }), so name-matching still passes. The ID-config path for these two triggers is untested.

    Fix is small and already scaffolded here: types.ts already widened issue.fields.status to { id?, name? }, so status.id is available on both payloads. Add a resolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions (mirroring the new shared resolver), make isInPlanningStatus compare id-or-name, and pass status.id alongside status.name from both triggers — plus one ID-config test each.

    If these two flows are intentionally out of scope, that should be stated explicitly: the work item and README.md/CLAUDE.md claim "both ends now match on the ID," which is currently untrue for label-added / comment-mention.

Notes (non-blocking)

  • moveWorkItem ID-or-name matching + dropping the vestigial t.id === destination branch is correct. The isAlreadyInStatus guard against spurious jira_transition_not_found captures is a good call, and a read failure correctly falls through so genuine misses still surface.
  • Docs (CLAUDE.md/AGENTS.md symlink, README.md, 04-agent-system.md) are consistent with the status-changed / move changes.

🕵️ claude-code · claude-opus-4-8 · run details

// (`s.id`), while the human-readable `s.name` is what the select
// displays. Previously both were `s.name`, which made status moves
// locale-fragile.
providerStates: (details?.statuses ?? []).map((s) => ({ id: s.id, name: s.name })),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

MNG-1768 write side: persisting status IDs here (and auto-migrating legacy configs via normalizeJiraStatusMappingsToIds) changes the semantics of every jira.statuses value. But only status-changed dispatch and moveWorkItem were updated to match by ID — two other consumers of the same map still match the issue's status.name by name only and will silently stop firing for ID-based configs (all new projects, plus any re-saved project):

  • src/triggers/jira/label-added.ts (L77, L91-94) — JiraReadyToProcessLabelTrigger, the cascade-ready flow.
  • src/triggers/jira/comment-mention.ts (L81, L181) — isInPlanningStatus, gating respond-to-planning-comment; planning is a migrated JIRA_STATUS_SLOTS entry.

Both need to read status.id (already typed via this PR's types.ts widening) and match via an id-or-name resolver, mirroring resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions. See the review summary for the exact failing scenario.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 4cf0ca2. Migrated both remaining jira.statuses readers to id-or-name matching, mirroring resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions:

  • New shared resolverresolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions in src/triggers/shared/pm-label.ts, delegating to the status resolver so custom-status + null-agentType handling stays identical.
  • label-added.ts (JiraReadyToProcessLabelTrigger, the cascade-ready flow) — now reads both status.id and status.name and resolves via the new id-or-name resolver.
  • comment-mention.tsisInPlanningStatus now compares the configured planning value against the issue's status.id first, then case-insensitively against status.name; the caller passes both.

No collision risk between the branches: JIRA status IDs are numeric strings while names are free text, so a configured value matches at most one interpretation.

Added ID-config unit tests for each trigger (foreign-language name + matching ID, e.g. "En cours" / 10010) plus three cases for the new shared resolver, and documented both readers in the MNG-1768 README subsection so the "every consumer matches by ID" claim is now accurate. npm run typecheck, biome, and the full unit-triggers suite (1284 tests) pass.

…ion (MNG-1768)

The MNG-1768 write side persists locale-invariant JIRA status IDs into
`jira.statuses`, but only status-changed dispatch and moveWorkItem were
migrated to match by ID. Two other readers of the same map still matched
the issue's `status.name` by name only, so they silently stopped firing
for ID-based configs (all new projects, plus any re-saved project):

- `JiraReadyToProcessLabelTrigger` (cascade-ready label flow)
- `JiraCommentMentionTrigger.isInPlanningStatus` (planning gate)

Add `resolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions`
(mirroring the shared status resolver), read `status.id` alongside
`status.name` in both triggers, and gate planning by ID-first with a
case-insensitive name fallback. Now every consumer of the ID-valued
`jira.statuses` map is locale-invariant. Adds ID-config unit tests for
each trigger and the new resolver, and documents the two readers in the
MNG-1768 README subsection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@nhopeatall nhopeatall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

APPROVE — MNG-1768 is implemented correctly and, importantly, completely. JIRA status matching is now locale-invariant (status ID first, case-insensitive name fallback) on every surface that reads jira.statuses, existing name-based configs keep working, and the previously-silent transition miss is now a Sentry signal. CI is green (7/7).

I traced every consumer of jira.statuses to confirm the read-side migration has no gaps (the class of bug flagged in the prior review round):

  • Live-status matchers (the bug class) — all migrated to id-or-name:
    • src/triggers/jira/status-changed.tsresolvePMStatusAgentByIdOrNameFromWorkflowDefinitions
    • src/triggers/jira/label-added.tsresolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions
    • src/triggers/jira/comment-mention.tsisInPlanningStatus (ID first, name fallback)
  • Destinations passed to moveWorkItem (which now matches to.id first): lifecycle moveOnPrepare/moveOnSuccess, pr-ready-to-merge (merged/done), createWorkItem backlog move — all covered by the same id-or-name matcher.
  • Presence checks / JQL filter (unaffected by name→ID): backlog-check, contextSteps (presence check only), listWorkItems (JQL — see below).

moveWorkItem's to.id-first matcher, the isAlreadyInStatus benign-no-op guard (correctly returning false on read failure so a genuine miss is never suppressed), and the wizard's self-healing normalizeJiraStatusMappingsToIds (id-set guard first, name→id rewrite, unknown/custom untouched, same-reference when unchanged) are all sound and well-covered by the new tests.

Questions / Considerations (non-blocking)

  • listWorkItems JQL relies on JIRA resolving a quoted numeric status ID (status = "<id>", src/pm/jira/adapter.ts). This is the one correctness point that depends on an external JIRA contract the code can't self-verify, and it feeds the backlog-manager pipeline snapshot (contextStepslistWorkItems({ status: statusKey }), which now resolves to an ID). fetchPipelineLists wraps each call in try/catch so a failure degrades per-list rather than crashing — but it would degrade silently to an empty list for ID-based JIRA configs if the quoted-ID assumption is wrong. The work item documents this as a considered decision; just flagging that the described manual test exercised the transition/move path, not this JQL read path — worth a one-time confirmation against a live JIRA instance with an ID-based config.

Nitpicks (optional)

  • After this PR, resolvePMStatusAgentByNameFromWorkflowDefinitions and resolvePMLabelAgentByStatusNameFromWorkflowDefinitions are no longer used by any production trigger (only by each other and their tests) — JIRA was the last caller and it moved to the id-or-name variants. Candidates for removal in a follow-up unless retained deliberately as shared toolkit.

🕵️ claude-code · claude-opus-4-8 · run details

Comment thread src/pm/jira/adapter.ts
//
// MNG-1768: config.statuses values are now status IDs (locale-proof),
// with names accepted as a legacy fallback. JQL accepts a quoted
// status ID (`status = "10010"`) just as it accepts a quoted name, so

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking: this JQL path now emits status = "10010" (quoted numeric ID) for migrated configs, and it backs the backlog-manager pipeline snapshot via contextStepslistWorkItems({ status: statusKey }). Correctness here depends on JIRA resolving a quoted numeric value against status IDs — an external contract the code can't self-verify. fetchPipelineLists try/catches per list, so a wrong assumption degrades silently to an empty list rather than erroring. Worth a one-time confirmation against a live JIRA with an ID-based config, since the described manual test exercised the transition/move path (moveWorkItem), not this read path.

@aaight
aaight merged commit 5d05922 into dev Aug 4, 2026
9 checks passed
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