@bugmole/cli 0.5.0 → 0.6.0

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 (153) hide show
  1. package/package.json +5 -2
  2. package/scripts/bugmole-admin.mjs +194 -0
  3. package/scripts/bugmole-admin.test.mjs +62 -0
  4. package/scripts/bugmole.test.ts +94 -2
  5. package/scripts/bugmole.ts +233 -46
  6. package/scripts/sync-byok.d.mts +2 -0
  7. package/scripts/sync-byok.mjs +18 -0
  8. package/scripts/sync-plan-catalog.d.mts +3 -0
  9. package/scripts/sync-plan-catalog.mjs +16 -6
  10. package/spec/domain_rules.yaml +139 -0
  11. package/spec/roles.yaml +20 -0
  12. package/spec/test-case-results.schema.json +20 -4
  13. package/spec/test-cases.schema.json +131 -16
  14. package/src/billing/plan-catalog.test.ts +82 -2
  15. package/src/billing/plan-catalog.ts +212 -9
  16. package/src/integrations/jira.ts +264 -0
  17. package/src/mcp/roles-and-review.test.ts +111 -0
  18. package/src/mcp/server.ts +358 -22
  19. package/src/mcp/write-test-cases.test.ts +68 -0
  20. package/src/registry/control-plane-client.ts +121 -6
  21. package/src/registry/migrations/0036_task_approval.sql +11 -0
  22. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  23. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  24. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  25. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  26. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  27. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  28. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  29. package/src/registry/migrations/0044_project_identity.sql +31 -0
  30. package/src/registry/project-identity.test.ts +99 -0
  31. package/src/registry/project-identity.ts +206 -0
  32. package/src/registry/roles.test.ts +66 -0
  33. package/src/registry/roles.ts +201 -0
  34. package/src/registry/task-scheduling.test.ts +94 -0
  35. package/src/registry/task-scheduling.ts +199 -2
  36. package/src/registry/test-case-revisions.test.ts +57 -0
  37. package/src/registry/test-case-revisions.ts +132 -0
  38. package/src/registry-worker/ai/routes.ts +3 -3
  39. package/src/registry-worker/artifacts.ts +23 -4
  40. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  41. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  42. package/src/registry-worker/billing/enforcement.ts +67 -12
  43. package/src/registry-worker/billing/paypal/api.ts +14 -0
  44. package/src/registry-worker/billing/paypal/client.ts +9 -0
  45. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  46. package/src/registry-worker/billing/paypal.test.ts +108 -1
  47. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  48. package/src/registry-worker/billing/provider.ts +8 -0
  49. package/src/registry-worker/billing/routes.ts +2 -1
  50. package/src/registry-worker/billing/subscriptions.ts +114 -4
  51. package/src/registry-worker/core.ts +22 -0
  52. package/src/registry-worker/devices/policy.ts +4 -5
  53. package/src/registry-worker/devices/routes.ts +8 -8
  54. package/src/registry-worker/feature-access.ts +165 -0
  55. package/src/registry-worker/feature-flags/admin.ts +300 -0
  56. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  57. package/src/registry-worker/feature-flags/routes.ts +102 -0
  58. package/src/registry-worker/features.ts +9 -0
  59. package/src/registry-worker/feedback/routes.ts +2 -2
  60. package/src/registry-worker/flags.ts +80 -18
  61. package/src/registry-worker/github/checks.ts +4 -4
  62. package/src/registry-worker/hooks.ts +9 -0
  63. package/src/registry-worker/identity/oidc.test.ts +103 -0
  64. package/src/registry-worker/identity/oidc.ts +176 -0
  65. package/src/registry-worker/index.ts +477 -174
  66. package/src/registry-worker/jira/connection.ts +111 -0
  67. package/src/registry-worker/jira/jira.test.ts +404 -0
  68. package/src/registry-worker/jira/routes.ts +432 -0
  69. package/src/registry-worker/jira/workflow.ts +577 -0
  70. package/src/registry-worker/jobs/retention.ts +21 -5
  71. package/src/registry-worker/mcp/tools.ts +2 -1
  72. package/src/registry-worker/notifications/alerts.ts +4 -4
  73. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  74. package/src/registry-worker/notifications/routes.ts +9 -10
  75. package/src/registry-worker/notifications/teams.ts +6 -2
  76. package/src/registry-worker/org/routes.ts +4 -4
  77. package/src/registry-worker/projects/identity.ts +194 -0
  78. package/src/registry-worker/projects/inactivity.ts +162 -0
  79. package/src/registry-worker/projects/projects.test.ts +283 -0
  80. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  81. package/src/registry-worker/proposals/routes.ts +183 -0
  82. package/src/registry-worker/roles/roles.test.ts +84 -0
  83. package/src/registry-worker/roles/routes.ts +141 -0
  84. package/src/registry-worker/runner/dispatch.ts +1 -0
  85. package/src/registry-worker/runner/routes.ts +22 -5
  86. package/src/registry-worker/runner/runner.test.ts +21 -0
  87. package/src/registry-worker/runner/tokens.ts +8 -0
  88. package/src/registry-worker/secrets/crypto.ts +135 -0
  89. package/src/registry-worker/secrets/routes.ts +296 -0
  90. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  91. package/src/registry-worker/signup/policy.ts +2 -2
  92. package/src/registry-worker/signup/routes.ts +12 -3
  93. package/src/registry-worker/sso/membership.ts +4 -2
  94. package/src/registry-worker/sso/routes.ts +24 -8
  95. package/src/registry-worker/sso/sso.test.ts +3 -2
  96. package/src/registry-worker/task-approval.test.ts +67 -0
  97. package/src/registry-worker/task-resume.test.ts +196 -0
  98. package/src/registry-worker/task-review.test.ts +121 -0
  99. package/src/runtime/appium-driver.ts +1 -1
  100. package/src/runtime/apply-proposals.test.ts +74 -0
  101. package/src/runtime/apply-proposals.ts +41 -0
  102. package/src/runtime/cloud-secrets.test.ts +201 -0
  103. package/src/runtime/cloud-secrets.ts +210 -0
  104. package/src/runtime/config-validate.ts +12 -9
  105. package/src/runtime/cursor-driver-run.ts +14 -3
  106. package/src/runtime/discovery-task.test.ts +26 -1
  107. package/src/runtime/discovery-task.ts +67 -6
  108. package/src/runtime/executor.ts +18 -10
  109. package/src/runtime/explorer.test.ts +32 -0
  110. package/src/runtime/explorer.ts +48 -0
  111. package/src/runtime/flow-language.ts +22 -8
  112. package/src/runtime/init-wizard.ts +112 -6
  113. package/src/runtime/journey-editor.ts +39 -2
  114. package/src/runtime/journey-graph.test.ts +18 -0
  115. package/src/runtime/local-registry-stub.test.ts +64 -0
  116. package/src/runtime/local-registry-stub.ts +255 -7
  117. package/src/runtime/local-vault.ts +65 -0
  118. package/src/runtime/pipeline.test.ts +23 -0
  119. package/src/runtime/pipeline.ts +61 -20
  120. package/src/runtime/planner.test.ts +24 -1
  121. package/src/runtime/planner.ts +88 -23
  122. package/src/runtime/playwright-driver.test.ts +21 -2
  123. package/src/runtime/playwright-driver.ts +49 -7
  124. package/src/runtime/project-identity.test.ts +161 -0
  125. package/src/runtime/project-identity.ts +232 -0
  126. package/src/runtime/project-roles.test.ts +107 -0
  127. package/src/runtime/project-roles.ts +158 -0
  128. package/src/runtime/propose-cli.ts +66 -0
  129. package/src/runtime/record-run-verdicts.test.ts +58 -0
  130. package/src/runtime/record-run-verdicts.ts +38 -4
  131. package/src/runtime/reporter.ts +1 -1
  132. package/src/runtime/reset.ts +2 -0
  133. package/src/runtime/roles-cli.test.ts +47 -0
  134. package/src/runtime/roles-cli.ts +60 -0
  135. package/src/runtime/run-once.ts +68 -5
  136. package/src/runtime/scenario-matrix.test.ts +41 -0
  137. package/src/runtime/scenario-matrix.ts +98 -0
  138. package/src/runtime/secret-driver.ts +77 -0
  139. package/src/runtime/secret-redaction.ts +69 -0
  140. package/src/runtime/secret-sources.test.ts +442 -0
  141. package/src/runtime/secret-sources.ts +176 -0
  142. package/src/runtime/secrets-cli.ts +146 -0
  143. package/src/runtime/serve-worker.ts +64 -6
  144. package/src/runtime/site-discovery.test.ts +47 -4
  145. package/src/runtime/site-discovery.ts +79 -9
  146. package/src/runtime/vault-federation.test.ts +37 -0
  147. package/src/runtime/vault-federation.ts +128 -0
  148. package/src/runtime/web-suite.ts +5 -2
  149. package/src/storage/create-object-store.ts +5 -1
  150. package/src/storage/object-store.ts +12 -1
  151. package/src/storage/test-case-results.test.ts +64 -0
  152. package/src/storage/test-case-results.ts +49 -0
  153. package/src/vendor/byok.ts +371 -0
@@ -0,0 +1,432 @@
1
+ // Jira Cloud integration routes, webhook, outbox handlers and hooks.
2
+ //
3
+ // Who can do what:
4
+ // - A project manager, in the dashboard, connects, updates and disconnects
5
+ // Jira. The API token is write-only: no route returns it, and machine API
6
+ // keys and runners can't set it.
7
+ // - Project members (people or API keys with project write) propose the test
8
+ // plan to Jira and create tickets for approved tests.
9
+ // - Jira posts comment events to /api/jira/webhook with the connection's own
10
+ // secret: as X-Hub-Signature (HMAC-SHA256 of the body, Jira's "Secret"
11
+ // field), an X-Bugmole-Webhook-Secret header (Jira Automation), or a
12
+ // `token` query parameter. Every accepted delivery is audited; rejections at most once a minute.
13
+ import type { Env, Principal } from "../core.js";
14
+ import { constantTimeEqual, memberOfProject, newId, randomToken, requestJson, response } from "../core.js";
15
+ import { proposalDecidedHooks, runChangedHooks } from "../hooks.js";
16
+ import { auditProject, recordAudit } from "../org/audit.js";
17
+ import { drainSoon, enqueue, PermanentDeliveryError, registerOutboxHandler } from "../notifications/outbox.js";
18
+ import { matchPath, registerPublicRoutes, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
19
+ import { ApiTokenAuth, adf, commentText, JIRA_PROJECT_KEY, JiraApiError, JiraClient, mentionedAccountIds, normalizeJiraSite, type JiraFetchEnv } from "../../integrations/jira.js";
20
+ import {
21
+ clientFor, connectionById, connectionOf, connectionView, JIRA_UNCONFIGURED, jiraConfigured, openField, sealField, webhookUrl, issueUrl, type ConnectionRow,
22
+ } from "./connection.js";
23
+ import { isPermanentJiraError, proposePlan, refreshPlanIssue, reportRun, requestRetest, ResultsNotReady, syncTickets } from "./workflow.js";
24
+
25
+ const EMAIL = /^[^\s@]{1,200}@[^\s@]{1,200}\.[^\s@]{2,}$/;
26
+ const TOKEN_MAX = 1000;
27
+ const KEYWORD_MAX = 60;
28
+ const WEBHOOK_BODY_MAX = 512 * 1024;
29
+
30
+ const forbidden = () => response({ error: "Project scope is not allowed" }, 403);
31
+ const unconfigured = () => response({ error: JIRA_UNCONFIGURED, code: "jira_unconfigured" }, 503);
32
+ const notConnected = () => response({ error: "This project isn't connected to Jira", code: "jira_not_connected" }, 404);
33
+
34
+ /** Credentials are entered by people in the dashboard, never by keys or runners. */
35
+ function mayManage(principal: Principal): boolean {
36
+ return !principal.runner && !principal.apiKeyId;
37
+ }
38
+
39
+ /** A Jira failure, described without anything that could carry a credential. */
40
+ function jiraProblem(error: unknown): string {
41
+ if (error instanceof JiraApiError) {
42
+ if (error.status === 401) return "Jira didn't accept that email and API token.";
43
+ if (error.status === 403) return "That Jira account doesn't have permission for this.";
44
+ if (error.status === 404) return "Jira couldn't find that project (check the key and that the account can browse it).";
45
+ return `Jira answered ${error.status}.`;
46
+ }
47
+ return "Couldn't reach Jira.";
48
+ }
49
+
50
+ async function getConnection({ env, principal }: AuthedRouteContext, projectId: string): Promise<Response> {
51
+ if (principal.runner || !(await memberOfProject(env, principal, projectId))) return forbidden();
52
+ const row = await connectionOf(env, projectId);
53
+ const linked = await env.DB.prepare("SELECT COUNT(*) AS count FROM jira_issue_links WHERE project_id = ? AND jira_issue_key IS NOT NULL")
54
+ .bind(projectId).first<{ count: number }>();
55
+ return response({
56
+ available: jiraConfigured(env),
57
+ connection: row ? connectionView(env, row) : null,
58
+ linkedTickets: linked?.count ?? 0,
59
+ });
60
+ }
61
+
62
+ async function saveConnection({ env, principal, request }: AuthedRouteContext, projectId: string): Promise<Response> {
63
+ if (!mayManage(principal)) return response({ error: "Jira is connected by a project manager in the dashboard" }, 403);
64
+ if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return forbidden();
65
+ if (!jiraConfigured(env)) return unconfigured();
66
+ const body = await requestJson(request);
67
+ const existing = await connectionOf(env, projectId);
68
+ const siteUrl = body?.siteUrl === undefined && existing ? existing.site_url : normalizeJiraSite(body?.siteUrl);
69
+ const email = typeof body?.email === "string" ? body.email.trim() : existing?.account_email ?? "";
70
+ const apiToken = typeof body?.apiToken === "string" ? body.apiToken.trim() : "";
71
+ const projectKey = typeof body?.projectKey === "string" ? body.projectKey.trim().toUpperCase() : existing?.jira_project_key ?? "";
72
+ const keyword = typeof body?.mentionKeyword === "string" ? body.mentionKeyword.trim() : existing?.mention_keyword ?? "@Bugmole";
73
+ const executionMode = body?.executionMode ?? existing?.execution_mode ?? "self";
74
+ const environmentId = body?.environmentId === undefined ? existing?.environment_id ?? null : (typeof body.environmentId === "string" && body.environmentId ? body.environmentId : null);
75
+ const enabled = body?.enabled === undefined ? existing ? Boolean(existing.enabled) : true : body.enabled === true;
76
+
77
+ if (!siteUrl) return response({ error: "siteUrl must be your Jira Cloud site, like https://your-team.atlassian.net" }, 422);
78
+ if (!EMAIL.test(email)) return response({ error: "email is the Atlassian account the API token belongs to" }, 422);
79
+ if (!JIRA_PROJECT_KEY.test(projectKey)) return response({ error: "projectKey is the Jira project key, like QA or SHOP" }, 422);
80
+ if (!keyword || keyword.length > KEYWORD_MAX) return response({ error: `mentionKeyword is the text that asks Bugmole to retest, up to ${KEYWORD_MAX} characters` }, 422);
81
+ if (executionMode !== "self" && executionMode !== "cloud") return response({ error: "executionMode must be self or cloud" }, 422);
82
+ if (apiToken.length > TOKEN_MAX) return response({ error: "That API token is too long" }, 422);
83
+ const credentialChanged = !existing || siteUrl !== existing.site_url || email !== existing.account_email;
84
+ if (credentialChanged && !apiToken) return response({ error: "apiToken is required to connect (and whenever the site or email changes)" }, 422);
85
+ if (environmentId) {
86
+ const environment = await env.DB.prepare("SELECT id FROM project_environments WHERE project_id = ? AND id = ?").bind(projectId, environmentId).first();
87
+ if (!environment) return response({ error: "Environment not found in this project" }, 422);
88
+ }
89
+
90
+ // Verify before storing anything: the credential works and can see the
91
+ // project. Pausing, resuming or changing the keyword needs no Jira call, so
92
+ // a revoked token can still be paused.
93
+ let accountId = existing?.account_id ?? "";
94
+ if (credentialChanged || apiToken || projectKey !== existing?.jira_project_key || !accountId) {
95
+ const token = apiToken || (existing ? await openField(env, existing, "credential") : "");
96
+ const client = new JiraClient(env as Env & JiraFetchEnv, new ApiTokenAuth(siteUrl, email, token));
97
+ try {
98
+ accountId = (await client.myself()).accountId;
99
+ await client.project(projectKey);
100
+ } catch (error) {
101
+ return response({ error: jiraProblem(error), code: "jira_unreachable" }, 422);
102
+ }
103
+ }
104
+
105
+ const id = existing?.id ?? newId("jira");
106
+ let webhookSecret: string | null = null;
107
+ if (existing) {
108
+ const credential = apiToken ? await sealField(env, projectId, id, "credential", apiToken) : null;
109
+ await env.DB.prepare(`
110
+ UPDATE project_jira_connections SET site_url = ?, account_email = ?, account_id = ?, jira_project_key = ?, mention_keyword = ?,
111
+ execution_mode = ?, environment_id = ?, enabled = ?, last_error = NULL, updated_by = ?, updated_at = CURRENT_TIMESTAMP
112
+ ${credential ? ", credential_ciphertext = ?, credential_iv = ?, credential_key_version = ?" : ""}
113
+ WHERE id = ?
114
+ `).bind(
115
+ siteUrl, email, accountId, projectKey, keyword, executionMode, environmentId, enabled ? 1 : 0, principal.aegisId,
116
+ ...(credential ? [credential.ciphertext, credential.iv, credential.keyVersion] : []),
117
+ id,
118
+ ).run();
119
+ } else {
120
+ webhookSecret = randomToken(24);
121
+ const credential = await sealField(env, projectId, id, "credential", apiToken);
122
+ const secret = await sealField(env, projectId, id, "webhook", webhookSecret);
123
+ try {
124
+ await env.DB.prepare(`
125
+ INSERT INTO project_jira_connections (id, project_id, site_url, jira_project_key, auth_type, account_email, account_id,
126
+ credential_ciphertext, credential_iv, credential_key_version, webhook_secret_ciphertext, webhook_secret_iv, webhook_key_version,
127
+ mention_keyword, execution_mode, environment_id, enabled, created_by, updated_by)
128
+ VALUES (?, ?, ?, ?, 'api_token', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
129
+ `).bind(
130
+ id, projectId, siteUrl, projectKey, email, accountId, credential.ciphertext, credential.iv, credential.keyVersion,
131
+ secret.ciphertext, secret.iv, secret.keyVersion, keyword, executionMode, environmentId, enabled ? 1 : 0, principal.aegisId, principal.aegisId,
132
+ ).run();
133
+ } catch {
134
+ return response({ error: "This project was just connected by someone else; reload and try again" }, 409);
135
+ }
136
+ }
137
+ await auditProject(env, principal, projectId, {
138
+ action: existing ? "jira.updated" : "jira.connected",
139
+ targetType: "jira_connection",
140
+ targetId: id,
141
+ data: { siteUrl, projectKey, credentialReplaced: Boolean(apiToken) },
142
+ }, request);
143
+ const row = (await connectionOf(env, projectId))!;
144
+ return response({
145
+ connection: connectionView(env, row),
146
+ ...(webhookSecret ? { webhookSecret } : {}),
147
+ }, existing ? 200 : 201);
148
+ }
149
+
150
+ async function deleteConnection({ env, principal, request }: AuthedRouteContext, projectId: string): Promise<Response> {
151
+ if (!mayManage(principal)) return response({ error: "Jira is disconnected by a project manager in the dashboard" }, 403);
152
+ if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return forbidden();
153
+ const row = await connectionOf(env, projectId);
154
+ if (!row) return notConnected();
155
+ await env.DB.batch([
156
+ env.DB.prepare("DELETE FROM project_jira_connections WHERE project_id = ?").bind(projectId),
157
+ // Tickets stay in Jira; reconnecting finds them again by their case label.
158
+ env.DB.prepare("DELETE FROM jira_issue_links WHERE project_id = ?").bind(projectId),
159
+ env.DB.prepare("DELETE FROM jira_plan_issues WHERE project_id = ?").bind(projectId),
160
+ ]);
161
+ await auditProject(env, principal, projectId, { action: "jira.disconnected", targetType: "jira_connection", targetId: row.id, data: { siteUrl: row.site_url, projectKey: row.jira_project_key } }, request);
162
+ return response({ deleted: true });
163
+ }
164
+
165
+ async function rotateWebhookSecret({ env, principal, request }: AuthedRouteContext, projectId: string): Promise<Response> {
166
+ if (!mayManage(principal)) return forbidden();
167
+ if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return forbidden();
168
+ if (!jiraConfigured(env)) return unconfigured();
169
+ const row = await connectionOf(env, projectId);
170
+ if (!row) return notConnected();
171
+ const secret = randomToken(24);
172
+ const sealed = await sealField(env, projectId, row.id, "webhook", secret);
173
+ await env.DB.prepare("UPDATE project_jira_connections SET webhook_secret_ciphertext = ?, webhook_secret_iv = ?, webhook_key_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
174
+ .bind(sealed.ciphertext, sealed.iv, sealed.keyVersion, row.id).run();
175
+ await auditProject(env, principal, projectId, { action: "jira.webhook_secret_rotated", targetType: "jira_connection", targetId: row.id }, request);
176
+ return response({ webhookUrl: webhookUrl(env, row.id), webhookSecret: secret });
177
+ }
178
+
179
+ async function writableConnection(context: AuthedRouteContext, projectId: string): Promise<ConnectionRow | Response> {
180
+ const { env, principal } = context;
181
+ if (principal.runner || !(await memberOfProject(env, principal, projectId, "project.write"))) return forbidden();
182
+ if (!jiraConfigured(env)) return unconfigured();
183
+ const row = await connectionOf(env, projectId);
184
+ if (!row) return notConnected();
185
+ if (!row.enabled) return response({ error: "The Jira connection is paused" }, 409);
186
+ return row;
187
+ }
188
+
189
+ async function postPlan(context: AuthedRouteContext, projectId: string): Promise<Response> {
190
+ const row = await writableConnection(context, projectId);
191
+ if (row instanceof Response) return row;
192
+ try {
193
+ const result = await proposePlan(context.env, context.principal, row);
194
+ if ("error" in result) return response({ error: result.error }, result.status);
195
+ if (result.status === 201) {
196
+ await auditProject(context.env, context.principal, projectId, {
197
+ action: "jira.plan_proposed", targetType: "jira_issue", targetId: result.planIssue.key, data: { proposals: result.planIssue.proposalIds.length },
198
+ }, context.request);
199
+ }
200
+ return response({ planIssue: result.planIssue }, result.status);
201
+ } catch (error) {
202
+ return response({ error: jiraProblem(error), code: "jira_unreachable" }, 502);
203
+ }
204
+ }
205
+
206
+ async function postTickets(context: AuthedRouteContext, projectId: string): Promise<Response> {
207
+ const row = await writableConnection(context, projectId);
208
+ if (row instanceof Response) return row;
209
+ const body = await requestJson(context.request);
210
+ try {
211
+ const result = await syncTickets(context.env, projectId, { includeExisting: body?.includeExisting === true });
212
+ if (result.created.length || result.linked.length || result.updated.length || result.retired.length) {
213
+ await auditProject(context.env, context.principal, projectId, {
214
+ action: "jira.tickets_synced", targetType: "jira_connection", targetId: row.id,
215
+ data: { created: result.created.length, linked: result.linked.length, updated: result.updated.length, retired: result.retired.length },
216
+ }, context.request);
217
+ }
218
+ return response(result);
219
+ } catch (error) {
220
+ return response({ error: jiraProblem(error), code: "jira_unreachable" }, 502);
221
+ }
222
+ }
223
+
224
+ async function listLinks({ env, principal }: AuthedRouteContext, projectId: string): Promise<Response> {
225
+ if (principal.runner || !(await memberOfProject(env, principal, projectId))) return forbidden();
226
+ const row = await connectionOf(env, projectId);
227
+ const links = await env.DB.prepare(`
228
+ SELECT case_set_id AS caseSetId, case_id AS caseId, case_revision AS caseRevision, case_title AS caseTitle,
229
+ jira_issue_key AS issueKey, last_result AS lastResult, last_run_id AS lastRunId, updated_at AS updatedAt
230
+ FROM jira_issue_links WHERE project_id = ? AND jira_issue_key IS NOT NULL
231
+ ORDER BY case_set_id, case_id
232
+ `).bind(projectId).all<Record<string, unknown> & { issueKey: string }>();
233
+ const plans = await env.DB.prepare(`
234
+ SELECT id, jira_issue_key AS issueKey, status, created_at AS createdAt FROM jira_plan_issues WHERE project_id = ? ORDER BY created_at DESC LIMIT 10
235
+ `).bind(projectId).all<Record<string, unknown> & { issueKey: string }>();
236
+ return response({
237
+ connected: Boolean(row),
238
+ projectKey: row?.jira_project_key ?? null,
239
+ links: links.results.map((link) => ({ ...link, issueUrl: row ? issueUrl(row, link.issueKey) : null })),
240
+ planIssues: plans.results.map((plan) => ({ ...plan, issueUrl: row ? issueUrl(row, plan.issueKey) : null })),
241
+ });
242
+ }
243
+
244
+ registerRoutes(async (context) => {
245
+ const { url, request } = context;
246
+ let params = matchPath(url.pathname, "/api/projects/:projectId/jira");
247
+ if (params && request.method === "GET") return getConnection(context, params.projectId);
248
+ if (params && request.method === "PUT") return saveConnection(context, params.projectId);
249
+ if (params && request.method === "DELETE") return deleteConnection(context, params.projectId);
250
+ params = matchPath(url.pathname, "/api/projects/:projectId/jira/rotate-webhook-secret");
251
+ if (params && request.method === "POST") return rotateWebhookSecret(context, params.projectId);
252
+ params = matchPath(url.pathname, "/api/projects/:projectId/jira/plan");
253
+ if (params && request.method === "POST") return postPlan(context, params.projectId);
254
+ params = matchPath(url.pathname, "/api/projects/:projectId/jira/tickets");
255
+ if (params && request.method === "POST") return postTickets(context, params.projectId);
256
+ params = matchPath(url.pathname, "/api/projects/:projectId/jira/links");
257
+ if (params && request.method === "GET") return listLinks(context, params.projectId);
258
+ return null;
259
+ });
260
+
261
+ // --- Webhook -----------------------------------------------------------------------------------
262
+
263
+ async function hmacHex(secret: string, body: string): Promise<string> {
264
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
265
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
266
+ return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
267
+ }
268
+
269
+ /** Whether a delivery carries this connection's secret, in any of the accepted forms. */
270
+ export async function webhookAuthentic(request: Request, url: URL, secret: string, body: string): Promise<boolean> {
271
+ const signature = request.headers.get("x-hub-signature") ?? "";
272
+ if (signature.startsWith("sha256=")) return constantTimeEqual(signature.slice(7).toLowerCase(), await hmacHex(secret, body));
273
+ const header = request.headers.get("x-bugmole-webhook-secret") ?? "";
274
+ if (header) return constantTimeEqual(header, secret);
275
+ const token = url.searchParams.get("token") ?? "";
276
+ return Boolean(token) && constantTimeEqual(token, secret);
277
+ }
278
+
279
+ type CommentEvent = { issueKey: string; projectKey: string | null; commentId: string; authorId: string | null; body: unknown };
280
+
281
+ export function parseCommentEvent(payload: Record<string, unknown> | null): CommentEvent | null {
282
+ if (!payload || payload.webhookEvent !== "comment_created") return null;
283
+ const issue = payload.issue as { key?: unknown; fields?: { project?: { key?: unknown } } } | undefined;
284
+ const comment = payload.comment as { id?: unknown; body?: unknown; author?: { accountId?: unknown } } | undefined;
285
+ const issueKey = typeof issue?.key === "string" ? issue.key : "";
286
+ if (!/^[A-Z][A-Z0-9_]{1,9}-\d{1,10}$/.test(issueKey) || !comment) return null;
287
+ const commentId = typeof comment.id === "string" || typeof comment.id === "number" ? String(comment.id) : "";
288
+ if (!/^\d{1,20}$/.test(commentId)) return null;
289
+ return {
290
+ issueKey,
291
+ projectKey: typeof issue?.fields?.project?.key === "string" ? issue.fields.project.key : null,
292
+ commentId,
293
+ authorId: typeof comment.author?.accountId === "string" ? comment.author.accountId : null,
294
+ body: comment.body,
295
+ };
296
+ }
297
+
298
+ /** Whether a comment asks Bugmole for a retest: the keyword, or an @mention of Bugmole's own Jira account. */
299
+ export function asksForRetest(row: Pick<ConnectionRow, "mention_keyword" | "account_id">, body: unknown): boolean {
300
+ if (row.account_id && mentionedAccountIds(body).includes(row.account_id)) return true;
301
+ return commentText(body).toLowerCase().includes(row.mention_keyword.toLowerCase());
302
+ }
303
+
304
+ async function webhookAudit(env: Env, row: ConnectionRow, request: Request, action: string, data: Record<string, unknown>, throttleSeconds = 0): Promise<void> {
305
+ const project = await env.DB.prepare("SELECT workspace_id AS workspaceId FROM projects WHERE id = ?").bind(row.project_id).first<{ workspaceId: string | null }>();
306
+ if (!project?.workspaceId) return;
307
+ if (throttleSeconds > 0) {
308
+ // Anyone who knows a connection id can send bad deliveries; one row per
309
+ // window records the attempt without letting them flood the audit log.
310
+ const recent = await env.DB.prepare(`
311
+ SELECT 1 AS found FROM audit_log
312
+ WHERE workspace_id = ? AND target_id = ? AND action = ?
313
+ AND created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
314
+ LIMIT 1
315
+ `).bind(project.workspaceId, row.id, action, `-${throttleSeconds} seconds`).first<{ found: number }>();
316
+ if (recent) return;
317
+ }
318
+ await recordAudit(env, { type: "webhook", id: `jira:${row.id}` }, {
319
+ workspaceId: project.workspaceId, projectId: row.project_id, action, targetType: "jira_connection", targetId: row.id, data,
320
+ }, request);
321
+ }
322
+
323
+ registerPublicRoutes(async ({ request, env, url, ctx }) => {
324
+ if (url.pathname !== "/api/jira/webhook" || request.method !== "POST") return null;
325
+ const row = await connectionById(env, url.searchParams.get("connection") ?? "");
326
+ if (!row || !row.enabled) return response({ error: "Unknown connection" }, 404);
327
+ const raw = await request.text();
328
+ if (raw.length > WEBHOOK_BODY_MAX) return response({ error: "Payload too large" }, 413);
329
+ let secret: string;
330
+ try {
331
+ secret = await openField(env, row, "webhook");
332
+ } catch {
333
+ return response({ error: "This connection can't verify webhooks; rotate its secret" }, 503);
334
+ }
335
+ if (!(await webhookAuthentic(request, url, secret, raw))) {
336
+ await webhookAudit(env, row, request, "jira.webhook_rejected", { reason: "bad_secret" }, 60);
337
+ return response({ error: "Invalid webhook secret" }, 401);
338
+ }
339
+ let payload: Record<string, unknown> | null = null;
340
+ try {
341
+ payload = JSON.parse(raw) as Record<string, unknown>;
342
+ } catch {
343
+ payload = null;
344
+ }
345
+ const event = parseCommentEvent(payload);
346
+ if (!event) return response({ ignored: "not_a_new_comment" });
347
+ if (event.projectKey && event.projectKey !== row.jira_project_key) return response({ ignored: "other_project" });
348
+ if (!event.projectKey && !event.issueKey.startsWith(`${row.jira_project_key}-`)) return response({ ignored: "other_project" });
349
+ if (event.authorId && event.authorId === row.account_id) return response({ ignored: "own_comment" });
350
+ if (!asksForRetest(row, event.body)) return response({ ignored: "no_mention" });
351
+
352
+ const outcome = await requestRetest(env, row, event.issueKey, event.commentId);
353
+ await webhookAudit(env, row, request, "jira.retest_requested", {
354
+ issueKey: event.issueKey, commentId: event.commentId, requestedBy: event.authorId, queued: outcome.queued,
355
+ ...(outcome.queued ? { runId: outcome.runId, caseSetId: outcome.caseSetId, caseId: outcome.caseId } : { reason: outcome.reason }),
356
+ });
357
+ if (outcome.queued) {
358
+ await enqueue(env, "jira.comment", {
359
+ projectId: row.project_id, issueKey: event.issueKey,
360
+ text: `Bugmole is retesting ${outcome.caseSetId}/${outcome.caseId} (run ${outcome.runId}). The result will be posted here.`,
361
+ }, { dedupeKey: `jira-retest-ack:${outcome.runId}` });
362
+ drainSoon(env, ctx);
363
+ return response({ queued: true, runId: outcome.runId }, 202);
364
+ }
365
+ if (outcome.reason !== "not_bugmole_ticket") {
366
+ const text = outcome.reason === "no_plan"
367
+ ? "Bugmole couldn't find a published plan for this test's journey, so it can't retest it yet. Publish the plan in Bugmole and mention Bugmole again."
368
+ : `Bugmole couldn't start a retest: ${outcome.message ?? "the run was refused"}.`;
369
+ await enqueue(env, "jira.comment", { projectId: row.project_id, issueKey: event.issueKey, text }, { dedupeKey: `jira-retest-fail:${row.id}:${event.issueKey}:${event.commentId}` });
370
+ drainSoon(env, ctx);
371
+ }
372
+ return response({ queued: false, reason: outcome.reason });
373
+ });
374
+
375
+ // --- Outbox and hooks --------------------------------------------------------------------------
376
+
377
+ async function withJira(env: Env, projectId: string, work: () => Promise<unknown>): Promise<void> {
378
+ try {
379
+ await work();
380
+ await env.DB.prepare("UPDATE project_jira_connections SET last_error = NULL WHERE project_id = ? AND last_error IS NOT NULL").bind(projectId).run();
381
+ } catch (error) {
382
+ if (error instanceof ResultsNotReady) throw error;
383
+ const message = error instanceof JiraApiError ? jiraProblem(error) : error instanceof Error ? error.message.slice(0, 300) : "Jira call failed";
384
+ await env.DB.prepare("UPDATE project_jira_connections SET last_error = ? WHERE project_id = ?").bind(message, projectId).run();
385
+ // A revoked token or a removed project won't start working on a retry.
386
+ if (isPermanentJiraError(error)) throw new PermanentDeliveryError(message);
387
+ throw error;
388
+ }
389
+ }
390
+
391
+ registerOutboxHandler("jira.tickets.sync", async (env, payload: { projectId: string }) => {
392
+ await withJira(env, payload.projectId, () => syncTickets(env, payload.projectId));
393
+ });
394
+
395
+ registerOutboxHandler("jira.plan.refresh", async (env, payload: { projectId: string; planIssueId: string }) => {
396
+ await withJira(env, payload.projectId, () => refreshPlanIssue(env, payload.planIssueId));
397
+ });
398
+
399
+ registerOutboxHandler("jira.run.report", async (env, payload: { projectId: string; runId: string }) => {
400
+ await withJira(env, payload.projectId, () => reportRun(env, payload.runId));
401
+ });
402
+
403
+ registerOutboxHandler("jira.comment", async (env, payload: { projectId: string; issueKey: string; text: string }) => {
404
+ const row = await connectionOf(env, payload.projectId);
405
+ if (!row?.enabled) return;
406
+ await withJira(env, payload.projectId, async () => (await clientFor(env, row)).comment(payload.issueKey, adf.doc(adf.paragraph(adf.text(payload.text)))));
407
+ });
408
+
409
+ proposalDecidedHooks.push(async (env, event) => {
410
+ const row = await env.DB.prepare("SELECT enabled FROM project_jira_connections WHERE project_id = ?").bind(event.projectId).first<{ enabled: number }>();
411
+ if (!row?.enabled) return;
412
+ if (event.status === "accepted") {
413
+ await enqueue(env, "jira.tickets.sync", { projectId: event.projectId }, { dedupeKey: `jira-sync:${event.proposalId}:${event.applied ? "applied" : "accepted"}` });
414
+ }
415
+ const plans = await env.DB.prepare("SELECT id, proposal_ids_json AS ids FROM jira_plan_issues WHERE project_id = ? AND status = 'proposed'")
416
+ .bind(event.projectId).all<{ id: string; ids: string }>();
417
+ for (const plan of plans.results) {
418
+ if (!(JSON.parse(plan.ids) as string[]).includes(event.proposalId)) continue;
419
+ await enqueue(env, "jira.plan.refresh", { projectId: event.projectId, planIssueId: plan.id }, { dedupeKey: `jira-plan:${plan.id}:${event.proposalId}:${event.status}` });
420
+ }
421
+ });
422
+
423
+ runChangedHooks.push(async (env, event) => {
424
+ if (!event.terminal) return;
425
+ const row = await env.DB.prepare(`
426
+ SELECT 1 AS linked FROM project_jira_connections c
427
+ WHERE c.project_id = ? AND c.enabled = 1
428
+ AND EXISTS (SELECT 1 FROM jira_issue_links l WHERE l.project_id = c.project_id AND l.jira_issue_key IS NOT NULL)
429
+ `).bind(event.projectId).first<{ linked: number }>();
430
+ if (!row) return;
431
+ await enqueue(env, "jira.run.report", { projectId: event.projectId, runId: event.runId }, { dedupeKey: `jira-report:${event.runId}` });
432
+ });