Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/architecture/04-agent-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<customKey>`, 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 <key> --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.
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions src/integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ See [`src/integrations/pm/manifest.ts`](./pm/manifest.ts) for the authoritative
| `wizardSpec?` | `{ steps: Array<StandardStep \| CustomStep> }`. 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
Expand Down Expand Up @@ -257,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.<customKey>` → Trello list ID | `src/pm/trello/integration.ts` |
| JIRA provider-native value | `statuses.<customKey>` → JIRA status name | `src/pm/jira/integration.ts` |
| JIRA provider-native value | `statuses.<customKey>` → JIRA status **ID** (locale-proof; name accepted as a legacy fallback — see below) | `src/pm/jira/integration.ts` |
| Linear provider-native value | `statuses.<customKey>` → 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:
Expand All @@ -282,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:
Expand Down
57 changes: 51 additions & 6 deletions src/jira/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ import type { JiraCredentials } from './types.js';

const jiraCredentialStore = new AsyncLocalStorage<JiraCredentials>();

/** 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<T>(creds: JiraCredentials, fn: () => Promise<T>): Promise<T> {
return jiraCredentialStore.run(creds, fn);
}
Expand Down Expand Up @@ -150,12 +161,46 @@ export const jiraClient = {

async searchProjects(): Promise<Array<{ key: string; name: string }>> {
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<Array<{ name: string; id: string }>> {
Expand Down
Loading
Loading