@mastra/factory 0.15.0 → 0.16.0-alpha.10
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.
- package/README.md +4 -3
- package/dist/boards/index.d.ts +1 -1
- package/dist/boards/index.d.ts.map +1 -1
- package/dist/boards/index.js +2 -2
- package/dist/boards/index.js.map +1 -1
- package/dist/boards/review.d.ts.map +1 -1
- package/dist/boards/review.js +4 -1
- package/dist/boards/review.js.map +1 -1
- package/dist/boards/semantics.d.ts +7 -0
- package/dist/boards/semantics.d.ts.map +1 -1
- package/dist/boards/semantics.js +15 -1
- package/dist/boards/semantics.js.map +1 -1
- package/dist/boards/transition-policy.d.ts +4 -0
- package/dist/boards/transition-policy.d.ts.map +1 -1
- package/dist/boards/transition-policy.js.map +1 -1
- package/dist/boards/work-transition-policy.d.ts.map +1 -1
- package/dist/boards/work-transition-policy.js +6 -1
- package/dist/boards/work-transition-policy.js.map +1 -1
- package/dist/boards/work.d.ts.map +1 -1
- package/dist/boards/work.js +37 -3
- package/dist/boards/work.js.map +1 -1
- package/dist/capabilities/intake.d.ts +4 -0
- package/dist/capabilities/intake.d.ts.map +1 -1
- package/dist/factory.d.ts +6 -3
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +66 -18
- package/dist/factory.js.map +1 -1
- package/dist/integrations/base.d.ts +6 -2
- package/dist/integrations/base.d.ts.map +1 -1
- package/dist/integrations/incidentio/agent-tools.d.ts +49 -0
- package/dist/integrations/incidentio/agent-tools.d.ts.map +1 -0
- package/dist/integrations/incidentio/agent-tools.js +90 -0
- package/dist/integrations/incidentio/agent-tools.js.map +1 -0
- package/dist/integrations/incidentio/claim.d.ts +8 -0
- package/dist/integrations/incidentio/claim.d.ts.map +1 -0
- package/dist/integrations/incidentio/claim.js +14 -0
- package/dist/integrations/incidentio/claim.js.map +1 -0
- package/dist/integrations/incidentio/default-rules.d.ts +46 -0
- package/dist/integrations/incidentio/default-rules.d.ts.map +1 -0
- package/dist/integrations/incidentio/default-rules.js +66 -0
- package/dist/integrations/incidentio/default-rules.js.map +1 -0
- package/dist/integrations/incidentio/integration.d.ts +48 -2
- package/dist/integrations/incidentio/integration.d.ts.map +1 -1
- package/dist/integrations/incidentio/integration.js +88 -2
- package/dist/integrations/incidentio/integration.js.map +1 -1
- package/dist/integrations/incidentio/issue-reconciler.d.ts +2 -0
- package/dist/integrations/incidentio/issue-reconciler.d.ts.map +1 -1
- package/dist/integrations/incidentio/issue-reconciler.js +38 -2
- package/dist/integrations/incidentio/issue-reconciler.js.map +1 -1
- package/dist/integrations/incidentio/routes.d.ts +71 -0
- package/dist/integrations/incidentio/routes.d.ts.map +1 -0
- package/dist/integrations/incidentio/routes.js +245 -0
- package/dist/integrations/incidentio/routes.js.map +1 -0
- package/dist/integrations/incidentio/rules.d.ts +55 -0
- package/dist/integrations/incidentio/rules.d.ts.map +1 -0
- package/dist/integrations/incidentio/rules.js +175 -0
- package/dist/integrations/incidentio/rules.js.map +1 -0
- package/dist/integrations/jira/adf.d.ts +26 -0
- package/dist/integrations/jira/adf.d.ts.map +1 -0
- package/dist/integrations/jira/adf.js +73 -0
- package/dist/integrations/jira/adf.js.map +1 -0
- package/dist/integrations/jira/agent-tools.d.ts +45 -0
- package/dist/integrations/jira/agent-tools.d.ts.map +1 -0
- package/dist/integrations/jira/agent-tools.js +87 -0
- package/dist/integrations/jira/agent-tools.js.map +1 -0
- package/dist/integrations/jira/api.d.ts +157 -0
- package/dist/integrations/jira/api.d.ts.map +1 -0
- package/dist/integrations/jira/api.js +141 -0
- package/dist/integrations/jira/api.js.map +1 -0
- package/dist/integrations/jira/claim.d.ts +9 -0
- package/dist/integrations/jira/claim.d.ts.map +1 -0
- package/dist/integrations/jira/claim.js +15 -0
- package/dist/integrations/jira/claim.js.map +1 -0
- package/dist/integrations/jira/default-rules.d.ts +47 -0
- package/dist/integrations/jira/default-rules.d.ts.map +1 -0
- package/dist/integrations/jira/default-rules.js +67 -0
- package/dist/integrations/jira/default-rules.js.map +1 -0
- package/dist/integrations/jira/integration.d.ts +114 -0
- package/dist/integrations/jira/integration.d.ts.map +1 -0
- package/dist/integrations/jira/integration.js +401 -0
- package/dist/integrations/jira/integration.js.map +1 -0
- package/dist/integrations/jira/issue-reconciler.d.ts +10 -0
- package/dist/integrations/jira/issue-reconciler.d.ts.map +1 -0
- package/dist/integrations/jira/issue-reconciler.js +70 -0
- package/dist/integrations/jira/issue-reconciler.js.map +1 -0
- package/dist/integrations/jira/reconciliation-config.d.ts +3 -0
- package/dist/integrations/jira/reconciliation-config.d.ts.map +1 -0
- package/dist/integrations/jira/reconciliation-config.js +17 -0
- package/dist/integrations/jira/reconciliation-config.js.map +1 -0
- package/dist/integrations/jira/routes.d.ts +54 -0
- package/dist/integrations/jira/routes.d.ts.map +1 -0
- package/dist/integrations/jira/routes.js +260 -0
- package/dist/integrations/jira/routes.js.map +1 -0
- package/dist/integrations/jira/rules.d.ts +57 -0
- package/dist/integrations/jira/rules.d.ts.map +1 -0
- package/dist/integrations/jira/rules.js +175 -0
- package/dist/integrations/jira/rules.js.map +1 -0
- package/dist/integrations/linear/claim.d.ts +8 -0
- package/dist/integrations/linear/claim.d.ts.map +1 -0
- package/dist/integrations/linear/claim.js +14 -0
- package/dist/integrations/linear/claim.js.map +1 -0
- package/dist/integrations/linear/default-rules.d.ts +1 -0
- package/dist/integrations/linear/default-rules.d.ts.map +1 -1
- package/dist/integrations/linear/default-rules.js +2 -0
- package/dist/integrations/linear/default-rules.js.map +1 -1
- package/dist/integrations/linear/integration.d.ts +24 -6
- package/dist/integrations/linear/integration.d.ts.map +1 -1
- package/dist/integrations/linear/integration.js +257 -37
- package/dist/integrations/linear/integration.js.map +1 -1
- package/dist/integrations/linear/routes.d.ts +10 -0
- package/dist/integrations/linear/routes.d.ts.map +1 -1
- package/dist/integrations/linear/routes.js +74 -12
- package/dist/integrations/linear/routes.js.map +1 -1
- package/dist/integrations/linear/rules.d.ts +1 -1
- package/dist/integrations/linear/rules.d.ts.map +1 -1
- package/dist/integrations/linear/rules.js +27 -2
- package/dist/integrations/linear/rules.js.map +1 -1
- package/dist/integrations/platform/connect/routes.d.ts +39 -0
- package/dist/integrations/platform/connect/routes.d.ts.map +1 -0
- package/dist/integrations/platform/connect/routes.js +129 -0
- package/dist/integrations/platform/connect/routes.js.map +1 -0
- package/dist/integrations/platform/github/event-worker.js +1 -1
- package/dist/integrations/platform/github/integration.js +1 -1
- package/dist/integrations/platform/incidentio/integration.d.ts +61 -4
- package/dist/integrations/platform/incidentio/integration.d.ts.map +1 -1
- package/dist/integrations/platform/incidentio/integration.js +371 -22
- package/dist/integrations/platform/incidentio/integration.js.map +1 -1
- package/dist/integrations/platform/jira/agent-tools.d.ts +36 -0
- package/dist/integrations/platform/jira/agent-tools.d.ts.map +1 -0
- package/dist/integrations/platform/jira/agent-tools.js +85 -0
- package/dist/integrations/platform/jira/agent-tools.js.map +1 -0
- package/dist/integrations/platform/jira/integration.d.ts +65 -0
- package/dist/integrations/platform/jira/integration.d.ts.map +1 -0
- package/dist/integrations/platform/jira/integration.js +540 -0
- package/dist/integrations/platform/jira/integration.js.map +1 -0
- package/dist/integrations/platform/jira/routes.d.ts +55 -0
- package/dist/integrations/platform/jira/routes.d.ts.map +1 -0
- package/dist/integrations/platform/jira/routes.js +274 -0
- package/dist/integrations/platform/jira/routes.js.map +1 -0
- package/dist/integrations/platform/linear/integration.d.ts +34 -1
- package/dist/integrations/platform/linear/integration.d.ts.map +1 -1
- package/dist/integrations/platform/linear/integration.js +271 -48
- package/dist/integrations/platform/linear/integration.js.map +1 -1
- package/dist/routes/intake.d.ts.map +1 -1
- package/dist/routes/intake.js +15 -5
- package/dist/routes/intake.js.map +1 -1
- package/dist/routes/oauth.js +2 -5
- package/dist/routes/oauth.js.map +1 -1
- package/dist/routes/surface.d.ts.map +1 -1
- package/dist/routes/surface.js +24 -4
- package/dist/routes/surface.js.map +1 -1
- package/dist/rules/dispatch-errors.d.ts.map +1 -1
- package/dist/rules/dispatch-errors.js +8 -0
- package/dist/rules/dispatch-errors.js.map +1 -1
- package/dist/rules/dispatcher.d.ts +1 -0
- package/dist/rules/dispatcher.d.ts.map +1 -1
- package/dist/rules/dispatcher.js +180 -106
- package/dist/rules/dispatcher.js.map +1 -1
- package/dist/rules/index.d.ts +2 -2
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +2 -2
- package/dist/rules/start-coordinator.d.ts.map +1 -1
- package/dist/rules/start-coordinator.js +4 -1
- package/dist/rules/start-coordinator.js.map +1 -1
- package/dist/rules/terminal-cleanup.d.ts +14 -1
- package/dist/rules/terminal-cleanup.d.ts.map +1 -1
- package/dist/rules/terminal-cleanup.js +20 -0
- package/dist/rules/terminal-cleanup.js.map +1 -1
- package/dist/rules/tools.d.ts +2 -0
- package/dist/rules/tools.d.ts.map +1 -1
- package/dist/rules/tools.js +30 -2
- package/dist/rules/tools.js.map +1 -1
- package/dist/rules/transition-service.d.ts +13 -0
- package/dist/rules/transition-service.d.ts.map +1 -1
- package/dist/rules/transition-service.js +11 -2
- package/dist/rules/transition-service.js.map +1 -1
- package/dist/rules/types.d.ts +72 -3
- package/dist/rules/types.d.ts.map +1 -1
- package/dist/rules/types.js +9 -1
- package/dist/rules/types.js.map +1 -1
- package/dist/rules/validation.d.ts.map +1 -1
- package/dist/rules/validation.js +13 -2
- package/dist/rules/validation.js.map +1 -1
- package/dist/session/run-audit.d.ts +9 -0
- package/dist/session/run-audit.d.ts.map +1 -1
- package/dist/session/run-audit.js +37 -6
- package/dist/session/run-audit.js.map +1 -1
- package/dist/skills/service.d.ts +12 -0
- package/dist/skills/service.d.ts.map +1 -1
- package/dist/skills/service.js +27 -1
- package/dist/skills/service.js.map +1 -1
- package/dist/storage/domains/intake/base.d.ts +11 -8
- package/dist/storage/domains/intake/base.d.ts.map +1 -1
- package/dist/storage/domains/intake/base.js +69 -24
- package/dist/storage/domains/intake/base.js.map +1 -1
- package/dist/storage/domains/work-items/base.d.ts +41 -2
- package/dist/storage/domains/work-items/base.d.ts.map +1 -1
- package/dist/storage/domains/work-items/base.js +159 -12
- package/dist/storage/domains/work-items/base.js.map +1 -1
- package/dist/supervisor/session-messaging.d.ts +23 -0
- package/dist/supervisor/session-messaging.d.ts.map +1 -0
- package/dist/supervisor/session-messaging.js +15 -0
- package/dist/supervisor/session-messaging.js.map +1 -0
- package/dist/supervisor/write-tools.d.ts +2 -1
- package/dist/supervisor/write-tools.d.ts.map +1 -1
- package/dist/supervisor/write-tools.js +11 -7
- package/dist/supervisor/write-tools.js.map +1 -1
- package/dist/work-item-branch.d.ts +1 -1
- package/dist/work-item-branch.d.ts.map +1 -1
- package/dist/work-item-branch.js +4 -2
- package/dist/work-item-branch.js.map +1 -1
- package/package.json +11 -11
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { JiraApiError } from "./api.js";
|
|
2
|
+
import { DEPLOYMENT_CONNECTION } from "./integration.js";
|
|
3
|
+
import { registerApiRoute } from "@mastra/core/server";
|
|
4
|
+
//#region src/integrations/jira/routes.ts
|
|
5
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
const ISSUE_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/;
|
|
7
|
+
/** Erase a route handler's path-parameterized context to a plain `Context`. */
|
|
8
|
+
function loose(c) {
|
|
9
|
+
return c;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Narrow the caller's selected Jira projects to the ones that feed this
|
|
13
|
+
* Factory project.
|
|
14
|
+
*
|
|
15
|
+
* A Jira issue carries no Factory project of its own, so without a binding
|
|
16
|
+
* every board view would ingest every selected project's issues into whichever
|
|
17
|
+
* Factory happened to be on screen. Routing is explicit: a project feeds this
|
|
18
|
+
* Factory only when its binding names both the Factory project and a board.
|
|
19
|
+
* Returns the bound board per source so consumers can land cards where the
|
|
20
|
+
* user asked. Mirrors the Linear routes' scoping semantics locally — the
|
|
21
|
+
* generic binding storage is provider-neutral.
|
|
22
|
+
*/
|
|
23
|
+
async function scopeSourceIdsToProject({ intake, orgId, factoryProjectId, selectedIds }) {
|
|
24
|
+
const selected = new Set(selectedIds);
|
|
25
|
+
const intakeBoards = {};
|
|
26
|
+
for (const binding of await intake.listBindings({
|
|
27
|
+
orgId,
|
|
28
|
+
integrationId: "jira"
|
|
29
|
+
})) if (binding.factoryProjectId === factoryProjectId && binding.board && selected.has(binding.sourceId)) intakeBoards[binding.sourceId] = binding.board;
|
|
30
|
+
return intakeBoards;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the org-scoped tenant for a Jira request. Intake selections are
|
|
34
|
+
* org-owned, so it requires both a signed-in user and an organization — same
|
|
35
|
+
* tenancy rules as the Linear routes.
|
|
36
|
+
*/
|
|
37
|
+
async function resolveOrgTenant(c, auth) {
|
|
38
|
+
await auth.ensureUser(c);
|
|
39
|
+
const tenant = auth.tenant(c);
|
|
40
|
+
if (!tenant) return { response: c.json({ error: "unauthorized" }, 401) };
|
|
41
|
+
if (!tenant.orgId) return { response: c.json({
|
|
42
|
+
error: "organization_required",
|
|
43
|
+
message: "Jira intake requires an organization. Personal accounts cannot use Jira intake."
|
|
44
|
+
}, 403) };
|
|
45
|
+
return { tenant: {
|
|
46
|
+
orgId: tenant.orgId,
|
|
47
|
+
userId: tenant.userId
|
|
48
|
+
} };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Validate an opaque Jira pagination cursor from the query string. Cursors are
|
|
52
|
+
* server-issued (`nextPageToken`), so anything outside a conservative
|
|
53
|
+
* charset/length is rejected rather than forwarded to Jira.
|
|
54
|
+
*/
|
|
55
|
+
function parseAfterCursor(raw) {
|
|
56
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
57
|
+
if (raw.length > 512 || !/^[\w+/=.:-]+$/.test(raw)) return null;
|
|
58
|
+
return raw;
|
|
59
|
+
}
|
|
60
|
+
/** Map a Jira read failure to the API response for the SPA. */
|
|
61
|
+
function jiraFetchError(c, err) {
|
|
62
|
+
if (err instanceof JiraApiError && err.code === "jira_auth_failed") return c.json({
|
|
63
|
+
error: "jira_auth_failed",
|
|
64
|
+
message: "Jira rejected the configured credentials. Check JIRA_EMAIL and JIRA_API_TOKEN."
|
|
65
|
+
}, 409);
|
|
66
|
+
return c.json({
|
|
67
|
+
error: "jira_fetch_failed",
|
|
68
|
+
message: err instanceof Error ? err.message : String(err)
|
|
69
|
+
}, 502);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build the Jira routes as Mastra `apiRoutes`. When the feature is disabled,
|
|
73
|
+
* returns only the `status` route so the SPA can detect the disabled state.
|
|
74
|
+
*/
|
|
75
|
+
function buildJiraRoutes(options) {
|
|
76
|
+
const routes = [];
|
|
77
|
+
const { jira, auth, intake } = options;
|
|
78
|
+
const enabled = Boolean(jira) && auth.enabled();
|
|
79
|
+
const diagnostics = () => ({
|
|
80
|
+
jiraConfigured: Boolean(jira),
|
|
81
|
+
factoryAuthEnabled: auth.enabled(),
|
|
82
|
+
appDbConfigured: true
|
|
83
|
+
});
|
|
84
|
+
routes.push(registerApiRoute("/web/jira/status", {
|
|
85
|
+
method: "GET",
|
|
86
|
+
requiresAuth: false,
|
|
87
|
+
handler: async (c) => {
|
|
88
|
+
if (!enabled || !jira || !intake) return c.json({
|
|
89
|
+
enabled: false,
|
|
90
|
+
configured: Boolean(jira),
|
|
91
|
+
mode: "direct",
|
|
92
|
+
site: null,
|
|
93
|
+
reason: "missing_config",
|
|
94
|
+
diagnostics: diagnostics()
|
|
95
|
+
});
|
|
96
|
+
await auth.ensureUser(loose(c));
|
|
97
|
+
const tenant = auth.tenant(loose(c));
|
|
98
|
+
if (!tenant) return c.json({
|
|
99
|
+
error: "unauthorized",
|
|
100
|
+
reason: "auth_required"
|
|
101
|
+
}, 401);
|
|
102
|
+
const site = new URL(jira.baseUrl).host;
|
|
103
|
+
if (!tenant.orgId) return c.json({
|
|
104
|
+
enabled: true,
|
|
105
|
+
configured: true,
|
|
106
|
+
mode: "direct",
|
|
107
|
+
organizationRequired: true,
|
|
108
|
+
site,
|
|
109
|
+
reason: "organization_required",
|
|
110
|
+
diagnostics: diagnostics()
|
|
111
|
+
});
|
|
112
|
+
return c.json({
|
|
113
|
+
enabled: true,
|
|
114
|
+
configured: true,
|
|
115
|
+
mode: "direct",
|
|
116
|
+
site,
|
|
117
|
+
reason: "ready",
|
|
118
|
+
diagnostics: diagnostics()
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}));
|
|
122
|
+
if (!enabled || !jira || !intake) return routes;
|
|
123
|
+
routes.push(registerApiRoute("/web/jira/projects", {
|
|
124
|
+
method: "GET",
|
|
125
|
+
requiresAuth: false,
|
|
126
|
+
handler: async (c) => {
|
|
127
|
+
const resolved = await resolveOrgTenant(loose(c), auth);
|
|
128
|
+
if ("response" in resolved) return resolved.response;
|
|
129
|
+
try {
|
|
130
|
+
const sources = await jira.intake.listSources(resolved.tenant);
|
|
131
|
+
return c.json({ projects: sources.map((source) => ({
|
|
132
|
+
id: source.id,
|
|
133
|
+
name: source.name,
|
|
134
|
+
key: typeof source.metadata?.key === "string" ? source.metadata.key : null
|
|
135
|
+
})) });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return jiraFetchError(loose(c), err);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}));
|
|
141
|
+
routes.push(registerApiRoute("/web/jira/issues", {
|
|
142
|
+
method: "GET",
|
|
143
|
+
requiresAuth: false,
|
|
144
|
+
handler: async (c) => {
|
|
145
|
+
const resolved = await resolveOrgTenant(loose(c), auth);
|
|
146
|
+
if ("response" in resolved) return resolved.response;
|
|
147
|
+
const after = parseAfterCursor(c.req.query("after"));
|
|
148
|
+
if (after === null) return c.json({ error: "invalid_cursor" }, 400);
|
|
149
|
+
const factoryProjectId = c.req.query("factoryProjectId");
|
|
150
|
+
if (factoryProjectId && !UUID_RE.test(factoryProjectId)) return c.json({ error: "invalid_factory_project_id" }, 400);
|
|
151
|
+
await intake.ensureReady();
|
|
152
|
+
const selection = (await intake.getConfig({
|
|
153
|
+
orgId: resolved.tenant.orgId,
|
|
154
|
+
integrationIds: ["jira"]
|
|
155
|
+
})).jira;
|
|
156
|
+
if (!selection.enabled) return c.json({
|
|
157
|
+
error: "jira_intake_disabled",
|
|
158
|
+
message: "Jira intake is turned off in Settings."
|
|
159
|
+
}, 404);
|
|
160
|
+
const selectedIds = selection.sourceIds ?? [];
|
|
161
|
+
const intakeBoards = factoryProjectId ? await scopeSourceIdsToProject({
|
|
162
|
+
intake,
|
|
163
|
+
orgId: resolved.tenant.orgId,
|
|
164
|
+
factoryProjectId,
|
|
165
|
+
selectedIds
|
|
166
|
+
}) : null;
|
|
167
|
+
const projectIds = intakeBoards ? Object.keys(intakeBoards) : selectedIds;
|
|
168
|
+
if (projectIds.length === 0) return c.json({
|
|
169
|
+
issues: [],
|
|
170
|
+
nextCursor: null
|
|
171
|
+
});
|
|
172
|
+
try {
|
|
173
|
+
const { issues, nextCursor } = await jira.listActiveIssues(after, projectIds);
|
|
174
|
+
const issuePayload = issues.map((issue) => ({
|
|
175
|
+
id: issue.id,
|
|
176
|
+
identifier: issue.identifier,
|
|
177
|
+
title: issue.title,
|
|
178
|
+
url: issue.url,
|
|
179
|
+
author: issue.author,
|
|
180
|
+
state: issue.state ?? "",
|
|
181
|
+
stateType: issue.stateType ?? "",
|
|
182
|
+
priorityLabel: issue.priority ?? "",
|
|
183
|
+
assignee: issue.assignee,
|
|
184
|
+
project: issue.source,
|
|
185
|
+
labels: issue.labels,
|
|
186
|
+
createdAt: issue.createdAt,
|
|
187
|
+
updatedAt: issue.updatedAt,
|
|
188
|
+
sourceId: issue.sourceId || null
|
|
189
|
+
}));
|
|
190
|
+
if (factoryProjectId && intakeBoards && options.ingestFactoryIssues) await options.ingestFactoryIssues({
|
|
191
|
+
orgId: resolved.tenant.orgId,
|
|
192
|
+
userId: resolved.tenant.userId,
|
|
193
|
+
factoryProjectId,
|
|
194
|
+
issues: issuePayload.map((issue) => ({
|
|
195
|
+
...issue,
|
|
196
|
+
site: null
|
|
197
|
+
})),
|
|
198
|
+
intakeBoards
|
|
199
|
+
});
|
|
200
|
+
return c.json({
|
|
201
|
+
issues: issuePayload,
|
|
202
|
+
nextCursor
|
|
203
|
+
});
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return jiraFetchError(loose(c), err);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}));
|
|
209
|
+
routes.push(registerApiRoute("/web/jira/issues/:identifier", {
|
|
210
|
+
method: "GET",
|
|
211
|
+
requiresAuth: false,
|
|
212
|
+
handler: async (c) => {
|
|
213
|
+
const resolved = await resolveOrgTenant(loose(c), auth);
|
|
214
|
+
if ("response" in resolved) return resolved.response;
|
|
215
|
+
const identifier = c.req.param("identifier");
|
|
216
|
+
const factoryProjectId = c.req.query("factoryProjectId");
|
|
217
|
+
const issueRef = c.req.query("issueRef");
|
|
218
|
+
if (!ISSUE_KEY_RE.test(identifier)) return c.json({ error: "invalid_issue_identifier" }, 400);
|
|
219
|
+
if (!factoryProjectId || !UUID_RE.test(factoryProjectId)) return c.json({ error: "invalid_factory_project_id" }, 400);
|
|
220
|
+
if (!issueRef || issueRef.length > 2048) return c.json({ error: "invalid_issue_ref" }, 400);
|
|
221
|
+
await intake.ensureReady();
|
|
222
|
+
const selection = (await intake.getConfig({
|
|
223
|
+
orgId: resolved.tenant.orgId,
|
|
224
|
+
integrationIds: ["jira"]
|
|
225
|
+
})).jira;
|
|
226
|
+
if (!selection.enabled) return c.json({
|
|
227
|
+
error: "jira_intake_disabled",
|
|
228
|
+
message: "Jira intake is turned off in Settings."
|
|
229
|
+
}, 404);
|
|
230
|
+
const intakeBoards = await scopeSourceIdsToProject({
|
|
231
|
+
intake,
|
|
232
|
+
orgId: resolved.tenant.orgId,
|
|
233
|
+
factoryProjectId,
|
|
234
|
+
selectedIds: selection.sourceIds ?? []
|
|
235
|
+
});
|
|
236
|
+
if (Object.keys(intakeBoards).length === 0) return c.json({ error: "issue_not_found" }, 404);
|
|
237
|
+
try {
|
|
238
|
+
const issue = await jira.intake.getIssue({
|
|
239
|
+
connection: DEPLOYMENT_CONNECTION,
|
|
240
|
+
issueId: issueRef
|
|
241
|
+
});
|
|
242
|
+
const routed = issue?.sourceId != null && issue.sourceId in intakeBoards;
|
|
243
|
+
if (!issue || issue.identifier.toUpperCase() !== identifier.toUpperCase() || !routed) return c.json({ error: "issue_not_found" }, 404);
|
|
244
|
+
return c.json({
|
|
245
|
+
identifier: issue.identifier,
|
|
246
|
+
title: issue.title,
|
|
247
|
+
url: issue.url,
|
|
248
|
+
description: issue.description
|
|
249
|
+
});
|
|
250
|
+
} catch (err) {
|
|
251
|
+
return jiraFetchError(loose(c), err);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}));
|
|
255
|
+
return routes;
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
export { buildJiraRoutes };
|
|
259
|
+
|
|
260
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.js","names":[],"sources":["../../../src/integrations/jira/routes.ts"],"sourcesContent":["/**\n * Mastra `apiRoutes` for the Jira intake feature.\n *\n * Registered alongside the other `/web/*` routes, behind the host auth gate.\n * Mirrors the Linear module minus everything OAuth: there is no\n * connect/callback flow, no state signer, and no per-org connection storage —\n * Jira credentials are deployment-global constructor config on the\n * integration instance. Every route still re-resolves the authenticated user\n * from the request and scopes intake selections by the caller's org.\n *\n * When the feature is disabled (no auth, or no intake storage),\n * `buildJiraRoutes` returns only `GET /web/jira/status`, which reports\n * `enabled:false` so the SPA can cleanly hide all Jira UI.\n */\n\nimport type { ApiRoute } from '@mastra/core/server';\nimport { registerApiRoute } from '@mastra/core/server';\nimport type { Context } from 'hono';\n\nimport type { RouteAuth } from '../../routes/route.js';\nimport type { IntakeStorage } from '../../storage/domains/intake/base.js';\nimport { JiraApiError } from './api.js';\nimport { DEPLOYMENT_CONNECTION } from './integration.js';\nimport type { JiraIntegration } from './integration.js';\nimport type { JiraRulesIngress } from './rules.js';\n\ntype RouteContext = Context;\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\nconst ISSUE_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\\d+$/;\n\n/** Erase a route handler's path-parameterized context to a plain `Context`. */\nfunction loose(c: unknown): RouteContext {\n return c as RouteContext;\n}\n\n/**\n * Non-secret diagnostic snapshot of every Jira feature gate, mirroring the\n * Linear diagnostics shape. Only booleans — never values.\n */\nexport interface JiraFeatureDiagnostics {\n jiraConfigured: boolean;\n factoryAuthEnabled: boolean;\n appDbConfigured: boolean;\n}\n\nexport interface MountJiraRoutesOptions {\n /**\n * The integration instance providing REST access. Required for everything\n * beyond the disabled `status` route.\n */\n jira?: JiraIntegration;\n /** Host auth seam. Intake selections are org-owned, so the feature is inert without it. */\n auth: RouteAuth;\n /**\n * Cross-integration intake selection domain. Required for the issues route's\n * project filter; when absent, only the disabled `status` route is served.\n */\n intake?: IntakeStorage;\n /**\n * Factory rules ingress for observed issues. When present, a board-scoped\n * issue listing also materializes new cards through the Jira event rules —\n * the same automatic intake Linear has.\n */\n ingestFactoryIssues?: (input: JiraRulesIngress) => Promise<unknown>;\n}\n\n/**\n * Narrow the caller's selected Jira projects to the ones that feed this\n * Factory project.\n *\n * A Jira issue carries no Factory project of its own, so without a binding\n * every board view would ingest every selected project's issues into whichever\n * Factory happened to be on screen. Routing is explicit: a project feeds this\n * Factory only when its binding names both the Factory project and a board.\n * Returns the bound board per source so consumers can land cards where the\n * user asked. Mirrors the Linear routes' scoping semantics locally — the\n * generic binding storage is provider-neutral.\n */\nasync function scopeSourceIdsToProject({\n intake,\n orgId,\n factoryProjectId,\n selectedIds,\n}: {\n intake: IntakeStorage;\n orgId: string;\n factoryProjectId: string;\n selectedIds: string[];\n}): Promise<Record<string, string>> {\n const selected = new Set(selectedIds);\n const intakeBoards: Record<string, string> = {};\n for (const binding of await intake.listBindings({ orgId, integrationId: 'jira' })) {\n if (binding.factoryProjectId === factoryProjectId && binding.board && selected.has(binding.sourceId)) {\n intakeBoards[binding.sourceId] = binding.board;\n }\n }\n return intakeBoards;\n}\n\n/**\n * Resolve the org-scoped tenant for a Jira request. Intake selections are\n * org-owned, so it requires both a signed-in user and an organization — same\n * tenancy rules as the Linear routes.\n */\nasync function resolveOrgTenant(\n c: RouteContext,\n auth: RouteAuth,\n): Promise<{ tenant: { orgId: string; userId: string } } | { response: Response }> {\n await auth.ensureUser(c);\n const tenant = auth.tenant(c);\n if (!tenant) return { response: c.json({ error: 'unauthorized' }, 401) };\n if (!tenant.orgId) {\n return {\n response: c.json(\n {\n error: 'organization_required',\n message: 'Jira intake requires an organization. Personal accounts cannot use Jira intake.',\n },\n 403,\n ),\n };\n }\n return { tenant: { orgId: tenant.orgId, userId: tenant.userId } };\n}\n\n/**\n * Validate an opaque Jira pagination cursor from the query string. Cursors are\n * server-issued (`nextPageToken`), so anything outside a conservative\n * charset/length is rejected rather than forwarded to Jira.\n */\nfunction parseAfterCursor(raw: string | undefined): string | undefined | null {\n if (raw === undefined || raw === '') return undefined;\n if (raw.length > 512 || !/^[\\w+/=.:-]+$/.test(raw)) return null;\n return raw;\n}\n\n/** Map a Jira read failure to the API response for the SPA. */\nfunction jiraFetchError(c: RouteContext, err: unknown) {\n if (err instanceof JiraApiError && err.code === 'jira_auth_failed') {\n return c.json(\n {\n error: 'jira_auth_failed',\n message: 'Jira rejected the configured credentials. Check JIRA_EMAIL and JIRA_API_TOKEN.',\n },\n 409,\n );\n }\n return c.json({ error: 'jira_fetch_failed', message: err instanceof Error ? err.message : String(err) }, 502);\n}\n\n/**\n * Build the Jira routes as Mastra `apiRoutes`. When the feature is disabled,\n * returns only the `status` route so the SPA can detect the disabled state.\n */\nexport function buildJiraRoutes(options: MountJiraRoutesOptions): ApiRoute[] {\n const routes: ApiRoute[] = [];\n const { jira, auth, intake } = options;\n const enabled = Boolean(jira) && auth.enabled();\n const diagnostics = (): JiraFeatureDiagnostics => ({\n jiraConfigured: Boolean(jira),\n factoryAuthEnabled: auth.enabled(),\n appDbConfigured: true,\n });\n\n // The status route is always registered so the SPA can detect the disabled state.\n routes.push(\n registerApiRoute('/web/jira/status', {\n method: 'GET',\n requiresAuth: false,\n handler: async c => {\n if (!enabled || !jira || !intake) {\n return c.json({\n enabled: false,\n configured: Boolean(jira),\n mode: 'direct',\n site: null,\n reason: 'missing_config',\n diagnostics: diagnostics(),\n });\n }\n await auth.ensureUser(loose(c));\n const tenant = auth.tenant(loose(c));\n if (!tenant) return c.json({ error: 'unauthorized', reason: 'auth_required' }, 401);\n\n const site = new URL(jira.baseUrl).host;\n if (!tenant.orgId) {\n return c.json({\n enabled: true,\n configured: true,\n mode: 'direct',\n organizationRequired: true,\n site,\n reason: 'organization_required',\n diagnostics: diagnostics(),\n });\n }\n\n // Deployment-global credentials: configured means ready — there is no\n // per-org connection step like Linear's OAuth.\n return c.json({\n enabled: true,\n configured: true,\n mode: 'direct',\n site,\n reason: 'ready',\n diagnostics: diagnostics(),\n });\n },\n }),\n );\n\n // Without the integration instance or the intake domain the feature can't\n // serve org-scoped data — serve only the disabled `status` route.\n if (!enabled || !jira || !intake) {\n return routes;\n }\n\n // ── List the site's projects (Settings intake-source picker) ────────────\n routes.push(\n registerApiRoute('/web/jira/projects', {\n method: 'GET',\n requiresAuth: false,\n handler: async c => {\n const resolved = await resolveOrgTenant(loose(c), auth);\n if ('response' in resolved) return resolved.response;\n\n try {\n const sources = await jira.intake.listSources(resolved.tenant);\n return c.json({\n projects: sources.map(source => ({\n id: source.id,\n name: source.name,\n key: typeof source.metadata?.key === 'string' ? source.metadata.key : null,\n })),\n });\n } catch (err) {\n return jiraFetchError(loose(c), err);\n }\n },\n }),\n );\n\n // ── List the site's active issues (cursor-paged) ────────────────────────\n // Respects the caller's intake config: disabled Jira intake 404s the\n // source, and an explicit project selection narrows the issue filter.\n routes.push(\n registerApiRoute('/web/jira/issues', {\n method: 'GET',\n requiresAuth: false,\n handler: async c => {\n const resolved = await resolveOrgTenant(loose(c), auth);\n if ('response' in resolved) return resolved.response;\n\n const after = parseAfterCursor(c.req.query('after'));\n if (after === null) return c.json({ error: 'invalid_cursor' }, 400);\n const factoryProjectId = c.req.query('factoryProjectId');\n if (factoryProjectId && !UUID_RE.test(factoryProjectId)) {\n return c.json({ error: 'invalid_factory_project_id' }, 400);\n }\n\n await intake.ensureReady();\n const config = await intake.getConfig({ orgId: resolved.tenant.orgId, integrationIds: ['jira'] });\n const selection = config.jira!;\n if (!selection.enabled) {\n return c.json({ error: 'jira_intake_disabled', message: 'Jira intake is turned off in Settings.' }, 404);\n }\n\n // No projects selected means nothing is synced — don't fan out to Jira.\n const selectedIds = selection.sourceIds ?? [];\n // A board request only ever sees the projects routed to a board of\n // that Factory project; unscoped listing stays available to callers\n // that don't view a specific board.\n const intakeBoards = factoryProjectId\n ? await scopeSourceIdsToProject({\n intake,\n orgId: resolved.tenant.orgId,\n factoryProjectId,\n selectedIds,\n })\n : null;\n const projectIds = intakeBoards ? Object.keys(intakeBoards) : selectedIds;\n if (projectIds.length === 0) {\n return c.json({ issues: [], nextCursor: null });\n }\n\n try {\n const { issues, nextCursor } = await jira.listActiveIssues(after, projectIds);\n const issuePayload = issues.map(issue => ({\n id: issue.id,\n identifier: issue.identifier,\n title: issue.title,\n url: issue.url,\n author: issue.author,\n state: issue.state ?? '',\n stateType: issue.stateType ?? '',\n priorityLabel: issue.priority ?? '',\n assignee: issue.assignee,\n project: issue.source,\n labels: issue.labels,\n createdAt: issue.createdAt,\n updatedAt: issue.updatedAt,\n sourceId: issue.sourceId || null,\n }));\n if (factoryProjectId && intakeBoards && options.ingestFactoryIssues) {\n await options.ingestFactoryIssues({\n orgId: resolved.tenant.orgId,\n userId: resolved.tenant.userId,\n factoryProjectId,\n issues: issuePayload.map(issue => ({ ...issue, site: null })),\n intakeBoards,\n });\n }\n return c.json({ issues: issuePayload, nextCursor });\n } catch (err) {\n return jiraFetchError(loose(c), err);\n }\n },\n }),\n );\n\n routes.push(\n registerApiRoute('/web/jira/issues/:identifier', {\n method: 'GET',\n requiresAuth: false,\n handler: async c => {\n const resolved = await resolveOrgTenant(loose(c), auth);\n if ('response' in resolved) return resolved.response;\n\n const identifier = c.req.param('identifier');\n const factoryProjectId = c.req.query('factoryProjectId');\n const issueRef = c.req.query('issueRef');\n if (!ISSUE_KEY_RE.test(identifier)) return c.json({ error: 'invalid_issue_identifier' }, 400);\n if (!factoryProjectId || !UUID_RE.test(factoryProjectId)) {\n return c.json({ error: 'invalid_factory_project_id' }, 400);\n }\n if (!issueRef || issueRef.length > 2_048) return c.json({ error: 'invalid_issue_ref' }, 400);\n\n await intake.ensureReady();\n const config = await intake.getConfig({ orgId: resolved.tenant.orgId, integrationIds: ['jira'] });\n const selection = config.jira!;\n if (!selection.enabled) {\n return c.json({ error: 'jira_intake_disabled', message: 'Jira intake is turned off in Settings.' }, 404);\n }\n const intakeBoards = await scopeSourceIdsToProject({\n intake,\n orgId: resolved.tenant.orgId,\n factoryProjectId,\n selectedIds: selection.sourceIds ?? [],\n });\n if (Object.keys(intakeBoards).length === 0) return c.json({ error: 'issue_not_found' }, 404);\n\n try {\n const issue = await jira.intake.getIssue({ connection: DEPLOYMENT_CONNECTION, issueId: issueRef });\n // The issue's project must be a source routed to this Factory — an\n // issue from an unbound project reads exactly like one that doesn't\n // exist (same stance as the Linear detail route).\n const routed = issue?.sourceId != null && issue.sourceId in intakeBoards;\n if (!issue || issue.identifier.toUpperCase() !== identifier.toUpperCase() || !routed) {\n return c.json({ error: 'issue_not_found' }, 404);\n }\n return c.json({\n identifier: issue.identifier,\n title: issue.title,\n url: issue.url,\n description: issue.description,\n });\n } catch (err) {\n return jiraFetchError(loose(c), err);\n }\n },\n }),\n );\n\n return routes;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { BoardRegistry } from '../../boards/index.js';
|
|
2
|
+
import type { FactoryProjectsStorage } from '../../storage/domains/projects/base.js';
|
|
3
|
+
import type { WorkItemsStorage } from '../../storage/domains/work-items/base.js';
|
|
4
|
+
import type { IntegrationContext } from '../base.js';
|
|
5
|
+
import type { JiraEventRules } from './default-rules.js';
|
|
6
|
+
export interface JiraIssueIngress {
|
|
7
|
+
/** Stable issue reference — direct Jira id or the Platform-encoded issue reference. */
|
|
8
|
+
id: string;
|
|
9
|
+
identifier: string;
|
|
10
|
+
title: string;
|
|
11
|
+
url: string;
|
|
12
|
+
state: string;
|
|
13
|
+
stateType: string;
|
|
14
|
+
priorityLabel: string;
|
|
15
|
+
assignee: string | null;
|
|
16
|
+
/** Display name of the Jira reporter, when available. */
|
|
17
|
+
author: string | null;
|
|
18
|
+
/** Jira project key the issue belongs to, when available. */
|
|
19
|
+
project: string | null;
|
|
20
|
+
/** Jira site host the issue was read from, when available. */
|
|
21
|
+
site: string | null;
|
|
22
|
+
labels: string[];
|
|
23
|
+
createdAt: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
/** Jira source the issue was read from; resolves the bound board via `intakeBoards`. */
|
|
26
|
+
sourceId?: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface JiraRulesOptions {
|
|
29
|
+
projects: Pick<FactoryProjectsStorage, 'get'>;
|
|
30
|
+
storage: WorkItemsStorage;
|
|
31
|
+
configVersion: string;
|
|
32
|
+
boards: BoardRegistry;
|
|
33
|
+
jiraRules: JiraEventRules;
|
|
34
|
+
}
|
|
35
|
+
export interface JiraRulesIngress {
|
|
36
|
+
orgId: string;
|
|
37
|
+
userId: string;
|
|
38
|
+
factoryProjectId: string;
|
|
39
|
+
issues: JiraIssueIngress[];
|
|
40
|
+
/** Board id per bound Jira source id, from the project's intake bindings. */
|
|
41
|
+
intakeBoards?: Readonly<Record<string, string>>;
|
|
42
|
+
}
|
|
43
|
+
type IngressStatus = 'committed' | 'replayed' | 'missing';
|
|
44
|
+
export declare class JiraRules {
|
|
45
|
+
#private;
|
|
46
|
+
private readonly options;
|
|
47
|
+
constructor(options: JiraRulesOptions);
|
|
48
|
+
ingest(input: JiraRulesIngress): Promise<{
|
|
49
|
+
status: IngressStatus;
|
|
50
|
+
ingested: number;
|
|
51
|
+
}>;
|
|
52
|
+
}
|
|
53
|
+
export declare function attachJiraRules(jira: {
|
|
54
|
+
readonly rules: JiraEventRules;
|
|
55
|
+
}, context: IntegrationContext): ((input: JiraRulesIngress) => Promise<unknown>) | undefined;
|
|
56
|
+
export {};
|
|
57
|
+
//# sourceMappingURL=rules.d.ts.map
|
|
@@ -0,0 +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;AAkBzD,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;CAqI5F;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"}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { assertFactoryDecisionTarget, validateFactoryRuleDecisions } from "../../rules/validation.js";
|
|
2
|
+
import { boardForWorkItem, isTerminalWorkItem } from "../../boards/semantics.js";
|
|
3
|
+
import "../../boards/index.js";
|
|
4
|
+
import { jiraClaimKey } from "./claim.js";
|
|
5
|
+
//#region src/integrations/jira/rules.ts
|
|
6
|
+
const RULE_TIMEOUT_MS = 5e3;
|
|
7
|
+
async function withRuleTimeout(promise) {
|
|
8
|
+
let timeout;
|
|
9
|
+
try {
|
|
10
|
+
return await Promise.race([promise, new Promise((_, reject) => {
|
|
11
|
+
timeout = setTimeout(() => reject(/* @__PURE__ */ new Error("FACTORY_RULE_TIMEOUT")), RULE_TIMEOUT_MS);
|
|
12
|
+
})]);
|
|
13
|
+
} finally {
|
|
14
|
+
if (timeout) clearTimeout(timeout);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
var JiraRules = class {
|
|
18
|
+
options;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
}
|
|
22
|
+
async ingest(input) {
|
|
23
|
+
if (!await this.options.projects.get({
|
|
24
|
+
orgId: input.orgId,
|
|
25
|
+
id: input.factoryProjectId
|
|
26
|
+
})) return {
|
|
27
|
+
status: "missing",
|
|
28
|
+
ingested: 0
|
|
29
|
+
};
|
|
30
|
+
const items = await this.options.storage.list({
|
|
31
|
+
orgId: input.orgId,
|
|
32
|
+
factoryProjectId: input.factoryProjectId
|
|
33
|
+
});
|
|
34
|
+
const itemsBySourceKey = new Map(items.map((item) => [item.externalSource?.externalId, item]));
|
|
35
|
+
const itemsByClaimKey = new Map(items.filter((item) => item.claimKey).map((item) => [item.claimKey, item]));
|
|
36
|
+
const statuses = [];
|
|
37
|
+
for (const issue of input.issues) {
|
|
38
|
+
const relatedItem = itemsByClaimKey.get(jiraClaimKey(issue.id)) ?? itemsBySourceKey.get(issue.id);
|
|
39
|
+
if (!relatedItem && await this.#heldElsewhere(input, issue)) {
|
|
40
|
+
statuses.push("missing");
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
statuses.push(await this.#ingestIssue(input, issue, relatedItem));
|
|
44
|
+
}
|
|
45
|
+
if (statuses.some((status) => status === "committed")) return {
|
|
46
|
+
status: "committed",
|
|
47
|
+
ingested: statuses.length
|
|
48
|
+
};
|
|
49
|
+
if (statuses.some((status) => status === "replayed")) return {
|
|
50
|
+
status: "replayed",
|
|
51
|
+
ingested: statuses.length
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
status: "missing",
|
|
55
|
+
ingested: statuses.length
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async #heldElsewhere(input, issue) {
|
|
59
|
+
const heldLive = (row) => row.factoryProjectId !== input.factoryProjectId && !isTerminalWorkItem(this.options.boards, row);
|
|
60
|
+
const claimant = await this.options.storage.getByClaimKey({
|
|
61
|
+
orgId: input.orgId,
|
|
62
|
+
claimKey: jiraClaimKey(issue.id)
|
|
63
|
+
});
|
|
64
|
+
if (claimant && heldLive(claimant)) return true;
|
|
65
|
+
return (await this.options.storage.listBySource({
|
|
66
|
+
orgId: input.orgId,
|
|
67
|
+
source: {
|
|
68
|
+
integrationId: "jira",
|
|
69
|
+
type: "issue",
|
|
70
|
+
externalId: issue.id
|
|
71
|
+
}
|
|
72
|
+
})).some(heldLive);
|
|
73
|
+
}
|
|
74
|
+
async #ingestIssue(input, issue, relatedItem) {
|
|
75
|
+
const ingressId = `jira:${issue.id}:${issue.updatedAt}`;
|
|
76
|
+
const actor = {
|
|
77
|
+
type: "human",
|
|
78
|
+
id: input.userId
|
|
79
|
+
};
|
|
80
|
+
const isClosed = issue.stateType === "completed" || issue.stateType === "canceled";
|
|
81
|
+
if (isClosed && !relatedItem) return "missing";
|
|
82
|
+
const event = isClosed ? "issueClosed" : "issueObserved";
|
|
83
|
+
const boundBoardId = issue.sourceId ? input.intakeBoards?.[issue.sourceId] : void 0;
|
|
84
|
+
const boundBoard = boundBoardId ? this.options.boards.get(boundBoardId) : void 0;
|
|
85
|
+
const context = {
|
|
86
|
+
tenant: {
|
|
87
|
+
orgId: input.orgId,
|
|
88
|
+
projectId: input.factoryProjectId
|
|
89
|
+
},
|
|
90
|
+
actor,
|
|
91
|
+
ingress: {
|
|
92
|
+
type: "jira",
|
|
93
|
+
id: ingressId
|
|
94
|
+
},
|
|
95
|
+
cause: `jira.${event}`,
|
|
96
|
+
causalChain: [],
|
|
97
|
+
configVersion: this.options.configVersion,
|
|
98
|
+
...relatedItem ? {
|
|
99
|
+
item: {
|
|
100
|
+
id: relatedItem.id,
|
|
101
|
+
source: "jira-issue",
|
|
102
|
+
sourceKey: relatedItem.externalSource?.externalId ?? null,
|
|
103
|
+
parentWorkItemId: relatedItem.parentWorkItemId,
|
|
104
|
+
title: relatedItem.title,
|
|
105
|
+
url: relatedItem.externalSource?.url ?? null,
|
|
106
|
+
stages: relatedItem.stages,
|
|
107
|
+
acceptedAt: relatedItem.acceptedAt,
|
|
108
|
+
metadata: relatedItem.metadata
|
|
109
|
+
},
|
|
110
|
+
board: boardForWorkItem(relatedItem),
|
|
111
|
+
itemRevision: relatedItem.revision
|
|
112
|
+
} : {},
|
|
113
|
+
...boundBoard ? { intake: {
|
|
114
|
+
board: boundBoard.id,
|
|
115
|
+
initialPhase: boundBoard.initialPhase
|
|
116
|
+
} } : {},
|
|
117
|
+
event,
|
|
118
|
+
issue
|
|
119
|
+
};
|
|
120
|
+
const rule = this.options.jiraRules[context.event];
|
|
121
|
+
let decision;
|
|
122
|
+
let decisions = [];
|
|
123
|
+
let outcome = { status: "accepted" };
|
|
124
|
+
try {
|
|
125
|
+
decision = rule ? await withRuleTimeout(Promise.resolve(rule(Object.freeze(context)))) : void 0;
|
|
126
|
+
if (decision?.type === "reject") outcome = {
|
|
127
|
+
status: "rejected",
|
|
128
|
+
code: decision.code,
|
|
129
|
+
reason: decision.reason
|
|
130
|
+
};
|
|
131
|
+
else if (decision) decisions = validateFactoryRuleDecisions([decision]).map((entry) => {
|
|
132
|
+
assertFactoryDecisionTarget(entry, this.options.boards, relatedItem ? boardForWorkItem(relatedItem) : void 0);
|
|
133
|
+
return { ...entry };
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const timedOut = error instanceof Error && error.message === "FACTORY_RULE_TIMEOUT";
|
|
137
|
+
outcome = {
|
|
138
|
+
status: "rejected",
|
|
139
|
+
code: timedOut ? "timeout" : "rule_error",
|
|
140
|
+
reason: timedOut ? "Factory rule evaluation timed out." : error instanceof Error ? error.message.slice(0, 2e3) : "Factory Jira rule failed."
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return (await this.options.storage.commitRuleEvaluation({
|
|
144
|
+
orgId: input.orgId,
|
|
145
|
+
factoryProjectId: input.factoryProjectId,
|
|
146
|
+
workItemId: relatedItem?.id ?? null,
|
|
147
|
+
ingress: {
|
|
148
|
+
identity: ingressId,
|
|
149
|
+
triggerType: "jira.issueObserved"
|
|
150
|
+
},
|
|
151
|
+
configVersion: this.options.configVersion,
|
|
152
|
+
expectedRevision: relatedItem?.revision ?? null,
|
|
153
|
+
actor,
|
|
154
|
+
outcome,
|
|
155
|
+
decisions,
|
|
156
|
+
causalChain: [],
|
|
157
|
+
now: /* @__PURE__ */ new Date()
|
|
158
|
+
})).status;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
function attachJiraRules(jira, context) {
|
|
162
|
+
if (!context.runtime) return void 0;
|
|
163
|
+
const rules = new JiraRules({
|
|
164
|
+
projects: context.storage.projects,
|
|
165
|
+
storage: context.runtime.workItems,
|
|
166
|
+
configVersion: context.runtime.configVersion,
|
|
167
|
+
boards: context.runtime.boards,
|
|
168
|
+
jiraRules: jira.rules
|
|
169
|
+
});
|
|
170
|
+
return (input) => rules.ingest(input);
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
export { JiraRules, attachJiraRules };
|
|
174
|
+
|
|
175
|
+
//# sourceMappingURL=rules.js.map
|
|
@@ -0,0 +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\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 ): 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 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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"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Org-wide ownership key for a Linear issue's Factory card. Keyed by the issue's
|
|
3
|
+
* stable UUID, never its human identifier: identifiers change when an issue
|
|
4
|
+
* moves between teams, and the UUID is unique across every Linear workspace, so
|
|
5
|
+
* one key names one issue no matter which workspace or source it was read from.
|
|
6
|
+
*/
|
|
7
|
+
export declare function linearClaimKey(issueId: string): string;
|
|
8
|
+
//# sourceMappingURL=claim.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claim.d.ts","sourceRoot":"","sources":["../../../src/integrations/linear/claim.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEtD"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/integrations/linear/claim.ts
|
|
2
|
+
/**
|
|
3
|
+
* Org-wide ownership key for a Linear issue's Factory card. Keyed by the issue's
|
|
4
|
+
* stable UUID, never its human identifier: identifiers change when an issue
|
|
5
|
+
* moves between teams, and the UUID is unique across every Linear workspace, so
|
|
6
|
+
* one key names one issue no matter which workspace or source it was read from.
|
|
7
|
+
*/
|
|
8
|
+
function linearClaimKey(issueId) {
|
|
9
|
+
return `linear:issue:${issueId}`;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { linearClaimKey };
|
|
13
|
+
|
|
14
|
+
//# sourceMappingURL=claim.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claim.js","names":[],"sources":["../../../src/integrations/linear/claim.ts"],"sourcesContent":["/**\n * Org-wide ownership key for a Linear issue's Factory card. Keyed by the issue's\n * stable UUID, never its human identifier: identifiers change when an issue\n * moves between teams, and the UUID is unique across every Linear workspace, so\n * one key names one issue no matter which workspace or source it was read from.\n */\nexport function linearClaimKey(issueId: string): string {\n return `linear:issue:${issueId}`;\n}\n"],"mappings":";;;;;;;AAMA,SAAgB,eAAe,SAAyB;CACtD,OAAO,gBAAgB;AACzB"}
|
|
@@ -7,6 +7,7 @@ declare function linearIssueObserved(context: FactoryLinearRuleContext): {
|
|
|
7
7
|
readonly board: string;
|
|
8
8
|
readonly source: "linear-issue";
|
|
9
9
|
readonly sourceKey: `linear:${string}`;
|
|
10
|
+
readonly claimKey: string;
|
|
10
11
|
readonly title: `${string}: ${string}`;
|
|
11
12
|
readonly url: string;
|
|
12
13
|
readonly stage: string;
|
|
@@ -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;
|
|
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;;;;;;;;;;;;;;;;;;;;;;;yBAwBnB,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,3 +1,4 @@
|
|
|
1
|
+
import { linearClaimKey } from "./claim.js";
|
|
1
2
|
//#region src/integrations/linear/default-rules.ts
|
|
2
3
|
function linearIssueObserved(context) {
|
|
3
4
|
if (context.item) return;
|
|
@@ -7,6 +8,7 @@ function linearIssueObserved(context) {
|
|
|
7
8
|
board: context.intake?.board ?? "work",
|
|
8
9
|
source: "linear-issue",
|
|
9
10
|
sourceKey: `linear:${context.issue.identifier}`,
|
|
11
|
+
claimKey: linearClaimKey(context.issue.id),
|
|
10
12
|
title: `${context.issue.identifier}: ${context.issue.title}`,
|
|
11
13
|
url: context.issue.url,
|
|
12
14
|
stage: context.intake?.initialPhase ?? "triage",
|