@mastra/factory 0.21.0-alpha.6 → 0.21.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +72 -1
  2. package/dist/boards/define-board.d.ts +36 -1
  3. package/dist/boards/define-board.d.ts.map +1 -1
  4. package/dist/boards/define-board.js +46 -0
  5. package/dist/boards/define-board.js.map +1 -1
  6. package/dist/boards/index.d.ts +2 -2
  7. package/dist/boards/index.d.ts.map +1 -1
  8. package/dist/boards/index.js +2 -2
  9. package/dist/boards/index.js.map +1 -1
  10. package/dist/boards/semantics.d.ts +34 -1
  11. package/dist/boards/semantics.d.ts.map +1 -1
  12. package/dist/boards/semantics.js +65 -1
  13. package/dist/boards/semantics.js.map +1 -1
  14. package/dist/boards/work.d.ts.map +1 -1
  15. package/dist/boards/work.js +5 -1
  16. package/dist/boards/work.js.map +1 -1
  17. package/dist/factory.d.ts.map +1 -1
  18. package/dist/factory.js +8 -0
  19. package/dist/factory.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/integrations/github/default-rules.d.ts +2 -2
  23. package/dist/integrations/github/default-rules.d.ts.map +1 -1
  24. package/dist/integrations/github/default-rules.js +11 -8
  25. package/dist/integrations/github/default-rules.js.map +1 -1
  26. package/dist/integrations/github/integration.d.ts +18 -0
  27. package/dist/integrations/github/integration.d.ts.map +1 -1
  28. package/dist/integrations/github/integration.js +53 -2
  29. package/dist/integrations/github/integration.js.map +1 -1
  30. package/dist/integrations/github/routes.d.ts +12 -0
  31. package/dist/integrations/github/routes.d.ts.map +1 -1
  32. package/dist/integrations/github/routes.js +63 -1
  33. package/dist/integrations/github/routes.js.map +1 -1
  34. package/dist/integrations/github/rules.d.ts +5 -2
  35. package/dist/integrations/github/rules.d.ts.map +1 -1
  36. package/dist/integrations/github/rules.js +72 -19
  37. package/dist/integrations/github/rules.js.map +1 -1
  38. package/dist/integrations/gitlab/default-rules.d.ts +3 -3
  39. package/dist/integrations/gitlab/default-rules.d.ts.map +1 -1
  40. package/dist/integrations/gitlab/default-rules.js +13 -6
  41. package/dist/integrations/gitlab/default-rules.js.map +1 -1
  42. package/dist/integrations/gitlab/rules.d.ts.map +1 -1
  43. package/dist/integrations/gitlab/rules.js +39 -8
  44. package/dist/integrations/gitlab/rules.js.map +1 -1
  45. package/dist/integrations/incidentio/default-rules.d.ts +3 -3
  46. package/dist/integrations/incidentio/default-rules.d.ts.map +1 -1
  47. package/dist/integrations/incidentio/default-rules.js +8 -6
  48. package/dist/integrations/incidentio/default-rules.js.map +1 -1
  49. package/dist/integrations/incidentio/rules.d.ts.map +1 -1
  50. package/dist/integrations/incidentio/rules.js +6 -3
  51. package/dist/integrations/incidentio/rules.js.map +1 -1
  52. package/dist/integrations/jira/default-rules.d.ts +3 -3
  53. package/dist/integrations/jira/default-rules.d.ts.map +1 -1
  54. package/dist/integrations/jira/default-rules.js +8 -6
  55. package/dist/integrations/jira/default-rules.js.map +1 -1
  56. package/dist/integrations/jira/rules.d.ts.map +1 -1
  57. package/dist/integrations/jira/rules.js +6 -3
  58. package/dist/integrations/jira/rules.js.map +1 -1
  59. package/dist/integrations/linear/default-rules.d.ts +3 -3
  60. package/dist/integrations/linear/default-rules.d.ts.map +1 -1
  61. package/dist/integrations/linear/default-rules.js +8 -6
  62. package/dist/integrations/linear/default-rules.js.map +1 -1
  63. package/dist/integrations/linear/routes.js +1 -1
  64. package/dist/integrations/linear/rules.d.ts.map +1 -1
  65. package/dist/integrations/linear/rules.js +6 -3
  66. package/dist/integrations/linear/rules.js.map +1 -1
  67. package/dist/integrations/pull-request-head.d.ts +18 -0
  68. package/dist/integrations/pull-request-head.d.ts.map +1 -0
  69. package/dist/integrations/pull-request-head.js +45 -0
  70. package/dist/integrations/pull-request-head.js.map +1 -0
  71. package/dist/routes/attention-providers.js +3 -3
  72. package/dist/routes/attention-providers.js.map +1 -1
  73. package/dist/routes/contracts.js +1 -1
  74. package/dist/routes/intake.d.ts +3 -0
  75. package/dist/routes/intake.d.ts.map +1 -1
  76. package/dist/routes/intake.js +47 -6
  77. package/dist/routes/intake.js.map +1 -1
  78. package/dist/routes/surface.d.ts.map +1 -1
  79. package/dist/routes/surface.js +3 -2
  80. package/dist/routes/surface.js.map +1 -1
  81. package/dist/routes/work-items.d.ts.map +1 -1
  82. package/dist/routes/work-items.js +3 -4
  83. package/dist/routes/work-items.js.map +1 -1
  84. package/dist/rules/decision-applicability.d.ts +6 -0
  85. package/dist/rules/decision-applicability.d.ts.map +1 -1
  86. package/dist/rules/decision-applicability.js +10 -1
  87. package/dist/rules/decision-applicability.js.map +1 -1
  88. package/dist/rules/dispatcher.d.ts.map +1 -1
  89. package/dist/rules/dispatcher.js +20 -15
  90. package/dist/rules/dispatcher.js.map +1 -1
  91. package/dist/rules/event-rules.d.ts +23 -0
  92. package/dist/rules/event-rules.d.ts.map +1 -0
  93. package/dist/rules/event-rules.js +46 -0
  94. package/dist/rules/event-rules.js.map +1 -0
  95. package/dist/rules/index.js +1 -1
  96. package/dist/rules/processor.js +1 -1
  97. package/dist/rules/review-source-tool.d.ts +5 -3
  98. package/dist/rules/review-source-tool.d.ts.map +1 -1
  99. package/dist/rules/review-source-tool.js.map +1 -1
  100. package/dist/rules/tools.js +1 -1
  101. package/dist/rules/transition-service.js +1 -1
  102. package/dist/rules/types.d.ts +47 -0
  103. package/dist/rules/types.d.ts.map +1 -1
  104. package/dist/rules/types.js.map +1 -1
  105. package/dist/storage/domains/comments/base.d.ts +3 -0
  106. package/dist/storage/domains/comments/base.d.ts.map +1 -1
  107. package/dist/storage/domains/comments/base.js +8 -1
  108. package/dist/storage/domains/comments/base.js.map +1 -1
  109. package/dist/storage/domains/work-items/base.d.ts +7 -3
  110. package/dist/storage/domains/work-items/base.d.ts.map +1 -1
  111. package/dist/storage/domains/work-items/base.js +66 -16
  112. package/dist/storage/domains/work-items/base.js.map +1 -1
  113. package/dist/storage/domains/work-items/board-version.d.ts +26 -0
  114. package/dist/storage/domains/work-items/board-version.d.ts.map +1 -0
  115. package/dist/storage/domains/work-items/board-version.js +110 -0
  116. package/dist/storage/domains/work-items/board-version.js.map +1 -0
  117. package/dist/supervisor/health.d.ts.map +1 -1
  118. package/dist/supervisor/health.js +17 -5
  119. package/dist/supervisor/health.js.map +1 -1
  120. package/dist/supervisor/read-tools.d.ts.map +1 -1
  121. package/dist/supervisor/read-tools.js +13 -8
  122. package/dist/supervisor/read-tools.js.map +1 -1
  123. package/dist/supervisor/write-tools.d.ts.map +1 -1
  124. package/dist/supervisor/write-tools.js +5 -1
  125. package/dist/supervisor/write-tools.js.map +1 -1
  126. package/dist/workspace.d.ts.map +1 -1
  127. package/dist/workspace.js +17 -1
  128. package/dist/workspace.js.map +1 -1
  129. package/factory-skills/factory-rereview/SKILL.md +4 -4
  130. package/factory-skills/factory-review/SKILL.md +2 -2
  131. package/package.json +6 -6
@@ -1 +1 @@
1
- {"version":3,"file":"rules.js","names":["#heldElsewhere","#ingestIssue"],"sources":["../../../src/integrations/incidentio/rules.ts"],"sourcesContent":["import { boardForWorkItem, isTerminalWorkItem } from '../../boards/index.js';\nimport type { BoardRegistry } from '../../boards/index.js';\nimport type { FactoryIncidentioRuleContext, FactoryRuleDecision } from '../../rules/types.js';\nimport { assertFactoryDecisionTarget, validateFactoryRuleDecisions } from '../../rules/validation.js';\nimport type { FactoryProjectsStorage } from '../../storage/domains/projects/base.js';\nimport type { WorkItemRow, WorkItemsStorage } from '../../storage/domains/work-items/base.js';\nimport type { IntegrationContext } from '../base.js';\nimport { incidentioClaimKey } from './claim.js';\nimport type { IncidentioEventRules } from './default-rules.js';\n\nconst RULE_TIMEOUT_MS = 5_000;\n\nasync function withRuleTimeout<T>(promise: Promise<T>): Promise<T> {\n let timeout: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timeout = setTimeout(() => reject(new Error('FACTORY_RULE_TIMEOUT')), RULE_TIMEOUT_MS);\n }),\n ]);\n } finally {\n if (timeout) clearTimeout(timeout);\n }\n}\n\nconst MAX_STALE_RULE_ATTEMPTS = 3;\n\nexport interface IncidentioIssueIngress {\n /** Stable item reference — the prefixed incident.io follow-up id (`incidentio:follow-up:<ulid>`). */\n id: string;\n identifier: string;\n title: string;\n url: string;\n state: string;\n stateType: string;\n priorityLabel: string;\n assignee: string | null;\n /** Display name of the follow-up creator, when available. */\n author: string | null;\n /** Reference of the incident this follow-up belongs to, when available. */\n incident: string | null;\n labels: string[];\n createdAt: string;\n updatedAt: string;\n /** incident.io source the follow-up was read from; resolves the bound board via `intakeBoards`. */\n sourceId?: string | null;\n}\n\nexport interface IncidentioRulesOptions {\n projects: Pick<FactoryProjectsStorage, 'get'>;\n storage: WorkItemsStorage;\n configVersion: string;\n boards: BoardRegistry;\n incidentioRules: IncidentioEventRules;\n}\n\nexport interface IncidentioRulesIngress {\n orgId: string;\n userId: string;\n factoryProjectId: string;\n issues: IncidentioIssueIngress[];\n /** Board id per bound incident.io source id, from the project's intake bindings. */\n intakeBoards?: Readonly<Record<string, string>>;\n}\n\ntype IngressStatus = 'committed' | 'replayed' | 'missing';\n\nexport class IncidentioRules {\n constructor(private readonly options: IncidentioRulesOptions) {}\n\n async ingest(input: IncidentioRulesIngress): Promise<{ status: IngressStatus; ingested: number }> {\n const project = await this.options.projects.get({ orgId: input.orgId, id: input.factoryProjectId });\n if (!project) return { status: 'missing', ingested: 0 };\n\n const items = await this.options.storage.list({ orgId: input.orgId, factoryProjectId: input.factoryProjectId });\n const itemsBySourceKey = new Map(items.map(item => [item.externalSource?.externalId, item]));\n const itemsByClaimKey = new Map(items.filter(item => item.claimKey).map(item => [item.claimKey, item]));\n const statuses: IngressStatus[] = [];\n for (const issue of input.issues) {\n // The claim finds the card even after a metadata drift; the raw item\n // reference covers cards filed before claims existed.\n const relatedItem = itemsByClaimKey.get(incidentioClaimKey(issue.id)) ?? itemsBySourceKey.get(issue.id);\n // One live card per follow-up per org. The store's claim index is the\n // guarantee; this check spares the dispatcher a refused upsert.\n if (!relatedItem && (await this.#heldElsewhere(input, issue))) {\n statuses.push('missing');\n continue;\n }\n statuses.push(await this.#ingestIssue(input, issue, relatedItem));\n }\n if (statuses.some(status => status === 'committed')) return { status: 'committed', ingested: statuses.length };\n if (statuses.some(status => status === 'replayed')) return { status: 'replayed', ingested: statuses.length };\n return { status: 'missing', ingested: statuses.length };\n }\n\n async #heldElsewhere(input: IncidentioRulesIngress, issue: IncidentioIssueIngress): Promise<boolean> {\n const heldLive = (row: WorkItemRow) =>\n row.factoryProjectId !== input.factoryProjectId && !isTerminalWorkItem(this.options.boards, row);\n const claimant = await this.options.storage.getByClaimKey({\n orgId: input.orgId,\n claimKey: incidentioClaimKey(issue.id),\n });\n if (claimant && heldLive(claimant)) return true;\n // Cards filed before claims existed carry only the item reference.\n const legacy = await this.options.storage.listBySource({\n orgId: input.orgId,\n source: { integrationId: 'incidentio', type: 'issue', externalId: issue.id },\n });\n return legacy.some(heldLive);\n }\n\n async #ingestIssue(\n input: IncidentioRulesIngress,\n issue: IncidentioIssueIngress,\n relatedItem: WorkItemRow | undefined,\n attempt = 1,\n ): Promise<IngressStatus> {\n const ingressId = `incidentio:${issue.id}:${issue.updatedAt}`;\n const actor = { type: 'human' as const, id: input.userId };\n\n // Closed follow-ups without existing work items should not create new ones.\n const isClosed = issue.stateType === 'completed' || issue.stateType === 'canceled';\n if (isClosed && !relatedItem) {\n return 'missing';\n }\n\n const event = isClosed ? 'followUpClosed' : 'followUpObserved';\n const boundBoardId = issue.sourceId ? input.intakeBoards?.[issue.sourceId] : undefined;\n const boundBoard = boundBoardId ? this.options.boards.get(boundBoardId) : undefined;\n const context: FactoryIncidentioRuleContext = {\n tenant: { orgId: input.orgId, projectId: input.factoryProjectId },\n actor,\n ingress: { type: 'incidentio', id: ingressId },\n cause: `incidentio.${event}`,\n causalChain: [],\n configVersion: this.options.configVersion,\n ...(relatedItem\n ? {\n item: {\n id: relatedItem.id,\n source: 'incidentio-follow-up',\n sourceKey: relatedItem.externalSource?.externalId ?? null,\n parentWorkItemId: relatedItem.parentWorkItemId,\n title: relatedItem.title,\n url: relatedItem.externalSource?.url ?? null,\n stages: relatedItem.stages,\n acceptedAt: relatedItem.acceptedAt,\n metadata: relatedItem.metadata,\n },\n board: boardForWorkItem(relatedItem),\n itemRevision: relatedItem.revision,\n }\n : {}),\n ...(boundBoard ? { intake: { board: boundBoard.id, initialPhase: boundBoard.initialPhase } } : {}),\n event,\n issue,\n };\n\n const rule = this.options.incidentioRules[context.event];\n let decision: FactoryRuleDecision | void;\n let decisions: Record<string, unknown>[] = [];\n let outcome: { status: 'accepted' | 'rejected'; code?: string; reason?: string } = { status: 'accepted' };\n try {\n decision = rule ? await withRuleTimeout(Promise.resolve(rule(Object.freeze(context)))) : undefined;\n if (decision?.type === 'reject') {\n outcome = { status: 'rejected', code: decision.code, reason: decision.reason };\n } else if (decision) {\n decisions = validateFactoryRuleDecisions([decision]).map(entry => {\n assertFactoryDecisionTarget(\n entry,\n this.options.boards,\n relatedItem ? boardForWorkItem(relatedItem) : undefined,\n );\n return { ...entry };\n });\n }\n } catch (error) {\n const timedOut = error instanceof Error && error.message === 'FACTORY_RULE_TIMEOUT';\n outcome = {\n status: 'rejected',\n code: timedOut ? 'timeout' : 'rule_error',\n reason: timedOut\n ? 'Factory rule evaluation timed out.'\n : error instanceof Error\n ? error.message.slice(0, 2_000)\n : 'Factory incident.io rule failed.',\n };\n }\n\n const committed = await this.options.storage.commitRuleEvaluation({\n orgId: input.orgId,\n factoryProjectId: input.factoryProjectId,\n workItemId: relatedItem?.id ?? null,\n ingress: { identity: ingressId, triggerType: `incidentio.${event}` },\n configVersion: this.options.configVersion,\n expectedRevision: relatedItem?.revision ?? null,\n actor,\n outcome,\n decisions,\n causalChain: [],\n now: new Date(),\n });\n if (committed.status === 'stale') {\n // Nothing was persisted: re-read the item and evaluate again at its fresh revision.\n if (attempt >= MAX_STALE_RULE_ATTEMPTS) {\n throw new Error(`Factory incident.io rule evaluation for ${ingressId} kept losing revision races.`);\n }\n const fresh = relatedItem ? await this.options.storage.get({ orgId: input.orgId, id: relatedItem.id }) : null;\n if (relatedItem && !fresh) return 'missing';\n return this.#ingestIssue(input, issue, fresh ?? undefined, attempt + 1);\n }\n return committed.status;\n }\n}\n\nexport function attachIncidentioRules(\n incidentio: { readonly rules: IncidentioEventRules },\n context: IntegrationContext,\n): ((input: IncidentioRulesIngress) => Promise<unknown>) | undefined {\n if (!context.runtime) return undefined;\n const rules = new IncidentioRules({\n projects: context.storage.projects,\n storage: context.runtime.workItems,\n configVersion: context.runtime.configVersion,\n boards: context.runtime.boards,\n incidentioRules: incidentio.rules,\n });\n return input => rules.ingest(input);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"rules.js","names":["#heldElsewhere","#ingestIssue"],"sources":["../../../src/integrations/incidentio/rules.ts"],"sourcesContent":["import {\n boardEventHandlers,\n boardForWorkItem,\n eventBoardId,\n isTerminalWorkItem,\n rulePhaseField,\n withBoardClosure,\n} from '../../boards/index.js';\nimport type { BoardRegistry } from '../../boards/index.js';\nimport { selectEventRule } from '../../rules/event-rules.js';\nimport type { FactoryIncidentioRuleContext, FactoryRuleDecision } from '../../rules/types.js';\nimport { assertFactoryDecisionTarget, validateFactoryRuleDecisions } from '../../rules/validation.js';\nimport type { FactoryProjectsStorage } from '../../storage/domains/projects/base.js';\nimport type { WorkItemRow, WorkItemsStorage } from '../../storage/domains/work-items/base.js';\nimport type { IntegrationContext } from '../base.js';\nimport { incidentioClaimKey } from './claim.js';\nimport type { IncidentioEventRules } from './default-rules.js';\n\nconst RULE_TIMEOUT_MS = 5_000;\n\nasync function withRuleTimeout<T>(promise: Promise<T>): Promise<T> {\n let timeout: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timeout = setTimeout(() => reject(new Error('FACTORY_RULE_TIMEOUT')), RULE_TIMEOUT_MS);\n }),\n ]);\n } finally {\n if (timeout) clearTimeout(timeout);\n }\n}\n\nconst MAX_STALE_RULE_ATTEMPTS = 3;\n\nexport interface IncidentioIssueIngress {\n /** Stable item reference — the prefixed incident.io follow-up id (`incidentio:follow-up:<ulid>`). */\n id: string;\n identifier: string;\n title: string;\n url: string;\n state: string;\n stateType: string;\n priorityLabel: string;\n assignee: string | null;\n /** Display name of the follow-up creator, when available. */\n author: string | null;\n /** Reference of the incident this follow-up belongs to, when available. */\n incident: string | null;\n labels: string[];\n createdAt: string;\n updatedAt: string;\n /** incident.io source the follow-up was read from; resolves the bound board via `intakeBoards`. */\n sourceId?: string | null;\n}\n\nexport interface IncidentioRulesOptions {\n projects: Pick<FactoryProjectsStorage, 'get'>;\n storage: WorkItemsStorage;\n configVersion: string;\n boards: BoardRegistry;\n incidentioRules: IncidentioEventRules;\n}\n\nexport interface IncidentioRulesIngress {\n orgId: string;\n userId: string;\n factoryProjectId: string;\n issues: IncidentioIssueIngress[];\n /** Board id per bound incident.io source id, from the project's intake bindings. */\n intakeBoards?: Readonly<Record<string, string>>;\n}\n\ntype IngressStatus = 'committed' | 'replayed' | 'missing';\n\nexport class IncidentioRules {\n constructor(private readonly options: IncidentioRulesOptions) {}\n\n async ingest(input: IncidentioRulesIngress): Promise<{ status: IngressStatus; ingested: number }> {\n const project = await this.options.projects.get({ orgId: input.orgId, id: input.factoryProjectId });\n if (!project) return { status: 'missing', ingested: 0 };\n\n const items = await this.options.storage.list({ orgId: input.orgId, factoryProjectId: input.factoryProjectId });\n const itemsBySourceKey = new Map(items.map(item => [item.externalSource?.externalId, item]));\n const itemsByClaimKey = new Map(items.filter(item => item.claimKey).map(item => [item.claimKey, item]));\n const statuses: IngressStatus[] = [];\n for (const issue of input.issues) {\n // The claim finds the card even after a metadata drift; the raw item\n // reference covers cards filed before claims existed.\n const relatedItem = itemsByClaimKey.get(incidentioClaimKey(issue.id)) ?? itemsBySourceKey.get(issue.id);\n // One live card per follow-up per org. The store's claim index is the\n // guarantee; this check spares the dispatcher a refused upsert.\n if (!relatedItem && (await this.#heldElsewhere(input, issue))) {\n statuses.push('missing');\n continue;\n }\n statuses.push(await this.#ingestIssue(input, issue, relatedItem));\n }\n if (statuses.some(status => status === 'committed')) return { status: 'committed', ingested: statuses.length };\n if (statuses.some(status => status === 'replayed')) return { status: 'replayed', ingested: statuses.length };\n return { status: 'missing', ingested: statuses.length };\n }\n\n async #heldElsewhere(input: IncidentioRulesIngress, issue: IncidentioIssueIngress): Promise<boolean> {\n const heldLive = (row: WorkItemRow) =>\n row.factoryProjectId !== input.factoryProjectId && !isTerminalWorkItem(this.options.boards, row);\n const claimant = await this.options.storage.getByClaimKey({\n orgId: input.orgId,\n claimKey: incidentioClaimKey(issue.id),\n });\n if (claimant && heldLive(claimant)) return true;\n // Cards filed before claims existed carry only the item reference.\n const legacy = await this.options.storage.listBySource({\n orgId: input.orgId,\n source: { integrationId: 'incidentio', type: 'issue', externalId: issue.id },\n });\n return legacy.some(heldLive);\n }\n\n async #ingestIssue(\n input: IncidentioRulesIngress,\n issue: IncidentioIssueIngress,\n relatedItem: WorkItemRow | undefined,\n attempt = 1,\n ): Promise<IngressStatus> {\n const ingressId = `incidentio:${issue.id}:${issue.updatedAt}`;\n const actor = { type: 'human' as const, id: input.userId };\n\n // Closed follow-ups without existing work items should not create new ones.\n const isClosed = issue.stateType === 'completed' || issue.stateType === 'canceled';\n if (isClosed && !relatedItem) {\n return 'missing';\n }\n\n const event = isClosed ? 'followUpClosed' : 'followUpObserved';\n const boundBoardId = issue.sourceId ? input.intakeBoards?.[issue.sourceId] : undefined;\n const boundBoard = boundBoardId ? this.options.boards.get(boundBoardId) : undefined;\n const context: FactoryIncidentioRuleContext = {\n tenant: { orgId: input.orgId, projectId: input.factoryProjectId },\n actor,\n ingress: { type: 'incidentio', id: ingressId },\n cause: `incidentio.${event}`,\n causalChain: [],\n configVersion: this.options.configVersion,\n ...(relatedItem\n ? {\n item: {\n id: relatedItem.id,\n source: 'incidentio-follow-up',\n sourceKey: relatedItem.externalSource?.externalId ?? null,\n parentWorkItemId: relatedItem.parentWorkItemId,\n title: relatedItem.title,\n url: relatedItem.externalSource?.url ?? null,\n stages: relatedItem.stages,\n acceptedAt: relatedItem.acceptedAt,\n metadata: relatedItem.metadata,\n },\n board: boardForWorkItem(relatedItem),\n itemRevision: relatedItem.revision,\n ...withBoardClosure(this.options.boards, relatedItem),\n ...rulePhaseField(this.options.boards, relatedItem),\n }\n : {}),\n ...(boundBoard ? { intake: { board: boundBoard.id, initialPhase: boundBoard.initialPhase } } : {}),\n event,\n issue,\n };\n\n const rule = selectEventRule(\n this.options.incidentioRules,\n boardEventHandlers(this.options.boards, eventBoardId(context), 'incidentio'),\n context.event,\n );\n let decision: FactoryRuleDecision | void;\n let decisions: Record<string, unknown>[] = [];\n let outcome: { status: 'accepted' | 'rejected'; code?: string; reason?: string } = { status: 'accepted' };\n try {\n decision = rule ? await withRuleTimeout(Promise.resolve(rule(Object.freeze(context)))) : undefined;\n if (decision?.type === 'reject') {\n outcome = { status: 'rejected', code: decision.code, reason: decision.reason };\n } else if (decision) {\n decisions = validateFactoryRuleDecisions([decision]).map(entry => {\n assertFactoryDecisionTarget(\n entry,\n this.options.boards,\n relatedItem ? boardForWorkItem(relatedItem) : undefined,\n );\n return { ...entry };\n });\n }\n } catch (error) {\n const timedOut = error instanceof Error && error.message === 'FACTORY_RULE_TIMEOUT';\n outcome = {\n status: 'rejected',\n code: timedOut ? 'timeout' : 'rule_error',\n reason: timedOut\n ? 'Factory rule evaluation timed out.'\n : error instanceof Error\n ? error.message.slice(0, 2_000)\n : 'Factory incident.io rule failed.',\n };\n }\n\n const committed = await this.options.storage.commitRuleEvaluation({\n orgId: input.orgId,\n factoryProjectId: input.factoryProjectId,\n workItemId: relatedItem?.id ?? null,\n ingress: { identity: ingressId, triggerType: `incidentio.${event}` },\n configVersion: this.options.configVersion,\n expectedRevision: relatedItem?.revision ?? null,\n actor,\n outcome,\n decisions,\n causalChain: [],\n now: new Date(),\n });\n if (committed.status === 'stale') {\n // Nothing was persisted: re-read the item and evaluate again at its fresh revision.\n if (attempt >= MAX_STALE_RULE_ATTEMPTS) {\n throw new Error(`Factory incident.io rule evaluation for ${ingressId} kept losing revision races.`);\n }\n const fresh = relatedItem ? await this.options.storage.get({ orgId: input.orgId, id: relatedItem.id }) : null;\n if (relatedItem && !fresh) return 'missing';\n return this.#ingestIssue(input, issue, fresh ?? undefined, attempt + 1);\n }\n return committed.status;\n }\n}\n\nexport function attachIncidentioRules(\n incidentio: { readonly rules: IncidentioEventRules },\n context: IntegrationContext,\n): ((input: IncidentioRulesIngress) => Promise<unknown>) | undefined {\n if (!context.runtime) return undefined;\n const rules = new IncidentioRules({\n projects: context.storage.projects,\n storage: context.runtime.workItems,\n configVersion: context.runtime.configVersion,\n boards: context.runtime.boards,\n incidentioRules: incidentio.rules,\n });\n return input => rules.ingest(input);\n}\n"],"mappings":"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"}
@@ -32,10 +32,10 @@ declare function jiraIssueObserved(context: FactoryJiraRuleContext): {
32
32
  declare function jiraIssueClosed(context: FactoryJiraRuleContext): {
33
33
  readonly type: "transition";
34
34
  readonly idempotencyKey: `${string}:issue-closed`;
35
- readonly board: "work";
36
- readonly stage: "done" | "canceled";
35
+ readonly board: import("../../rules/types.js").FactoryRuleBoard;
36
+ readonly stage: string;
37
37
  readonly message: {
38
- readonly text: `Jira issue ${string} was canceled; this Work card was moved to Done.` | `Jira issue ${string} was canceled; this Work card was moved to Canceled.` | `Jira issue ${string} was completed; this Work card was moved to Done.` | `Jira issue ${string} was completed; this Work card was moved to Canceled.`;
38
+ readonly text: `Jira issue ${string} was canceled; this ${string} card was moved to ${string}.` | `Jira issue ${string} was completed; this ${string} card was moved to ${string}.`;
39
39
  };
40
40
  } | undefined;
41
41
  export declare const defaultJiraRules: Readonly<{
@@ -1 +1 @@
1
- {"version":3,"file":"default-rules.d.ts","sourceRoot":"","sources":["../../../src/integrations/jira/default-rules.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAG7G,MAAM,MAAM,iBAAiB,GAAG,OAAO,CACrC,MAAM,CAAC,oBAAoB,EAAE,kBAAkB,CAAC,sBAAsB,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,CAC5F,CAAC;AACF,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC,MAAM,CAAC,oBAAoB,EAAE,kBAAkB,CAAC,sBAAsB,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;AAEvH,iBAAS,iBAAiB,CAAC,OAAO,EAAE,sBAAsB;;;;;;;;;;;;;;;;;;;;4BAwBmB,MAAM,EAAE;;;yBAG1C,MAAM,EAAE;;;;cAKlD;AAED,iBAAS,eAAe,CAAC,OAAO,EAAE,sBAAsB;;;;;;;;cAkBvD;AAED,eAAO,MAAM,gBAAgB;;;EAGF,CAAC;AAE5B,wBAAgB,gBAAgB,CAAC,SAAS,CAAC,EAAE,iBAAiB,GAAG,cAAc,CAsB9E"}
1
+ {"version":3,"file":"default-rules.d.ts","sourceRoot":"","sources":["../../../src/integrations/jira/default-rules.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAG7G,MAAM,MAAM,iBAAiB,GAAG,OAAO,CACrC,MAAM,CAAC,oBAAoB,EAAE,kBAAkB,CAAC,sBAAsB,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,CAC5F,CAAC;AACF,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC,MAAM,CAAC,oBAAoB,EAAE,kBAAkB,CAAC,sBAAsB,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;AAEvH,iBAAS,iBAAiB,CAAC,OAAO,EAAE,sBAAsB;;;;;;;;;;;;;;;;;;;;4BAwBmB,MAAM,EAAE;;;yBAG1C,MAAM,EAAE;;;;cAKlD;AAED,iBAAS,eAAe,CAAC,OAAO,EAAE,sBAAsB;;;;;;;;cAoBvD;AAED,eAAO,MAAM,gBAAgB;;;EAGF,CAAC;AAE5B,wBAAgB,gBAAgB,CAAC,SAAS,CAAC,EAAE,iBAAiB,GAAG,cAAc,CAsB9E"}
@@ -1,3 +1,4 @@
1
+ import { recordExplicitRuleOverrides } from "../../rules/event-rules.js";
1
2
  import { jiraClaimKey } from "./claim.js";
2
3
  //#region src/integrations/jira/default-rules.ts
3
4
  function jiraIssueObserved(context) {
@@ -33,17 +34,18 @@ function jiraIssueObserved(context) {
33
34
  }
34
35
  function jiraIssueClosed(context) {
35
36
  if (!context.item || context.item.source !== "jira-issue") return;
36
- if (context.board !== "work") return;
37
- if (context.item.stages.some((stage) => stage === "done" || stage === "canceled")) return;
37
+ const closure = context.boardClosure;
38
+ if (!context.board || !closure || closure.itemTerminal) return;
38
39
  const stateType = context.issue.stateType;
39
40
  if (stateType !== "completed" && stateType !== "canceled") return;
40
41
  const canceled = stateType === "canceled";
42
+ const target = canceled ? closure.canceled : closure.completed;
41
43
  return {
42
44
  type: "transition",
43
45
  idempotencyKey: `${context.ingress.id}:issue-closed`,
44
- board: "work",
45
- stage: canceled ? "canceled" : "done",
46
- message: { text: `Jira issue ${context.issue.identifier} was ${canceled ? "canceled" : "completed"}; this Work card was moved to ${canceled ? "Canceled" : "Done"}.` }
46
+ board: context.board,
47
+ stage: target.phase,
48
+ message: { text: `Jira issue ${context.issue.identifier} was ${canceled ? "canceled" : "completed"}; this ${closure.boardTitle} card was moved to ${target.title}.` }
47
49
  };
48
50
  }
49
51
  const defaultJiraRules = Object.freeze({
@@ -59,7 +61,7 @@ function resolveJiraRules(overrides) {
59
61
  if (handler !== void 0 && handler !== null && typeof handler !== "function") throw new Error(`Jira rule ${key} must be a function, null, or undefined.`);
60
62
  if (handler !== void 0) rules[key] = handler;
61
63
  }
62
- return Object.freeze(rules);
64
+ return recordExplicitRuleOverrides(Object.freeze(rules), overrides);
63
65
  }
64
66
  //#endregion
65
67
  export { defaultJiraRules, resolveJiraRules };
@@ -1 +1 @@
1
- {"version":3,"file":"default-rules.js","names":[],"sources":["../../../src/integrations/jira/default-rules.ts"],"sourcesContent":["import type { FactoryJiraEventName, FactoryJiraRuleContext, FactoryRuleHandler } from '../../rules/types.js';\nimport { jiraClaimKey } from './claim.js';\n\nexport type JiraRuleOverrides = Partial<\n Record<FactoryJiraEventName, FactoryRuleHandler<FactoryJiraRuleContext> | null | undefined>\n>;\nexport type JiraEventRules = Readonly<Record<FactoryJiraEventName, FactoryRuleHandler<FactoryJiraRuleContext> | null>>;\n\nfunction jiraIssueObserved(context: FactoryJiraRuleContext) {\n if (context.item) return;\n return {\n type: 'upsertLinkedWorkItem',\n idempotencyKey: `${context.ingress.id}:issue-triage`,\n // A source bound to a custom board lands on that board's initial phase;\n // otherwise the issue waits in Work Intake.\n board: context.intake?.board ?? 'work',\n source: 'jira-issue',\n sourceKey: context.issue.id,\n claimKey: jiraClaimKey(context.issue.id),\n title: context.issue.title,\n url: context.issue.url,\n stage: context.intake?.initialPhase ?? 'intake',\n metadata: {\n identifier: context.issue.identifier,\n issueRef: context.issue.id,\n sourceCreatedAt: context.issue.createdAt,\n state: context.issue.state,\n stateType: context.issue.stateType,\n priority: context.issue.priorityLabel,\n project: context.issue.project,\n site: context.issue.site,\n assignee: context.issue.assignee,\n assignees: (context.issue.assignee ? [context.issue.assignee] : []) as string[],\n creator: context.issue.author,\n author: context.issue.author,\n labels: [...context.issue.labels] as string[],\n createdAt: context.issue.createdAt,\n updatedAt: context.issue.updatedAt,\n },\n } as const;\n}\n\nfunction jiraIssueClosed(context: FactoryJiraRuleContext) {\n if (!context.item || context.item.source !== 'jira-issue') return;\n if (context.board !== 'work') return;\n // Already off the board: nothing to reconcile.\n if (context.item.stages.some(stage => stage === 'done' || stage === 'canceled')) return;\n // Only terminal state types trigger close.\n const stateType = context.issue.stateType;\n if (stateType !== 'completed' && stateType !== 'canceled') return;\n const canceled = stateType === 'canceled';\n return {\n type: 'transition',\n idempotencyKey: `${context.ingress.id}:issue-closed`,\n board: 'work',\n stage: canceled ? 'canceled' : 'done',\n message: {\n text: `Jira issue ${context.issue.identifier} was ${canceled ? 'canceled' : 'completed'}; this Work card was moved to ${canceled ? 'Canceled' : 'Done'}.`,\n },\n } as const;\n}\n\nexport const defaultJiraRules = Object.freeze({\n issueObserved: jiraIssueObserved,\n issueClosed: jiraIssueClosed,\n} satisfies JiraEventRules);\n\nexport function resolveJiraRules(overrides?: JiraRuleOverrides): JiraEventRules {\n if (\n overrides !== undefined &&\n (overrides === null ||\n typeof overrides !== 'object' ||\n Array.isArray(overrides) ||\n ![Object.prototype, null].includes(Object.getPrototypeOf(overrides)))\n ) {\n throw new Error('Jira rules must be a plain object.');\n }\n const rules: Record<string, FactoryRuleHandler<FactoryJiraRuleContext> | null> = { ...defaultJiraRules };\n for (const key of Reflect.ownKeys(overrides ?? {})) {\n if (typeof key !== 'string' || !Object.hasOwn(defaultJiraRules, key)) {\n throw new Error(`Unknown Jira rule event: ${String(key)}.`);\n }\n const handler = overrides?.[key as FactoryJiraEventName];\n if (handler !== undefined && handler !== null && typeof handler !== 'function') {\n throw new Error(`Jira rule ${key} must be a function, null, or undefined.`);\n }\n if (handler !== undefined) rules[key] = handler;\n }\n return Object.freeze(rules) as JiraEventRules;\n}\n"],"mappings":";;AAQA,SAAS,kBAAkB,SAAiC;CAC1D,IAAI,QAAQ,MAAM;CAClB,OAAO;EACL,MAAM;EACN,gBAAgB,GAAG,QAAQ,QAAQ,GAAG;EAGtC,OAAO,QAAQ,QAAQ,SAAS;EAChC,QAAQ;EACR,WAAW,QAAQ,MAAM;EACzB,UAAU,aAAa,QAAQ,MAAM,EAAE;EACvC,OAAO,QAAQ,MAAM;EACrB,KAAK,QAAQ,MAAM;EACnB,OAAO,QAAQ,QAAQ,gBAAgB;EACvC,UAAU;GACR,YAAY,QAAQ,MAAM;GAC1B,UAAU,QAAQ,MAAM;GACxB,iBAAiB,QAAQ,MAAM;GAC/B,OAAO,QAAQ,MAAM;GACrB,WAAW,QAAQ,MAAM;GACzB,UAAU,QAAQ,MAAM;GACxB,SAAS,QAAQ,MAAM;GACvB,MAAM,QAAQ,MAAM;GACpB,UAAU,QAAQ,MAAM;GACxB,WAAY,QAAQ,MAAM,WAAW,CAAC,QAAQ,MAAM,QAAQ,IAAI,CAAC;GACjE,SAAS,QAAQ,MAAM;GACvB,QAAQ,QAAQ,MAAM;GACtB,QAAQ,CAAC,GAAG,QAAQ,MAAM,MAAM;GAChC,WAAW,QAAQ,MAAM;GACzB,WAAW,QAAQ,MAAM;EAC3B;CACF;AACF;AAEA,SAAS,gBAAgB,SAAiC;CACxD,IAAI,CAAC,QAAQ,QAAQ,QAAQ,KAAK,WAAW,cAAc;CAC3D,IAAI,QAAQ,UAAU,QAAQ;CAE9B,IAAI,QAAQ,KAAK,OAAO,MAAK,UAAS,UAAU,UAAU,UAAU,UAAU,GAAG;CAEjF,MAAM,YAAY,QAAQ,MAAM;CAChC,IAAI,cAAc,eAAe,cAAc,YAAY;CAC3D,MAAM,WAAW,cAAc;CAC/B,OAAO;EACL,MAAM;EACN,gBAAgB,GAAG,QAAQ,QAAQ,GAAG;EACtC,OAAO;EACP,OAAO,WAAW,aAAa;EAC/B,SAAS,EACP,MAAM,cAAc,QAAQ,MAAM,WAAW,OAAO,WAAW,aAAa,YAAY,gCAAgC,WAAW,aAAa,OAAO,GACzJ;CACF;AACF;AAEA,MAAa,mBAAmB,OAAO,OAAO;CAC5C,eAAe;CACf,aAAa;AACf,CAA0B;AAE1B,SAAgB,iBAAiB,WAA+C;CAC9E,IACE,cAAc,KAAA,MACb,cAAc,QACb,OAAO,cAAc,YACrB,MAAM,QAAQ,SAAS,KACvB,CAAC,CAAC,OAAO,WAAW,IAAI,CAAC,CAAC,SAAS,OAAO,eAAe,SAAS,CAAC,IAErE,MAAM,IAAI,MAAM,oCAAoC;CAEtD,MAAM,QAA2E,EAAE,GAAG,iBAAiB;CACvG,KAAK,MAAM,OAAO,QAAQ,QAAQ,aAAa,CAAC,CAAC,GAAG;EAClD,IAAI,OAAO,QAAQ,YAAY,CAAC,OAAO,OAAO,kBAAkB,GAAG,GACjE,MAAM,IAAI,MAAM,4BAA4B,OAAO,GAAG,EAAE,EAAE;EAE5D,MAAM,UAAU,YAAY;EAC5B,IAAI,YAAY,KAAA,KAAa,YAAY,QAAQ,OAAO,YAAY,YAClE,MAAM,IAAI,MAAM,aAAa,IAAI,yCAAyC;EAE5E,IAAI,YAAY,KAAA,GAAW,MAAM,OAAO;CAC1C;CACA,OAAO,OAAO,OAAO,KAAK;AAC5B"}
1
+ {"version":3,"file":"default-rules.js","names":[],"sources":["../../../src/integrations/jira/default-rules.ts"],"sourcesContent":["import { recordExplicitRuleOverrides } from '../../rules/event-rules.js';\nimport type { FactoryJiraEventName, FactoryJiraRuleContext, FactoryRuleHandler } from '../../rules/types.js';\nimport { jiraClaimKey } from './claim.js';\n\nexport type JiraRuleOverrides = Partial<\n Record<FactoryJiraEventName, FactoryRuleHandler<FactoryJiraRuleContext> | null | undefined>\n>;\nexport type JiraEventRules = Readonly<Record<FactoryJiraEventName, FactoryRuleHandler<FactoryJiraRuleContext> | null>>;\n\nfunction jiraIssueObserved(context: FactoryJiraRuleContext) {\n if (context.item) return;\n return {\n type: 'upsertLinkedWorkItem',\n idempotencyKey: `${context.ingress.id}:issue-triage`,\n // A source bound to a custom board lands on that board's initial phase;\n // otherwise the issue waits in Work Intake.\n board: context.intake?.board ?? 'work',\n source: 'jira-issue',\n sourceKey: context.issue.id,\n claimKey: jiraClaimKey(context.issue.id),\n title: context.issue.title,\n url: context.issue.url,\n stage: context.intake?.initialPhase ?? 'intake',\n metadata: {\n identifier: context.issue.identifier,\n issueRef: context.issue.id,\n sourceCreatedAt: context.issue.createdAt,\n state: context.issue.state,\n stateType: context.issue.stateType,\n priority: context.issue.priorityLabel,\n project: context.issue.project,\n site: context.issue.site,\n assignee: context.issue.assignee,\n assignees: (context.issue.assignee ? [context.issue.assignee] : []) as string[],\n creator: context.issue.author,\n author: context.issue.author,\n labels: [...context.issue.labels] as string[],\n createdAt: context.issue.createdAt,\n updatedAt: context.issue.updatedAt,\n },\n } as const;\n}\n\nfunction jiraIssueClosed(context: FactoryJiraRuleContext) {\n if (!context.item || context.item.source !== 'jira-issue') return;\n // The card's board decides where a closed source sends it; boards that declare no\n // `sourceClosed` mapping keep the card where it is, as do cards already finished.\n const closure = context.boardClosure;\n if (!context.board || !closure || closure.itemTerminal) return;\n // Only terminal state types trigger close.\n const stateType = context.issue.stateType;\n if (stateType !== 'completed' && stateType !== 'canceled') return;\n const canceled = stateType === 'canceled';\n const target = canceled ? closure.canceled : closure.completed;\n return {\n type: 'transition',\n idempotencyKey: `${context.ingress.id}:issue-closed`,\n board: context.board,\n stage: target.phase,\n message: {\n text: `Jira issue ${context.issue.identifier} was ${canceled ? 'canceled' : 'completed'}; this ${closure.boardTitle} card was moved to ${target.title}.`,\n },\n } as const;\n}\n\nexport const defaultJiraRules = Object.freeze({\n issueObserved: jiraIssueObserved,\n issueClosed: jiraIssueClosed,\n} satisfies JiraEventRules);\n\nexport function resolveJiraRules(overrides?: JiraRuleOverrides): JiraEventRules {\n if (\n overrides !== undefined &&\n (overrides === null ||\n typeof overrides !== 'object' ||\n Array.isArray(overrides) ||\n ![Object.prototype, null].includes(Object.getPrototypeOf(overrides)))\n ) {\n throw new Error('Jira rules must be a plain object.');\n }\n const rules: Record<string, FactoryRuleHandler<FactoryJiraRuleContext> | null> = { ...defaultJiraRules };\n for (const key of Reflect.ownKeys(overrides ?? {})) {\n if (typeof key !== 'string' || !Object.hasOwn(defaultJiraRules, key)) {\n throw new Error(`Unknown Jira rule event: ${String(key)}.`);\n }\n const handler = overrides?.[key as FactoryJiraEventName];\n if (handler !== undefined && handler !== null && typeof handler !== 'function') {\n throw new Error(`Jira rule ${key} must be a function, null, or undefined.`);\n }\n if (handler !== undefined) rules[key] = handler;\n }\n return recordExplicitRuleOverrides(Object.freeze(rules) as JiraEventRules, overrides);\n}\n"],"mappings":";;;AASA,SAAS,kBAAkB,SAAiC;CAC1D,IAAI,QAAQ,MAAM;CAClB,OAAO;EACL,MAAM;EACN,gBAAgB,GAAG,QAAQ,QAAQ,GAAG;EAGtC,OAAO,QAAQ,QAAQ,SAAS;EAChC,QAAQ;EACR,WAAW,QAAQ,MAAM;EACzB,UAAU,aAAa,QAAQ,MAAM,EAAE;EACvC,OAAO,QAAQ,MAAM;EACrB,KAAK,QAAQ,MAAM;EACnB,OAAO,QAAQ,QAAQ,gBAAgB;EACvC,UAAU;GACR,YAAY,QAAQ,MAAM;GAC1B,UAAU,QAAQ,MAAM;GACxB,iBAAiB,QAAQ,MAAM;GAC/B,OAAO,QAAQ,MAAM;GACrB,WAAW,QAAQ,MAAM;GACzB,UAAU,QAAQ,MAAM;GACxB,SAAS,QAAQ,MAAM;GACvB,MAAM,QAAQ,MAAM;GACpB,UAAU,QAAQ,MAAM;GACxB,WAAY,QAAQ,MAAM,WAAW,CAAC,QAAQ,MAAM,QAAQ,IAAI,CAAC;GACjE,SAAS,QAAQ,MAAM;GACvB,QAAQ,QAAQ,MAAM;GACtB,QAAQ,CAAC,GAAG,QAAQ,MAAM,MAAM;GAChC,WAAW,QAAQ,MAAM;GACzB,WAAW,QAAQ,MAAM;EAC3B;CACF;AACF;AAEA,SAAS,gBAAgB,SAAiC;CACxD,IAAI,CAAC,QAAQ,QAAQ,QAAQ,KAAK,WAAW,cAAc;CAG3D,MAAM,UAAU,QAAQ;CACxB,IAAI,CAAC,QAAQ,SAAS,CAAC,WAAW,QAAQ,cAAc;CAExD,MAAM,YAAY,QAAQ,MAAM;CAChC,IAAI,cAAc,eAAe,cAAc,YAAY;CAC3D,MAAM,WAAW,cAAc;CAC/B,MAAM,SAAS,WAAW,QAAQ,WAAW,QAAQ;CACrD,OAAO;EACL,MAAM;EACN,gBAAgB,GAAG,QAAQ,QAAQ,GAAG;EACtC,OAAO,QAAQ;EACf,OAAO,OAAO;EACd,SAAS,EACP,MAAM,cAAc,QAAQ,MAAM,WAAW,OAAO,WAAW,aAAa,YAAY,SAAS,QAAQ,WAAW,qBAAqB,OAAO,MAAM,GACxJ;CACF;AACF;AAEA,MAAa,mBAAmB,OAAO,OAAO;CAC5C,eAAe;CACf,aAAa;AACf,CAA0B;AAE1B,SAAgB,iBAAiB,WAA+C;CAC9E,IACE,cAAc,KAAA,MACb,cAAc,QACb,OAAO,cAAc,YACrB,MAAM,QAAQ,SAAS,KACvB,CAAC,CAAC,OAAO,WAAW,IAAI,CAAC,CAAC,SAAS,OAAO,eAAe,SAAS,CAAC,IAErE,MAAM,IAAI,MAAM,oCAAoC;CAEtD,MAAM,QAA2E,EAAE,GAAG,iBAAiB;CACvG,KAAK,MAAM,OAAO,QAAQ,QAAQ,aAAa,CAAC,CAAC,GAAG;EAClD,IAAI,OAAO,QAAQ,YAAY,CAAC,OAAO,OAAO,kBAAkB,GAAG,GACjE,MAAM,IAAI,MAAM,4BAA4B,OAAO,GAAG,EAAE,EAAE;EAE5D,MAAM,UAAU,YAAY;EAC5B,IAAI,YAAY,KAAA,KAAa,YAAY,QAAQ,OAAO,YAAY,YAClE,MAAM,IAAI,MAAM,aAAa,IAAI,yCAAyC;EAE5E,IAAI,YAAY,KAAA,GAAW,MAAM,OAAO;CAC1C;CACA,OAAO,4BAA4B,OAAO,OAAO,KAAK,GAAqB,SAAS;AACtF"}
@@ -1 +1 @@
1
- {"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../../src/integrations/jira/rules.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAG3D,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,wCAAwC,CAAC;AACrF,OAAO,KAAK,EAAe,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAC9F,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAErD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAoBzD,MAAM,WAAW,gBAAgB;IAC/B,uFAAuF;IACvF,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,yDAAyD;IACzD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,6DAA6D;IAC7D,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,8DAA8D;IAC9D,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,wFAAwF;IACxF,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,IAAI,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAAC;IAC9C,OAAO,EAAE,gBAAgB,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,aAAa,CAAC;IACtB,SAAS,EAAE,cAAc,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,6EAA6E;IAC7E,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACjD;AAED,KAAK,aAAa,GAAG,WAAW,GAAG,UAAU,GAAG,SAAS,CAAC;AAE1D,qBAAa,SAAS;;IACR,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,gBAAgB;IAEhD,MAAM,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,aAAa,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CA+I5F;AAED,wBAAgB,eAAe,CAC7B,IAAI,EAAE;IAAE,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAA;CAAE,EACxC,OAAO,EAAE,kBAAkB,GAC1B,CAAC,CAAC,KAAK,EAAE,gBAAgB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,SAAS,CAU7D"}
1
+ {"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../../src/integrations/jira/rules.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAI3D,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,wCAAwC,CAAC;AACrF,OAAO,KAAK,EAAe,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAC9F,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAErD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAoBzD,MAAM,WAAW,gBAAgB;IAC/B,uFAAuF;IACvF,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,yDAAyD;IACzD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,6DAA6D;IAC7D,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,8DAA8D;IAC9D,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,wFAAwF;IACxF,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,IAAI,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAAC;IAC9C,OAAO,EAAE,gBAAgB,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,aAAa,CAAC;IACtB,SAAS,EAAE,cAAc,CAAC;CAC3B;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,6EAA6E;IAC7E,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACjD;AAED,KAAK,aAAa,GAAG,WAAW,GAAG,UAAU,GAAG,SAAS,CAAC;AAE1D,qBAAa,SAAS;;IACR,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,gBAAgB;IAEhD,MAAM,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,aAAa,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CAqJ5F;AAED,wBAAgB,eAAe,CAC7B,IAAI,EAAE;IAAE,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAA;CAAE,EACxC,OAAO,EAAE,kBAAkB,GAC1B,CAAC,CAAC,KAAK,EAAE,gBAAgB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,SAAS,CAU7D"}
@@ -1,6 +1,7 @@
1
1
  import { assertFactoryDecisionTarget, validateFactoryRuleDecisions } from "../../rules/validation.js";
2
- import { boardForWorkItem, isTerminalWorkItem } from "../../boards/semantics.js";
2
+ import { boardEventHandlers, boardForWorkItem, eventBoardId, isTerminalWorkItem, rulePhaseField, withBoardClosure } from "../../boards/semantics.js";
3
3
  import "../../boards/index.js";
4
+ import { selectEventRule } from "../../rules/event-rules.js";
4
5
  import { jiraClaimKey } from "./claim.js";
5
6
  //#region src/integrations/jira/rules.ts
6
7
  const RULE_TIMEOUT_MS = 5e3;
@@ -109,7 +110,9 @@ var JiraRules = class {
109
110
  metadata: relatedItem.metadata
110
111
  },
111
112
  board: boardForWorkItem(relatedItem),
112
- itemRevision: relatedItem.revision
113
+ itemRevision: relatedItem.revision,
114
+ ...withBoardClosure(this.options.boards, relatedItem),
115
+ ...rulePhaseField(this.options.boards, relatedItem)
113
116
  } : {},
114
117
  ...boundBoard ? { intake: {
115
118
  board: boundBoard.id,
@@ -118,7 +121,7 @@ var JiraRules = class {
118
121
  event,
119
122
  issue
120
123
  };
121
- const rule = this.options.jiraRules[context.event];
124
+ const rule = selectEventRule(this.options.jiraRules, boardEventHandlers(this.options.boards, eventBoardId(context), "jira"), context.event);
122
125
  let decision;
123
126
  let decisions = [];
124
127
  let outcome = { status: "accepted" };
@@ -1 +1 @@
1
- {"version":3,"file":"rules.js","names":["#heldElsewhere","#ingestIssue"],"sources":["../../../src/integrations/jira/rules.ts"],"sourcesContent":["import { boardForWorkItem, isTerminalWorkItem } from '../../boards/index.js';\nimport type { BoardRegistry } from '../../boards/index.js';\nimport type { FactoryJiraRuleContext, FactoryRuleDecision } from '../../rules/types.js';\nimport { assertFactoryDecisionTarget, validateFactoryRuleDecisions } from '../../rules/validation.js';\nimport type { FactoryProjectsStorage } from '../../storage/domains/projects/base.js';\nimport type { WorkItemRow, WorkItemsStorage } from '../../storage/domains/work-items/base.js';\nimport type { IntegrationContext } from '../base.js';\nimport { jiraClaimKey } from './claim.js';\nimport type { JiraEventRules } from './default-rules.js';\n\nconst RULE_TIMEOUT_MS = 5_000;\n\nasync function withRuleTimeout<T>(promise: Promise<T>): Promise<T> {\n let timeout: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timeout = setTimeout(() => reject(new Error('FACTORY_RULE_TIMEOUT')), RULE_TIMEOUT_MS);\n }),\n ]);\n } finally {\n if (timeout) clearTimeout(timeout);\n }\n}\n\nconst MAX_STALE_RULE_ATTEMPTS = 3;\n\nexport interface JiraIssueIngress {\n /** Stable issue reference — direct Jira id or the Platform-encoded issue reference. */\n id: string;\n identifier: string;\n title: string;\n url: string;\n state: string;\n stateType: string;\n priorityLabel: string;\n assignee: string | null;\n /** Display name of the Jira reporter, when available. */\n author: string | null;\n /** Jira project key the issue belongs to, when available. */\n project: string | null;\n /** Jira site host the issue was read from, when available. */\n site: string | null;\n labels: string[];\n createdAt: string;\n updatedAt: string;\n /** Jira source the issue was read from; resolves the bound board via `intakeBoards`. */\n sourceId?: string | null;\n}\n\nexport interface JiraRulesOptions {\n projects: Pick<FactoryProjectsStorage, 'get'>;\n storage: WorkItemsStorage;\n configVersion: string;\n boards: BoardRegistry;\n jiraRules: JiraEventRules;\n}\n\nexport interface JiraRulesIngress {\n orgId: string;\n userId: string;\n factoryProjectId: string;\n issues: JiraIssueIngress[];\n /** Board id per bound Jira source id, from the project's intake bindings. */\n intakeBoards?: Readonly<Record<string, string>>;\n}\n\ntype IngressStatus = 'committed' | 'replayed' | 'missing';\n\nexport class JiraRules {\n constructor(private readonly options: JiraRulesOptions) {}\n\n async ingest(input: JiraRulesIngress): Promise<{ status: IngressStatus; ingested: number }> {\n const project = await this.options.projects.get({ orgId: input.orgId, id: input.factoryProjectId });\n if (!project) return { status: 'missing', ingested: 0 };\n\n const items = await this.options.storage.list({ orgId: input.orgId, factoryProjectId: input.factoryProjectId });\n const itemsBySourceKey = new Map(items.map(item => [item.externalSource?.externalId, item]));\n const itemsByClaimKey = new Map(items.filter(item => item.claimKey).map(item => [item.claimKey, item]));\n const statuses: IngressStatus[] = [];\n for (const issue of input.issues) {\n // The claim finds the card even after a metadata drift; the raw issue\n // reference covers cards filed before claims existed.\n const relatedItem = itemsByClaimKey.get(jiraClaimKey(issue.id)) ?? itemsBySourceKey.get(issue.id);\n // One live card per Jira issue per org. The store's claim index is the\n // guarantee; this check spares the dispatcher a refused upsert.\n if (!relatedItem && (await this.#heldElsewhere(input, issue))) {\n statuses.push('missing');\n continue;\n }\n statuses.push(await this.#ingestIssue(input, issue, relatedItem));\n }\n if (statuses.some(status => status === 'committed')) return { status: 'committed', ingested: statuses.length };\n if (statuses.some(status => status === 'replayed')) return { status: 'replayed', ingested: statuses.length };\n return { status: 'missing', ingested: statuses.length };\n }\n\n async #heldElsewhere(input: JiraRulesIngress, issue: JiraIssueIngress): Promise<boolean> {\n const heldLive = (row: WorkItemRow) =>\n row.factoryProjectId !== input.factoryProjectId && !isTerminalWorkItem(this.options.boards, row);\n const claimant = await this.options.storage.getByClaimKey({\n orgId: input.orgId,\n claimKey: jiraClaimKey(issue.id),\n });\n if (claimant && heldLive(claimant)) return true;\n // Cards filed before claims existed carry only the issue reference.\n const legacy = await this.options.storage.listBySource({\n orgId: input.orgId,\n source: { integrationId: 'jira', type: 'issue', externalId: issue.id },\n });\n return legacy.some(heldLive);\n }\n\n async #ingestIssue(\n input: JiraRulesIngress,\n issue: JiraIssueIngress,\n relatedItem: WorkItemRow | undefined,\n attempt = 1,\n ): Promise<IngressStatus> {\n const ingressId = `jira:${issue.id}:${issue.updatedAt}`;\n const actor = { type: 'human' as const, id: input.userId };\n\n // Closed issues without existing work items should not create new ones.\n const isClosed = issue.stateType === 'completed' || issue.stateType === 'canceled';\n if (isClosed && !relatedItem) {\n return 'missing';\n }\n\n const event = isClosed ? 'issueClosed' : 'issueObserved';\n const boundBoardId = issue.sourceId ? input.intakeBoards?.[issue.sourceId] : undefined;\n const boundBoard = boundBoardId ? this.options.boards.get(boundBoardId) : undefined;\n const context: FactoryJiraRuleContext = {\n tenant: { orgId: input.orgId, projectId: input.factoryProjectId },\n actor,\n ingress: { type: 'jira', id: ingressId },\n cause: `jira.${event}`,\n causalChain: [],\n configVersion: this.options.configVersion,\n ...(relatedItem\n ? {\n item: {\n id: relatedItem.id,\n source: 'jira-issue',\n sourceKey: relatedItem.externalSource?.externalId ?? null,\n parentWorkItemId: relatedItem.parentWorkItemId,\n title: relatedItem.title,\n url: relatedItem.externalSource?.url ?? null,\n stages: relatedItem.stages,\n acceptedAt: relatedItem.acceptedAt,\n metadata: relatedItem.metadata,\n },\n board: boardForWorkItem(relatedItem),\n itemRevision: relatedItem.revision,\n }\n : {}),\n ...(boundBoard ? { intake: { board: boundBoard.id, initialPhase: boundBoard.initialPhase } } : {}),\n event,\n issue,\n };\n\n const rule = this.options.jiraRules[context.event];\n let decision: FactoryRuleDecision | void;\n let decisions: Record<string, unknown>[] = [];\n let outcome: { status: 'accepted' | 'rejected'; code?: string; reason?: string } = { status: 'accepted' };\n try {\n decision = rule ? await withRuleTimeout(Promise.resolve(rule(Object.freeze(context)))) : undefined;\n if (decision?.type === 'reject') {\n outcome = { status: 'rejected', code: decision.code, reason: decision.reason };\n } else if (decision) {\n decisions = validateFactoryRuleDecisions([decision]).map(entry => {\n assertFactoryDecisionTarget(\n entry,\n this.options.boards,\n relatedItem ? boardForWorkItem(relatedItem) : undefined,\n );\n return { ...entry };\n });\n }\n } catch (error) {\n const timedOut = error instanceof Error && error.message === 'FACTORY_RULE_TIMEOUT';\n outcome = {\n status: 'rejected',\n code: timedOut ? 'timeout' : 'rule_error',\n reason: timedOut\n ? 'Factory rule evaluation timed out.'\n : error instanceof Error\n ? error.message.slice(0, 2_000)\n : 'Factory Jira rule failed.',\n };\n }\n\n const committed = await this.options.storage.commitRuleEvaluation({\n orgId: input.orgId,\n factoryProjectId: input.factoryProjectId,\n workItemId: relatedItem?.id ?? null,\n ingress: { identity: ingressId, triggerType: 'jira.issueObserved' },\n configVersion: this.options.configVersion,\n expectedRevision: relatedItem?.revision ?? null,\n actor,\n outcome,\n decisions,\n causalChain: [],\n now: new Date(),\n });\n if (committed.status === 'stale') {\n // Nothing was persisted: re-read the item and evaluate again at its fresh revision.\n if (attempt >= MAX_STALE_RULE_ATTEMPTS) {\n throw new Error(`Factory Jira rule evaluation for ${ingressId} kept losing revision races.`);\n }\n const fresh = relatedItem ? await this.options.storage.get({ orgId: input.orgId, id: relatedItem.id }) : null;\n if (relatedItem && !fresh) return 'missing';\n return this.#ingestIssue(input, issue, fresh ?? undefined, attempt + 1);\n }\n return committed.status;\n }\n}\n\nexport function attachJiraRules(\n jira: { readonly rules: JiraEventRules },\n context: IntegrationContext,\n): ((input: JiraRulesIngress) => Promise<unknown>) | undefined {\n if (!context.runtime) return undefined;\n const rules = new JiraRules({\n projects: context.storage.projects,\n storage: context.runtime.workItems,\n configVersion: context.runtime.configVersion,\n boards: context.runtime.boards,\n jiraRules: jira.rules,\n });\n return input => rules.ingest(input);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"rules.js","names":["#heldElsewhere","#ingestIssue"],"sources":["../../../src/integrations/jira/rules.ts"],"sourcesContent":["import {\n boardEventHandlers,\n boardForWorkItem,\n eventBoardId,\n isTerminalWorkItem,\n rulePhaseField,\n withBoardClosure,\n} from '../../boards/index.js';\nimport type { BoardRegistry } from '../../boards/index.js';\nimport { selectEventRule } from '../../rules/event-rules.js';\nimport type { FactoryJiraRuleContext, FactoryRuleDecision } from '../../rules/types.js';\nimport { assertFactoryDecisionTarget, validateFactoryRuleDecisions } from '../../rules/validation.js';\nimport type { FactoryProjectsStorage } from '../../storage/domains/projects/base.js';\nimport type { WorkItemRow, WorkItemsStorage } from '../../storage/domains/work-items/base.js';\nimport type { IntegrationContext } from '../base.js';\nimport { jiraClaimKey } from './claim.js';\nimport type { JiraEventRules } from './default-rules.js';\n\nconst RULE_TIMEOUT_MS = 5_000;\n\nasync function withRuleTimeout<T>(promise: Promise<T>): Promise<T> {\n let timeout: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timeout = setTimeout(() => reject(new Error('FACTORY_RULE_TIMEOUT')), RULE_TIMEOUT_MS);\n }),\n ]);\n } finally {\n if (timeout) clearTimeout(timeout);\n }\n}\n\nconst MAX_STALE_RULE_ATTEMPTS = 3;\n\nexport interface JiraIssueIngress {\n /** Stable issue reference — direct Jira id or the Platform-encoded issue reference. */\n id: string;\n identifier: string;\n title: string;\n url: string;\n state: string;\n stateType: string;\n priorityLabel: string;\n assignee: string | null;\n /** Display name of the Jira reporter, when available. */\n author: string | null;\n /** Jira project key the issue belongs to, when available. */\n project: string | null;\n /** Jira site host the issue was read from, when available. */\n site: string | null;\n labels: string[];\n createdAt: string;\n updatedAt: string;\n /** Jira source the issue was read from; resolves the bound board via `intakeBoards`. */\n sourceId?: string | null;\n}\n\nexport interface JiraRulesOptions {\n projects: Pick<FactoryProjectsStorage, 'get'>;\n storage: WorkItemsStorage;\n configVersion: string;\n boards: BoardRegistry;\n jiraRules: JiraEventRules;\n}\n\nexport interface JiraRulesIngress {\n orgId: string;\n userId: string;\n factoryProjectId: string;\n issues: JiraIssueIngress[];\n /** Board id per bound Jira source id, from the project's intake bindings. */\n intakeBoards?: Readonly<Record<string, string>>;\n}\n\ntype IngressStatus = 'committed' | 'replayed' | 'missing';\n\nexport class JiraRules {\n constructor(private readonly options: JiraRulesOptions) {}\n\n async ingest(input: JiraRulesIngress): Promise<{ status: IngressStatus; ingested: number }> {\n const project = await this.options.projects.get({ orgId: input.orgId, id: input.factoryProjectId });\n if (!project) return { status: 'missing', ingested: 0 };\n\n const items = await this.options.storage.list({ orgId: input.orgId, factoryProjectId: input.factoryProjectId });\n const itemsBySourceKey = new Map(items.map(item => [item.externalSource?.externalId, item]));\n const itemsByClaimKey = new Map(items.filter(item => item.claimKey).map(item => [item.claimKey, item]));\n const statuses: IngressStatus[] = [];\n for (const issue of input.issues) {\n // The claim finds the card even after a metadata drift; the raw issue\n // reference covers cards filed before claims existed.\n const relatedItem = itemsByClaimKey.get(jiraClaimKey(issue.id)) ?? itemsBySourceKey.get(issue.id);\n // One live card per Jira issue per org. The store's claim index is the\n // guarantee; this check spares the dispatcher a refused upsert.\n if (!relatedItem && (await this.#heldElsewhere(input, issue))) {\n statuses.push('missing');\n continue;\n }\n statuses.push(await this.#ingestIssue(input, issue, relatedItem));\n }\n if (statuses.some(status => status === 'committed')) return { status: 'committed', ingested: statuses.length };\n if (statuses.some(status => status === 'replayed')) return { status: 'replayed', ingested: statuses.length };\n return { status: 'missing', ingested: statuses.length };\n }\n\n async #heldElsewhere(input: JiraRulesIngress, issue: JiraIssueIngress): Promise<boolean> {\n const heldLive = (row: WorkItemRow) =>\n row.factoryProjectId !== input.factoryProjectId && !isTerminalWorkItem(this.options.boards, row);\n const claimant = await this.options.storage.getByClaimKey({\n orgId: input.orgId,\n claimKey: jiraClaimKey(issue.id),\n });\n if (claimant && heldLive(claimant)) return true;\n // Cards filed before claims existed carry only the issue reference.\n const legacy = await this.options.storage.listBySource({\n orgId: input.orgId,\n source: { integrationId: 'jira', type: 'issue', externalId: issue.id },\n });\n return legacy.some(heldLive);\n }\n\n async #ingestIssue(\n input: JiraRulesIngress,\n issue: JiraIssueIngress,\n relatedItem: WorkItemRow | undefined,\n attempt = 1,\n ): Promise<IngressStatus> {\n const ingressId = `jira:${issue.id}:${issue.updatedAt}`;\n const actor = { type: 'human' as const, id: input.userId };\n\n // Closed issues without existing work items should not create new ones.\n const isClosed = issue.stateType === 'completed' || issue.stateType === 'canceled';\n if (isClosed && !relatedItem) {\n return 'missing';\n }\n\n const event = isClosed ? 'issueClosed' : 'issueObserved';\n const boundBoardId = issue.sourceId ? input.intakeBoards?.[issue.sourceId] : undefined;\n const boundBoard = boundBoardId ? this.options.boards.get(boundBoardId) : undefined;\n const context: FactoryJiraRuleContext = {\n tenant: { orgId: input.orgId, projectId: input.factoryProjectId },\n actor,\n ingress: { type: 'jira', id: ingressId },\n cause: `jira.${event}`,\n causalChain: [],\n configVersion: this.options.configVersion,\n ...(relatedItem\n ? {\n item: {\n id: relatedItem.id,\n source: 'jira-issue',\n sourceKey: relatedItem.externalSource?.externalId ?? null,\n parentWorkItemId: relatedItem.parentWorkItemId,\n title: relatedItem.title,\n url: relatedItem.externalSource?.url ?? null,\n stages: relatedItem.stages,\n acceptedAt: relatedItem.acceptedAt,\n metadata: relatedItem.metadata,\n },\n board: boardForWorkItem(relatedItem),\n itemRevision: relatedItem.revision,\n ...withBoardClosure(this.options.boards, relatedItem),\n ...rulePhaseField(this.options.boards, relatedItem),\n }\n : {}),\n ...(boundBoard ? { intake: { board: boundBoard.id, initialPhase: boundBoard.initialPhase } } : {}),\n event,\n issue,\n };\n\n const rule = selectEventRule(\n this.options.jiraRules,\n boardEventHandlers(this.options.boards, eventBoardId(context), 'jira'),\n context.event,\n );\n let decision: FactoryRuleDecision | void;\n let decisions: Record<string, unknown>[] = [];\n let outcome: { status: 'accepted' | 'rejected'; code?: string; reason?: string } = { status: 'accepted' };\n try {\n decision = rule ? await withRuleTimeout(Promise.resolve(rule(Object.freeze(context)))) : undefined;\n if (decision?.type === 'reject') {\n outcome = { status: 'rejected', code: decision.code, reason: decision.reason };\n } else if (decision) {\n decisions = validateFactoryRuleDecisions([decision]).map(entry => {\n assertFactoryDecisionTarget(\n entry,\n this.options.boards,\n relatedItem ? boardForWorkItem(relatedItem) : undefined,\n );\n return { ...entry };\n });\n }\n } catch (error) {\n const timedOut = error instanceof Error && error.message === 'FACTORY_RULE_TIMEOUT';\n outcome = {\n status: 'rejected',\n code: timedOut ? 'timeout' : 'rule_error',\n reason: timedOut\n ? 'Factory rule evaluation timed out.'\n : error instanceof Error\n ? error.message.slice(0, 2_000)\n : 'Factory Jira rule failed.',\n };\n }\n\n const committed = await this.options.storage.commitRuleEvaluation({\n orgId: input.orgId,\n factoryProjectId: input.factoryProjectId,\n workItemId: relatedItem?.id ?? null,\n ingress: { identity: ingressId, triggerType: 'jira.issueObserved' },\n configVersion: this.options.configVersion,\n expectedRevision: relatedItem?.revision ?? null,\n actor,\n outcome,\n decisions,\n causalChain: [],\n now: new Date(),\n });\n if (committed.status === 'stale') {\n // Nothing was persisted: re-read the item and evaluate again at its fresh revision.\n if (attempt >= MAX_STALE_RULE_ATTEMPTS) {\n throw new Error(`Factory Jira rule evaluation for ${ingressId} kept losing revision races.`);\n }\n const fresh = relatedItem ? await this.options.storage.get({ orgId: input.orgId, id: relatedItem.id }) : null;\n if (relatedItem && !fresh) return 'missing';\n return this.#ingestIssue(input, issue, fresh ?? undefined, attempt + 1);\n }\n return committed.status;\n }\n}\n\nexport function attachJiraRules(\n jira: { readonly rules: JiraEventRules },\n context: IntegrationContext,\n): ((input: JiraRulesIngress) => Promise<unknown>) | undefined {\n if (!context.runtime) return undefined;\n const rules = new JiraRules({\n projects: context.storage.projects,\n storage: context.runtime.workItems,\n configVersion: context.runtime.configVersion,\n boards: context.runtime.boards,\n jiraRules: jira.rules,\n });\n return input => rules.ingest(input);\n}\n"],"mappings":"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"}
@@ -31,10 +31,10 @@ declare function linearIssueObserved(context: FactoryLinearRuleContext): {
31
31
  declare function linearIssueClosed(context: FactoryLinearRuleContext): {
32
32
  readonly type: "transition";
33
33
  readonly idempotencyKey: `${string}:issue-closed`;
34
- readonly board: "work";
35
- readonly stage: "done" | "canceled";
34
+ readonly board: import("../../rules/types.js").FactoryRuleBoard;
35
+ readonly stage: string;
36
36
  readonly message: {
37
- readonly text: `Linear issue ${string} was canceled; this Work card was moved to Done.` | `Linear issue ${string} was canceled; this Work card was moved to Canceled.` | `Linear issue ${string} was completed; this Work card was moved to Done.` | `Linear issue ${string} was completed; this Work card was moved to Canceled.`;
37
+ readonly text: `Linear issue ${string} was canceled; this ${string} card was moved to ${string}.` | `Linear issue ${string} was completed; this ${string} card was moved to ${string}.`;
38
38
  };
39
39
  } | undefined;
40
40
  export declare const defaultLinearRules: Readonly<{
@@ -1 +1 @@
1
- {"version":3,"file":"default-rules.d.ts","sourceRoot":"","sources":["../../../src/integrations/linear/default-rules.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,wBAAwB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAGjH,MAAM,MAAM,mBAAmB,GAAG,OAAO,CACvC,MAAM,CAAC,sBAAsB,EAAE,kBAAkB,CAAC,wBAAwB,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,CAChG,CAAC;AACF,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CACrC,MAAM,CAAC,sBAAsB,EAAE,kBAAkB,CAAC,wBAAwB,CAAC,GAAG,IAAI,CAAC,CACpF,CAAC;AAEF,iBAAS,mBAAmB,CAAC,OAAO,EAAE,wBAAwB;;;;;;;;;;;;;;;;;;;;;;yBAyBnB,MAAM,EAAE;;;;cAKlD;AAED,iBAAS,iBAAiB,CAAC,OAAO,EAAE,wBAAwB;;;;;;;;cAkB3D;AAED,eAAO,MAAM,kBAAkB;;;EAGF,CAAC;AAE9B,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,mBAAmB,GAAG,gBAAgB,CAsBpF"}
1
+ {"version":3,"file":"default-rules.d.ts","sourceRoot":"","sources":["../../../src/integrations/linear/default-rules.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,wBAAwB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAGjH,MAAM,MAAM,mBAAmB,GAAG,OAAO,CACvC,MAAM,CAAC,sBAAsB,EAAE,kBAAkB,CAAC,wBAAwB,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,CAChG,CAAC;AACF,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CACrC,MAAM,CAAC,sBAAsB,EAAE,kBAAkB,CAAC,wBAAwB,CAAC,GAAG,IAAI,CAAC,CACpF,CAAC;AAEF,iBAAS,mBAAmB,CAAC,OAAO,EAAE,wBAAwB;;;;;;;;;;;;;;;;;;;;;;yBAyBnB,MAAM,EAAE;;;;cAKlD;AAED,iBAAS,iBAAiB,CAAC,OAAO,EAAE,wBAAwB;;;;;;;;cAoB3D;AAED,eAAO,MAAM,kBAAkB;;;EAGF,CAAC;AAE9B,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,mBAAmB,GAAG,gBAAgB,CAsBpF"}
@@ -1,3 +1,4 @@
1
+ import { recordExplicitRuleOverrides } from "../../rules/event-rules.js";
1
2
  import { linearClaimKey } from "./claim.js";
2
3
  //#region src/integrations/linear/default-rules.ts
3
4
  function linearIssueObserved(context) {
@@ -34,17 +35,18 @@ function linearIssueObserved(context) {
34
35
  }
35
36
  function linearIssueClosed(context) {
36
37
  if (!context.item || context.item.source !== "linear-issue") return;
37
- if (context.board !== "work") return;
38
- if (context.item.stages.some((stage) => stage === "done" || stage === "canceled")) return;
38
+ const closure = context.boardClosure;
39
+ if (!context.board || !closure || closure.itemTerminal) return;
39
40
  const stateType = context.issue.stateType;
40
41
  if (stateType !== "completed" && stateType !== "canceled") return;
41
42
  const canceled = stateType === "canceled";
43
+ const target = canceled ? closure.canceled : closure.completed;
42
44
  return {
43
45
  type: "transition",
44
46
  idempotencyKey: `${context.ingress.id}:issue-closed`,
45
- board: "work",
46
- stage: canceled ? "canceled" : "done",
47
- message: { text: `Linear issue ${context.issue.identifier} was ${canceled ? "canceled" : "completed"}; this Work card was moved to ${canceled ? "Canceled" : "Done"}.` }
47
+ board: context.board,
48
+ stage: target.phase,
49
+ message: { text: `Linear issue ${context.issue.identifier} was ${canceled ? "canceled" : "completed"}; this ${closure.boardTitle} card was moved to ${target.title}.` }
48
50
  };
49
51
  }
50
52
  const defaultLinearRules = Object.freeze({
@@ -60,7 +62,7 @@ function resolveLinearRules(overrides) {
60
62
  if (handler !== void 0 && handler !== null && typeof handler !== "function") throw new Error(`Linear rule ${key} must be a function, null, or undefined.`);
61
63
  if (handler !== void 0) rules[key] = handler;
62
64
  }
63
- return Object.freeze(rules);
65
+ return recordExplicitRuleOverrides(Object.freeze(rules), overrides);
64
66
  }
65
67
  //#endregion
66
68
  export { defaultLinearRules, resolveLinearRules };
@@ -1 +1 @@
1
- {"version":3,"file":"default-rules.js","names":[],"sources":["../../../src/integrations/linear/default-rules.ts"],"sourcesContent":["import type { FactoryLinearEventName, FactoryLinearRuleContext, FactoryRuleHandler } from '../../rules/types.js';\nimport { linearClaimKey } from './claim.js';\n\nexport type LinearRuleOverrides = Partial<\n Record<FactoryLinearEventName, FactoryRuleHandler<FactoryLinearRuleContext> | null | undefined>\n>;\nexport type LinearEventRules = Readonly<\n Record<FactoryLinearEventName, FactoryRuleHandler<FactoryLinearRuleContext> | null>\n>;\n\nfunction linearIssueObserved(context: FactoryLinearRuleContext) {\n if (context.item) return;\n return {\n type: 'upsertLinkedWorkItem',\n idempotencyKey: `${context.ingress.id}:issue-triage`,\n // A source bound to a custom board lands on that board's initial phase;\n // otherwise the issue waits in Work Intake.\n board: context.intake?.board ?? 'work',\n source: 'linear-issue',\n sourceKey: `linear:${context.issue.identifier}`,\n claimKey: linearClaimKey(context.issue.id),\n title: `${context.issue.identifier}: ${context.issue.title}`,\n url: context.issue.url,\n stage: context.intake?.initialPhase ?? 'intake',\n metadata: {\n linearIssueId: context.issue.id,\n ...(context.issue.projectId ? { linearProjectId: context.issue.projectId } : {}),\n identifier: context.issue.identifier,\n sourceCreatedAt: context.issue.createdAt,\n linearState: context.issue.state,\n linearStateType: context.issue.stateType,\n linearPriority: context.issue.priorityLabel,\n linearAssignee: context.issue.assignee,\n linearCreator: context.issue.creator,\n linearTeam: context.issue.team,\n labels: [...context.issue.labels] as string[],\n ...(context.issue.assignee ? { assignee: context.issue.assignee } : {}),\n ...(context.issue.creator ? { creator: context.issue.creator, author: context.issue.creator } : {}),\n },\n } as const;\n}\n\nfunction linearIssueClosed(context: FactoryLinearRuleContext) {\n if (!context.item || context.item.source !== 'linear-issue') return;\n if (context.board !== 'work') return;\n // Already off the board: nothing to reconcile.\n if (context.item.stages.some(stage => stage === 'done' || stage === 'canceled')) return;\n // Only terminal state types trigger close.\n const stateType = context.issue.stateType;\n if (stateType !== 'completed' && stateType !== 'canceled') return;\n const canceled = stateType === 'canceled';\n return {\n type: 'transition',\n idempotencyKey: `${context.ingress.id}:issue-closed`,\n board: 'work',\n stage: canceled ? 'canceled' : 'done',\n message: {\n text: `Linear issue ${context.issue.identifier} was ${canceled ? 'canceled' : 'completed'}; this Work card was moved to ${canceled ? 'Canceled' : 'Done'}.`,\n },\n } as const;\n}\n\nexport const defaultLinearRules = Object.freeze({\n issueObserved: linearIssueObserved,\n issueClosed: linearIssueClosed,\n} satisfies LinearEventRules);\n\nexport function resolveLinearRules(overrides?: LinearRuleOverrides): LinearEventRules {\n if (\n overrides !== undefined &&\n (overrides === null ||\n typeof overrides !== 'object' ||\n Array.isArray(overrides) ||\n ![Object.prototype, null].includes(Object.getPrototypeOf(overrides)))\n ) {\n throw new Error('Linear rules must be a plain object.');\n }\n const rules: Record<string, FactoryRuleHandler<FactoryLinearRuleContext> | null> = { ...defaultLinearRules };\n for (const key of Reflect.ownKeys(overrides ?? {})) {\n if (typeof key !== 'string' || !Object.hasOwn(defaultLinearRules, key)) {\n throw new Error(`Unknown Linear rule event: ${String(key)}.`);\n }\n const handler = overrides?.[key as FactoryLinearEventName];\n if (handler !== undefined && handler !== null && typeof handler !== 'function') {\n throw new Error(`Linear rule ${key} must be a function, null, or undefined.`);\n }\n if (handler !== undefined) rules[key] = handler;\n }\n return Object.freeze(rules) as LinearEventRules;\n}\n"],"mappings":";;AAUA,SAAS,oBAAoB,SAAmC;CAC9D,IAAI,QAAQ,MAAM;CAClB,OAAO;EACL,MAAM;EACN,gBAAgB,GAAG,QAAQ,QAAQ,GAAG;EAGtC,OAAO,QAAQ,QAAQ,SAAS;EAChC,QAAQ;EACR,WAAW,UAAU,QAAQ,MAAM;EACnC,UAAU,eAAe,QAAQ,MAAM,EAAE;EACzC,OAAO,GAAG,QAAQ,MAAM,WAAW,IAAI,QAAQ,MAAM;EACrD,KAAK,QAAQ,MAAM;EACnB,OAAO,QAAQ,QAAQ,gBAAgB;EACvC,UAAU;GACR,eAAe,QAAQ,MAAM;GAC7B,GAAI,QAAQ,MAAM,YAAY,EAAE,iBAAiB,QAAQ,MAAM,UAAU,IAAI,CAAC;GAC9E,YAAY,QAAQ,MAAM;GAC1B,iBAAiB,QAAQ,MAAM;GAC/B,aAAa,QAAQ,MAAM;GAC3B,iBAAiB,QAAQ,MAAM;GAC/B,gBAAgB,QAAQ,MAAM;GAC9B,gBAAgB,QAAQ,MAAM;GAC9B,eAAe,QAAQ,MAAM;GAC7B,YAAY,QAAQ,MAAM;GAC1B,QAAQ,CAAC,GAAG,QAAQ,MAAM,MAAM;GAChC,GAAI,QAAQ,MAAM,WAAW,EAAE,UAAU,QAAQ,MAAM,SAAS,IAAI,CAAC;GACrE,GAAI,QAAQ,MAAM,UAAU;IAAE,SAAS,QAAQ,MAAM;IAAS,QAAQ,QAAQ,MAAM;GAAQ,IAAI,CAAC;EACnG;CACF;AACF;AAEA,SAAS,kBAAkB,SAAmC;CAC5D,IAAI,CAAC,QAAQ,QAAQ,QAAQ,KAAK,WAAW,gBAAgB;CAC7D,IAAI,QAAQ,UAAU,QAAQ;CAE9B,IAAI,QAAQ,KAAK,OAAO,MAAK,UAAS,UAAU,UAAU,UAAU,UAAU,GAAG;CAEjF,MAAM,YAAY,QAAQ,MAAM;CAChC,IAAI,cAAc,eAAe,cAAc,YAAY;CAC3D,MAAM,WAAW,cAAc;CAC/B,OAAO;EACL,MAAM;EACN,gBAAgB,GAAG,QAAQ,QAAQ,GAAG;EACtC,OAAO;EACP,OAAO,WAAW,aAAa;EAC/B,SAAS,EACP,MAAM,gBAAgB,QAAQ,MAAM,WAAW,OAAO,WAAW,aAAa,YAAY,gCAAgC,WAAW,aAAa,OAAO,GAC3J;CACF;AACF;AAEA,MAAa,qBAAqB,OAAO,OAAO;CAC9C,eAAe;CACf,aAAa;AACf,CAA4B;AAE5B,SAAgB,mBAAmB,WAAmD;CACpF,IACE,cAAc,KAAA,MACb,cAAc,QACb,OAAO,cAAc,YACrB,MAAM,QAAQ,SAAS,KACvB,CAAC,CAAC,OAAO,WAAW,IAAI,CAAC,CAAC,SAAS,OAAO,eAAe,SAAS,CAAC,IAErE,MAAM,IAAI,MAAM,sCAAsC;CAExD,MAAM,QAA6E,EAAE,GAAG,mBAAmB;CAC3G,KAAK,MAAM,OAAO,QAAQ,QAAQ,aAAa,CAAC,CAAC,GAAG;EAClD,IAAI,OAAO,QAAQ,YAAY,CAAC,OAAO,OAAO,oBAAoB,GAAG,GACnE,MAAM,IAAI,MAAM,8BAA8B,OAAO,GAAG,EAAE,EAAE;EAE9D,MAAM,UAAU,YAAY;EAC5B,IAAI,YAAY,KAAA,KAAa,YAAY,QAAQ,OAAO,YAAY,YAClE,MAAM,IAAI,MAAM,eAAe,IAAI,yCAAyC;EAE9E,IAAI,YAAY,KAAA,GAAW,MAAM,OAAO;CAC1C;CACA,OAAO,OAAO,OAAO,KAAK;AAC5B"}
1
+ {"version":3,"file":"default-rules.js","names":[],"sources":["../../../src/integrations/linear/default-rules.ts"],"sourcesContent":["import { recordExplicitRuleOverrides } from '../../rules/event-rules.js';\nimport type { FactoryLinearEventName, FactoryLinearRuleContext, FactoryRuleHandler } from '../../rules/types.js';\nimport { linearClaimKey } from './claim.js';\n\nexport type LinearRuleOverrides = Partial<\n Record<FactoryLinearEventName, FactoryRuleHandler<FactoryLinearRuleContext> | null | undefined>\n>;\nexport type LinearEventRules = Readonly<\n Record<FactoryLinearEventName, FactoryRuleHandler<FactoryLinearRuleContext> | null>\n>;\n\nfunction linearIssueObserved(context: FactoryLinearRuleContext) {\n if (context.item) return;\n return {\n type: 'upsertLinkedWorkItem',\n idempotencyKey: `${context.ingress.id}:issue-triage`,\n // A source bound to a custom board lands on that board's initial phase;\n // otherwise the issue waits in Work Intake.\n board: context.intake?.board ?? 'work',\n source: 'linear-issue',\n sourceKey: `linear:${context.issue.identifier}`,\n claimKey: linearClaimKey(context.issue.id),\n title: `${context.issue.identifier}: ${context.issue.title}`,\n url: context.issue.url,\n stage: context.intake?.initialPhase ?? 'intake',\n metadata: {\n linearIssueId: context.issue.id,\n ...(context.issue.projectId ? { linearProjectId: context.issue.projectId } : {}),\n identifier: context.issue.identifier,\n sourceCreatedAt: context.issue.createdAt,\n linearState: context.issue.state,\n linearStateType: context.issue.stateType,\n linearPriority: context.issue.priorityLabel,\n linearAssignee: context.issue.assignee,\n linearCreator: context.issue.creator,\n linearTeam: context.issue.team,\n labels: [...context.issue.labels] as string[],\n ...(context.issue.assignee ? { assignee: context.issue.assignee } : {}),\n ...(context.issue.creator ? { creator: context.issue.creator, author: context.issue.creator } : {}),\n },\n } as const;\n}\n\nfunction linearIssueClosed(context: FactoryLinearRuleContext) {\n if (!context.item || context.item.source !== 'linear-issue') return;\n // The card's board decides where a closed source sends it; boards that declare no\n // `sourceClosed` mapping keep the card where it is, as do cards already finished.\n const closure = context.boardClosure;\n if (!context.board || !closure || closure.itemTerminal) return;\n // Only terminal state types trigger close.\n const stateType = context.issue.stateType;\n if (stateType !== 'completed' && stateType !== 'canceled') return;\n const canceled = stateType === 'canceled';\n const target = canceled ? closure.canceled : closure.completed;\n return {\n type: 'transition',\n idempotencyKey: `${context.ingress.id}:issue-closed`,\n board: context.board,\n stage: target.phase,\n message: {\n text: `Linear issue ${context.issue.identifier} was ${canceled ? 'canceled' : 'completed'}; this ${closure.boardTitle} card was moved to ${target.title}.`,\n },\n } as const;\n}\n\nexport const defaultLinearRules = Object.freeze({\n issueObserved: linearIssueObserved,\n issueClosed: linearIssueClosed,\n} satisfies LinearEventRules);\n\nexport function resolveLinearRules(overrides?: LinearRuleOverrides): LinearEventRules {\n if (\n overrides !== undefined &&\n (overrides === null ||\n typeof overrides !== 'object' ||\n Array.isArray(overrides) ||\n ![Object.prototype, null].includes(Object.getPrototypeOf(overrides)))\n ) {\n throw new Error('Linear rules must be a plain object.');\n }\n const rules: Record<string, FactoryRuleHandler<FactoryLinearRuleContext> | null> = { ...defaultLinearRules };\n for (const key of Reflect.ownKeys(overrides ?? {})) {\n if (typeof key !== 'string' || !Object.hasOwn(defaultLinearRules, key)) {\n throw new Error(`Unknown Linear rule event: ${String(key)}.`);\n }\n const handler = overrides?.[key as FactoryLinearEventName];\n if (handler !== undefined && handler !== null && typeof handler !== 'function') {\n throw new Error(`Linear rule ${key} must be a function, null, or undefined.`);\n }\n if (handler !== undefined) rules[key] = handler;\n }\n return recordExplicitRuleOverrides(Object.freeze(rules) as LinearEventRules, overrides);\n}\n"],"mappings":";;;AAWA,SAAS,oBAAoB,SAAmC;CAC9D,IAAI,QAAQ,MAAM;CAClB,OAAO;EACL,MAAM;EACN,gBAAgB,GAAG,QAAQ,QAAQ,GAAG;EAGtC,OAAO,QAAQ,QAAQ,SAAS;EAChC,QAAQ;EACR,WAAW,UAAU,QAAQ,MAAM;EACnC,UAAU,eAAe,QAAQ,MAAM,EAAE;EACzC,OAAO,GAAG,QAAQ,MAAM,WAAW,IAAI,QAAQ,MAAM;EACrD,KAAK,QAAQ,MAAM;EACnB,OAAO,QAAQ,QAAQ,gBAAgB;EACvC,UAAU;GACR,eAAe,QAAQ,MAAM;GAC7B,GAAI,QAAQ,MAAM,YAAY,EAAE,iBAAiB,QAAQ,MAAM,UAAU,IAAI,CAAC;GAC9E,YAAY,QAAQ,MAAM;GAC1B,iBAAiB,QAAQ,MAAM;GAC/B,aAAa,QAAQ,MAAM;GAC3B,iBAAiB,QAAQ,MAAM;GAC/B,gBAAgB,QAAQ,MAAM;GAC9B,gBAAgB,QAAQ,MAAM;GAC9B,eAAe,QAAQ,MAAM;GAC7B,YAAY,QAAQ,MAAM;GAC1B,QAAQ,CAAC,GAAG,QAAQ,MAAM,MAAM;GAChC,GAAI,QAAQ,MAAM,WAAW,EAAE,UAAU,QAAQ,MAAM,SAAS,IAAI,CAAC;GACrE,GAAI,QAAQ,MAAM,UAAU;IAAE,SAAS,QAAQ,MAAM;IAAS,QAAQ,QAAQ,MAAM;GAAQ,IAAI,CAAC;EACnG;CACF;AACF;AAEA,SAAS,kBAAkB,SAAmC;CAC5D,IAAI,CAAC,QAAQ,QAAQ,QAAQ,KAAK,WAAW,gBAAgB;CAG7D,MAAM,UAAU,QAAQ;CACxB,IAAI,CAAC,QAAQ,SAAS,CAAC,WAAW,QAAQ,cAAc;CAExD,MAAM,YAAY,QAAQ,MAAM;CAChC,IAAI,cAAc,eAAe,cAAc,YAAY;CAC3D,MAAM,WAAW,cAAc;CAC/B,MAAM,SAAS,WAAW,QAAQ,WAAW,QAAQ;CACrD,OAAO;EACL,MAAM;EACN,gBAAgB,GAAG,QAAQ,QAAQ,GAAG;EACtC,OAAO,QAAQ;EACf,OAAO,OAAO;EACd,SAAS,EACP,MAAM,gBAAgB,QAAQ,MAAM,WAAW,OAAO,WAAW,aAAa,YAAY,SAAS,QAAQ,WAAW,qBAAqB,OAAO,MAAM,GAC1J;CACF;AACF;AAEA,MAAa,qBAAqB,OAAO,OAAO;CAC9C,eAAe;CACf,aAAa;AACf,CAA4B;AAE5B,SAAgB,mBAAmB,WAAmD;CACpF,IACE,cAAc,KAAA,MACb,cAAc,QACb,OAAO,cAAc,YACrB,MAAM,QAAQ,SAAS,KACvB,CAAC,CAAC,OAAO,WAAW,IAAI,CAAC,CAAC,SAAS,OAAO,eAAe,SAAS,CAAC,IAErE,MAAM,IAAI,MAAM,sCAAsC;CAExD,MAAM,QAA6E,EAAE,GAAG,mBAAmB;CAC3G,KAAK,MAAM,OAAO,QAAQ,QAAQ,aAAa,CAAC,CAAC,GAAG;EAClD,IAAI,OAAO,QAAQ,YAAY,CAAC,OAAO,OAAO,oBAAoB,GAAG,GACnE,MAAM,IAAI,MAAM,8BAA8B,OAAO,GAAG,EAAE,EAAE;EAE9D,MAAM,UAAU,YAAY;EAC5B,IAAI,YAAY,KAAA,KAAa,YAAY,QAAQ,OAAO,YAAY,YAClE,MAAM,IAAI,MAAM,eAAe,IAAI,yCAAyC;EAE9E,IAAI,YAAY,KAAA,GAAW,MAAM,OAAO;CAC1C;CACA,OAAO,4BAA4B,OAAO,OAAO,KAAK,GAAuB,SAAS;AACxF"}
@@ -1,5 +1,5 @@
1
- import { isTerminalWorkItem } from "../../boards/semantics.js";
2
1
  import { isTerminalFactoryRuleStage } from "../../rules/types.js";
2
+ import { isTerminalWorkItem } from "../../boards/semantics.js";
3
3
  import "../../boards/index.js";
4
4
  import { linearClaimKey } from "./claim.js";
5
5
  import { LinearReauthRequiredError } from "./integration.js";
@@ -1 +1 @@
1
- {"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../../src/integrations/linear/rules.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAG3D,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,wCAAwC,CAAC;AACrF,OAAO,KAAK,EAAe,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAC9F,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAErD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAoB3D,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,6EAA6E;IAC7E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,4GAA4G;IAC5G,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,IAAI,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAAC;IAC9C,OAAO,EAAE,gBAAgB,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,aAAa,CAAC;IACtB,WAAW,EAAE,gBAAgB,CAAC;CAC/B;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE,kBAAkB,EAAE,CAAC;IAC7B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACjD;AAED,KAAK,aAAa,GAAG,WAAW,GAAG,UAAU,GAAG,SAAS,CAAC;AAE1D,qBAAa,WAAW;;IACV,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,kBAAkB;IAElD,MAAM,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,aAAa,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CAmJ9F;AAED,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE;IAAE,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAA;CAAE,EAC5C,OAAO,EAAE,kBAAkB,GAC1B,CAAC,CAAC,KAAK,EAAE,kBAAkB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,SAAS,CAU/D"}
1
+ {"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../../src/integrations/linear/rules.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAI3D,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,wCAAwC,CAAC;AACrF,OAAO,KAAK,EAAe,gBAAgB,EAAE,MAAM,0CAA0C,CAAC;AAC9F,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAErD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAoB3D,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,6EAA6E;IAC7E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,4GAA4G;IAC5G,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,IAAI,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAAC;IAC9C,OAAO,EAAE,gBAAgB,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,aAAa,CAAC;IACtB,WAAW,EAAE,gBAAgB,CAAC;CAC/B;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE,kBAAkB,EAAE,CAAC;IAC7B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACjD;AAED,KAAK,aAAa,GAAG,WAAW,GAAG,UAAU,GAAG,SAAS,CAAC;AAE1D,qBAAa,WAAW;;IACV,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,kBAAkB;IAElD,MAAM,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,aAAa,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CAyJ9F;AAED,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE;IAAE,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAA;CAAE,EAC5C,OAAO,EAAE,kBAAkB,GAC1B,CAAC,CAAC,KAAK,EAAE,kBAAkB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,SAAS,CAU/D"}
@@ -1,6 +1,7 @@
1
1
  import { assertFactoryDecisionTarget, validateFactoryRuleDecisions } from "../../rules/validation.js";
2
- import { boardForWorkItem, isTerminalWorkItem } from "../../boards/semantics.js";
2
+ import { boardEventHandlers, boardForWorkItem, eventBoardId, isTerminalWorkItem, rulePhaseField, withBoardClosure } from "../../boards/semantics.js";
3
3
  import "../../boards/index.js";
4
+ import { selectEventRule } from "../../rules/event-rules.js";
4
5
  import { linearClaimKey } from "./claim.js";
5
6
  //#region src/integrations/linear/rules.ts
6
7
  const RULE_TIMEOUT_MS = 5e3;
@@ -109,7 +110,9 @@ var LinearRules = class {
109
110
  metadata: relatedItem.metadata
110
111
  },
111
112
  board: boardForWorkItem(relatedItem),
112
- itemRevision: relatedItem.revision
113
+ itemRevision: relatedItem.revision,
114
+ ...withBoardClosure(this.options.boards, relatedItem),
115
+ ...rulePhaseField(this.options.boards, relatedItem)
113
116
  } : {},
114
117
  ...boundBoard ? { intake: {
115
118
  board: boundBoard.id,
@@ -118,7 +121,7 @@ var LinearRules = class {
118
121
  event,
119
122
  issue
120
123
  };
121
- const rule = this.options.linearRules[context.event];
124
+ const rule = selectEventRule(this.options.linearRules, boardEventHandlers(this.options.boards, eventBoardId(context), "linear"), context.event);
122
125
  let decision;
123
126
  let decisions = [];
124
127
  let outcome = { status: "accepted" };
@@ -1 +1 @@
1
- {"version":3,"file":"rules.js","names":["#heldElsewhere","#ingestIssue"],"sources":["../../../src/integrations/linear/rules.ts"],"sourcesContent":["import { boardForWorkItem, isTerminalWorkItem } from '../../boards/index.js';\nimport type { BoardRegistry } from '../../boards/index.js';\nimport type { FactoryLinearRuleContext, FactoryRuleDecision } from '../../rules/types.js';\nimport { assertFactoryDecisionTarget, validateFactoryRuleDecisions } from '../../rules/validation.js';\nimport type { FactoryProjectsStorage } from '../../storage/domains/projects/base.js';\nimport type { WorkItemRow, WorkItemsStorage } from '../../storage/domains/work-items/base.js';\nimport type { IntegrationContext } from '../base.js';\nimport { linearClaimKey } from './claim.js';\nimport type { LinearEventRules } from './default-rules.js';\n\nconst RULE_TIMEOUT_MS = 5_000;\n\nasync function withRuleTimeout<T>(promise: Promise<T>): Promise<T> {\n let timeout: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timeout = setTimeout(() => reject(new Error('FACTORY_RULE_TIMEOUT')), RULE_TIMEOUT_MS);\n }),\n ]);\n } finally {\n if (timeout) clearTimeout(timeout);\n }\n}\n\nconst MAX_STALE_RULE_ATTEMPTS = 3;\n\nexport interface LinearIssueIngress {\n id: string;\n identifier: string;\n title: string;\n url: string;\n state: string;\n stateType: string;\n priorityLabel: string;\n assignee: string | null;\n /** Display name of the Linear user who created the issue, when available. */\n creator: string | null;\n team: string | null;\n labels: string[];\n createdAt: string;\n updatedAt: string;\n /** Linear source the issue was read from (project or team); resolves the bound board via `intakeBoards`. */\n sourceId?: string | null;\n projectId?: string | null;\n}\n\nexport interface LinearRulesOptions {\n projects: Pick<FactoryProjectsStorage, 'get'>;\n storage: WorkItemsStorage;\n configVersion: string;\n boards: BoardRegistry;\n linearRules: LinearEventRules;\n}\n\nexport interface LinearRulesIngress {\n orgId: string;\n userId: string;\n factoryProjectId: string;\n issues: LinearIssueIngress[];\n /** Board id per bound Linear source id, from the project's intake bindings. */\n intakeBoards?: Readonly<Record<string, string>>;\n}\n\ntype IngressStatus = 'committed' | 'replayed' | 'missing';\n\nexport class LinearRules {\n constructor(private readonly options: LinearRulesOptions) {}\n\n async ingest(input: LinearRulesIngress): Promise<{ status: IngressStatus; ingested: number }> {\n const project = await this.options.projects.get({ orgId: input.orgId, id: input.factoryProjectId });\n if (!project) return { status: 'missing', ingested: 0 };\n\n const items = await this.options.storage.list({ orgId: input.orgId, factoryProjectId: input.factoryProjectId });\n const itemsBySourceKey = new Map(items.map(item => [item.externalSource?.externalId, item]));\n const itemsByClaimKey = new Map(items.filter(item => item.claimKey).map(item => [item.claimKey, item]));\n const statuses: IngressStatus[] = [];\n for (const issue of input.issues) {\n // The stable issue id finds the card even after Linear renamed the\n // identifier; the identifier covers cards filed before claims existed.\n const relatedItem =\n itemsByClaimKey.get(linearClaimKey(issue.id)) ?? itemsBySourceKey.get(`linear:${issue.identifier}`);\n // One live card per Linear issue per org. When the winning source for an\n // issue moves to a source routed elsewhere (a project deselected under a\n // selected team, or the reverse), the card that already exists keeps the\n // issue; this Factory must not mint a second one. The store's claim index\n // is the guarantee; this check spares the dispatcher a refused upsert.\n if (!relatedItem && (await this.#heldElsewhere(input, issue))) {\n statuses.push('missing');\n continue;\n }\n statuses.push(await this.#ingestIssue(input, issue, relatedItem));\n }\n if (statuses.some(status => status === 'committed')) return { status: 'committed', ingested: statuses.length };\n if (statuses.some(status => status === 'replayed')) return { status: 'replayed', ingested: statuses.length };\n return { status: 'missing', ingested: statuses.length };\n }\n\n async #heldElsewhere(input: LinearRulesIngress, issue: LinearIssueIngress): Promise<boolean> {\n const heldLive = (row: WorkItemRow) =>\n row.factoryProjectId !== input.factoryProjectId && !isTerminalWorkItem(this.options.boards, row);\n const claimant = await this.options.storage.getByClaimKey({\n orgId: input.orgId,\n claimKey: linearClaimKey(issue.id),\n });\n if (claimant && heldLive(claimant)) return true;\n // Cards filed before claims existed carry only the identifier.\n const legacy = await this.options.storage.listBySource({\n orgId: input.orgId,\n source: { integrationId: 'linear', type: 'issue', externalId: `linear:${issue.identifier}` },\n });\n return legacy.some(heldLive);\n }\n\n async #ingestIssue(\n input: LinearRulesIngress,\n issue: LinearIssueIngress,\n relatedItem: WorkItemRow | undefined,\n attempt = 1,\n ): Promise<IngressStatus> {\n const ingressId = `linear:${issue.id}:${issue.updatedAt}`;\n const actor = { type: 'human' as const, id: input.userId };\n\n // Closed issues without existing work items should not create new ones.\n const isClosed = issue.stateType === 'completed' || issue.stateType === 'canceled';\n if (isClosed && !relatedItem) {\n return 'missing';\n }\n\n const event = isClosed ? 'issueClosed' : 'issueObserved';\n const boundBoardId = issue.sourceId ? input.intakeBoards?.[issue.sourceId] : undefined;\n const boundBoard = boundBoardId ? this.options.boards.get(boundBoardId) : undefined;\n const context: FactoryLinearRuleContext = {\n tenant: { orgId: input.orgId, projectId: input.factoryProjectId },\n actor,\n ingress: { type: 'linear', id: ingressId },\n cause: `linear.${event}`,\n causalChain: [],\n configVersion: this.options.configVersion,\n ...(relatedItem\n ? {\n item: {\n id: relatedItem.id,\n source: 'linear-issue',\n sourceKey: relatedItem.externalSource?.externalId ?? null,\n parentWorkItemId: relatedItem.parentWorkItemId,\n title: relatedItem.title,\n url: relatedItem.externalSource?.url ?? null,\n stages: relatedItem.stages,\n acceptedAt: relatedItem.acceptedAt,\n metadata: relatedItem.metadata,\n },\n board: boardForWorkItem(relatedItem),\n itemRevision: relatedItem.revision,\n }\n : {}),\n ...(boundBoard ? { intake: { board: boundBoard.id, initialPhase: boundBoard.initialPhase } } : {}),\n event,\n issue,\n };\n\n const rule = this.options.linearRules[context.event];\n let decision: FactoryRuleDecision | void;\n let decisions: Record<string, unknown>[] = [];\n let outcome: { status: 'accepted' | 'rejected'; code?: string; reason?: string } = { status: 'accepted' };\n try {\n decision = rule ? await withRuleTimeout(Promise.resolve(rule(Object.freeze(context)))) : undefined;\n if (decision?.type === 'reject') {\n outcome = { status: 'rejected', code: decision.code, reason: decision.reason };\n } else if (decision) {\n decisions = validateFactoryRuleDecisions([decision]).map(entry => {\n assertFactoryDecisionTarget(\n entry,\n this.options.boards,\n relatedItem ? boardForWorkItem(relatedItem) : undefined,\n );\n return { ...entry };\n });\n }\n } catch (error) {\n const timedOut = error instanceof Error && error.message === 'FACTORY_RULE_TIMEOUT';\n outcome = {\n status: 'rejected',\n code: timedOut ? 'timeout' : 'rule_error',\n reason: timedOut\n ? 'Factory rule evaluation timed out.'\n : error instanceof Error\n ? error.message.slice(0, 2_000)\n : 'Factory Linear rule failed.',\n };\n }\n\n const committed = await this.options.storage.commitRuleEvaluation({\n orgId: input.orgId,\n factoryProjectId: input.factoryProjectId,\n workItemId: relatedItem?.id ?? null,\n ingress: { identity: ingressId, triggerType: 'linear.issueObserved' },\n configVersion: this.options.configVersion,\n expectedRevision: relatedItem?.revision ?? null,\n actor,\n outcome,\n decisions,\n causalChain: [],\n now: new Date(),\n });\n if (committed.status === 'stale') {\n // Nothing was persisted: re-read the item and evaluate again at its fresh revision.\n if (attempt >= MAX_STALE_RULE_ATTEMPTS) {\n throw new Error(`Factory Linear rule evaluation for ${ingressId} kept losing revision races.`);\n }\n const fresh = relatedItem ? await this.options.storage.get({ orgId: input.orgId, id: relatedItem.id }) : null;\n if (relatedItem && !fresh) return 'missing';\n return this.#ingestIssue(input, issue, fresh ?? undefined, attempt + 1);\n }\n return committed.status;\n }\n}\n\nexport function attachLinearRules(\n linear: { readonly rules: LinearEventRules },\n context: IntegrationContext,\n): ((input: LinearRulesIngress) => Promise<unknown>) | undefined {\n if (!context.runtime) return undefined;\n const rules = new LinearRules({\n projects: context.storage.projects,\n storage: context.runtime.workItems,\n configVersion: context.runtime.configVersion,\n boards: context.runtime.boards,\n linearRules: linear.rules,\n });\n return input => rules.ingest(input);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"rules.js","names":["#heldElsewhere","#ingestIssue"],"sources":["../../../src/integrations/linear/rules.ts"],"sourcesContent":["import {\n boardEventHandlers,\n boardForWorkItem,\n eventBoardId,\n isTerminalWorkItem,\n rulePhaseField,\n withBoardClosure,\n} from '../../boards/index.js';\nimport type { BoardRegistry } from '../../boards/index.js';\nimport { selectEventRule } from '../../rules/event-rules.js';\nimport type { FactoryLinearRuleContext, FactoryRuleDecision } from '../../rules/types.js';\nimport { assertFactoryDecisionTarget, validateFactoryRuleDecisions } from '../../rules/validation.js';\nimport type { FactoryProjectsStorage } from '../../storage/domains/projects/base.js';\nimport type { WorkItemRow, WorkItemsStorage } from '../../storage/domains/work-items/base.js';\nimport type { IntegrationContext } from '../base.js';\nimport { linearClaimKey } from './claim.js';\nimport type { LinearEventRules } from './default-rules.js';\n\nconst RULE_TIMEOUT_MS = 5_000;\n\nasync function withRuleTimeout<T>(promise: Promise<T>): Promise<T> {\n let timeout: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timeout = setTimeout(() => reject(new Error('FACTORY_RULE_TIMEOUT')), RULE_TIMEOUT_MS);\n }),\n ]);\n } finally {\n if (timeout) clearTimeout(timeout);\n }\n}\n\nconst MAX_STALE_RULE_ATTEMPTS = 3;\n\nexport interface LinearIssueIngress {\n id: string;\n identifier: string;\n title: string;\n url: string;\n state: string;\n stateType: string;\n priorityLabel: string;\n assignee: string | null;\n /** Display name of the Linear user who created the issue, when available. */\n creator: string | null;\n team: string | null;\n labels: string[];\n createdAt: string;\n updatedAt: string;\n /** Linear source the issue was read from (project or team); resolves the bound board via `intakeBoards`. */\n sourceId?: string | null;\n projectId?: string | null;\n}\n\nexport interface LinearRulesOptions {\n projects: Pick<FactoryProjectsStorage, 'get'>;\n storage: WorkItemsStorage;\n configVersion: string;\n boards: BoardRegistry;\n linearRules: LinearEventRules;\n}\n\nexport interface LinearRulesIngress {\n orgId: string;\n userId: string;\n factoryProjectId: string;\n issues: LinearIssueIngress[];\n /** Board id per bound Linear source id, from the project's intake bindings. */\n intakeBoards?: Readonly<Record<string, string>>;\n}\n\ntype IngressStatus = 'committed' | 'replayed' | 'missing';\n\nexport class LinearRules {\n constructor(private readonly options: LinearRulesOptions) {}\n\n async ingest(input: LinearRulesIngress): Promise<{ status: IngressStatus; ingested: number }> {\n const project = await this.options.projects.get({ orgId: input.orgId, id: input.factoryProjectId });\n if (!project) return { status: 'missing', ingested: 0 };\n\n const items = await this.options.storage.list({ orgId: input.orgId, factoryProjectId: input.factoryProjectId });\n const itemsBySourceKey = new Map(items.map(item => [item.externalSource?.externalId, item]));\n const itemsByClaimKey = new Map(items.filter(item => item.claimKey).map(item => [item.claimKey, item]));\n const statuses: IngressStatus[] = [];\n for (const issue of input.issues) {\n // The stable issue id finds the card even after Linear renamed the\n // identifier; the identifier covers cards filed before claims existed.\n const relatedItem =\n itemsByClaimKey.get(linearClaimKey(issue.id)) ?? itemsBySourceKey.get(`linear:${issue.identifier}`);\n // One live card per Linear issue per org. When the winning source for an\n // issue moves to a source routed elsewhere (a project deselected under a\n // selected team, or the reverse), the card that already exists keeps the\n // issue; this Factory must not mint a second one. The store's claim index\n // is the guarantee; this check spares the dispatcher a refused upsert.\n if (!relatedItem && (await this.#heldElsewhere(input, issue))) {\n statuses.push('missing');\n continue;\n }\n statuses.push(await this.#ingestIssue(input, issue, relatedItem));\n }\n if (statuses.some(status => status === 'committed')) return { status: 'committed', ingested: statuses.length };\n if (statuses.some(status => status === 'replayed')) return { status: 'replayed', ingested: statuses.length };\n return { status: 'missing', ingested: statuses.length };\n }\n\n async #heldElsewhere(input: LinearRulesIngress, issue: LinearIssueIngress): Promise<boolean> {\n const heldLive = (row: WorkItemRow) =>\n row.factoryProjectId !== input.factoryProjectId && !isTerminalWorkItem(this.options.boards, row);\n const claimant = await this.options.storage.getByClaimKey({\n orgId: input.orgId,\n claimKey: linearClaimKey(issue.id),\n });\n if (claimant && heldLive(claimant)) return true;\n // Cards filed before claims existed carry only the identifier.\n const legacy = await this.options.storage.listBySource({\n orgId: input.orgId,\n source: { integrationId: 'linear', type: 'issue', externalId: `linear:${issue.identifier}` },\n });\n return legacy.some(heldLive);\n }\n\n async #ingestIssue(\n input: LinearRulesIngress,\n issue: LinearIssueIngress,\n relatedItem: WorkItemRow | undefined,\n attempt = 1,\n ): Promise<IngressStatus> {\n const ingressId = `linear:${issue.id}:${issue.updatedAt}`;\n const actor = { type: 'human' as const, id: input.userId };\n\n // Closed issues without existing work items should not create new ones.\n const isClosed = issue.stateType === 'completed' || issue.stateType === 'canceled';\n if (isClosed && !relatedItem) {\n return 'missing';\n }\n\n const event = isClosed ? 'issueClosed' : 'issueObserved';\n const boundBoardId = issue.sourceId ? input.intakeBoards?.[issue.sourceId] : undefined;\n const boundBoard = boundBoardId ? this.options.boards.get(boundBoardId) : undefined;\n const context: FactoryLinearRuleContext = {\n tenant: { orgId: input.orgId, projectId: input.factoryProjectId },\n actor,\n ingress: { type: 'linear', id: ingressId },\n cause: `linear.${event}`,\n causalChain: [],\n configVersion: this.options.configVersion,\n ...(relatedItem\n ? {\n item: {\n id: relatedItem.id,\n source: 'linear-issue',\n sourceKey: relatedItem.externalSource?.externalId ?? null,\n parentWorkItemId: relatedItem.parentWorkItemId,\n title: relatedItem.title,\n url: relatedItem.externalSource?.url ?? null,\n stages: relatedItem.stages,\n acceptedAt: relatedItem.acceptedAt,\n metadata: relatedItem.metadata,\n },\n board: boardForWorkItem(relatedItem),\n itemRevision: relatedItem.revision,\n ...withBoardClosure(this.options.boards, relatedItem),\n ...rulePhaseField(this.options.boards, relatedItem),\n }\n : {}),\n ...(boundBoard ? { intake: { board: boundBoard.id, initialPhase: boundBoard.initialPhase } } : {}),\n event,\n issue,\n };\n\n const rule = selectEventRule(\n this.options.linearRules,\n boardEventHandlers(this.options.boards, eventBoardId(context), 'linear'),\n context.event,\n );\n let decision: FactoryRuleDecision | void;\n let decisions: Record<string, unknown>[] = [];\n let outcome: { status: 'accepted' | 'rejected'; code?: string; reason?: string } = { status: 'accepted' };\n try {\n decision = rule ? await withRuleTimeout(Promise.resolve(rule(Object.freeze(context)))) : undefined;\n if (decision?.type === 'reject') {\n outcome = { status: 'rejected', code: decision.code, reason: decision.reason };\n } else if (decision) {\n decisions = validateFactoryRuleDecisions([decision]).map(entry => {\n assertFactoryDecisionTarget(\n entry,\n this.options.boards,\n relatedItem ? boardForWorkItem(relatedItem) : undefined,\n );\n return { ...entry };\n });\n }\n } catch (error) {\n const timedOut = error instanceof Error && error.message === 'FACTORY_RULE_TIMEOUT';\n outcome = {\n status: 'rejected',\n code: timedOut ? 'timeout' : 'rule_error',\n reason: timedOut\n ? 'Factory rule evaluation timed out.'\n : error instanceof Error\n ? error.message.slice(0, 2_000)\n : 'Factory Linear rule failed.',\n };\n }\n\n const committed = await this.options.storage.commitRuleEvaluation({\n orgId: input.orgId,\n factoryProjectId: input.factoryProjectId,\n workItemId: relatedItem?.id ?? null,\n ingress: { identity: ingressId, triggerType: 'linear.issueObserved' },\n configVersion: this.options.configVersion,\n expectedRevision: relatedItem?.revision ?? null,\n actor,\n outcome,\n decisions,\n causalChain: [],\n now: new Date(),\n });\n if (committed.status === 'stale') {\n // Nothing was persisted: re-read the item and evaluate again at its fresh revision.\n if (attempt >= MAX_STALE_RULE_ATTEMPTS) {\n throw new Error(`Factory Linear rule evaluation for ${ingressId} kept losing revision races.`);\n }\n const fresh = relatedItem ? await this.options.storage.get({ orgId: input.orgId, id: relatedItem.id }) : null;\n if (relatedItem && !fresh) return 'missing';\n return this.#ingestIssue(input, issue, fresh ?? undefined, attempt + 1);\n }\n return committed.status;\n }\n}\n\nexport function attachLinearRules(\n linear: { readonly rules: LinearEventRules },\n context: IntegrationContext,\n): ((input: LinearRulesIngress) => Promise<unknown>) | undefined {\n if (!context.runtime) return undefined;\n const rules = new LinearRules({\n projects: context.storage.projects,\n storage: context.runtime.workItems,\n configVersion: context.runtime.configVersion,\n boards: context.runtime.boards,\n linearRules: linear.rules,\n });\n return input => rules.ingest(input);\n}\n"],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import type { WorkItemsStorage } from '../storage/domains/work-items/base.js';
2
+ /**
3
+ * Records the head a pull request or merge request was observed at, so a
4
+ * review verdict given on an earlier head can be shown as outdated. Webhooks
5
+ * arrive out of order and get redelivered, so a write only lands when its
6
+ * event is newer than the one the card last recorded; an event without a
7
+ * timestamp cannot be ordered and is dropped. The check and the write run
8
+ * against the same revision, so a concurrent delivery cannot slip between them.
9
+ */
10
+ export declare function recordPullRequestHead(storage: WorkItemsStorage, input: {
11
+ orgId: string;
12
+ id: string;
13
+ headSha: string;
14
+ headAt: number | undefined;
15
+ /** Only write while the card has no open pull request or has this one out. */
16
+ ownedPullRequestNumber?: number;
17
+ }): Promise<void>;
18
+ //# sourceMappingURL=pull-request-head.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pull-request-head.d.ts","sourceRoot":"","sources":["../../src/integrations/pull-request-head.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uCAAuC,CAAC;AAE9E;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE;IACL,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,8EAA8E;IAC9E,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACjC,GACA,OAAO,CAAC,IAAI,CAAC,CA0Bf"}
@@ -0,0 +1,45 @@
1
+ import { WorkItemUpdateConflictError } from "../storage/domains/work-items/base.js";
2
+ //#region src/integrations/pull-request-head.ts
3
+ /**
4
+ * Records the head a pull request or merge request was observed at, so a
5
+ * review verdict given on an earlier head can be shown as outdated. Webhooks
6
+ * arrive out of order and get redelivered, so a write only lands when its
7
+ * event is newer than the one the card last recorded; an event without a
8
+ * timestamp cannot be ordered and is dropped. The check and the write run
9
+ * against the same revision, so a concurrent delivery cannot slip between them.
10
+ */
11
+ async function recordPullRequestHead(storage, input) {
12
+ if (input.headAt === void 0) return;
13
+ for (let attempt = 0; attempt < 3; attempt++) {
14
+ const card = await storage.get({
15
+ orgId: input.orgId,
16
+ id: input.id
17
+ });
18
+ if (!card) return;
19
+ if (input.ownedPullRequestNumber !== void 0) {
20
+ const recorded = card.metadata?.openPullRequestNumber;
21
+ if (typeof recorded === "number" && recorded !== input.ownedPullRequestNumber) return;
22
+ }
23
+ const recordedAt = card.metadata?.pullRequestHeadAt;
24
+ if (typeof recordedAt === "number" && recordedAt >= input.headAt) return;
25
+ try {
26
+ await storage.update({
27
+ orgId: card.orgId,
28
+ id: card.id,
29
+ userId: "factory-rule-dispatcher",
30
+ expectedRevision: card.revision,
31
+ patch: { metadata: {
32
+ pullRequestHeadSha: input.headSha,
33
+ pullRequestHeadAt: input.headAt
34
+ } }
35
+ });
36
+ return;
37
+ } catch (error) {
38
+ if (!(error instanceof WorkItemUpdateConflictError)) throw error;
39
+ }
40
+ }
41
+ }
42
+ //#endregion
43
+ export { recordPullRequestHead };
44
+
45
+ //# sourceMappingURL=pull-request-head.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pull-request-head.js","names":[],"sources":["../../src/integrations/pull-request-head.ts"],"sourcesContent":["import { WorkItemUpdateConflictError } from '../storage/domains/work-items/base.js';\nimport type { WorkItemsStorage } from '../storage/domains/work-items/base.js';\n\n/**\n * Records the head a pull request or merge request was observed at, so a\n * review verdict given on an earlier head can be shown as outdated. Webhooks\n * arrive out of order and get redelivered, so a write only lands when its\n * event is newer than the one the card last recorded; an event without a\n * timestamp cannot be ordered and is dropped. The check and the write run\n * against the same revision, so a concurrent delivery cannot slip between them.\n */\nexport async function recordPullRequestHead(\n storage: WorkItemsStorage,\n input: {\n orgId: string;\n id: string;\n headSha: string;\n headAt: number | undefined;\n /** Only write while the card has no open pull request or has this one out. */\n ownedPullRequestNumber?: number;\n },\n): Promise<void> {\n if (input.headAt === undefined) return;\n for (let attempt = 0; attempt < 3; attempt++) {\n const card = await storage.get({ orgId: input.orgId, id: input.id });\n if (!card) return;\n if (input.ownedPullRequestNumber !== undefined) {\n const recorded = card.metadata?.openPullRequestNumber;\n if (typeof recorded === 'number' && recorded !== input.ownedPullRequestNumber) return;\n }\n const recordedAt = card.metadata?.pullRequestHeadAt;\n if (typeof recordedAt === 'number' && recordedAt >= input.headAt) return;\n try {\n // The time advances even when the head is unchanged: a head that returns\n // to an earlier commit must still beat a late event for the one between.\n await storage.update({\n orgId: card.orgId,\n id: card.id,\n userId: 'factory-rule-dispatcher',\n expectedRevision: card.revision,\n patch: { metadata: { pullRequestHeadSha: input.headSha, pullRequestHeadAt: input.headAt } },\n });\n return;\n } catch (error) {\n if (!(error instanceof WorkItemUpdateConflictError)) throw error;\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAWA,eAAsB,sBACpB,SACA,OAQe;CACf,IAAI,MAAM,WAAW,KAAA,GAAW;CAChC,KAAK,IAAI,UAAU,GAAG,UAAU,GAAG,WAAW;EAC5C,MAAM,OAAO,MAAM,QAAQ,IAAI;GAAE,OAAO,MAAM;GAAO,IAAI,MAAM;EAAG,CAAC;EACnE,IAAI,CAAC,MAAM;EACX,IAAI,MAAM,2BAA2B,KAAA,GAAW;GAC9C,MAAM,WAAW,KAAK,UAAU;GAChC,IAAI,OAAO,aAAa,YAAY,aAAa,MAAM,wBAAwB;EACjF;EACA,MAAM,aAAa,KAAK,UAAU;EAClC,IAAI,OAAO,eAAe,YAAY,cAAc,MAAM,QAAQ;EAClE,IAAI;GAGF,MAAM,QAAQ,OAAO;IACnB,OAAO,KAAK;IACZ,IAAI,KAAK;IACT,QAAQ;IACR,kBAAkB,KAAK;IACvB,OAAO,EAAE,UAAU;KAAE,oBAAoB,MAAM;KAAS,mBAAmB,MAAM;IAAO,EAAE;GAC5F,CAAC;GACD;EACF,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiB,8BAA8B,MAAM;EAC7D;CACF;AACF"}
@@ -1,6 +1,6 @@
1
- import { factoryAttentionKey, factoryDecisionAttentionIdentity, factoryMentionAttentionIdentity, factorySupervisorFindingAttentionIdentity } from "../storage/domains/work-items/base.js";
2
1
  import { factoryDispatchFailureMetadata } from "../rules/dispatch-errors.js";
3
- import { overtakenDecisionIds } from "../rules/decision-applicability.js";
2
+ import { factoryAttentionKey, factoryDecisionAttentionIdentity, factoryMentionAttentionIdentity, factorySupervisorFindingAttentionIdentity } from "../storage/domains/work-items/base.js";
3
+ import { decisionRetryable, overtakenDecisionIds } from "../rules/decision-applicability.js";
4
4
  //#region src/routes/attention-providers.ts
5
5
  /**
6
6
  * Per-kind attention providers. Each kind owns its counts, its bounded page
@@ -233,7 +233,7 @@ const failedDecisionAttentionSpec = {
233
233
  detail: (decision) => decision.lastError?.slice(0, 512) ?? factoryDispatchFailureMetadata(decision.failureCode).label,
234
234
  extra: (decision, { overtaken }) => ({
235
235
  failureCode: decision.failureCode,
236
- canRetry: !overtaken && factoryDispatchFailureMetadata(decision.failureCode).canRetry
236
+ canRetry: decisionRetryable(decision, overtaken)
237
237
  }),
238
238
  matches: (decision, item, search) => item?.title.toLowerCase().includes(search) === true || decision.lastError?.toLowerCase().includes(search) === true || factoryDecisionType(decision).toLowerCase().includes(search)
239
239
  };