@bugmole/cli 0.4.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 (173) hide show
  1. package/LICENSE +18 -4
  2. package/package.json +7 -4
  3. package/scripts/bugmole-admin.mjs +194 -0
  4. package/scripts/bugmole-admin.test.mjs +62 -0
  5. package/scripts/bugmole.test.ts +94 -2
  6. package/scripts/bugmole.ts +290 -43
  7. package/scripts/ensure-playwright.cjs +87 -0
  8. package/scripts/ensure-playwright.test.ts +36 -0
  9. package/scripts/sync-byok.d.mts +2 -0
  10. package/scripts/sync-byok.mjs +18 -0
  11. package/scripts/sync-launcher-version.cjs +24 -0
  12. package/scripts/sync-launcher-version.test.ts +28 -0
  13. package/scripts/sync-plan-catalog.d.mts +3 -0
  14. package/scripts/sync-plan-catalog.mjs +16 -6
  15. package/spec/domain_rules.yaml +139 -0
  16. package/spec/roles.yaml +20 -0
  17. package/spec/test-case-results.schema.json +20 -4
  18. package/spec/test-cases.schema.json +131 -16
  19. package/src/billing/plan-catalog.test.ts +82 -2
  20. package/src/billing/plan-catalog.ts +212 -9
  21. package/src/integrations/jira.ts +264 -0
  22. package/src/mcp/roles-and-review.test.ts +111 -0
  23. package/src/mcp/server.ts +358 -22
  24. package/src/mcp/write-test-cases.test.ts +68 -0
  25. package/src/registry/control-plane-client.ts +121 -6
  26. package/src/registry/migrations/0034_signup_attribution.sql +19 -0
  27. package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
  28. package/src/registry/migrations/0036_task_approval.sql +11 -0
  29. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  30. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  31. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  32. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  33. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  34. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  35. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  36. package/src/registry/migrations/0044_project_identity.sql +31 -0
  37. package/src/registry/project-identity.test.ts +99 -0
  38. package/src/registry/project-identity.ts +206 -0
  39. package/src/registry/roles.test.ts +66 -0
  40. package/src/registry/roles.ts +201 -0
  41. package/src/registry/task-scheduling.test.ts +94 -0
  42. package/src/registry/task-scheduling.ts +199 -2
  43. package/src/registry/test-case-revisions.test.ts +57 -0
  44. package/src/registry/test-case-revisions.ts +132 -0
  45. package/src/registry-worker/ai/routes.ts +3 -3
  46. package/src/registry-worker/artifacts.ts +23 -4
  47. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  48. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  49. package/src/registry-worker/billing/enforcement.ts +67 -12
  50. package/src/registry-worker/billing/paypal/api.ts +14 -0
  51. package/src/registry-worker/billing/paypal/client.ts +9 -0
  52. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  53. package/src/registry-worker/billing/paypal.test.ts +108 -1
  54. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  55. package/src/registry-worker/billing/provider.ts +8 -0
  56. package/src/registry-worker/billing/routes.ts +2 -1
  57. package/src/registry-worker/billing/subscriptions.ts +114 -4
  58. package/src/registry-worker/core.ts +24 -0
  59. package/src/registry-worker/devices/policy.ts +4 -5
  60. package/src/registry-worker/devices/routes.ts +8 -8
  61. package/src/registry-worker/domains/domains.test.ts +64 -1
  62. package/src/registry-worker/domains/routes.ts +32 -15
  63. package/src/registry-worker/domains.ts +40 -2
  64. package/src/registry-worker/feature-access.ts +165 -0
  65. package/src/registry-worker/feature-flags/admin.ts +300 -0
  66. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  67. package/src/registry-worker/feature-flags/routes.ts +102 -0
  68. package/src/registry-worker/features.ts +11 -0
  69. package/src/registry-worker/feedback/feedback.test.ts +47 -0
  70. package/src/registry-worker/feedback/routes.ts +2 -2
  71. package/src/registry-worker/feedback/website.ts +77 -0
  72. package/src/registry-worker/flags.ts +80 -18
  73. package/src/registry-worker/github/checks.ts +4 -4
  74. package/src/registry-worker/hooks.ts +9 -0
  75. package/src/registry-worker/identity/oidc.test.ts +103 -0
  76. package/src/registry-worker/identity/oidc.ts +176 -0
  77. package/src/registry-worker/index.ts +494 -173
  78. package/src/registry-worker/jira/connection.ts +111 -0
  79. package/src/registry-worker/jira/jira.test.ts +404 -0
  80. package/src/registry-worker/jira/routes.ts +432 -0
  81. package/src/registry-worker/jira/workflow.ts +577 -0
  82. package/src/registry-worker/jobs/retention.ts +21 -5
  83. package/src/registry-worker/mcp/tools.ts +2 -1
  84. package/src/registry-worker/notifications/alerts.ts +4 -4
  85. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  86. package/src/registry-worker/notifications/routes.ts +9 -10
  87. package/src/registry-worker/notifications/teams.ts +6 -2
  88. package/src/registry-worker/org/routes.test.ts +24 -1
  89. package/src/registry-worker/org/routes.ts +9 -8
  90. package/src/registry-worker/projects/identity.ts +194 -0
  91. package/src/registry-worker/projects/inactivity.ts +162 -0
  92. package/src/registry-worker/projects/projects.test.ts +283 -0
  93. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  94. package/src/registry-worker/proposals/routes.ts +183 -0
  95. package/src/registry-worker/roles/roles.test.ts +84 -0
  96. package/src/registry-worker/roles/routes.ts +141 -0
  97. package/src/registry-worker/runner/dispatch.ts +1 -0
  98. package/src/registry-worker/runner/routes.ts +22 -5
  99. package/src/registry-worker/runner/runner.test.ts +59 -0
  100. package/src/registry-worker/runner/tokens.ts +12 -1
  101. package/src/registry-worker/secrets/crypto.ts +135 -0
  102. package/src/registry-worker/secrets/routes.ts +296 -0
  103. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  104. package/src/registry-worker/signup/attribution.test.ts +147 -0
  105. package/src/registry-worker/signup/attribution.ts +194 -0
  106. package/src/registry-worker/signup/policy.ts +2 -2
  107. package/src/registry-worker/signup/routes.ts +12 -3
  108. package/src/registry-worker/sso/membership.ts +4 -2
  109. package/src/registry-worker/sso/routes.ts +24 -8
  110. package/src/registry-worker/sso/sso.test.ts +3 -2
  111. package/src/registry-worker/task-approval.test.ts +67 -0
  112. package/src/registry-worker/task-resume.test.ts +196 -0
  113. package/src/registry-worker/task-review.test.ts +121 -0
  114. package/src/runtime/ai-exploration.test.ts +34 -1
  115. package/src/runtime/ai-exploration.ts +97 -3
  116. package/src/runtime/appium-driver.ts +7 -1
  117. package/src/runtime/apply-proposals.test.ts +74 -0
  118. package/src/runtime/apply-proposals.ts +41 -0
  119. package/src/runtime/cloud-secrets.test.ts +201 -0
  120. package/src/runtime/cloud-secrets.ts +210 -0
  121. package/src/runtime/config-validate.ts +12 -9
  122. package/src/runtime/cursor-driver-run.ts +14 -3
  123. package/src/runtime/discovery-task.test.ts +26 -1
  124. package/src/runtime/discovery-task.ts +87 -7
  125. package/src/runtime/executor.ts +18 -10
  126. package/src/runtime/explorer.test.ts +32 -0
  127. package/src/runtime/explorer.ts +48 -0
  128. package/src/runtime/flow-language.ts +43 -8
  129. package/src/runtime/init-wizard.ts +112 -6
  130. package/src/runtime/journey-editor.ts +78 -2
  131. package/src/runtime/journey-graph.test.ts +60 -0
  132. package/src/runtime/local-registry-stub.test.ts +64 -0
  133. package/src/runtime/local-registry-stub.ts +255 -7
  134. package/src/runtime/local-vault.ts +65 -0
  135. package/src/runtime/pipeline.test.ts +23 -0
  136. package/src/runtime/pipeline.ts +61 -20
  137. package/src/runtime/planner.test.ts +24 -1
  138. package/src/runtime/planner.ts +88 -23
  139. package/src/runtime/playwright-driver.test.ts +164 -0
  140. package/src/runtime/playwright-driver.ts +286 -15
  141. package/src/runtime/project-identity.test.ts +161 -0
  142. package/src/runtime/project-identity.ts +232 -0
  143. package/src/runtime/project-roles.test.ts +107 -0
  144. package/src/runtime/project-roles.ts +158 -0
  145. package/src/runtime/propose-cli.ts +66 -0
  146. package/src/runtime/record-run-verdicts.test.ts +58 -0
  147. package/src/runtime/record-run-verdicts.ts +38 -4
  148. package/src/runtime/reporter.ts +1 -1
  149. package/src/runtime/reset.ts +2 -0
  150. package/src/runtime/roles-cli.test.ts +47 -0
  151. package/src/runtime/roles-cli.ts +60 -0
  152. package/src/runtime/run-job.test.ts +21 -0
  153. package/src/runtime/run-job.ts +8 -0
  154. package/src/runtime/run-once.ts +68 -5
  155. package/src/runtime/scenario-matrix.test.ts +41 -0
  156. package/src/runtime/scenario-matrix.ts +98 -0
  157. package/src/runtime/secret-driver.ts +77 -0
  158. package/src/runtime/secret-redaction.ts +69 -0
  159. package/src/runtime/secret-sources.test.ts +442 -0
  160. package/src/runtime/secret-sources.ts +176 -0
  161. package/src/runtime/secrets-cli.ts +146 -0
  162. package/src/runtime/serve-worker.ts +64 -6
  163. package/src/runtime/site-discovery.test.ts +181 -4
  164. package/src/runtime/site-discovery.ts +248 -29
  165. package/src/runtime/vault-federation.test.ts +37 -0
  166. package/src/runtime/vault-federation.ts +128 -0
  167. package/src/runtime/web-suite.test.ts +65 -0
  168. package/src/runtime/web-suite.ts +8 -2
  169. package/src/storage/create-object-store.ts +5 -1
  170. package/src/storage/object-store.ts +12 -1
  171. package/src/storage/test-case-results.test.ts +64 -0
  172. package/src/storage/test-case-results.ts +49 -0
  173. package/src/vendor/byok.ts +371 -0
@@ -0,0 +1,111 @@
1
+ // A project's Jira Cloud connection: the stored row, its sealed credentials,
2
+ // and the client that acts with them.
3
+ //
4
+ // Credentials are sealed under the same root key as project secrets
5
+ // (PROJECT_SECRETS_KEY) with their own HKDF info, "bugmole:jira:<projectId>",
6
+ // and additional data binding each value to its project, connection and
7
+ // field. They are opened only here, to make a Jira call, and never leave the
8
+ // registry: no route returns them and no log line includes them.
9
+ import type { Env } from "../core.js";
10
+ import { openInContext, sealInContext, secretsConfigured, type SealContext, type SealedSecret } from "../secrets/crypto.js";
11
+ import { ApiTokenAuth, JiraClient, type JiraAuthProvider, type JiraFetchEnv } from "../../integrations/jira.js";
12
+
13
+ export type ConnectionRow = {
14
+ id: string;
15
+ project_id: string;
16
+ site_url: string;
17
+ jira_project_key: string;
18
+ auth_type: "api_token" | "oauth";
19
+ account_email: string | null;
20
+ account_id: string | null;
21
+ credential_ciphertext: string;
22
+ credential_iv: string;
23
+ credential_key_version: number;
24
+ webhook_secret_ciphertext: string;
25
+ webhook_secret_iv: string;
26
+ webhook_key_version: number;
27
+ mention_keyword: string;
28
+ execution_mode: "self" | "cloud";
29
+ environment_id: string | null;
30
+ enabled: number;
31
+ last_error: string | null;
32
+ created_by: string;
33
+ updated_by: string;
34
+ created_at: string;
35
+ updated_at: string;
36
+ };
37
+
38
+ export type SecretField = "credential" | "webhook";
39
+
40
+ export const JIRA_UNCONFIGURED =
41
+ "The Jira integration needs PROJECT_SECRETS_KEY on the registry to store credentials. Nothing was stored.";
42
+
43
+ export function jiraConfigured(env: Env): boolean {
44
+ return secretsConfigured(env);
45
+ }
46
+
47
+ export function sealContext(projectId: string, connectionId: string, field: SecretField): SealContext {
48
+ return { info: `bugmole:jira:${projectId}`, aad: `${projectId}|jira|${connectionId}|${field}` };
49
+ }
50
+
51
+ export function sealField(env: Env, projectId: string, connectionId: string, field: SecretField, value: string): Promise<SealedSecret> {
52
+ return sealInContext(env, sealContext(projectId, connectionId, field), value);
53
+ }
54
+
55
+ export function openField(env: Env, row: ConnectionRow, field: SecretField): Promise<string> {
56
+ const sealed = field === "credential"
57
+ ? { ciphertext: row.credential_ciphertext, iv: row.credential_iv, keyVersion: row.credential_key_version }
58
+ : { ciphertext: row.webhook_secret_ciphertext, iv: row.webhook_secret_iv, keyVersion: row.webhook_key_version };
59
+ return openInContext(env, sealContext(row.project_id, row.id, field), sealed);
60
+ }
61
+
62
+ export async function connectionOf(env: Env, projectId: string): Promise<ConnectionRow | null> {
63
+ return env.DB.prepare("SELECT * FROM project_jira_connections WHERE project_id = ?").bind(projectId).first<ConnectionRow>();
64
+ }
65
+
66
+ export async function connectionById(env: Env, id: string): Promise<ConnectionRow | null> {
67
+ return env.DB.prepare("SELECT * FROM project_jira_connections WHERE id = ?").bind(id).first<ConnectionRow>();
68
+ }
69
+
70
+ /** The auth provider for a stored connection. 3LO connections need an Atlassian app (see integrations/jira.ts). */
71
+ export async function authFor(env: Env, row: ConnectionRow): Promise<JiraAuthProvider> {
72
+ if (row.auth_type !== "api_token") throw new Error(`Jira ${row.auth_type} connections aren't available yet`);
73
+ return new ApiTokenAuth(row.site_url, row.account_email ?? "", await openField(env, row, "credential"));
74
+ }
75
+
76
+ export async function clientFor(env: Env, row: ConnectionRow): Promise<JiraClient> {
77
+ return new JiraClient(env as Env & JiraFetchEnv, await authFor(env, row));
78
+ }
79
+
80
+ export function dashboardUrl(env: Env): string {
81
+ return (env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/$/, "");
82
+ }
83
+
84
+ export function webhookUrl(env: Env, connectionId: string): string {
85
+ return `${(env.REGISTRY_PUBLIC_URL || "https://api.bugmole.com").replace(/\/$/, "")}/api/jira/webhook?connection=${encodeURIComponent(connectionId)}`;
86
+ }
87
+
88
+ export function issueUrl(row: Pick<ConnectionRow, "site_url">, key: string): string {
89
+ return `${row.site_url}/browse/${encodeURIComponent(key)}`;
90
+ }
91
+
92
+ /** What a browser may see of a connection: never a credential or the webhook secret. */
93
+ export function connectionView(env: Env, row: ConnectionRow) {
94
+ return {
95
+ id: row.id,
96
+ siteUrl: row.site_url,
97
+ projectKey: row.jira_project_key,
98
+ authType: row.auth_type,
99
+ accountEmail: row.account_email,
100
+ mentionKeyword: row.mention_keyword,
101
+ executionMode: row.execution_mode,
102
+ environmentId: row.environment_id,
103
+ enabled: Boolean(row.enabled),
104
+ lastError: row.last_error,
105
+ webhookUrl: webhookUrl(env, row.id),
106
+ createdBy: row.created_by,
107
+ updatedBy: row.updated_by,
108
+ createdAt: row.created_at,
109
+ updatedAt: row.updated_at,
110
+ };
111
+ }
@@ -0,0 +1,404 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { Env } from "../core.js";
4
+ import { call, createTestEnv, seedPlan, seedProject } from "../testing/harness.js";
5
+ import { drainOutbox } from "../notifications/outbox.js";
6
+ import { commentText, normalizeJiraSite } from "../../integrations/jira.js";
7
+ import { asksForRetest, parseCommentEvent } from "./routes.js";
8
+
9
+ const KEY = Buffer.alloc(32, 5).toString("base64");
10
+ const TOKEN = "ATATT-super-secret-jira-token-value";
11
+ const EMAIL = "qa-bot@acme.test";
12
+ const BOT = "acct-bugmole-bot";
13
+
14
+ type FakeIssue = {
15
+ id: string;
16
+ key: string;
17
+ fields: { summary: string; labels: string[]; status: { name: string; statusCategory: { key: string } }; assignee: any; reporter: any; description?: unknown };
18
+ };
19
+
20
+ const STATUSES: Record<string, { name: string; statusCategory: { key: string } }> = {
21
+ "11": { name: "To Do", statusCategory: { key: "new" } },
22
+ "21": { name: "In Progress", statusCategory: { key: "indeterminate" } },
23
+ "31": { name: "Done", statusCategory: { key: "done" } },
24
+ "41": { name: "Blocked", statusCategory: { key: "indeterminate" } },
25
+ };
26
+
27
+ function fakeJira() {
28
+ const state = {
29
+ issues: new Map<string, FakeIssue>(),
30
+ comments: [] as Array<{ key: string; body: any }>,
31
+ transitioned: [] as Array<{ key: string; id: string }>,
32
+ creates: 0,
33
+ /** Issues whose workflow has no Blocked status. */
34
+ noBlocked: new Set<string>(),
35
+ authHeaders: new Set<string>(),
36
+ };
37
+ const expected = `Basic ${Buffer.from(`${EMAIL}:${TOKEN}`).toString("base64")}`;
38
+ const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
39
+ const url = new URL(String(input));
40
+ const method = init?.method ?? "GET";
41
+ const headers = new Headers(init?.headers);
42
+ state.authHeaders.add(headers.get("authorization") ?? "");
43
+ if (url.origin !== "https://acme.atlassian.net") return new Response("wrong host", { status: 500 });
44
+ if (headers.get("authorization") !== expected) return Response.json({ errorMessages: ["Unauthorized"] }, { status: 401 });
45
+ const body = init?.body ? JSON.parse(String(init.body)) : null;
46
+ const path = url.pathname;
47
+ if (path === "/rest/api/3/myself") return Response.json({ accountId: BOT, displayName: "Bugmole" });
48
+ const project = path.match(/^\/rest\/api\/3\/project\/([^/]+)$/);
49
+ if (project) return project[1] === "QA" ? Response.json({ id: "10000", key: "QA", name: "QA" }) : Response.json({ errorMessages: ["No project"] }, { status: 404 });
50
+ if (path === "/rest/api/3/issue" && method === "POST") {
51
+ state.creates += 1;
52
+ const key = `QA-${state.issues.size + 1}`;
53
+ state.issues.set(key, {
54
+ id: String(20000 + state.issues.size),
55
+ key,
56
+ fields: { summary: body.fields.summary, labels: body.fields.labels ?? [], status: STATUSES["11"], assignee: null, reporter: { accountId: BOT, displayName: "Bugmole" }, description: body.fields.description },
57
+ });
58
+ return Response.json({ id: state.issues.get(key)!.id, key }, { status: 201 });
59
+ }
60
+ if (path === "/rest/api/3/search/jql") {
61
+ const jql = url.searchParams.get("jql") ?? "";
62
+ const labels = [...jql.matchAll(/labels = "([^"]+)"/g)].map((match) => match[1]);
63
+ const issues = [...state.issues.values()].filter((issue) => labels.every((label) => issue.fields.labels.includes(label)));
64
+ return Response.json({ issues });
65
+ }
66
+ const issuePath = path.match(/^\/rest\/api\/3\/issue\/([A-Z]+-\d+)(\/transitions|\/comment)?$/);
67
+ const issue = issuePath ? state.issues.get(issuePath[1]) : undefined;
68
+ if (!issuePath || !issue) return Response.json({ errorMessages: ["Issue does not exist"] }, { status: 404 });
69
+ if (!issuePath[2] && method === "GET") return Response.json(issue);
70
+ if (!issuePath[2] && method === "PUT") {
71
+ if (body.fields) Object.assign(issue.fields, body.fields);
72
+ for (const operation of body.update?.labels ?? []) {
73
+ if (operation.add && !issue.fields.labels.includes(operation.add)) issue.fields.labels.push(operation.add);
74
+ if (operation.remove) issue.fields.labels = issue.fields.labels.filter((label) => label !== operation.remove);
75
+ }
76
+ return new Response(null, { status: 204 });
77
+ }
78
+ if (issuePath[2] === "/transitions" && method === "GET") {
79
+ const ids = Object.keys(STATUSES).filter((id) => !(id === "41" && state.noBlocked.has(issue.key)));
80
+ return Response.json({ transitions: ids.map((id) => ({ id, name: id === "41" ? "Block" : STATUSES[id].name, to: STATUSES[id] })) });
81
+ }
82
+ if (issuePath[2] === "/transitions" && method === "POST") {
83
+ state.transitioned.push({ key: issue.key, id: body.transition.id });
84
+ issue.fields.status = STATUSES[body.transition.id];
85
+ return new Response(null, { status: 204 });
86
+ }
87
+ if (issuePath[2] === "/comment" && method === "POST") {
88
+ state.comments.push({ key: issue.key, body: body.body });
89
+ return Response.json({ id: String(state.comments.length) }, { status: 201 });
90
+ }
91
+ return new Response("unhandled", { status: 500 });
92
+ }) as unknown as typeof fetch;
93
+ return { state, doFetch };
94
+ }
95
+
96
+ const loginCase = (title: string, nodeId = "screen:/login") => ({
97
+ title, nodeId, intent: "Prove a person can sign in.",
98
+ steps: ["Open /login", "Sign in"], expected: ["The dashboard opens"], confidence: "high", basis: ["spec"],
99
+ });
100
+
101
+ /** A managed project with a published plan and a case set for its journey; logs are captured. */
102
+ async function setup() {
103
+ const jira = fakeJira();
104
+ const created = createTestEnv({ PROJECT_SECRETS_KEY: KEY, JIRA_FETCH: jira.doFetch, DASHBOARD_URL: "https://dash.test", REGISTRY_PUBLIC_URL: "https://registry.test" } as Partial<Env>);
105
+ const project = await seedProject(created.env, { storageProvider: "bugmole" });
106
+ await seedPlan(created.env, project.projectId, project.owner, "checkout");
107
+ await created.artifacts.put(`projects/${project.projectId}/test-cases/checkout-cases.json`, JSON.stringify({
108
+ schemaVersion: 1, caseSetId: "checkout-cases", journeyId: "checkout", journeyRevision: 1, cases: [],
109
+ }));
110
+ const connect = (body: Record<string, unknown> = {}, as = project.owner) => call(created.env, "PUT", `/api/projects/${project.projectId}/jira`, {
111
+ as, body: { siteUrl: "acme.atlassian.net", email: EMAIL, apiToken: TOKEN, projectKey: "QA", ...body },
112
+ });
113
+ const propose = (caseId: string, title: string) => call(created.env, "POST", `/api/projects/${project.projectId}/proposals`, {
114
+ as: project.owner, body: { action: "add", caseSetId: "checkout-cases", caseId, case: loginCase(title, `screen:/${caseId}`), summary: `Cover ${title}`, source: "mcp" },
115
+ });
116
+ const drain = () => drainOutbox(created.env, new Date(Date.now() + 24 * 60 * 60_000));
117
+ return { ...created, ...project, jira, connect, propose, drain };
118
+ }
119
+
120
+ function captureLogs() {
121
+ const lines: string[] = [];
122
+ const originals = { log: console.log, warn: console.warn, error: console.error, info: console.info };
123
+ for (const name of Object.keys(originals) as Array<keyof typeof originals>) {
124
+ console[name] = (...args: unknown[]) => {
125
+ lines.push(args.map((arg) => (arg instanceof Error ? `${arg.message} ${arg.stack}` : typeof arg === "string" ? arg : JSON.stringify(arg))).join(" "));
126
+ };
127
+ }
128
+ return { lines, restore: () => Object.assign(console, originals) };
129
+ }
130
+
131
+ async function hubSignature(secret: string, body: string): Promise<string> {
132
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
133
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
134
+ return `sha256=${Buffer.from(signature).toString("hex")}`;
135
+ }
136
+
137
+ function commentEvent(issueKey: string, text: string, authorId = "acct-dev-1", id = "9001") {
138
+ return { webhookEvent: "comment_created", issue: { key: issueKey, fields: { project: { key: "QA" } } }, comment: { id, author: { accountId: authorId }, body: text } };
139
+ }
140
+
141
+ /** A run that finished, with the given verdicts written to Bugmole storage the way a worker writes them. */
142
+ async function finishRun(context: Awaited<ReturnType<typeof setup>>, runId: string, status: string, verdicts: Array<{ caseId: string; status: string; detail: string }> | null) {
143
+ const { env, owner, projectId, artifacts } = context;
144
+ assert.equal((await call(env, "POST", `/api/runs/${runId}/claim`, { as: owner, body: { workerId: "w1" } })).status, 200);
145
+ if (verdicts) {
146
+ await artifacts.put(`projects/${projectId}/test-case-results/checkout-cases.json`, JSON.stringify({
147
+ schemaVersion: 1, caseSetId: "checkout-cases", projectId, journeyId: "checkout", runId, recordedAt: new Date().toISOString(), results: verdicts,
148
+ }));
149
+ }
150
+ assert.equal((await call(env, "POST", `/api/runs/${runId}`, { as: owner, body: { workerId: "w1", status, stepsPassed: 1, stepsFailed: status === "success" ? 0 : 1 } })).status, 200);
151
+ }
152
+
153
+ test("site URLs are Jira Cloud only, and comment text and mentions are read from ADF and wiki markup", () => {
154
+ assert.equal(normalizeJiraSite("acme.atlassian.net"), "https://acme.atlassian.net");
155
+ assert.equal(normalizeJiraSite("https://Acme.atlassian.net/jira/software"), "https://acme.atlassian.net");
156
+ assert.equal(normalizeJiraSite("http://acme.atlassian.net"), null, "https only");
157
+ assert.equal(normalizeJiraSite("https://evil.example.com"), null, "no arbitrary hosts");
158
+ assert.equal(normalizeJiraSite("https://user:pw@acme.atlassian.net"), null);
159
+ const body = { type: "doc", version: 1, content: [{ type: "paragraph", content: [{ type: "mention", attrs: { id: BOT, text: "@Bugmole" } }, { type: "text", text: " fixed, please retest" }] }] };
160
+ assert.match(commentText(body), /@Bugmole fixed, please retest/);
161
+ assert.equal(asksForRetest({ mention_keyword: "bugmole retest", account_id: BOT }, body), true, "an @mention of Bugmole's account counts");
162
+ assert.equal(asksForRetest({ mention_keyword: "@Bugmole", account_id: null }, "Fixed. @bugmole please look"), true, "the keyword, any case");
163
+ assert.equal(asksForRetest({ mention_keyword: "@Bugmole", account_id: BOT }, "Fixed, thanks"), false);
164
+ assert.equal(asksForRetest({ mention_keyword: "@Bugmole", account_id: BOT }, `[~accountid:${BOT}] retest`), true, "wiki markup mentions");
165
+ assert.equal(parseCommentEvent({ webhookEvent: "comment_updated" }), null);
166
+ assert.equal(parseCommentEvent({ webhookEvent: "comment_created", issue: { key: "not a key" }, comment: { id: "1" } }), null);
167
+ });
168
+
169
+ test("connecting verifies the token, stores it sealed and write-only, and never returns or logs it", async () => {
170
+ const logs = captureLogs();
171
+ try {
172
+ const context = await setup();
173
+ const { env, db, owner, projectId, workspaceId, connect } = context;
174
+ assert.equal((await connect({ siteUrl: "https://evil.example.com" })).status, 422);
175
+ const wrong = await connect({ apiToken: "not-the-token" });
176
+ assert.equal(wrong.status, 422);
177
+ assert.match(wrong.json.error, /didn't accept/);
178
+ assert.equal((await connect({ projectKey: "NOPE" })).status, 422, "a project the account can't see");
179
+ assert.equal(db.db.prepare("SELECT COUNT(*) AS n FROM project_jira_connections").get()!.n, 0, "nothing stored before it works");
180
+
181
+ const connected = await connect();
182
+ assert.equal(connected.status, 201, JSON.stringify(connected.json));
183
+ assert.ok(connected.json.webhookSecret, "the webhook secret is shown once");
184
+ assert.match(connected.json.connection.webhookUrl, /^https:\/\/registry\.test\/api\/jira\/webhook\?connection=jira_/);
185
+ const got = await call(env, "GET", `/api/projects/${projectId}/jira`, { as: owner });
186
+ assert.equal(got.status, 200);
187
+ assert.equal(got.json.connection.siteUrl, "https://acme.atlassian.net");
188
+ assert.equal(got.json.connection.mentionKeyword, "@Bugmole");
189
+ assert.equal(got.json.webhookSecret, undefined);
190
+
191
+ const stored = db.db.prepare("SELECT * FROM project_jira_connections").get() as Record<string, unknown>;
192
+ assert.ok(!JSON.stringify(stored).includes(TOKEN), "sealed at rest");
193
+ assert.ok(!JSON.stringify(stored).includes(connected.json.webhookSecret), "the webhook secret too");
194
+ assert.equal(stored.account_id, BOT);
195
+
196
+ // Updating without a token keeps the stored one; a stranger or member can't touch it.
197
+ const updated = await connect({ apiToken: undefined, mentionKeyword: "bugmole retest" });
198
+ assert.equal(updated.status, 200, JSON.stringify(updated.json));
199
+ assert.equal(updated.json.connection.mentionKeyword, "bugmole retest");
200
+ assert.equal((await connect({ apiToken: undefined, email: "someone-else@acme.test" })).status, 422, "a new email needs its token");
201
+ assert.equal((await connect({}, "user_stranger")).status, 403);
202
+ const invited = await call(env, "POST", `/api/workspaces/${workspaceId}/invitations`, { as: owner, body: { email: "member@example.com", role: "member" } });
203
+ await call(env, "POST", `/api/invitations/${invited.json.invitation.acceptUrl.split("/invite/")[1]}/accept`, { as: "user_member" });
204
+ assert.equal((await connect({}, "user_member")).status, 403, "only managers connect");
205
+ assert.equal((await call(env, "GET", `/api/projects/${projectId}/jira`, { as: "user_member" })).status, 200, "members see the status");
206
+
207
+ const audit = db.db.prepare("SELECT action, data_json AS data FROM audit_log WHERE action LIKE 'jira.%'").all() as Array<{ action: string; data: string }>;
208
+ assert.deepEqual(audit.map((row) => row.action), ["jira.connected", "jira.updated"]);
209
+ for (const text of [JSON.stringify(got.json), JSON.stringify(updated.json), JSON.stringify(audit), logs.lines.join("\n")]) {
210
+ assert.ok(!text.includes(TOKEN), "the token is in no response, audit row or log line");
211
+ }
212
+ assert.ok(!logs.lines.join("\n").includes(Buffer.from(`${EMAIL}:${TOKEN}`).toString("base64")), "nor its Basic header");
213
+ } finally {
214
+ logs.restore();
215
+ }
216
+ });
217
+
218
+ test("the plan goes to Jira, approval creates one ticket per test idempotently, and the plan issue shows the decision", async () => {
219
+ const context = await setup();
220
+ const { env, db, owner, projectId, jira, connect, propose, drain } = context;
221
+ assert.equal((await call(env, "POST", `/api/projects/${projectId}/jira/plan`, { as: owner })).status, 404, "not connected yet");
222
+ assert.equal((await connect()).status, 201);
223
+ assert.equal((await call(env, "POST", `/api/projects/${projectId}/jira/plan`, { as: owner })).status, 422, "nothing proposed");
224
+
225
+ const first = (await propose("TC-login", "Sign in works")).json.proposal.id;
226
+ const second = (await propose("TC-logout", "Sign out works")).json.proposal.id;
227
+ const plan = await call(env, "POST", `/api/projects/${projectId}/jira/plan`, { as: owner });
228
+ assert.equal(plan.status, 201, JSON.stringify(plan.json));
229
+ const planIssue = jira.state.issues.get(plan.json.planIssue.key)!;
230
+ assert.deepEqual(planIssue.fields.labels, ["bugmole-plan"]);
231
+ assert.match(planIssue.fields.summary, /Proposed test plan .*2 test cases/);
232
+ assert.match(JSON.stringify(planIssue.fields.description), /Sign in works.*Sign out works/s);
233
+ assert.equal(plan.json.planIssue.url, `https://acme.atlassian.net/browse/${plan.json.planIssue.key}`);
234
+ const again = await call(env, "POST", `/api/projects/${projectId}/jira/plan`, { as: owner });
235
+ assert.deepEqual([again.status, again.json.planIssue.key], [200, plan.json.planIssue.key], "the same proposals aren't posted twice");
236
+
237
+ // Approve in Bugmole: tickets follow through the outbox.
238
+ assert.equal((await call(env, "POST", `/api/proposals/${first}/accept`, { as: owner })).status, 200);
239
+ assert.equal((await call(env, "POST", `/api/proposals/${second}/accept`, { as: owner })).status, 200);
240
+ await drain();
241
+ const tickets = [...jira.state.issues.values()].filter((issue) => issue.fields.labels.includes("bugmole-test"));
242
+ assert.equal(tickets.length, 2);
243
+ assert.ok(tickets.every((ticket) => ticket.fields.labels.some((label) => /^bugmole-case-[0-9a-f]{16}$/.test(label))));
244
+ assert.match(JSON.stringify(tickets[0].fields.description), /Open \/login.*The dashboard opens.*https:\/\/dash\.test\/plans\?project=shop/s);
245
+ assert.equal(planIssue.fields.status.name, "Done", "the plan issue closes once every proposal is decided");
246
+ assert.ok(jira.state.comments.some((comment) => comment.key === planIssue.key && /approved in Bugmole: 2 of 2/.test(JSON.stringify(comment.body))));
247
+
248
+ // Syncing again creates nothing; losing the link finds the same ticket by its case label.
249
+ const creates = jira.state.creates;
250
+ const resync = await call(env, "POST", `/api/projects/${projectId}/jira/tickets`, { as: owner, body: {} });
251
+ assert.equal(resync.status, 200, JSON.stringify(resync.json));
252
+ assert.deepEqual(resync.json.created, []);
253
+ db.db.prepare("UPDATE jira_issue_links SET jira_issue_key = NULL, jira_issue_id = NULL WHERE case_id = 'TC-login'").run();
254
+ const relinked = await call(env, "POST", `/api/projects/${projectId}/jira/tickets`, { as: owner, body: {} });
255
+ assert.deepEqual(relinked.json.created, []);
256
+ assert.equal(relinked.json.linked.length, 1);
257
+ assert.equal(jira.state.creates, creates, "no duplicate ticket");
258
+
259
+ const links = await call(env, "GET", `/api/projects/${projectId}/jira/links`, { as: owner });
260
+ assert.deepEqual(links.json.links.map((link: { caseId: string; caseRevision: number }) => [link.caseId, link.caseRevision]), [["TC-login", 1], ["TC-logout", 1]]);
261
+ assert.match(links.json.links[0].issueUrl, /^https:\/\/acme\.atlassian\.net\/browse\/QA-\d+$/);
262
+ });
263
+
264
+ async function withTickets() {
265
+ const context = await setup();
266
+ const { env, owner, projectId, jira, connect, propose, drain } = context;
267
+ const connected = await connect();
268
+ for (const [caseId, title] of [["TC-login", "Sign in works"], ["TC-logout", "Sign out works"], ["TC-reset", "Reset password works"]]) {
269
+ const id = (await propose(caseId, title)).json.proposal.id;
270
+ await call(env, "POST", `/api/proposals/${id}/accept`, { as: owner });
271
+ }
272
+ await drain();
273
+ const keyOf = (caseId: string) => (context.db.db.prepare("SELECT jira_issue_key AS key FROM jira_issue_links WHERE case_id = ?").get(caseId) as { key: string }).key;
274
+ const keys = { login: keyOf("TC-login"), logout: keyOf("TC-logout"), reset: keyOf("TC-reset") };
275
+ for (const key of Object.values(keys)) {
276
+ const issue = jira.state.issues.get(key)!;
277
+ issue.fields.assignee = { accountId: "acct-dev-1", displayName: "Dana Dev" };
278
+ issue.fields.reporter = { accountId: "acct-pm-1", displayName: "Pat PM" };
279
+ }
280
+ jira.state.noBlocked.add(keys.reset);
281
+ return { ...context, keys, webhookSecret: connected.json.webhookSecret as string, connectionId: connected.json.connection.id as string };
282
+ }
283
+
284
+ test("run results move passed tickets to Done and failed ones to Blocked, mentioning the people on them", async () => {
285
+ const context = await withTickets();
286
+ const { env, owner, projectId, jira, keys, drain } = context;
287
+ const run = await call(env, "POST", `/api/projects/${projectId}/runs`, { as: owner, body: { planId: "checkout", idempotencyKey: "full-1" } });
288
+ await finishRun(context, run.json.run.id, "partial", [
289
+ { caseId: "TC-login", status: "passed", detail: "Signed in." },
290
+ { caseId: "TC-logout", status: "failed", detail: "The sign-out button did nothing." },
291
+ { caseId: "TC-reset", status: "failed", detail: "No reset email." },
292
+ ]);
293
+ await drain();
294
+
295
+ assert.equal(jira.state.issues.get(keys.login)!.fields.status.name, "Done");
296
+ assert.equal(jira.state.issues.get(keys.logout)!.fields.status.name, "Blocked");
297
+ const reset = jira.state.issues.get(keys.reset)!;
298
+ assert.equal(reset.fields.status.name, "To Do", "no Blocked status in this workflow…");
299
+ assert.ok(reset.fields.labels.includes("bugmole-blocked"), "…so it gets the bugmole-blocked label");
300
+
301
+ const failedComment = jira.state.comments.find((comment) => comment.key === keys.logout)!;
302
+ const mentions = JSON.stringify(failedComment.body).match(/"type":"mention","attrs":\{"id":"([^"]+)"/g) ?? [];
303
+ assert.deepEqual(mentions.map((match) => match.split('"id":"')[1].replace('"', "")), ["acct-dev-1", "acct-pm-1"], "assignee and reporter, never Bugmole itself");
304
+ assert.match(JSON.stringify(failedComment.body), /Bugmole: failed\..*sign-out button did nothing.*https:\/\/dash\.test\/runs\?project=shop&run=run_/s);
305
+ const passedComment = jira.state.comments.find((comment) => comment.key === keys.login)!;
306
+ assert.match(JSON.stringify(passedComment.body), /Bugmole: passed\./);
307
+ assert.ok(!JSON.stringify(passedComment.body).includes('"type":"mention"'), "a pass bothers no one");
308
+
309
+ // A redelivered report changes nothing.
310
+ const comments = jira.state.comments.length;
311
+ const { reportRun } = await import("./workflow.js");
312
+ assert.deepEqual((await reportRun(env, run.json.run.id)).reported, []);
313
+ assert.equal(jira.state.comments.length, comments);
314
+ });
315
+
316
+ test("the webhook rejects a wrong secret and ignores its own comments; a mention retests only that ticket's case", async () => {
317
+ const context = await withTickets();
318
+ const { env, db, owner, projectId, jira, keys, drain, webhookSecret, connectionId } = context;
319
+ // First, the logout ticket fails.
320
+ const full = await call(env, "POST", `/api/projects/${projectId}/runs`, { as: owner, body: { planId: "checkout", idempotencyKey: "full-2" } });
321
+ await finishRun(context, full.json.run.id, "partial", [
322
+ { caseId: "TC-login", status: "passed", detail: "" },
323
+ { caseId: "TC-logout", status: "failed", detail: "Broken." },
324
+ { caseId: "TC-reset", status: "passed", detail: "" },
325
+ ]);
326
+ await drain();
327
+ assert.equal(jira.state.issues.get(keys.logout)!.fields.status.name, "Blocked");
328
+
329
+ const post = (payload: unknown, options: { secret?: string; header?: "hub" | "plain" | "query"; connection?: string } = {}) => {
330
+ const raw = JSON.stringify(payload);
331
+ const secret = options.secret ?? webhookSecret;
332
+ const connection = options.connection ?? connectionId;
333
+ const query = options.header === "query" ? `&token=${encodeURIComponent(secret)}` : "";
334
+ return (async () => call(env, "POST", `/api/jira/webhook?connection=${connection}${query}`, {
335
+ body: raw,
336
+ headers: options.header === "plain" ? { "x-bugmole-webhook-secret": secret } : options.header === "query" ? {} : { "x-hub-signature": await hubSignature(secret, raw) },
337
+ }))();
338
+ };
339
+
340
+ assert.equal((await post(commentEvent(keys.logout, "@Bugmole retest"), { connection: "jira_nope" })).status, 404);
341
+ assert.equal((await post(commentEvent(keys.logout, "@Bugmole retest"), { secret: "wrong-secret" })).status, 401);
342
+ assert.equal((await post(commentEvent(keys.logout, "@Bugmole retest"), { secret: "wrong-secret", header: "plain" })).status, 401);
343
+ assert.equal((await call(env, "POST", `/api/jira/webhook?connection=${connectionId}`, { body: JSON.stringify(commentEvent(keys.logout, "@Bugmole")) })).status, 401, "no secret at all");
344
+ const rejected = db.db.prepare("SELECT actor_type AS actorType, actor_id AS actorId FROM audit_log WHERE action = 'jira.webhook_rejected'").all() as any[];
345
+ assert.equal(rejected.length, 1, "rejections are audited, once a minute per connection");
346
+ assert.deepEqual({ ...rejected[0] }, { actorType: "webhook", actorId: `jira:${connectionId}` });
347
+
348
+ assert.equal((await post(commentEvent(keys.logout, "Bugmole: failed. mention @Bugmole", BOT))).json.ignored, "own_comment");
349
+ assert.equal((await post(commentEvent(keys.logout, "Looking into it"))).json.ignored, "no_mention");
350
+ assert.equal((await post({ ...commentEvent(keys.logout, "@Bugmole"), issue: { key: "OPS-1", fields: { project: { key: "OPS" } } } })).json.ignored, "other_project");
351
+ assert.equal(db.db.prepare("SELECT COUNT(*) AS n FROM runs WHERE trigger_source = 'jira'").get()!.n, 0);
352
+
353
+ const queued = await post(commentEvent(keys.logout, "Fixed in #42, @Bugmole please retest"));
354
+ assert.equal(queued.status, 202, JSON.stringify(queued.json));
355
+ const run = db.db.prepare("SELECT id, plan_id AS planId, trigger_source AS trigger, case_scope_json AS scope FROM runs WHERE id = ?").get(queued.json.runId) as any;
356
+ assert.equal(run.planId, "checkout");
357
+ assert.equal(run.trigger, "jira");
358
+ assert.deepEqual(JSON.parse(run.scope), { caseSetId: "checkout-cases", caseIds: ["TC-logout"], jiraIssueKey: keys.logout }, "scoped to that ticket's case only");
359
+ const worker = await call(env, "POST", `/api/runs/${run.id}/claim`, { as: owner, body: { workerId: "w9" } });
360
+ assert.deepEqual(worker.json.caseScope.caseIds, ["TC-logout"], "the worker is told the scope");
361
+ await call(env, "POST", `/api/runs/${run.id}`, { as: owner, body: { workerId: "w9", status: "running" } });
362
+ assert.equal((await post(commentEvent(keys.logout, "@Bugmole please retest"), { header: "plain" })).json.runId, run.id, "a redelivered comment doesn't start a second run");
363
+ assert.equal((await post(commentEvent(keys.logout, "@Bugmole please retest"), { header: "query" })).status, 202, "the token may also be in the URL");
364
+ const accepted = db.db.prepare("SELECT data_json AS data FROM audit_log WHERE action = 'jira.retest_requested'").all() as Array<{ data: string }>;
365
+ assert.equal(JSON.parse(accepted[0].data).runId, run.id);
366
+
367
+ await drain();
368
+ assert.ok(jira.state.comments.some((comment) => comment.key === keys.logout && /retesting checkout-cases\/TC-logout/.test(commentText(comment.body))), "the ticket hears the retest is queued");
369
+
370
+ // The retest judges only its case; other tickets keep their state.
371
+ const loginComments = jira.state.comments.filter((comment) => comment.key === keys.login).length;
372
+ await context.artifacts.put(`projects/${projectId}/test-case-results/checkout-cases.json`, JSON.stringify({
373
+ caseSetId: "checkout-cases", runId: run.id, recordedAt: new Date().toISOString(),
374
+ results: [{ caseId: "TC-login", status: "failed", detail: "stale" }, { caseId: "TC-logout", status: "passed", detail: "Sign-out works now." }],
375
+ }));
376
+ await call(env, "POST", `/api/runs/${run.id}`, { as: owner, body: { workerId: "w9", status: "success", stepsPassed: 2, stepsFailed: 0 } });
377
+ await drain();
378
+ const logout = jira.state.issues.get(keys.logout)!;
379
+ assert.equal(logout.fields.status.name, "Done");
380
+ assert.ok(jira.state.comments.some((comment) => comment.key === keys.logout && /Bugmole: passed\..*Sign-out works now/.test(commentText(comment.body))));
381
+ assert.equal(jira.state.issues.get(keys.login)!.fields.status.name, "Done", "out of scope: untouched");
382
+ assert.equal(jira.state.comments.filter((comment) => comment.key === keys.login).length, loginComments);
383
+ const link = db.db.prepare("SELECT last_result AS result, last_run_id AS runId FROM jira_issue_links WHERE case_id = 'TC-logout'").get() as any;
384
+ assert.deepEqual({ ...link }, { result: "passed", runId: run.id });
385
+ });
386
+
387
+ test("a scoped retest on a project's own storage is judged by the run itself", async () => {
388
+ const context = await withTickets();
389
+ const { env, db, owner, projectId, jira, keys, drain, webhookSecret, connectionId } = context;
390
+ db.db.prepare("UPDATE projects SET storage_mode = 'byo' WHERE id = ?").run(projectId);
391
+ const raw = JSON.stringify(commentEvent(keys.reset, "@bugmole retest"));
392
+ const queued = await call(env, "POST", `/api/jira/webhook?connection=${connectionId}`, { body: raw, headers: { "x-hub-signature": await hubSignature(webhookSecret, raw) } });
393
+ assert.equal(queued.status, 202, JSON.stringify(queued.json));
394
+ await finishRun(context, queued.json.runId, "error", null);
395
+ await drain();
396
+ const reset = jira.state.issues.get(keys.reset)!;
397
+ assert.ok(reset.fields.labels.includes("bugmole-blocked"));
398
+ assert.ok(jira.state.comments.some((comment) => comment.key === keys.reset && /Bugmole: failed\./.test(commentText(comment.body)) && /acct-dev-1/.test(JSON.stringify(comment.body))));
399
+
400
+ // Disconnecting forgets the links (tickets stay in Jira) and stops the webhook.
401
+ assert.equal((await call(env, "DELETE", `/api/projects/${projectId}/jira`, { as: owner })).status, 200);
402
+ assert.equal(db.db.prepare("SELECT COUNT(*) AS n FROM jira_issue_links").get()!.n, 0);
403
+ assert.equal((await call(env, "POST", `/api/jira/webhook?connection=${connectionId}`, { body: raw, headers: { "x-hub-signature": await hubSignature(webhookSecret, raw) } })).status, 404);
404
+ });