@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,264 @@
1
+ // A small Jira Cloud REST client (platform API v3) for what the Bugmole
2
+ // integration needs: create and find issues, read transitions, move an
3
+ // issue, comment with @mentions, and edit labels.
4
+ //
5
+ // Authentication is behind JiraAuthProvider so OAuth 2.0 (3LO) can be added
6
+ // without touching the calls. Today there is one provider, an Atlassian API
7
+ // token (Basic auth with the account email). 3LO needs an Atlassian developer
8
+ // app registered at developer.atlassian.com (client id and secret, callback
9
+ // URL, scopes read:jira-work write:jira-work read:jira-user offline_access);
10
+ // its calls go to https://api.atlassian.com/ex/jira/<cloudId> with a Bearer
11
+ // token instead of the site URL.
12
+ //
13
+ // Nothing here logs or returns a credential: errors carry the HTTP status and
14
+ // Jira's own error text, never request headers.
15
+
16
+ export type JiraFetchEnv = {
17
+ /** Test seam. */
18
+ JIRA_FETCH?: typeof fetch;
19
+ };
20
+
21
+ export class JiraApiError extends Error {
22
+ constructor(readonly status: number, message: string) {
23
+ super(message);
24
+ }
25
+ }
26
+
27
+ /** Supplies the base URL and Authorization header for Jira calls. */
28
+ export interface JiraAuthProvider {
29
+ readonly kind: "api_token" | "oauth";
30
+ /** Where REST paths are resolved: the site for API tokens, api.atlassian.com/ex/jira/<cloudId> for 3LO. */
31
+ readonly apiBase: string;
32
+ authorization(): Promise<string>;
33
+ }
34
+
35
+ export class ApiTokenAuth implements JiraAuthProvider {
36
+ readonly kind = "api_token" as const;
37
+ readonly apiBase: string;
38
+ readonly #header: string;
39
+
40
+ constructor(siteUrl: string, email: string, token: string) {
41
+ this.apiBase = siteUrl.replace(/\/+$/, "");
42
+ const bytes = new TextEncoder().encode(`${email}:${token}`);
43
+ let binary = "";
44
+ for (const byte of bytes) binary += String.fromCharCode(byte);
45
+ this.#header = `Basic ${btoa(binary)}`;
46
+ }
47
+
48
+ async authorization(): Promise<string> {
49
+ return this.#header;
50
+ }
51
+
52
+ /** Never serialize the credential, even by accident. */
53
+ toJSON() {
54
+ return { kind: this.kind, apiBase: this.apiBase };
55
+ }
56
+ }
57
+
58
+ const SITE = /^https:\/\/[a-z0-9][a-z0-9-]{0,62}\.atlassian\.net$/;
59
+
60
+ /**
61
+ * A Jira Cloud site URL, normalized to https://<name>.atlassian.net, or null.
62
+ * Only Atlassian-hosted sites are accepted, so a connection can't point the
63
+ * registry's requests at an arbitrary host.
64
+ */
65
+ export function normalizeJiraSite(value: unknown): string | null {
66
+ if (typeof value !== "string") return null;
67
+ let text = value.trim().toLowerCase();
68
+ if (!text) return null;
69
+ if (!/^https?:\/\//.test(text)) text = `https://${text}`;
70
+ let url: URL;
71
+ try {
72
+ url = new URL(text);
73
+ } catch {
74
+ return null;
75
+ }
76
+ if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
77
+ const site = `https://${url.hostname}`;
78
+ return SITE.test(site) ? site : null;
79
+ }
80
+
81
+ export const JIRA_PROJECT_KEY = /^[A-Z][A-Z0-9_]{1,9}$/;
82
+
83
+ // --- Atlassian Document Format ----------------------------------------------------------------
84
+
85
+ export type AdfNode = { type: string; attrs?: Record<string, unknown>; content?: AdfNode[]; text?: string; marks?: Array<{ type: string; attrs?: Record<string, unknown> }> };
86
+ export type AdfDoc = { type: "doc"; version: 1; content: AdfNode[] };
87
+
88
+ export const adf = {
89
+ doc(...content: AdfNode[]): AdfDoc {
90
+ return { type: "doc", version: 1, content };
91
+ },
92
+ paragraph(...content: AdfNode[]): AdfNode {
93
+ return { type: "paragraph", content };
94
+ },
95
+ text(text: string, marks?: AdfNode["marks"]): AdfNode {
96
+ return marks?.length ? { type: "text", text, marks } : { type: "text", text };
97
+ },
98
+ strong(text: string): AdfNode {
99
+ return { type: "text", text, marks: [{ type: "strong" }] };
100
+ },
101
+ link(text: string, href: string): AdfNode {
102
+ return { type: "text", text, marks: [{ type: "link", attrs: { href } }] };
103
+ },
104
+ mention(accountId: string, name?: string): AdfNode {
105
+ return { type: "mention", attrs: { id: accountId, text: `@${name ?? accountId}` } };
106
+ },
107
+ heading(level: number, text: string): AdfNode {
108
+ return { type: "heading", attrs: { level }, content: [{ type: "text", text }] };
109
+ },
110
+ bullets(items: AdfNode[][]): AdfNode {
111
+ return { type: "bulletList", content: items.map((content) => ({ type: "listItem", content: [{ type: "paragraph", content }] })) };
112
+ },
113
+ ordered(items: string[]): AdfNode {
114
+ return { type: "orderedList", content: items.map((item) => ({ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: item }] }] })) };
115
+ },
116
+ };
117
+
118
+ /** Plain text of a comment body: ADF (API v3) or a wiki-markup string (webhooks, API v2). */
119
+ export function commentText(body: unknown): string {
120
+ if (typeof body === "string") return body;
121
+ const parts: string[] = [];
122
+ const walk = (node: unknown) => {
123
+ if (!node || typeof node !== "object") return;
124
+ const value = node as AdfNode;
125
+ if (value.type === "text" && typeof value.text === "string") parts.push(value.text);
126
+ if (value.type === "mention" && typeof value.attrs?.text === "string") parts.push(value.attrs.text as string);
127
+ if (Array.isArray(value.content)) {
128
+ for (const child of value.content) walk(child);
129
+ if (value.type === "paragraph") parts.push("\n");
130
+ }
131
+ };
132
+ walk(body);
133
+ return parts.join(" ").replace(/[ \t]+/g, " ").trim();
134
+ }
135
+
136
+ /** Account ids mentioned in a comment body: ADF mention nodes, or [~accountid:…] in wiki markup. */
137
+ export function mentionedAccountIds(body: unknown): string[] {
138
+ const ids = new Set<string>();
139
+ if (typeof body === "string") {
140
+ for (const match of body.matchAll(/\[~accountid:([^\]]+)\]/g)) ids.add(match[1]);
141
+ return [...ids];
142
+ }
143
+ const walk = (node: unknown) => {
144
+ if (!node || typeof node !== "object") return;
145
+ const value = node as AdfNode;
146
+ if (value.type === "mention" && typeof value.attrs?.id === "string") ids.add(value.attrs.id as string);
147
+ if (Array.isArray(value.content)) for (const child of value.content) walk(child);
148
+ };
149
+ walk(body);
150
+ return [...ids];
151
+ }
152
+
153
+ // --- REST --------------------------------------------------------------------------------------
154
+
155
+ export type JiraUser = { accountId: string; displayName?: string; emailAddress?: string };
156
+ export type JiraStatus = { name: string; statusCategory?: { key: string; name?: string } };
157
+ export type JiraTransition = { id: string; name: string; to: JiraStatus };
158
+ export type JiraIssue = {
159
+ id: string;
160
+ key: string;
161
+ fields: {
162
+ summary?: string;
163
+ status?: JiraStatus;
164
+ labels?: string[];
165
+ assignee?: JiraUser | null;
166
+ reporter?: JiraUser | null;
167
+ };
168
+ };
169
+
170
+ export class JiraClient {
171
+ constructor(private readonly env: JiraFetchEnv, readonly auth: JiraAuthProvider) {}
172
+
173
+ async request<T>(method: string, path: string, body?: unknown): Promise<T> {
174
+ const response = await (this.env.JIRA_FETCH ?? fetch)(`${this.auth.apiBase}${path}`, {
175
+ method,
176
+ headers: {
177
+ authorization: await this.auth.authorization(),
178
+ accept: "application/json",
179
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
180
+ },
181
+ body: body === undefined ? undefined : JSON.stringify(body),
182
+ });
183
+ if (!response.ok) {
184
+ const text = await response.text().catch(() => "");
185
+ throw new JiraApiError(response.status, `Jira ${method} ${path.split("?")[0]} failed (${response.status}) ${jiraErrorText(text)}`.trim());
186
+ }
187
+ if (response.status === 204) return undefined as T;
188
+ const text = await response.text();
189
+ return (text ? JSON.parse(text) : undefined) as T;
190
+ }
191
+
192
+ myself(): Promise<JiraUser> {
193
+ return this.request("GET", "/rest/api/3/myself");
194
+ }
195
+
196
+ project(key: string): Promise<{ id: string; key: string; name: string }> {
197
+ return this.request("GET", `/rest/api/3/project/${encodeURIComponent(key)}`);
198
+ }
199
+
200
+ createIssue(fields: Record<string, unknown>): Promise<{ id: string; key: string }> {
201
+ return this.request("POST", "/rest/api/3/issue", { fields });
202
+ }
203
+
204
+ getIssue(key: string, fields = "summary,status,labels,assignee,reporter"): Promise<JiraIssue> {
205
+ return this.request("GET", `/rest/api/3/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent(fields)}`);
206
+ }
207
+
208
+ editIssue(key: string, body: Record<string, unknown>): Promise<void> {
209
+ return this.request("PUT", `/rest/api/3/issue/${encodeURIComponent(key)}`, body);
210
+ }
211
+
212
+ /** Issues matching a JQL query (the enhanced search endpoint). */
213
+ async search(jql: string, fields = "summary,labels"): Promise<JiraIssue[]> {
214
+ const query = new URLSearchParams({ jql, fields, maxResults: "10" });
215
+ const result = await this.request<{ issues?: JiraIssue[] }>("GET", `/rest/api/3/search/jql?${query}`);
216
+ return result?.issues ?? [];
217
+ }
218
+
219
+ async transitions(key: string): Promise<JiraTransition[]> {
220
+ const result = await this.request<{ transitions?: JiraTransition[] }>("GET", `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`);
221
+ return result?.transitions ?? [];
222
+ }
223
+
224
+ transition(key: string, transitionId: string): Promise<void> {
225
+ return this.request("POST", `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, { transition: { id: transitionId } });
226
+ }
227
+
228
+ comment(key: string, body: AdfDoc): Promise<{ id: string }> {
229
+ return this.request("POST", `/rest/api/3/issue/${encodeURIComponent(key)}/comment`, { body });
230
+ }
231
+
232
+ labels(key: string, change: { add?: string[]; remove?: string[] }): Promise<void> {
233
+ const operations = [...(change.add ?? []).map((label) => ({ add: label })), ...(change.remove ?? []).map((label) => ({ remove: label }))];
234
+ return this.editIssue(key, { update: { labels: operations } });
235
+ }
236
+ }
237
+
238
+ function jiraErrorText(text: string): string {
239
+ try {
240
+ const parsed = JSON.parse(text) as { errorMessages?: string[]; errors?: Record<string, string> };
241
+ const messages = [...(parsed.errorMessages ?? []), ...Object.entries(parsed.errors ?? {}).map(([field, message]) => `${field}: ${message}`)];
242
+ return messages.join("; ").slice(0, 300);
243
+ } catch {
244
+ return text.slice(0, 200);
245
+ }
246
+ }
247
+
248
+ /** The transition that reaches a status in the Done category; one named "Done" first. */
249
+ export function doneTransition(transitions: JiraTransition[]): JiraTransition | undefined {
250
+ const done = transitions.filter((transition) => transition.to?.statusCategory?.key === "done");
251
+ return done.find((transition) => transition.to.name.toLowerCase() === "done") ?? done[0];
252
+ }
253
+
254
+ /** The transition to a status named Blocked (or itself named Blocked), if the workflow has one. */
255
+ export function blockedTransition(transitions: JiraTransition[]): JiraTransition | undefined {
256
+ return transitions.find((transition) => transition.to?.name?.toLowerCase() === "blocked")
257
+ ?? transitions.find((transition) => transition.name.toLowerCase() === "blocked");
258
+ }
259
+
260
+ /** A transition back to a To Do/In Progress-style status, for a ticket that is retested. */
261
+ export function reopenTransition(transitions: JiraTransition[]): JiraTransition | undefined {
262
+ const open = transitions.filter((transition) => transition.to?.statusCategory?.key === "indeterminate" || transition.to?.statusCategory?.key === "new");
263
+ return open.find((transition) => /in progress|in test|testing|retest/i.test(transition.to.name)) ?? open[0];
264
+ }
@@ -0,0 +1,111 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import YAML from "yaml";
7
+ import { callToolInternal, writeSpecFile } from "./server.js";
8
+ import { startLocalRegistryStub } from "../runtime/local-registry-stub.js";
9
+
10
+ async function project(files: Record<string, string> = {}) {
11
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-roles-mcp-"));
12
+ const specDir = path.join(directory, "spec");
13
+ await fs.mkdir(specDir, { recursive: true });
14
+ for (const [name, content] of Object.entries(files)) await fs.writeFile(path.join(specDir, name), content);
15
+ const cfg = {
16
+ project: { id: "demo" },
17
+ runtime: { spec_dir: specDir, artifacts_dir: directory },
18
+ storage: { provider: "local", localDirectory: directory },
19
+ };
20
+ return { directory, specDir, cfg };
21
+ }
22
+
23
+ /** Points the MCP's registry calls at a local stub for the duration of `run`. */
24
+ async function withRegistry(reviewMode: "manual" | "auto" | "extreme", run: (origin: string) => Promise<void>) {
25
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-roles-registry-"));
26
+ const stub = await startLocalRegistryStub({ directory, reviewMode });
27
+ const saved = { url: process.env.BUGMOLE_REGISTRY_URL, key: process.env.BUGMOLE_API_KEY };
28
+ process.env.BUGMOLE_REGISTRY_URL = stub.origin;
29
+ process.env.BUGMOLE_API_KEY = "bm_test";
30
+ try {
31
+ await run(stub.origin);
32
+ } finally {
33
+ if (saved.url === undefined) delete process.env.BUGMOLE_REGISTRY_URL; else process.env.BUGMOLE_REGISTRY_URL = saved.url;
34
+ if (saved.key === undefined) delete process.env.BUGMOLE_API_KEY; else process.env.BUGMOLE_API_KEY = saved.key;
35
+ await stub.close();
36
+ await fs.rm(directory, { recursive: true, force: true });
37
+ }
38
+ }
39
+
40
+ test("bugmole_write_spec lets an agent suggest roles in roles.yaml, never confirm them", async () => {
41
+ const { specDir, cfg } = await project({ "roles.yaml": "roles:\n - name: user\n description: Signed in.\n" });
42
+ const result = writeSpecFile("roles.yaml", "roles:\n - name: user\n - name: admin\n description: Admin.\n", cfg);
43
+ assert.deepEqual(result.suggestedRoles, ["admin"]);
44
+ assert.match(result.message, /person confirms/);
45
+ const written = YAML.parse(await fs.readFile(path.join(specDir, "roles.yaml"), "utf8"));
46
+ assert.equal(written.roles[0].status, undefined);
47
+ assert.equal(written.roles[1].status, "suggested");
48
+ assert.equal(writeSpecFile("blockers.yaml", "blockers: []\n", cfg).suggestedRoles, undefined, "other spec files are written as given");
49
+ });
50
+
51
+ test("bugmole_write_test_cases refuses a case set for a role nobody confirmed", async () => {
52
+ const graph = {
53
+ version: 2,
54
+ nodes: [{ id: "screen:/", title: "Home", kind: "screen", route: "/" }],
55
+ edges: [],
56
+ journeys: [{ id: "flow:home", name: "Home", actor: "user", nodeIds: ["screen:/"], edgeIds: [], revision: 1, status: "draft" }],
57
+ };
58
+ const { cfg } = await project({
59
+ "journeys.graph.json": JSON.stringify(graph),
60
+ "roles.yaml": "roles:\n - name: user\n - name: admin\n status: suggested\n",
61
+ });
62
+ const caseSet = (actor: string) => ({
63
+ schemaVersion: 1, caseSetId: "home", journeyId: "flow:home", journeyRevision: 1, actor,
64
+ cases: [{ caseId: "TC-1", title: "Home loads", nodeId: "screen:/", intent: "Prove home renders", steps: ["Open /"], expected: ["Home renders"], confidence: "low" }],
65
+ });
66
+ await assert.rejects(callToolInternal("bugmole_write_test_cases", { caseSet: caseSet("admin") }, cfg), /only suggested/);
67
+ await assert.rejects(callToolInternal("bugmole_write_test_cases", { caseSet: caseSet("ghost") }, cfg), /not a confirmed role/);
68
+ const ok = await callToolInternal("bugmole_write_test_cases", { caseSet: caseSet("user") }, cfg);
69
+ assert.equal(ok.status, "success");
70
+ });
71
+
72
+ test("bugmole_role_propose sends a suggestion; bugmole_roles_list shows it waiting", async () => {
73
+ const { cfg } = await project({ "roles.yaml": "roles:\n - name: user\n" });
74
+ await withRegistry("extreme", async () => {
75
+ await assert.rejects(callToolInternal("bugmole_role_propose", { name: "admin" }, cfg), /evidence/);
76
+ const proposed = await callToolInternal("bugmole_role_propose", {
77
+ name: "Admin", description: "Admin area.", source: "role_gated_route", evidence: "route /admin",
78
+ }, cfg);
79
+ assert.equal(proposed.role.name, "admin");
80
+ assert.equal(proposed.role.status, "suggested");
81
+ assert.match(proposed.message, /only after a person confirms/);
82
+
83
+ const listed = await callToolInternal("bugmole_roles_list", {}, cfg);
84
+ assert.deepEqual(listed.confirmed, ["user"]);
85
+ assert.deepEqual(listed.suggested, ["admin"]);
86
+ assert.match(listed.nextSteps[0], /Roles page/);
87
+ });
88
+ });
89
+
90
+ test("bugmole_task_update says when a completed task went to review, and the note comes back with it", async () => {
91
+ const { cfg } = await project();
92
+ await withRegistry("manual", async (origin) => {
93
+ const created = await callToolInternal("bugmole_task_create", { title: "Fix it", description: "Fix the header", taskType: "code", idempotencyKey: "fix-1" }, cfg);
94
+ const taskId = created.task.id;
95
+ const claimed = await callToolInternal("bugmole_task_next", { workerId: "agent-1" }, cfg);
96
+ assert.equal(claimed.task.id, taskId);
97
+
98
+ const done = await callToolInternal("bugmole_task_update", { taskId, workerId: "agent-1", status: "completed", completionSummary: "Fixed" }, cfg);
99
+ assert.equal(done.inReview, true);
100
+ assert.equal(done.task.status, "in_review");
101
+ assert.match(done.nextSteps[0], /not treat this work as accepted/);
102
+
103
+ const sent = await fetch(`${origin}/api/tasks/${taskId}/send-back`, {
104
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ note: "Also fix the footer" }),
105
+ });
106
+ assert.equal(sent.status, 200);
107
+ const again = await callToolInternal("bugmole_task_next", { workerId: "agent-1" }, cfg);
108
+ assert.equal(again.task.id, taskId);
109
+ assert.match(again.message, /Also fix the footer/);
110
+ });
111
+ });