From eb8f36a5392520f10cc0b137954b3bc4de81d1d0 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 4 Aug 2026 13:24:05 +0200 Subject: [PATCH 1/2] fix(jira): paginate searchProjects() + make Combobox name-searchable (#1524) * fix(jira): paginate searchProjects() + make Combobox name-searchable * fix(ci): bump brace-expansion override to ^5.0.9 to clear high-severity audit npm audit --omit=dev --audit-level=high flagged brace-expansion@5.0.8 (GHSA-rgw5-rvv9-x895, DoS via unbounded intermediate arrays). Bump the existing override to ^5.0.9 so the transitive dependency (via @oclif/core -> minimatch) resolves to the patched release. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- package-lock.json | 6 +- package.json | 2 +- src/integrations/README.md | 2 + src/jira/client.ts | 57 ++++++++++++-- tests/unit/jira/client.test.ts | 117 +++++++++++++++++++++++++++-- tests/unit/web/combobox.test.ts | 18 +++++ web/src/components/ui/combobox.tsx | 1 + 7 files changed, 185 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 03bac8204..e9ae974a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4136,9 +4136,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" diff --git a/package.json b/package.json index 0884689ab..59547bf25 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,7 @@ "overrides": { "lodash": "^4.18.1", "lodash-es": "^4.18.1", - "brace-expansion": "^5.0.8", + "brace-expansion": "^5.0.9", "axios": "^1.15.0", "protobufjs": "^7.6.5", "form-data": "^4.0.6", diff --git a/src/integrations/README.md b/src/integrations/README.md index 441c7641a..5134aeeb9 100644 --- a/src/integrations/README.md +++ b/src/integrations/README.md @@ -75,6 +75,8 @@ See [`src/integrations/pm/manifest.ts`](./pm/manifest.ts) for the authoritative | `wizardSpec?` | `{ steps: Array }`. Declarative step list the shared wizard generator renders. Standard kinds: `credentials`, `container-pick`, `status-mapping`, `label-mapping`, `webhook-url-display`, `project-scope`. | | `lifecycle?` | `{ enabled: true, fixtureKey: string }`. Opts into the behavioral conformance harness's full lifecycle scenario. `fixtureKey` is looked up in the test-local `LIFECYCLE_FIXTURES` registry — the manifest doesn't import from `tests/helpers/`. | +> **Discovery must return the _complete_ provider list.** A discovery capability that backs a wizard picker (e.g. `container-pick` for `projects` / `boards` / `teams`) must return **every** item from the provider, not just the first page. The dashboard picker filters **client-side** (the shared `Combobox` does the search locally), so a provider adapter that returns a truncated first page silently hides everything past it — the operator can neither see nor search for the missing entries. Provider adapters must therefore **paginate the underlying API** until it reports the last page. The reference case is JIRA's `jiraClient.searchProjects()` (`src/jira/client.ts`): JIRA's `/rest/api/3/project/search` endpoint is paginated, so the method loops on `isLast` / `startAt` (with a `MAX_PROJECT_PAGES` safety cap) to collect all projects before returning. (A server-side `query` param + async debounced picker is the scalable follow-up for orgs with thousands of items, but full pagination is the correct baseline.) + --- ## The ProviderWizardDefinition contract diff --git a/src/jira/client.ts b/src/jira/client.ts index 3f9a5c349..a3ea3b51e 100644 --- a/src/jira/client.ts +++ b/src/jira/client.ts @@ -13,6 +13,17 @@ import type { JiraCredentials } from './types.js'; const jiraCredentialStore = new AsyncLocalStorage(); +/** Page size used when paginating JIRA's `/rest/api/3/project/search` endpoint. */ +const PROJECT_PAGE_SIZE = 50; + +/** + * Safety cap on the number of project-search pages fetched in one + * `searchProjects()` call. At {@link PROJECT_PAGE_SIZE} per page this allows up + * to 10k projects; it exists purely to guarantee loop termination if the API + * never reports the last page. + */ +const MAX_PROJECT_PAGES = 200; + export function withJiraCredentials(creds: JiraCredentials, fn: () => Promise): Promise { return jiraCredentialStore.run(creds, fn); } @@ -150,12 +161,46 @@ export const jiraClient = { async searchProjects(): Promise> { logger.debug('Searching JIRA projects'); - const result = await (await getClientForRequest()).projects.searchProjects({ maxResults: 100 }); - const values = (result.values ?? []) as Array<{ key?: string; name?: string }>; - return values.map((p) => ({ - key: p.key ?? '', - name: p.name ?? '', - })); + // JIRA's /rest/api/3/project/search endpoint is paginated. A single + // request only returns the first page (historically capped at 100), so + // orgs with more projects than fit on one page silently lost the rest. + // Loop until the API reports the last page (`isLast`), returns an empty + // page, or `startAt` has reached `total`. A safety cap guarantees the + // loop terminates even if the API misbehaves. + const client = await getClientForRequest(); + const projects: Array<{ key: string; name: string }> = []; + let startAt = 0; + let page = 0; + + while (page < MAX_PROJECT_PAGES) { + const result = await client.projects.searchProjects({ + startAt, + maxResults: PROJECT_PAGE_SIZE, + orderBy: 'name', + }); + const values = (result.values ?? []) as Array<{ key?: string; name?: string }>; + for (const p of values) { + projects.push({ key: p.key ?? '', name: p.name ?? '' }); + } + + page += 1; + startAt += values.length; + + const isLast = (result as { isLast?: boolean }).isLast === true; + const total = (result as { total?: number }).total; + if (isLast || values.length === 0 || (typeof total === 'number' && startAt >= total)) { + break; + } + + if (page >= MAX_PROJECT_PAGES) { + logger.warn('JIRA project pagination hit safety cap', { + maxPages: MAX_PROJECT_PAGES, + collected: projects.length, + }); + } + } + + return projects; }, async getProjectStatuses(projectKey: string): Promise> { diff --git a/tests/unit/jira/client.test.ts b/tests/unit/jira/client.test.ts index c8567f08d..276095bd0 100644 --- a/tests/unit/jira/client.test.ts +++ b/tests/unit/jira/client.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../../src/utils/logging.js', () => ({ - logger: { +const { mockLogger } = vi.hoisted(() => ({ + mockLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), @@ -9,6 +9,10 @@ vi.mock('../../../src/utils/logging.js', () => ({ }, })); +vi.mock('../../../src/utils/logging.js', () => ({ + logger: mockLogger, +})); + // Use vi.hoisted to create mock objects before vi.mock factories run const { mockIssues, @@ -670,26 +674,103 @@ describe('jiraClient', () => { }); describe('searchProjects', () => { - it('returns project keys and names', async () => { - mockProjects.searchProjects.mockResolvedValue({ + it('returns a single page when isLast is true', async () => { + mockProjects.searchProjects.mockResolvedValueOnce({ values: [ { key: 'PROJ', name: 'My Project' }, { key: 'TEST', name: 'Test Project' }, ], + isLast: true, + total: 2, + startAt: 0, + maxResults: 50, + }); + + const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); + + expect(result).toEqual([ + { key: 'PROJ', name: 'My Project' }, + { key: 'TEST', name: 'Test Project' }, + ]); + expect(mockProjects.searchProjects).toHaveBeenCalledTimes(1); + expect(mockProjects.searchProjects).toHaveBeenCalledWith({ + startAt: 0, + maxResults: 50, + orderBy: 'name', }); + }); + + it('paginates across multiple pages and concatenates the results', async () => { + mockProjects.searchProjects + .mockResolvedValueOnce({ + values: [ + { key: 'PROJ', name: 'My Project' }, + { key: 'TEST', name: 'Test Project' }, + ], + isLast: false, + total: 3, + startAt: 0, + maxResults: 50, + }) + .mockResolvedValueOnce({ + values: [{ key: 'THIRD', name: 'Third Project' }], + isLast: true, + total: 3, + startAt: 2, + maxResults: 50, + }); const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); expect(result).toEqual([ { key: 'PROJ', name: 'My Project' }, { key: 'TEST', name: 'Test Project' }, + { key: 'THIRD', name: 'Third Project' }, ]); - expect(mockProjects.searchProjects).toHaveBeenCalledWith({ maxResults: 100 }); + expect(mockProjects.searchProjects).toHaveBeenCalledTimes(2); + expect(mockProjects.searchProjects).toHaveBeenNthCalledWith(1, { + startAt: 0, + maxResults: 50, + orderBy: 'name', + }); + expect(mockProjects.searchProjects).toHaveBeenNthCalledWith(2, { + startAt: 2, + maxResults: 50, + orderBy: 'name', + }); + }); + + it('terminates via startAt >= total even when isLast is absent', async () => { + mockProjects.searchProjects + .mockResolvedValueOnce({ + values: [ + { key: 'A', name: 'Alpha' }, + { key: 'B', name: 'Beta' }, + ], + total: 2, + startAt: 0, + maxResults: 50, + }) + .mockResolvedValueOnce({ + values: [{ key: 'C', name: 'Gamma' }], + total: 2, + startAt: 2, + maxResults: 50, + }); + + const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); + + expect(result).toEqual([ + { key: 'A', name: 'Alpha' }, + { key: 'B', name: 'Beta' }, + ]); + expect(mockProjects.searchProjects).toHaveBeenCalledTimes(1); }); it('handles missing fields gracefully', async () => { - mockProjects.searchProjects.mockResolvedValue({ + mockProjects.searchProjects.mockResolvedValueOnce({ values: [{}, { key: 'X' }], + isLast: true, }); const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); @@ -700,12 +781,32 @@ describe('jiraClient', () => { ]); }); - it('returns empty array when values is missing', async () => { - mockProjects.searchProjects.mockResolvedValue({}); + it('returns empty array and terminates when values is missing', async () => { + mockProjects.searchProjects.mockResolvedValueOnce({}); const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); expect(result).toEqual([]); + expect(mockProjects.searchProjects).toHaveBeenCalledTimes(1); + }); + + it('stops at the safety cap when the API never reports the last page', async () => { + // Always return a full page with isLast: false and no reachable total, + // so only the MAX_PROJECT_PAGES safety cap can terminate the loop. + mockProjects.searchProjects.mockResolvedValue({ + values: Array.from({ length: 50 }, (_, i) => ({ key: `K${i}`, name: `Name ${i}` })), + isLast: false, + }); + + const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); + + // 200 pages (MAX_PROJECT_PAGES) × 50 per page. + expect(mockProjects.searchProjects).toHaveBeenCalledTimes(200); + expect(result).toHaveLength(200 * 50); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'JIRA project pagination hit safety cap', + expect.objectContaining({ maxPages: 200 }), + ); }); }); diff --git a/tests/unit/web/combobox.test.ts b/tests/unit/web/combobox.test.ts index c9b351304..b4d6c4cac 100644 --- a/tests/unit/web/combobox.test.ts +++ b/tests/unit/web/combobox.test.ts @@ -50,3 +50,21 @@ describe('Combobox — disabled-item CSS regression guard', () => { ); }); }); + +describe('Combobox — name-search regression guard', () => { + it('passes cmdk keywords (label + detail) on each CommandItem so name typing matches', () => { + const source = readFileSync(COMBOBOX_PATH, 'utf8'); + // cmdk's default filter scores against the item's `value` PLUS `keywords`. + // Without keywords, only `value` (e.g. the JIRA project key) is matched, so + // operators typing a project *name* find nothing. This guard locks in the + // keywords prop so a future refactor can't silently drop name-search. + expect( + source, + 'CommandItem must set keywords={...} so cmdk matches on label/detail, not just value', + ).toMatch(/keywords=\{/); + expect( + source, + 'keywords should include the visible label so typing the name matches', + ).toContain('option.label'); + }); +}); diff --git a/web/src/components/ui/combobox.tsx b/web/src/components/ui/combobox.tsx index 3c682204a..2d9d54a5e 100644 --- a/web/src/components/ui/combobox.tsx +++ b/web/src/components/ui/combobox.tsx @@ -143,6 +143,7 @@ export function Combobox({ handleSelect(option.value)} className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground" > From 5d059228f83405070ac5f93f800ed9b4e0c98dc5 Mon Sep 17 00:00:00 2001 From: aaight Date: Tue, 4 Aug 2026 14:59:17 +0200 Subject: [PATCH 2/2] fix(jira): match status by locale-invariant ID with name fallback (MNG-1768) (#1528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jira): match status by locale-invariant ID with name fallback (MNG-1768) * fix(jira): skip jira_transition_not_found capture on benign no-op moves 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 * fix(jira): match status by id-or-name in label-added and comment-mention (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 --------- Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- CLAUDE.md | 4 + docs/architecture/04-agent-system.md | 2 +- src/integrations/README.md | 16 ++- src/pm/jira/adapter.ts | 86 +++++++++++-- src/triggers/jira/comment-mention.ts | 30 +++-- src/triggers/jira/label-added.ts | 24 ++-- src/triggers/jira/status-changed.ts | 53 ++++++-- src/triggers/jira/types.ts | 9 +- src/triggers/shared/pm-label.ts | 24 ++++ src/triggers/shared/pm-status.ts | 42 +++++++ tests/unit/pm/jira/adapter.test.ts | 89 +++++++++++++ .../triggers/jira-comment-mention.test.ts | 44 ++++++- tests/unit/triggers/jira-label-added.test.ts | 52 +++++++- .../unit/triggers/jira-status-changed.test.ts | 110 +++++++++++++++- tests/unit/triggers/shared/pm-label.test.ts | 40 ++++++ tests/unit/triggers/shared/pm-status.test.ts | 100 +++++++++++++++ .../unit/web/jira-status-mapping-ids.test.ts | 117 ++++++++++++++++++ .../projects/pm-providers/jira/state.ts | 50 +++++++- .../projects/pm-providers/jira/wizard.ts | 15 ++- 19 files changed, 853 insertions(+), 54 deletions(-) create mode 100644 tests/unit/web/jira-status-mapping-ids.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index af7c2c9c6..5ec5e0ecb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,6 +208,10 @@ Optional: JIRA supports classic site tokens **and** Atlassian API tokens with scopes. The optional `authType` field on the JIRA integration config (`'basic' | 'scoped'`, default `'basic'`) is a **non-secret connection setting** (mirrors `baseUrl`, not a credential role) that selects the REST v3 host — **both modes authenticate with HTTP Basic (`email:api_token`)**, so `authType` picks the host, not the auth scheme. Every REST v3 call site routes through the shared resolver `resolveJiraApiBaseUrl(creds)` (`src/jira/api-host.ts`): `basic`/absent keeps the tenant **site URL**; `scoped` routes through the Atlassian **gateway** `https://api.atlassian.com/ex/jira/{cloudId}`, where `cloudId` is resolved from `${baseUrl}/_edge/tenant_info` (always the site URL, never the gateway) and cached per `baseUrl`. The worker carries the mode across process boundaries via `CASCADE_JIRA_AUTH_TYPE`. **Required scopes:** read/write Jira work, plus `manage:jira-webhook` (or granular `write:webhook:jira` + `read:field:jira` + `read:project:jira`) for programmatic `/rest/api/3/webhook` management — a scoped token lacking them gets `401`/`403`, and operators should register the webhook manually. **Known limitation:** ack reactions are unavailable under scoped tokens (`/rest/reactions/1.0/` is not exposed on the gateway), so the reaction degrades to a skipped no-op; `accessible-resources` is intentionally not used for cloudId (it is OAuth 2.0 / 3LO guidance and returns `401` for scoped API tokens). +## JIRA status matching (locale-invariant) + +JIRA status matching is **ID-based**, not name-based (MNG-1768). JIRA status *names* render in the language of whichever account a request is scoped to, so name-on-both-ends matching silently no-op'd status moves when the credential account's language differed from the site language. Both ends now match on the locale-invariant JIRA status **ID** — the dispatch trigger (`JiraStatusChangedTrigger`) reads `changelog.items[].to` / `issue.fields.status.id` and resolves via `resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions`; `moveWorkItem` matches `transitions[].to.id` first — with case-insensitive **name matching kept as a fallback** so existing name-based configs keep working (zero forced migration). The wizard persists status IDs going forward and auto-upgrades legacy name-valued mappings → IDs when project details load. A genuine no-transition-found miss emits a Sentry `captureException` tagged `jira_transition_not_found` instead of a silent WARN. See @src/integrations/README.md for the full contract. + ## Git hooks Lefthook runs pre-commit (lint, typecheck) and pre-push (unit + integration tests) hooks automatically. Pre-push auto-starts an ephemeral Postgres via `npm run test:db:up` — Docker must be running. diff --git a/docs/architecture/04-agent-system.md b/docs/architecture/04-agent-system.md index 7c13623a5..2866a49f4 100644 --- a/docs/architecture/04-agent-system.md +++ b/docs/architecture/04-agent-system.md @@ -141,7 +141,7 @@ CASCADE separates two concepts that custom workflows need both of: All three production providers (Trello, JIRA, Linear) support custom statuses with the same dispatch contract: - **Trello** (`src/triggers/trello/status-changed.ts`) — `TrelloCustomStatusChangedTrigger` matches `createCard` / `updateCard` events whose destination list ID maps to a custom (non-built-in) key in `trello.lists.`, then resolves the dispatch agent through `resolvePMStatusAgentByIdFromWorkflowDefinitions`. Built-in keys (e.g. `todo`, `planning`) continue to flow through the per-list `TrelloStatusChanged*Trigger` handlers. -- **JIRA** (`src/triggers/jira/status-changed.ts`) — `JiraStatusChangedTrigger` resolves the new status name against `jira.statuses` via `resolvePMStatusAgentByNameFromWorkflowDefinitions`, picking up custom keys alongside built-ins. +- **JIRA** (`src/triggers/jira/status-changed.ts`) — `JiraStatusChangedTrigger` resolves the new status against `jira.statuses` via `resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions` (locale-invariant status **ID** first, case-insensitive **name** fallback — MNG-1768), picking up custom keys alongside built-ins. - **Linear** (`src/triggers/linear/status-changed.ts`) — `LinearStatusChangedTrigger` resolves the new state UUID against `linear.statuses` via `resolvePMStatusAgentByIdFromWorkflowDefinitions`. All three paths share `resolvePMStatusAgentFromWorkflowDefinitions` in `src/triggers/shared/pm-status.ts` and obey the same dispatch precondition: a custom status only dispatches an agent when its definition has a non-null `agentType` AND a `pm:status-changed` trigger config is enabled for that agent. A custom status with `agentType: null` (created via `cascade workflow-statuses update --no-agent` or set without `--agent-type`) renders in the wizard and persists in the provider config, but the trigger handlers return `null` instead of dispatching — useful for board columns that should appear in CASCADE's wizard without spawning agents. diff --git a/src/integrations/README.md b/src/integrations/README.md index 5134aeeb9..6521d2de1 100644 --- a/src/integrations/README.md +++ b/src/integrations/README.md @@ -259,11 +259,23 @@ CASCADE supports custom workflow statuses (e.g. `prd`, `story`, `phased-plan`) o | Status definition (`key`, `label`, dispatch `agentType`, `sortOrder`) | `workflow_status_definitions` table; managed via `cascade workflow-statuses *` or `workflowStatuses.create/update/delete` (superadmin tRPC) | `src/db/repositories/workflowStatusDefinitionsRepository.ts`, `src/api/routers/workflowStatuses.ts` | | Provider-native mapping for each custom key | `project_integrations.config` JSON, under the same key shape as built-in slots | per-provider | | Trello provider-native value | `lists.` → Trello list ID | `src/pm/trello/integration.ts` | -| JIRA provider-native value | `statuses.` → JIRA status name | `src/pm/jira/integration.ts` | +| JIRA provider-native value | `statuses.` → JIRA status **ID** (locale-proof; name accepted as a legacy fallback — see below) | `src/pm/jira/integration.ts` | | Linear provider-native value | `statuses.` → Linear workflow state UUID | `src/pm/linear/integration.ts` | The lifecycle config resolver on each `PMIntegration` (`resolveLifecycleConfig`) **must** spread the full `lists` / `statuses` record so custom keys survive normalization and are available to `moveOnPrepare` / `moveOnSuccess` lifecycle hooks for custom agents. Look at `LinearIntegration.resolveLifecycleConfig` for the canonical shape — `statuses: { ...(linearConfig?.statuses ?? {}) }` rather than handpicked built-in keys. +#### JIRA status matching is ID-based, not locale-fragile (MNG-1768) + +JIRA status **names** are rendered in the language of whichever account a request is scoped to: the dispatch webhook carries `changelog.items[].toString` / `issue.fields.status.name` in the **site** language, while the move side (`moveWorkItem`) matches `getTransitions()` names in the **credential account's** language. When those two languages differ for *system* statuses, the old name-on-both-ends matching silently no-op'd the move. + +The fix matches on the **locale-invariant JIRA status ID** on both ends, with name matching kept as a fallback (zero forced migration): + +- **Dispatch** — `JiraStatusChangedTrigger` reads `changelog.items[].to` (update path) / `issue.fields.status.id` (create path) and resolves via `resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions({ statusId, statusName, configuredStatuses })` in `src/triggers/shared/pm-status.ts` (ID match first, case-insensitive name fallback). +- **Move** — `JiraPMProvider.moveWorkItem` matches `transitions[].to.id === destination` first (distinct from the *transition* `t.id`), then falls back to the name branches. A genuine no-transition-found miss now emits `logger.warn` **and** a Sentry `captureException` tagged `jira_transition_not_found` so a localized/misconfigured account is caught on the first run. +- **Other `jira.statuses` readers** — every consumer of the (now ID-valued) `jira.statuses` map matches ID-first with a name fallback, so none silently no-op on ID-based configs. `JiraReadyToProcessLabelTrigger` (the `cascade-ready` label flow) reads the issue's `status.id`/`status.name` and resolves via `resolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions`; `JiraCommentMentionTrigger`'s `isInPlanningStatus` gate compares the configured `planning` value against the issue's status ID first, then its name. +- **Wizard** — the status-mapping select now persists the status **ID** (`{ id: s.id, name: s.name }`) while still displaying the name. `normalizeJiraStatusMappingsToIds` auto-upgrades legacy name-valued mappings → IDs in the `SET_JIRA_PROJECT_DETAILS` reducer when project details load, so re-saving any project backfills IDs. Values already-ID or unrecognized (custom) are left untouched. +- **JQL** — `listWorkItems` quotes the status value; JIRA resolves a quoted numeric value against status IDs, so ID-based config values remain valid with no behavior change. + ### Wizard path — metadata-driven, shared between providers The PM wizards consume the workflow status definition list through a single tRPC query (`trpc.workflowStatuses.list`) and render mapping rows for every key — built-in and custom alike. The provider's `useProviderHooks` resolves the list and forwards it as `workflowStatuses` on the hook return; the shared `StatusMappingStep` renders rows in the returned order. Reference implementations: @@ -284,7 +296,7 @@ This means the operator never has to manually run `cascade projects trigger-set Custom-status dispatch reuses the same `pm:status-changed` trigger registry that built-in statuses use: - **Trello** (`src/triggers/trello/status-changed.ts`) — `TrelloCustomStatusChangedTrigger` claims `createCard` / `updateCard` events whose destination list ID maps to a custom (non-built-in) key in `trello.lists`. Built-in keys are still handled by the per-list triggers (`TrelloStatusChangedTodoTrigger`, etc.). -- **JIRA** (`src/triggers/jira/status-changed.ts`) — `JiraStatusChangedTrigger` resolves the new status name against `jira.statuses` via `resolvePMStatusAgentByNameFromWorkflowDefinitions`, picking up custom keys alongside built-ins in a single handler. +- **JIRA** (`src/triggers/jira/status-changed.ts`) — `JiraStatusChangedTrigger` resolves the new status against `jira.statuses` via `resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions` (locale-invariant status **ID** first, case-insensitive **name** fallback — MNG-1768), picking up custom keys alongside built-ins in a single handler. - **Linear** (`src/triggers/linear/status-changed.ts`) — `LinearStatusChangedTrigger` resolves the new state UUID against `linear.statuses` via `resolvePMStatusAgentByIdFromWorkflowDefinitions`, also a single handler. All three resolve through the shared `resolvePMStatusAgentFromWorkflowDefinitions` in `src/triggers/shared/pm-status.ts` and obey one dispatch precondition: a status only dispatches an agent when **both** of the following hold: diff --git a/src/pm/jira/adapter.ts b/src/pm/jira/adapter.ts index 2fbf32792..ba4c448b9 100644 --- a/src/pm/jira/adapter.ts +++ b/src/pm/jira/adapter.ts @@ -5,6 +5,7 @@ */ import { jiraClient } from '../../jira/client.js'; +import { captureException } from '../../sentry.js'; import { logger } from '../../utils/logging.js'; import { withDescriptionMutationLock } from '../_shared/description-mutation-lock.js'; import { @@ -101,9 +102,15 @@ interface JiraAttachment { /** Partial shape of a JIRA transition */ interface JiraTransition { + /** The *transition* ID (distinct from `to.id`, the target status ID). */ id?: string; name?: string; - to?: { name?: string }; + /** + * The target status object. `to.id` is the locale-invariant status ID — + * the JIRA REST transitions endpoint returns a full status object here. + * `to.name` is the localized status name. + */ + to?: { id?: string; name?: string }; } export class JiraPMProvider implements PMProvider { @@ -232,10 +239,15 @@ export class JiraPMProvider implements PMProvider { if (!projectKey) return []; let jql = `project = "${projectKey}"`; if (filter?.status) { - // Map CASCADE status key (e.g. 'todo') to native JIRA status name - // via config.statuses. Falls through to the literal value when no - // mapping exists, preserving backwards compat with callers that + // Map CASCADE status key (e.g. 'todo') to the native JIRA status + // value via config.statuses. Falls through to the literal value when + // no mapping exists, preserving backwards compat with callers that // pass status names directly. + // + // 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 + // ID-based config values continue to resolve here with no change. const native = this.config.statuses?.[filter.status] ?? filter.status; jql += ` AND status = "${native}"`; } @@ -256,25 +268,83 @@ export class JiraPMProvider implements PMProvider { } async moveWorkItem(id: string, destination: ContainerId): Promise { - // destination is a JIRA status name — find the transition ID + // `destination` is a JIRA status ID (MNG-1768: locale-invariant) or, + // for legacy name-based configs, a status name. Find the transition + // whose *target status* matches. const transitions = await jiraClient.getTransitions(id); const transition = transitions.find( (t: JiraTransition) => + // Prefer the locale-invariant target status ID (`t.to.id`, distinct + // from `t.id` which is the *transition* ID no caller passes here). + t.to?.id === destination || + // Legacy name-based configs: match the transition's own name or its + // target status name (both localized, case-insensitive). t.name?.toLowerCase() === destination.toLowerCase() || - t.to?.name?.toLowerCase() === destination.toLowerCase() || - t.id === destination, + t.to?.name?.toLowerCase() === destination.toLowerCase(), ); if (!transition) { + // A missing transition is benign when the issue is *already* in the + // destination status: JIRA exposes no self-transition, so best-effort + // callers (createWorkItem's backlog move at ~L214, lifecycle + // moveOnPrepare/moveOnSuccess) legitimately reach here on a no-op. + // Only a genuine locale/misconfig miss should be surfaced loudly, so + // the `jira_transition_not_found` Sentry signal stays meaningful. + if (await this.isAlreadyInStatus(id, destination)) { + logger.debug('JIRA issue already in destination status; move is a no-op', { + issueKey: id, + destination, + }); + return; + } + const available = transitions.map( + (t: JiraTransition) => `${t.id}:${t.name} (to ${t.to?.id}:${t.to?.name})`, + ); logger.warn('No JIRA transition found for destination', { issueKey: id, destination, - available: transitions.map((t: JiraTransition) => `${t.id}:${t.name}`), + available, + }); + // MNG-1768: make the silent miss loud. A localized / misconfigured + // 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'), { + tags: { jira_transition_not_found: 'true' }, + extra: { issueKey: id, destination, available }, }); return; } await jiraClient.transitionIssue(id, transition.id ?? ''); } + /** + * MNG-1768: report whether an issue is already in `destination` so a missing + * transition can be treated as a benign no-op rather than a genuine + * locale/misconfig miss worth a Sentry capture. Best-effort callers + * (`createWorkItem`'s backlog move, lifecycle `moveOnPrepare`/`moveOnSuccess`) + * move unconditionally without first checking the current status, and JIRA + * offers no self-transition, so an already-there issue is the common way a + * legitimate move reaches the miss path. Matches the current status by + * locale-invariant ID first, then case-insensitive name (mirroring the + * transition matcher). Any read failure returns `false` so a real miss is + * never suppressed. + */ + private async isAlreadyInStatus(id: string, destination: string): Promise { + try { + const issue = await jiraClient.getIssue(id); + const status = issue?.fields?.status as { id?: string; name?: string } | undefined; + return ( + status?.id === destination || status?.name?.toLowerCase() === destination.toLowerCase() + ); + } catch (err) { + logger.debug('Could not read current JIRA status for no-op check', { + issueKey: id, + error: String(err), + }); + return false; + } + } + async addLabel(id: string, labelName: LabelId): Promise { const currentLabels = await jiraClient.getIssueLabels(id); if (!currentLabels.includes(labelName)) { diff --git a/src/triggers/jira/comment-mention.ts b/src/triggers/jira/comment-mention.ts index 88983f839..a7964a66c 100644 --- a/src/triggers/jira/comment-mention.ts +++ b/src/triggers/jira/comment-mention.ts @@ -72,25 +72,38 @@ function hasMention(body: unknown, accountId: string, depth = 0): boolean { * Check if the issue is in the configured PLANNING status. * Returns false (and logs) when the project has no planning status configured * or the issue's current status doesn't match. + * + * MNG-1768: the configured `planning` value is a locale-invariant status ID for + * migrated configs (a status name for legacy configs). Match the ID first, then + * fall back to a case-insensitive name comparison so both config shapes work. + * Without the ID branch, an ID-based config (all new projects, plus any re-saved + * project) would compare the localized `status.name` against a numeric ID and + * silently never gate the comment-mention trigger. */ function isInPlanningStatus( project: TriggerContext['project'], issueKey: string, + currentStatusId: string | undefined, currentStatusName: string | undefined, ): boolean { - const planningStatusName = getJiraConfig(project)?.statuses.planning; - if (!planningStatusName) { + const configuredPlanningStatus = getJiraConfig(project)?.statuses.planning; + if (!configuredPlanningStatus) { logger.debug( 'Planning status not configured for JIRA project, skipping comment mention trigger', { projectId: project.id }, ); return false; } - if (currentStatusName?.toLowerCase() !== planningStatusName.toLowerCase()) { + const matchesId = currentStatusId !== undefined && currentStatusId === configuredPlanningStatus; + const matchesName = + currentStatusName !== undefined && + currentStatusName.toLowerCase() === configuredPlanningStatus.toLowerCase(); + if (!matchesId && !matchesName) { logger.debug('JIRA issue not in planning status, skipping comment mention trigger', { issueKey, - currentStatus: currentStatusName, - planningStatus: planningStatusName, + currentStatusId, + currentStatusName, + planningStatus: configuredPlanningStatus, }); return false; } @@ -177,9 +190,12 @@ export class JiraCommentMentionTrigger implements TriggerHandler { return null; } - // Gate on PLANNING status — only respond to comments on PLANNING issues + // Gate on PLANNING status — only respond to comments on PLANNING issues. + // MNG-1768: pass both the locale-invariant status ID and the localized + // name so the gate matches ID-based configs and legacy name-based configs. + const currentStatusId = payload.issue?.fields?.status?.id; const currentStatusName = payload.issue?.fields?.status?.name; - if (!isInPlanningStatus(ctx.project, issueKey, currentStatusName)) { + if (!isInPlanningStatus(ctx.project, issueKey, currentStatusId, currentStatusName)) { return null; } const jiraConfig = getJiraConfig(ctx.project); diff --git a/src/triggers/jira/label-added.ts b/src/triggers/jira/label-added.ts index 2e73c3cc0..639433873 100644 --- a/src/triggers/jira/label-added.ts +++ b/src/triggers/jira/label-added.ts @@ -16,7 +16,7 @@ import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/ import { logger } from '../../utils/logging.js'; import { buildPMLabelDispatchResult, - resolvePMLabelAgentByStatusNameFromWorkflowDefinitions, + resolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions, } from '../shared/pm-label.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; import type { JiraWebhookPayload } from './types.js'; @@ -74,8 +74,11 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { return null; } - const currentStatus = payload.issue?.fields?.status?.name; - if (!currentStatus) { + // MNG-1768: read both the locale-invariant status ID and the localized + // status name so the resolver can match either. JIRA always sends both. + const currentStatusId = payload.issue?.fields?.status?.id; + const currentStatusName = payload.issue?.fields?.status?.name; + if (!currentStatusId && !currentStatusName) { logger.debug('No status on JIRA issue, cannot determine agent type', { issueKey }); return null; } @@ -88,15 +91,21 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { return null; } - const resolved = await resolvePMLabelAgentByStatusNameFromWorkflowDefinitions({ - statusName: currentStatus, + // MNG-1768: match on the locale-invariant status ID first, falling back + // to the localized status name so existing name-based configs keep + // dispatching untouched. Without this, ID-based configs (all new projects, + // plus any re-saved project) would silently stop firing the label flow. + const resolved = await resolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions({ + statusId: currentStatusId, + statusName: currentStatusName, configuredStatuses: jiraConfig.statuses, }); if (!resolved) { logger.debug('JIRA issue status does not map to any agent', { issueKey, - currentStatus, + currentStatusId, + currentStatusName, configuredStatuses: jiraConfig.statuses, }); return null; @@ -110,7 +119,8 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { logger.info('JIRA "Ready to Process" label added, triggering agent', { issueKey, - currentStatus, + currentStatusId, + currentStatusName, cascadeStatus: matchedCascadeStatus, agentType, }); diff --git a/src/triggers/jira/status-changed.ts b/src/triggers/jira/status-changed.ts index 41f3154c1..d245b66df 100644 --- a/src/triggers/jira/status-changed.ts +++ b/src/triggers/jira/status-changed.ts @@ -15,7 +15,7 @@ import { logger } from '../../utils/logging.js'; import { shouldBlockForPipelineCapacity } from '../shared/pipeline-capacity-gate.js'; import { buildPMStatusDispatchResult, - resolvePMStatusAgentByNameFromWorkflowDefinitions, + resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions, shouldFirePMStatusEvent, } from '../shared/pm-status.js'; import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; @@ -27,19 +27,35 @@ function isCreateEvent(payload: JiraWebhookPayload): boolean { function findStatusChange( payload: JiraWebhookPayload, -): { fromString?: string; toString?: string } | undefined { +): { from?: string; to?: string; fromString?: string; toString?: string } | undefined { return payload.changelog?.items?.find((item) => item.field === 'status'); } /** - * Resolve the new status name from a JIRA webhook payload. - * Returns `undefined` when the status cannot be determined. + * The new status a JIRA webhook is transitioning into. + * + * MNG-1768: `id` is the locale-invariant status ID (matched first); `name` + * is the localized status name (matched as a fallback). At least one must be + * present for the trigger to attempt a resolution. + */ +interface ResolvedNewStatus { + id?: string; + name?: string; +} + +/** + * Resolve the new status (id + name) from a JIRA webhook payload. + * Returns `undefined` when neither identity can be determined. */ -function resolveNewStatus(payload: JiraWebhookPayload): string | undefined { +function resolveNewStatus(payload: JiraWebhookPayload): ResolvedNewStatus | undefined { if (isCreateEvent(payload)) { - return payload.issue?.fields?.status?.name; + const status = payload.issue?.fields?.status; + if (!status?.id && !status?.name) return undefined; + return { id: status.id, name: status.name }; } - return findStatusChange(payload)?.toString; + const change = findStatusChange(payload); + if (!change?.to && !change?.toString) return undefined; + return { id: change.to, name: change.toString }; } export class JiraStatusChangedTrigger implements TriggerHandler { @@ -51,9 +67,12 @@ export class JiraStatusChangedTrigger implements TriggerHandler { const payload = ctx.payload as JiraWebhookPayload; - // Create path: require resolvable status so handle() has something to map + // Create path: require a resolvable status (id or name) so handle() has + // something to map. JIRA always sends both, but accepting either keeps + // the match path consistent with the id-or-name read path. if (isCreateEvent(payload)) { - return typeof payload.issue?.fields?.status?.name === 'string'; + const status = payload.issue?.fields?.status; + return typeof status?.id === 'string' || typeof status?.name === 'string'; } if (!payload.webhookEvent?.startsWith('jira:issue_updated')) return false; @@ -83,14 +102,19 @@ export class JiraStatusChangedTrigger implements TriggerHandler { return null; } - const resolved = await resolvePMStatusAgentByNameFromWorkflowDefinitions({ - statusName: newStatus, + // MNG-1768: match on the locale-invariant status ID first, falling back + // to the localized status name so existing name-based configs keep + // dispatching untouched. + const resolved = await resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions({ + statusId: newStatus.id, + statusName: newStatus.name, configuredStatuses: jiraConfig.statuses, }); if (!resolved) { logger.debug('JIRA status transition does not map to any agent', { issueKey, - newStatus, + newStatusId: newStatus.id, + newStatusName: newStatus.name, configuredStatuses: jiraConfig.statuses, }); return null; @@ -132,7 +156,10 @@ export class JiraStatusChangedTrigger implements TriggerHandler { issueKey, eventKind: isCreate ? 'create' : 'move', ...(isCreate ? {} : { fromStatus: statusChange?.fromString }), - toStatus: newStatus, + toStatus: newStatus.name, + // MNG-1768: surface the locale-invariant status ID so triage can see + // which side (id vs name) the match resolved against. + toStatusId: newStatus.id, cascadeStatus: matchedCascadeStatus, agentType, }); diff --git a/src/triggers/jira/types.ts b/src/triggers/jira/types.ts index 37941c661..53adcf1a7 100644 --- a/src/triggers/jira/types.ts +++ b/src/triggers/jira/types.ts @@ -13,13 +13,20 @@ export interface JiraWebhookPayload { key: string; fields?: { project?: { key?: string }; - status?: { name?: string }; + // MNG-1768: `status.id` is the locale-invariant status identity JIRA + // always sends alongside the localized `status.name`. + status?: { id?: string; name?: string }; summary?: string; }; }; changelog?: { items?: Array<{ field?: string; + // MNG-1768: `from`/`to` carry the locale-invariant status IDs; + // `fromString`/`toString` carry the localized status names. JIRA + // includes all four on a status changelog item. + from?: string; + to?: string; fromString?: string; toString?: string; }>; diff --git a/src/triggers/shared/pm-label.ts b/src/triggers/shared/pm-label.ts index cf03ff221..efaa214f9 100644 --- a/src/triggers/shared/pm-label.ts +++ b/src/triggers/shared/pm-label.ts @@ -3,6 +3,7 @@ import { TRIGGER_EVENTS } from './events.js'; import { resolvePMStatusAgentById, resolvePMStatusAgentByIdFromWorkflowDefinitions, + resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions, resolvePMStatusAgentByName, resolvePMStatusAgentByNameFromWorkflowDefinitions, } from './pm-status.js'; @@ -58,6 +59,29 @@ export function resolvePMLabelAgentByStatusNameFromWorkflowDefinitions(args: { }); } +/** + * Resolve a label-trigger agent from a JIRA issue's current status by matching + * on the locale-invariant status ID first, falling back to a case-insensitive + * status-name match (MNG-1768). + * + * The `jira.statuses` config values are locale-invariant status IDs for + * migrated configs (status names for legacy configs). Delegating to + * `resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions` keeps the label + * trigger consistent with the status-changed dispatch path, so the + * `cascade-ready` label flow keeps firing once a project's config holds IDs. + */ +export function resolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions(args: { + statusId?: string; + statusName?: string; + configuredStatuses: Record; +}): Promise<{ agentType: string; cascadeStatus: string } | undefined> { + return resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions({ + statusId: args.statusId, + statusName: args.statusName, + configuredStatuses: args.configuredStatuses, + }); +} + export function buildPMLabelDispatchResult(args: { agentType: string; workItemId: string; diff --git a/src/triggers/shared/pm-status.ts b/src/triggers/shared/pm-status.ts index 07e56bef5..022b113e5 100644 --- a/src/triggers/shared/pm-status.ts +++ b/src/triggers/shared/pm-status.ts @@ -105,6 +105,48 @@ export function resolvePMStatusAgentByNameFromWorkflowDefinitions(args: { }); } +/** + * Resolve an agent from a configured-status map by matching on a + * locale-invariant status ID first, falling back to a case-insensitive + * status-name match (MNG-1768). + * + * JIRA status *names* are rendered in the language of whichever account + * the webhook / credential is scoped to, so name-only matching silently + * no-ops when the site language differs from the credential account's + * language. Matching on the numeric status ID (`"10010"`) is locale-proof. + * Name matching is retained as a fallback so existing name-based configs + * keep dispatching untouched. + * + * There is no collision risk between the two branches: JIRA status IDs are + * numeric strings while names are free text, so a configured value matches + * at most one interpretation. + * + * Reuses `resolvePMStatusAgentFromWorkflowDefinitions` (via a closure + * matcher) so the `resolveWorkflowStatusDefinition(cascadeStatus)` lookup — + * and therefore custom workflow statuses plus the null-`agentType` guard — + * keeps working unchanged. + */ +export function resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions(args: { + statusId?: string; + statusName?: string; + configuredStatuses: Record; +}): Promise { + const { statusId, statusName } = args; + const matcher: StatusMatcher = (configuredStatus) => { + if (statusId && configuredStatus === statusId) return true; + if (statusName && configuredStatus.toLowerCase() === statusName.toLowerCase()) return true; + return false; + }; + + return resolvePMStatusAgentFromWorkflowDefinitions({ + // `incomingStatus` is unused by the closure matcher above (it captures + // both id and name directly); pass the id when present for readable logs. + incomingStatus: statusId ?? statusName ?? '', + configuredStatuses: args.configuredStatuses, + matcher, + }); +} + export function buildPMStatusCoalesceKey(projectId: string, workItemId: string): string { return `${projectId}:${workItemId}`; } diff --git a/tests/unit/pm/jira/adapter.test.ts b/tests/unit/pm/jira/adapter.test.ts index a2141e4c4..0677d57dd 100644 --- a/tests/unit/pm/jira/adapter.test.ts +++ b/tests/unit/pm/jira/adapter.test.ts @@ -7,6 +7,7 @@ const { mockMarkdownToAdf, mockExtractAdfMediaNodes, mockResolveJiraMediaUrls, + mockCaptureException, } = vi.hoisted(() => ({ mockJiraClient: { getIssue: vi.fn(), @@ -32,12 +33,17 @@ const { mockMarkdownToAdf: vi.fn(), mockExtractAdfMediaNodes: vi.fn(), mockResolveJiraMediaUrls: vi.fn(), + mockCaptureException: vi.fn(), })); vi.mock('../../../../src/jira/client.js', () => ({ jiraClient: mockJiraClient, })); +vi.mock('../../../../src/sentry.js', () => ({ + captureException: mockCaptureException, +})); + vi.mock('../../../../src/pm/jira/adf.js', () => ({ adfToPlainText: mockAdfToPlainText, markdownToAdf: mockMarkdownToAdf, @@ -592,6 +598,89 @@ describe('JiraPMProvider', () => { await expect(provider.moveWorkItem('PROJ-1', 'unknown-status')).resolves.toBeUndefined(); }); + + it('matches by target status ID (to.id) when destination is an ID, ignoring foreign-language names (MNG-1768)', async () => { + mockJiraClient.getTransitions.mockResolvedValue([ + // Localized (French) name; only `to.id` matches the ID destination. + { id: 't-9', name: 'Terminer', to: { id: '10011', name: 'Terminé' } }, + ]); + mockJiraClient.transitionIssue.mockResolvedValue(undefined); + + await provider.moveWorkItem('PROJ-1', '10011'); + + expect(mockJiraClient.transitionIssue).toHaveBeenCalledWith('PROJ-1', 't-9'); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('captures a Sentry event tagged jira_transition_not_found on a genuine miss (MNG-1768)', async () => { + mockJiraClient.getTransitions.mockResolvedValue([ + { id: 't-1', name: 'Done', to: { id: '10011', name: 'Done' } }, + ]); + // The issue is NOT already in the destination — a real miss. + mockJiraClient.getIssue.mockResolvedValue({ + key: 'PROJ-1', + fields: { status: { id: '10011', name: 'Done' } }, + }); + + await provider.moveWorkItem('PROJ-1', '99999'); + + expect(mockJiraClient.transitionIssue).not.toHaveBeenCalled(); + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { jira_transition_not_found: 'true' }, + extra: expect.objectContaining({ issueKey: 'PROJ-1', destination: '99999' }), + }), + ); + }); + + it('does not capture Sentry when the issue is already in the destination status (benign no-op) (MNG-1768)', async () => { + // No transition matches the destination because JIRA offers no + // self-transition — but the issue is *already* in the destination. This + // is the common best-effort path (createWorkItem's backlog move, lifecycle + // moveOnPrepare/moveOnSuccess) and must not pollute the genuine-miss signal. + mockJiraClient.getTransitions.mockResolvedValue([ + { id: 't-1', name: 'Start Progress', to: { id: '10005', name: 'In Progress' } }, + ]); + mockJiraClient.getIssue.mockResolvedValue({ + key: 'PROJ-1', + fields: { status: { id: '10000', name: 'Backlog' } }, + }); + + await provider.moveWorkItem('PROJ-1', '10000'); + + expect(mockJiraClient.transitionIssue).not.toHaveBeenCalled(); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('treats an already-in-destination match by status name as a benign no-op (back-compat) (MNG-1768)', async () => { + // Legacy name-based config: destination is a status name and the issue is + // already in it. Still a benign no-op, so no Sentry capture. + mockJiraClient.getTransitions.mockResolvedValue([ + { id: 't-1', name: 'Start Progress', to: { id: '10005', name: 'In Progress' } }, + ]); + mockJiraClient.getIssue.mockResolvedValue({ + key: 'PROJ-1', + fields: { status: { id: '10000', name: 'Backlog' } }, + }); + + await provider.moveWorkItem('PROJ-1', 'backlog'); + + expect(mockJiraClient.transitionIssue).not.toHaveBeenCalled(); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('does not capture Sentry when a name-based transition still resolves (back-compat)', async () => { + mockJiraClient.getTransitions.mockResolvedValue([ + { id: 't-2', name: 'Done', to: { id: '10011', name: 'Done' } }, + ]); + mockJiraClient.transitionIssue.mockResolvedValue(undefined); + + await provider.moveWorkItem('PROJ-1', 'Done'); + + expect(mockJiraClient.transitionIssue).toHaveBeenCalledWith('PROJ-1', 't-2'); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); }); describe('addLabel', () => { diff --git a/tests/unit/triggers/jira-comment-mention.test.ts b/tests/unit/triggers/jira-comment-mention.test.ts index e5b8c4921..5df904641 100644 --- a/tests/unit/triggers/jira-comment-mention.test.ts +++ b/tests/unit/triggers/jira-comment-mention.test.ts @@ -33,7 +33,7 @@ const OTHER_ACCOUNT_ID = 'user-account-456'; const ISSUE_KEY = 'PROJ-123'; const PLANNING_STATUS = 'Planning'; -function makeProject() { +function makeProject(configuredPlanningStatus: string = PLANNING_STATUS) { return { id: 'project-1', name: 'Test Project', @@ -41,7 +41,7 @@ function makeProject() { baseBranch: 'main', jira: { projectKey: 'PROJ', - statuses: { planning: PLANNING_STATUS }, + statuses: { planning: configuredPlanningStatus }, }, } as TriggerContext['project']; } @@ -52,6 +52,10 @@ function makeCtx( webhookEvent?: string; issueKey?: string; issueStatusName?: string; + /** Status ID in issue.fields.status.id (MNG-1768 id-based configs) */ + issueStatusId?: string; + /** Override the configured jira.statuses.planning value (id-based repros) */ + configuredPlanningStatus?: string; commentBody?: unknown; commentAuthorAccountId?: string; commentAuthorDisplayName?: string; @@ -62,7 +66,10 @@ function makeCtx( issue: { key: overrides.issueKey ?? ISSUE_KEY, fields: { - status: { name: overrides.issueStatusName ?? PLANNING_STATUS }, + status: { + name: overrides.issueStatusName ?? PLANNING_STATUS, + ...(overrides.issueStatusId !== undefined ? { id: overrides.issueStatusId } : {}), + }, summary: 'Test Issue Summary', }, }, @@ -76,7 +83,7 @@ function makeCtx( }; return { - project: makeProject(), + project: makeProject(overrides.configuredPlanningStatus), source: overrides.source ?? 'jira', payload, }; @@ -233,6 +240,35 @@ describe('JiraCommentMentionTrigger', () => { expect(result).toBeNull(); }); + it('gates on planning status by ID when config stores a status ID (MNG-1768)', async () => { + // Config planning slot holds a locale-invariant status ID; the localized + // name ("En planification") would never match the ID by name, but the + // issue's status.id does. Without id-based matching this silently no-ops. + const result = await trigger.handle( + makeCtx({ + configuredPlanningStatus: '10001', + issueStatusId: '10001', + issueStatusName: 'En planification', + }), + ); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('respond-to-planning-comment'); + expect(result?.workItemId).toBe(ISSUE_KEY); + }); + + it('returns null when the issue status ID differs from the configured planning ID (MNG-1768)', async () => { + const result = await trigger.handle( + makeCtx({ + configuredPlanningStatus: '10001', + issueStatusId: '10020', + issueStatusName: 'In Progress', + }), + ); + + expect(result).toBeNull(); + }); + it('includes triggerCommentText and triggerCommentBody in agentInput (wiki markup)', async () => { const result = await trigger.handle( makeCtx({ commentBody: `[~accountid:${BOT_ACCOUNT_ID}] please do this thing` }), diff --git a/tests/unit/triggers/jira-label-added.test.ts b/tests/unit/triggers/jira-label-added.test.ts index 2a9b44310..67298bfaa 100644 --- a/tests/unit/triggers/jira-label-added.test.ts +++ b/tests/unit/triggers/jira-label-added.test.ts @@ -78,6 +78,8 @@ function buildCtx(overrides: { webhookEvent?: string; issueKey?: string; statusName?: string; + /** Status ID in issue.fields.status.id (MNG-1768 id-based configs) */ + statusId?: string; changelogItems?: Array<{ field?: string; fromString?: string; toString?: string }>; project?: TriggerContext['project']; }): TriggerContext { @@ -90,7 +92,10 @@ function buildCtx(overrides: { key: overrides.issueKey ?? 'TEST-42', fields: { project: { key: 'TEST' }, - status: { name: overrides.statusName ?? 'Splitting' }, + status: { + name: overrides.statusName ?? 'Splitting', + ...(overrides.statusId !== undefined ? { id: overrides.statusId } : {}), + }, summary: 'Test issue', }, }, @@ -455,4 +460,49 @@ describe('JiraReadyToProcessLabelTrigger', () => { expect(result).toBeNull(); }); }); + + describe('ID-based status config (MNG-1768)', () => { + // Config maps CASCADE stage keys → locale-invariant JIRA status IDs, the + // shape every new (or re-saved) project now persists via the wizard. + const idBasedProject = { + ...baseProject, + jira: { + ...baseJiraConfig, + statuses: { + splitting: '10005', + planning: '10001', + todo: '10010', + inProgress: '10020', + inReview: '10030', + done: '10011', + }, + }, + } as TriggerContext['project']; + + it('dispatches via status ID when config stores IDs and the name is a foreign language', async () => { + // Only `status.id` ("10010") matches; the localized French name would + // never match the configured value by name. + const result = await trigger.handle( + buildCtx({ project: idBasedProject, statusName: 'En cours', statusId: '10010' }), + ); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('implementation'); + expect(result?.workItemId).toBe('TEST-42'); + expect(checkTriggerEnabled).toHaveBeenCalledWith( + 'test-project', + 'implementation', + 'pm:label-added', + 'jira-ready-to-process-label-added', + ); + }); + + it('returns null when the issue status ID maps to no configured stage', async () => { + const result = await trigger.handle( + buildCtx({ project: idBasedProject, statusName: 'Terminé', statusId: '99999' }), + ); + + expect(result).toBeNull(); + }); + }); }); diff --git a/tests/unit/triggers/jira-status-changed.test.ts b/tests/unit/triggers/jira-status-changed.test.ts index d6c20266b..38315fa8a 100644 --- a/tests/unit/triggers/jira-status-changed.test.ts +++ b/tests/unit/triggers/jira-status-changed.test.ts @@ -54,13 +54,29 @@ function buildCtx( source?: TriggerContext['source']; webhookEvent?: string; issueKey?: string; - statusChangeItems?: Array<{ field?: string; fromString?: string; toString?: string }>; + statusChangeItems?: Array<{ + field?: string; + from?: string; + to?: string; + fromString?: string; + toString?: string; + }>; noJiraConfig?: boolean; /** Status name in issue.fields.status.name (for creation events) */ issueStatusName?: string; + /** Status ID in issue.fields.status.id (for creation events) */ + issueStatusId?: string; + /** Override the configured jira.statuses map (locale/id-based repros) */ + configuredStatuses?: Record; } = {}, ): TriggerContext { - const project = overrides.noJiraConfig ? { ...mockProject, jira: undefined } : mockProject; + const baseProject = overrides.configuredStatuses + ? { + ...mockProject, + jira: { ...mockProject.jira, statuses: overrides.configuredStatuses }, + } + : mockProject; + const project = overrides.noJiraConfig ? { ...baseProject, jira: undefined } : baseProject; return { project: project as TriggerContext['project'], @@ -71,8 +87,15 @@ function buildCtx( key: overrides.issueKey ?? 'PROJ-42', fields: { summary: 'Test Issue', - ...(overrides.issueStatusName !== undefined - ? { status: { name: overrides.issueStatusName } } + ...(overrides.issueStatusName !== undefined || overrides.issueStatusId !== undefined + ? { + status: { + ...(overrides.issueStatusName !== undefined + ? { name: overrides.issueStatusName } + : {}), + ...(overrides.issueStatusId !== undefined ? { id: overrides.issueStatusId } : {}), + }, + } : {}), }, }, @@ -218,6 +241,67 @@ describe('JiraStatusChangedTrigger', () => { expect(await trigger.handle(ctx)).toBeNull(); }); + it('dispatches via status ID when config stores IDs and toString is a foreign language (MNG-1768 repro)', async () => { + // Config maps `todo` → status ID "10010". The webhook carries the + // localized name "En cours" (French) which would never match by name, + // but the id `to: '10010'` matches locale-invariantly. + const ctx = buildCtx({ + configuredStatuses: { + backlog: '10000', + todo: '10010', + done: '10011', + }, + statusChangeItems: [ + { + field: 'status', + from: '10000', + to: '10010', + fromString: 'Backlog', + toString: 'En cours', + }, + ], + }); + + const result = await trigger.handle(ctx); + + expect(result?.agentType).toBe('implementation'); + expect(result?.workItemId).toBe('PROJ-42'); + }); + + it('still dispatches via toString when config stores names (back-compat)', async () => { + const ctx = buildCtx({ + statusChangeItems: [ + { field: 'status', from: '10000', to: '10010', fromString: 'Backlog', toString: 'To Do' }, + ], + }); + + expect((await trigger.handle(ctx))?.agentType).toBe('implementation'); + }); + + it('logs toStatusId alongside toStatus on the update path', async () => { + const ctx = buildCtx({ + statusChangeItems: [ + { + field: 'status', + from: '10000', + to: '10005', + fromString: 'Backlog', + toString: 'Splitting', + }, + ], + }); + await trigger.handle(ctx); + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('JIRA'), + expect.objectContaining({ + toStatus: 'Splitting', + toStatusId: '10005', + eventKind: 'move', + }), + ); + }); + it('logs fromStatus on the update path', async () => { const ctx = buildCtx({ statusChangeItems: [{ field: 'status', fromString: 'Backlog', toString: 'Splitting' }], @@ -270,6 +354,24 @@ describe('JiraStatusChangedTrigger', () => { expect((await trigger.handle(ctx))?.agentType).toBe('splitting'); }); + it('resolves the create path via status.id when config stores IDs (MNG-1768)', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: true }); + const ctx = buildCtx({ + webhookEvent: 'jira:issue_created', + configuredStatuses: { + todo: '10010', + }, + // Foreign-language name; only the id matches. + issueStatusName: 'En cours', + issueStatusId: '10010', + }); + + const result = await trigger.handle(ctx); + + expect(result?.agentType).toBe('implementation'); + expect(result?.workItemId).toBe('PROJ-42'); + }); + it('returns null when onCreate is true but status is unmapped', async () => { mockTriggerConfig(true, { onCreate: true, onMove: true }); const ctx = buildCtx({ diff --git a/tests/unit/triggers/shared/pm-label.test.ts b/tests/unit/triggers/shared/pm-label.test.ts index 140ac29a1..bcfda4f88 100644 --- a/tests/unit/triggers/shared/pm-label.test.ts +++ b/tests/unit/triggers/shared/pm-label.test.ts @@ -14,6 +14,7 @@ import { resolvePMLabelAgentByList, resolvePMLabelAgentByStatusId, resolvePMLabelAgentByStatusIdFromWorkflowDefinitions, + resolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions, resolvePMLabelAgentByStatusName, resolvePMLabelAgentByStatusNameFromWorkflowDefinitions, } from '../../../../src/triggers/shared/pm-label.js'; @@ -113,6 +114,45 @@ describe('PM label helpers', () => { ).resolves.toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); }); + describe('resolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions (MNG-1768)', () => { + it('matches on a locale-invariant JIRA status ID (foreign-language name)', async () => { + await expect( + resolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions({ + statusId: '10010', + // Foreign-language name that would never match by name. + statusName: 'En cours', + configuredStatuses: { + todo: '10010', + }, + }), + ).resolves.toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + + it('falls back to case-insensitive name matching for legacy name-based configs', async () => { + await expect( + resolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions({ + statusId: '10010', + statusName: 'to do', + configuredStatuses: { + todo: 'To Do', + }, + }), + ).resolves.toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + + it('returns undefined when neither id nor name matches', async () => { + await expect( + resolvePMLabelAgentByStatusIdOrNameFromWorkflowDefinitions({ + statusId: '99999', + statusName: 'Unknown', + configuredStatuses: { + todo: '10010', + }, + }), + ).resolves.toBeUndefined(); + }); + }); + it('builds canonical label-added dispatch results', () => { expect( buildPMLabelDispatchResult({ diff --git a/tests/unit/triggers/shared/pm-status.test.ts b/tests/unit/triggers/shared/pm-status.test.ts index e834369d5..8bdab093d 100644 --- a/tests/unit/triggers/shared/pm-status.test.ts +++ b/tests/unit/triggers/shared/pm-status.test.ts @@ -14,6 +14,7 @@ import { buildPMStatusDispatchResult, resolvePMStatusAgentById, resolvePMStatusAgentByIdFromWorkflowDefinitions, + resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions, resolvePMStatusAgentByName, resolvePMStatusAgentByNameFromWorkflowDefinitions, shouldFirePMStatusEvent, @@ -147,6 +148,105 @@ describe('PM status helpers', () => { ).resolves.toBeUndefined(); }); + describe('resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions (MNG-1768)', () => { + it('matches on a locale-invariant status ID', async () => { + await expect( + resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions({ + statusId: '10010', + // Deliberately foreign-language name that would never match by name. + statusName: 'En cours', + configuredStatuses: { + todo: '10010', + }, + }), + ).resolves.toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + + it('matches on the status name (case-insensitive) when config stores names', async () => { + await expect( + resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions({ + statusId: '10010', + statusName: 'to do', + configuredStatuses: { + todo: 'To Do', + }, + }), + ).resolves.toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + + it('checks the ID branch before the name branch within a single configured entry', async () => { + await expect( + resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions({ + statusId: '10010', + statusName: 'To Do', + configuredStatuses: { + // Both entries are matchable: `planning` by ID, `todo` by name. + // The winner is the first entry that matches during iteration + // (here `planning`), NOT a global id-over-name preference — + // reversing the entry order would let `todo` win on the name. + // What this asserts is only that the ID branch is evaluated + // for `planning` before the name branch, so an ID-valued entry + // resolves without needing a name. + planning: '10010', + todo: 'To Do', + }, + }), + ).resolves.toEqual({ agentType: 'planning', cascadeStatus: 'planning' }); + }); + + it('returns undefined when neither id nor name matches', async () => { + await expect( + resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions({ + statusId: '99999', + statusName: 'Unknown', + configuredStatuses: { + todo: '10010', + planning: 'Planning', + }, + }), + ).resolves.toBeUndefined(); + }); + + it('resolves custom workflow statuses via ID matching', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + await expect( + resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions({ + statusId: '10050', + statusName: 'Revisión PRD', + configuredStatuses: { + prd: '10050', + }, + }), + ).resolves.toEqual({ agentType: 'prd', cascadeStatus: 'prd' }); + }); + + it('ignores a matched status with no dispatch agent', async () => { + await expect( + resolvePMStatusAgentByIdOrNameFromWorkflowDefinitions({ + statusId: '10011', + statusName: 'Done', + configuredStatuses: { + done: '10011', + }, + }), + ).resolves.toBeUndefined(); + }); + }); + it('applies shared onCreate/onMove trigger parameter semantics', () => { expect(shouldFirePMStatusEvent(true, { onCreate: true })).toBe(true); expect(shouldFirePMStatusEvent(true, {})).toBe(false); diff --git a/tests/unit/web/jira-status-mapping-ids.test.ts b/tests/unit/web/jira-status-mapping-ids.test.ts new file mode 100644 index 000000000..b52a3a70f --- /dev/null +++ b/tests/unit/web/jira-status-mapping-ids.test.ts @@ -0,0 +1,117 @@ +/** + * MNG-1768: JIRA status mappings persist locale-invariant status IDs, and + * legacy name-valued configs auto-upgrade to IDs when project details load. + */ + +import { describe, expect, it } from 'vitest'; +import { + createInitialJiraState, + type JiraProjectDetails, + jiraWizardReducer, + normalizeJiraStatusMappingsToIds, +} from '../../../web/src/components/projects/pm-providers/jira/state.js'; + +const DISCOVERED_STATUSES = [ + { id: '10000', name: 'Backlog' }, + { id: '10005', name: 'Splitting' }, + { id: '10010', name: 'To Do' }, + { id: '10011', name: 'Done' }, +]; + +function makeDetails(overrides?: Partial): JiraProjectDetails { + return { + statuses: DISCOVERED_STATUSES, + issueTypes: [], + fields: [], + ...overrides, + }; +} + +function baseState() { + return { + ...createInitialJiraState(), + verificationResult: null as { provider: string; display: string } | null, + verifyError: null as string | null, + }; +} + +describe('normalizeJiraStatusMappingsToIds (MNG-1768)', () => { + it('rewrites name-valued mappings to their status ID (case-insensitive)', () => { + const result = normalizeJiraStatusMappingsToIds( + { todo: 'To Do', done: 'done' }, + DISCOVERED_STATUSES, + ); + expect(result).toEqual({ todo: '10010', done: '10011' }); + }); + + it('leaves values that are already IDs untouched', () => { + const mappings = { todo: '10010', done: '10011' }; + const result = normalizeJiraStatusMappingsToIds(mappings, DISCOVERED_STATUSES); + expect(result).toEqual(mappings); + // Unchanged → same reference (no needless re-render churn). + expect(result).toBe(mappings); + }); + + it('leaves unknown custom names untouched', () => { + const result = normalizeJiraStatusMappingsToIds( + { prd: 'Some Custom Status', todo: 'To Do' }, + DISCOVERED_STATUSES, + ); + expect(result).toEqual({ prd: 'Some Custom Status', todo: '10010' }); + }); + + it('returns the input unchanged when no statuses are discovered yet', () => { + const mappings = { todo: 'To Do' }; + expect(normalizeJiraStatusMappingsToIds(mappings, [])).toBe(mappings); + }); +}); + +describe('SET_JIRA_STATUS_MAPPING persists the selected value (status ID)', () => { + it('stores the value passed by the select (the status ID)', () => { + const next = jiraWizardReducer(baseState(), { + type: 'SET_JIRA_STATUS_MAPPING', + key: 'todo', + value: '10010', + }); + expect(next.jiraStatusMappings.todo).toBe('10010'); + }); +}); + +describe('SET_JIRA_PROJECT_DETAILS auto-migrates legacy name mappings to IDs', () => { + it('upgrades a legacy name-valued mapping to its ID when details load', () => { + const state = { + ...baseState(), + jiraStatusMappings: { todo: 'To Do', done: 'Done' }, + }; + + const next = jiraWizardReducer(state, { + type: 'SET_JIRA_PROJECT_DETAILS', + details: makeDetails(), + }); + + expect(next.jiraStatusMappings).toEqual({ todo: '10010', done: '10011' }); + }); + + it('leaves already-id and unknown-custom mappings untouched on details load', () => { + const state = { + ...baseState(), + jiraStatusMappings: { todo: '10010', prd: 'Custom Thing' }, + }; + + const next = jiraWizardReducer(state, { + type: 'SET_JIRA_PROJECT_DETAILS', + details: makeDetails(), + }); + + expect(next.jiraStatusMappings).toEqual({ todo: '10010', prd: 'Custom Thing' }); + }); + + it('still stores the loaded project details', () => { + const details = makeDetails(); + const next = jiraWizardReducer(baseState(), { + type: 'SET_JIRA_PROJECT_DETAILS', + details, + }); + expect(next.jiraProjectDetails).toBe(details); + }); +}); diff --git a/web/src/components/projects/pm-providers/jira/state.ts b/web/src/components/projects/pm-providers/jira/state.ts index 0843b8247..f7ffdc788 100644 --- a/web/src/components/projects/pm-providers/jira/state.ts +++ b/web/src/components/projects/pm-providers/jira/state.ts @@ -128,7 +128,19 @@ export function jiraWizardReducer, + statuses: Array<{ id: string; name: string }>, +): Record { + if (statuses.length === 0) return mappings; + + const idSet = new Set(statuses.map((s) => s.id)); + const nameToId = new Map(statuses.map((s) => [s.name.toLowerCase(), s.id])); + + let changed = false; + const next: Record = {}; + for (const [key, value] of Object.entries(mappings)) { + if (value && !idSet.has(value)) { + const mappedId = nameToId.get(value.toLowerCase()); + if (mappedId) { + next[key] = mappedId; + changed = true; + continue; + } + } + next[key] = value; + } + + return changed ? next : mappings; +} + export function resetJiraProjectState( jiraProjectKey: string, ): Pick< diff --git a/web/src/components/projects/pm-providers/jira/wizard.ts b/web/src/components/projects/pm-providers/jira/wizard.ts index 75e24529c..4ca980f91 100644 --- a/web/src/components/projects/pm-providers/jira/wizard.ts +++ b/web/src/components/projects/pm-providers/jira/wizard.ts @@ -36,8 +36,11 @@ import { IssueTypeMappingStep } from './issue-type-step.js'; import type { JiraWizardAuthType } from './state.js'; import { JiraWebhookAdapter, normalizeJiraActiveWebhooks } from './webhook-step.js'; -// CASCADE stage keys that map to JIRA statuses (name-based, not id-based -// — JIRA statuses are configured per project, name is the stable identity). +// CASCADE stage keys that map to JIRA statuses. MNG-1768: the mapping value +// persisted per slot is the locale-invariant JIRA status ID (name matching is +// retained only as a legacy fallback in the trigger/adapter), so status moves +// no longer silently no-op when the credential account's language differs from +// the site language. export const JIRA_STATUS_SLOTS = [ { key: 'backlog', label: 'Backlog' }, { key: 'splitting', label: 'Splitting' }, @@ -500,9 +503,11 @@ export const jiraProviderWizard: ProviderWizardDefinition = { : undefined, onProjectSelect: discovery.handleProjectSelect, projectDetailsLoading: discovery.jiraDetailsMutation.isPending, - // JIRA statuses carry a `name` used as the id in mappings (JIRA's - // status-name is the stable identity the adapter writes back). - providerStates: (details?.statuses ?? []).map((s) => ({ id: s.name, name: s.name })), + // MNG-1768: the mapping value is the locale-invariant JIRA status ID + // (`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 })), // JIRA's discovery returns `{id, name, custom}` for custom fields; // map `custom: boolean` to a string `type` to satisfy the shared // `providerCustomFields` prop contract.