@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
@@ -12,6 +12,16 @@ export type UsageKind = "browser_minute" | "device_minute" | "ai_credit";
12
12
  export type AiAction = "analysis" | "repair" | "exploration" | "discovery";
13
13
 
14
14
  export type PlanFeatures = {
15
+ /** Every plan: no per-seat pricing. */
16
+ unlimitedUsers: boolean;
17
+ /** Every plan: runs on your own machines and CI are never counted. */
18
+ unlimitedLocalRuns: boolean;
19
+ /** Every plan: Chrome, Firefox, Safari and Edge. */
20
+ desktopBrowsers: boolean;
21
+ /** Every plan: emulated iPhone and Android viewports. */
22
+ emulatedMobile: boolean;
23
+ /** Every plan: AI explains a failed run (uses AI credits). */
24
+ aiFailureAnalysis: boolean;
15
25
  prChecks: boolean;
16
26
  slack: boolean;
17
27
  teams: boolean;
@@ -38,12 +48,55 @@ export type PlanDef = {
38
48
  aiCredits: number | null;
39
49
  parallel: number | null;
40
50
  historyDays: number | null;
51
+ /**
52
+ * Days without any activity (a run, a task, exploration, a CLI/MCP call or a
53
+ * dashboard change) before a project is paused; null means never. A paused
54
+ * project is read-only and doesn't count toward `projects` until someone
55
+ * activates it again.
56
+ */
57
+ inactiveProjectDays: number | null;
58
+ /**
59
+ * How far one exploration on Bugmole Cloud goes: pages opened, and clicks
60
+ * from home. A project's own machine sets its own (BUGMOLE_DISCOVERY_MAX_*).
61
+ */
62
+ discovery: { maxPages: number; maxDepth: number };
41
63
  /** Cents per unit beyond the allowance; null means usage stops at the allowance. */
42
64
  overage: { browserMinuteCents: number | null; creditCents: number | null };
43
65
  features: PlanFeatures;
66
+ /**
67
+ * What a contract adds beyond the limits and features above, as shown on the
68
+ * pricing page. Descriptive only: NOT enforced by the registry or read by
69
+ * feature flags; the contract itself sets them up.
70
+ */
71
+ extras: PlanExtras;
44
72
  };
45
73
 
46
- const NO_FEATURES: PlanFeatures = {
74
+ /** Descriptive, contract-only plan extras (never enforced; see PlanDef.extras). */
75
+ export type PlanExtras = {
76
+ dedicatedInfrastructure: boolean;
77
+ privateDevices: boolean;
78
+ ownModelAndStorage: boolean;
79
+ customRetention: boolean;
80
+ sla: boolean;
81
+ volumePricing: boolean;
82
+ };
83
+
84
+ const NO_EXTRAS: PlanExtras = {
85
+ dedicatedInfrastructure: false,
86
+ privateDevices: false,
87
+ ownModelAndStorage: false,
88
+ customRetention: false,
89
+ sla: false,
90
+ volumePricing: false,
91
+ };
92
+
93
+ /** What every plan, Free included, gets; paid plans add to it. */
94
+ const BASE_FEATURES: PlanFeatures = {
95
+ unlimitedUsers: true,
96
+ unlimitedLocalRuns: true,
97
+ desktopBrowsers: true,
98
+ emulatedMobile: true,
99
+ aiFailureAnalysis: true,
47
100
  prChecks: false,
48
101
  slack: false,
49
102
  teams: false,
@@ -68,8 +121,11 @@ export const PLANS: Record<PlanId, PlanDef> = {
68
121
  aiCredits: 100,
69
122
  parallel: 2,
70
123
  historyDays: 7,
124
+ inactiveProjectDays: 30,
125
+ discovery: { maxPages: 12, maxDepth: 2 },
71
126
  overage: { browserMinuteCents: null, creditCents: null },
72
- features: { ...NO_FEATURES },
127
+ features: { ...BASE_FEATURES },
128
+ extras: { ...NO_EXTRAS },
73
129
  },
74
130
  team: {
75
131
  id: "team",
@@ -82,8 +138,11 @@ export const PLANS: Record<PlanId, PlanDef> = {
82
138
  aiCredits: 1_500,
83
139
  parallel: 8,
84
140
  historyDays: 30,
141
+ inactiveProjectDays: null,
142
+ discovery: { maxPages: 40, maxDepth: 3 },
85
143
  overage: { browserMinuteCents: 4, creditCents: 1 },
86
- features: { ...NO_FEATURES, prChecks: true, slack: true, teams: true, aiFixes: true },
144
+ features: { ...BASE_FEATURES, prChecks: true, slack: true, teams: true, aiFixes: true },
145
+ extras: { ...NO_EXTRAS },
87
146
  },
88
147
  business: {
89
148
  id: "business",
@@ -96,9 +155,11 @@ export const PLANS: Record<PlanId, PlanDef> = {
96
155
  aiCredits: 7_500,
97
156
  parallel: 25,
98
157
  historyDays: 90,
158
+ inactiveProjectDays: null,
159
+ discovery: { maxPages: 100, maxDepth: 4 },
99
160
  overage: { browserMinuteCents: 3, creditCents: 1 },
100
161
  features: {
101
- ...NO_FEATURES,
162
+ ...BASE_FEATURES,
102
163
  prChecks: true,
103
164
  slack: true,
104
165
  teams: true,
@@ -108,6 +169,7 @@ export const PLANS: Record<PlanId, PlanDef> = {
108
169
  auditLogExport: true,
109
170
  prioritySupport: true,
110
171
  },
172
+ extras: { ...NO_EXTRAS },
111
173
  },
112
174
  enterprise: {
113
175
  id: "enterprise",
@@ -120,8 +182,11 @@ export const PLANS: Record<PlanId, PlanDef> = {
120
182
  aiCredits: null,
121
183
  parallel: null,
122
184
  historyDays: 365,
185
+ inactiveProjectDays: null,
186
+ discovery: { maxPages: 250, maxDepth: 5 },
123
187
  overage: { browserMinuteCents: 3, creditCents: 1 },
124
188
  features: {
189
+ ...BASE_FEATURES,
125
190
  prChecks: true,
126
191
  slack: true,
127
192
  teams: true,
@@ -133,6 +198,16 @@ export const PLANS: Record<PlanId, PlanDef> = {
133
198
  prioritySupport: true,
134
199
  selfHostedWorkers: true,
135
200
  },
201
+ // Agreed per contract; additional usage is priced there too, not at the list
202
+ // rates above (which only apply until a contract sets its own).
203
+ extras: {
204
+ dedicatedInfrastructure: true,
205
+ privateDevices: true,
206
+ ownModelAndStorage: true,
207
+ customRetention: true,
208
+ sla: true,
209
+ volumePricing: true,
210
+ },
136
211
  },
137
212
  };
138
213
 
@@ -150,12 +225,12 @@ export const AI_CREDIT_COST: Record<AiAction, number> = {
150
225
  export const DEVICE_FARM_CENTS_PER_MIN = 25;
151
226
 
152
227
  /** Contract-specific adjustments stored with a workspace's plan (Enterprise, trials). */
153
- export type PlanOverrides = Partial<Pick<PlanDef, "projects" | "cloudMinutes" | "aiCredits" | "parallel" | "historyDays">> & {
228
+ export type PlanOverrides = Partial<Pick<PlanDef, LimitKey>> & {
154
229
  features?: Partial<PlanFeatures>;
155
230
  overage?: Partial<PlanDef["overage"]>;
156
231
  };
157
232
 
158
- export type Entitlements = Omit<PlanDef, "name" | "audience" | "priceMonthlyCents" | "priceYearlyMonthlyCents">;
233
+ export type Entitlements = Omit<PlanDef, "name" | "audience" | "priceMonthlyCents" | "priceYearlyMonthlyCents" | "extras">;
159
234
 
160
235
  export function isPlanId(value: unknown): value is PlanId {
161
236
  return typeof value === "string" && (PLAN_IDS as string[]).includes(value);
@@ -163,7 +238,7 @@ export function isPlanId(value: unknown): value is PlanId {
163
238
 
164
239
  export function entitlementsFor(plan: PlanId, overrides: PlanOverrides = {}): Entitlements {
165
240
  const base = PLANS[plan];
166
- const pick = <K extends "projects" | "cloudMinutes" | "aiCredits" | "parallel" | "historyDays">(key: K) =>
241
+ const pick = <K extends LimitKey>(key: K) =>
167
242
  overrides[key] !== undefined ? (overrides[key] as PlanDef[K]) : base[key];
168
243
  return {
169
244
  id: base.id,
@@ -172,6 +247,8 @@ export function entitlementsFor(plan: PlanId, overrides: PlanOverrides = {}): En
172
247
  aiCredits: pick("aiCredits"),
173
248
  parallel: pick("parallel"),
174
249
  historyDays: pick("historyDays"),
250
+ inactiveProjectDays: pick("inactiveProjectDays"),
251
+ discovery: { ...base.discovery },
175
252
  overage: { ...base.overage, ...overrides.overage },
176
253
  features: { ...base.features, ...overrides.features },
177
254
  };
@@ -188,8 +265,8 @@ export function includedQuantity(entitlements: Entitlements, kind: UsageKind): n
188
265
  export function overageCents(entitlements: Entitlements, kind: UsageKind): number | null {
189
266
  if (kind === "browser_minute") return entitlements.overage.browserMinuteCents;
190
267
  if (kind === "ai_credit") return entitlements.overage.creditCents;
191
- // Real devices need a paid plan with a subscription; the registry checks that.
192
- return entitlements.id === "free" ? null : DEVICE_FARM_CENTS_PER_MIN;
268
+ // Real devices come with device clouds (Business and up), billed as you go.
269
+ return entitlements.features.deviceClouds ? DEVICE_FARM_CENTS_PER_MIN : null;
193
270
  }
194
271
 
195
272
  export function formatUsd(cents: number, { minimumFractionDigits }: { minimumFractionDigits?: number } = {}): string {
@@ -197,3 +274,129 @@ export function formatUsd(cents: number, { minimumFractionDigits }: { minimumFra
197
274
  const fraction = minimumFractionDigits ?? (Number.isInteger(dollars) ? 0 : 2);
198
275
  return `$${dollars.toLocaleString("en-US", { minimumFractionDigits: fraction, maximumFractionDigits: Math.max(fraction, 2) })}`;
199
276
  }
277
+
278
+ // ---------- pricing page ----------
279
+ // The bugmole.com pricing page renders every plan feature, extra and limit from
280
+ // these rows, so a row can't claim something the catalog doesn't grant. A test
281
+ // fails when a catalog key has no row here and isn't in PRICING_UNLISTED.
282
+
283
+ export type LimitKey = "projects" | "cloudMinutes" | "aiCredits" | "parallel" | "historyDays" | "inactiveProjectDays";
284
+ export type PricingKey =
285
+ | `features.${keyof PlanFeatures}`
286
+ | `extras.${keyof PlanExtras}`
287
+ | `limits.${LimitKey}`
288
+ | `overage.${UsageKind}`;
289
+
290
+ export type PricingRow = {
291
+ key: PricingKey;
292
+ /** Compare-table row label. */
293
+ label: string;
294
+ /** Plan-card bullet, when it differs from the label; for an overage, {value} is the rate. */
295
+ card?: string;
296
+ /** Unit after a limit or overage value: " days", " / min", " each". */
297
+ unit?: string;
298
+ /** What a limit of null (no limit) reads as; "Custom" when unset. */
299
+ unlimited?: string;
300
+ };
301
+
302
+ export const LIMIT_KEYS: LimitKey[] = ["projects", "cloudMinutes", "aiCredits", "parallel", "historyDays", "inactiveProjectDays"];
303
+
304
+ export const PRICING_ROWS: PricingRow[] = [
305
+ { key: "features.unlimitedUsers", label: "Unlimited users" },
306
+ { key: "features.unlimitedLocalRuns", label: "Unlimited local & CI runs" },
307
+ { key: "features.desktopBrowsers", label: "Chrome, Firefox, Safari & Edge" },
308
+ { key: "features.emulatedMobile", label: "Emulated iPhone & Android" },
309
+ { key: "features.aiFailureAnalysis", label: "AI failure analysis" },
310
+ { key: "limits.projects", label: "Projects" },
311
+ { key: "limits.cloudMinutes", label: "Cloud minutes / month" },
312
+ { key: "limits.aiCredits", label: "AI credits / month" },
313
+ { key: "limits.parallel", label: "Cloud runs at once" },
314
+ { key: "limits.historyDays", label: "Run history", unit: " days" },
315
+ { key: "limits.inactiveProjectDays", label: "Projects paused after inactivity", unit: " days", unlimited: "Never" },
316
+ { key: "overage.browser_minute", label: "Additional cloud minutes", unit: " / min" },
317
+ { key: "overage.ai_credit", label: "Additional AI credits", unit: " each" },
318
+ {
319
+ key: "overage.device_minute",
320
+ label: "Real-device minutes (Bugmole device cloud)",
321
+ card: "Real devices on Bugmole's device cloud at {value}, pay as you go",
322
+ unit: " / min",
323
+ },
324
+ { key: "features.prChecks", label: "PR checks" },
325
+ { key: "features.slack", label: "Slack alerts" },
326
+ { key: "features.teams", label: "Microsoft Teams alerts" },
327
+ { key: "features.aiFixes", label: "AI test fixes" },
328
+ {
329
+ key: "features.deviceClouds",
330
+ label: "Device clouds (simulators, emulators, real devices)",
331
+ card: "iOS simulators, Android emulators & real devices through BrowserStack, Kobiton or AWS Device Farm",
332
+ },
333
+ { key: "features.sso", label: "SSO" },
334
+ { key: "features.auditLogExport", label: "Audit log export" },
335
+ { key: "features.prioritySupport", label: "Priority support" },
336
+ { key: "features.scim", label: "SCIM provisioning" },
337
+ { key: "features.selfHostedWorkers", label: "Self-hosted workers" },
338
+ { key: "extras.dedicatedInfrastructure", label: "Dedicated infrastructure" },
339
+ { key: "extras.privateDevices", label: "Private devices" },
340
+ { key: "extras.ownModelAndStorage", label: "Your own AI model and storage" },
341
+ { key: "extras.customRetention", label: "Custom retention" },
342
+ { key: "extras.sla", label: "SLA" },
343
+ { key: "extras.volumePricing", label: "Volume pricing" },
344
+ ];
345
+
346
+ /**
347
+ * Catalog keys deliberately left off the pricing page, each with the reason.
348
+ * Empty today: everything a plan grants is shown.
349
+ */
350
+ export const PRICING_UNLISTED: Partial<Record<PricingKey, string>> = {};
351
+
352
+ /** Every key the catalog defines for a plan, in pricing-key form. */
353
+ export function catalogPricingKeys(): PricingKey[] {
354
+ const plan = PLANS.free;
355
+ return [
356
+ ...(Object.keys(plan.features) as Array<keyof PlanFeatures>).map((key) => `features.${key}` as const),
357
+ ...(Object.keys(plan.extras) as Array<keyof PlanExtras>).map((key) => `extras.${key}` as const),
358
+ ...LIMIT_KEYS.map((key) => `limits.${key}` as const),
359
+ ...(["browser_minute", "ai_credit", "device_minute"] as UsageKind[]).map((key) => `overage.${key}` as const),
360
+ ];
361
+ }
362
+
363
+ /**
364
+ * A plan's raw value for a pricing key: a flag, a limit (null: set per
365
+ * contract) or cents per unit (null: not available). undefined means the key
366
+ * isn't in the catalog.
367
+ */
368
+ export function pricingValue(plan: PlanId, key: string): boolean | number | null | undefined {
369
+ const [group, name] = key.split(".");
370
+ if (!name || !isPlanId(plan)) return undefined;
371
+ const def = PLANS[plan];
372
+ if (group === "features") return Object.hasOwn(def.features, name) ? def.features[name as keyof PlanFeatures] : undefined;
373
+ if (group === "extras") return Object.hasOwn(def.extras, name) ? def.extras[name as keyof PlanExtras] : undefined;
374
+ if (group === "limits") return (LIMIT_KEYS as string[]).includes(name) ? entitlementsFor(plan)[name as LimitKey] : undefined;
375
+ if (group === "overage") {
376
+ return (["browser_minute", "ai_credit", "device_minute"] as string[]).includes(name)
377
+ ? overageCents(entitlementsFor(plan), name as UsageKind)
378
+ : undefined;
379
+ }
380
+ return undefined;
381
+ }
382
+
383
+ /** Whether a plan has a row's feature, extra or overage at all (limits always apply). */
384
+ export function pricingIncluded(plan: PlanId, row: PricingRow): boolean {
385
+ const value = pricingValue(plan, row.key);
386
+ if (value === undefined) throw new Error(`Unknown pricing key: ${row.key}`);
387
+ return value !== false && value !== null;
388
+ }
389
+
390
+ /** The compare-table cell for a plan: ✓ or — for flags, the value with its unit otherwise. */
391
+ export function pricingCell(plan: PlanId, row: PricingRow): string {
392
+ const value = pricingValue(plan, row.key);
393
+ if (value === undefined) throw new Error(`Unknown pricing key: ${row.key}`);
394
+ if (typeof value === "boolean") return value ? "\u2713" : "\u2014";
395
+ const custom = PLANS[plan].priceMonthlyCents === null;
396
+ if (row.key.startsWith("overage.")) {
397
+ // Contract plans price additional usage in the contract, not at list rates.
398
+ if (custom) return value === null ? "\u2014" : "Volume pricing";
399
+ return value === null ? "\u2014" : `${formatUsd(value, { minimumFractionDigits: 2 })}${row.unit ?? ""}`;
400
+ }
401
+ return value === null ? (row.unlimited ?? "Custom") : `${value.toLocaleString("en-US")}${row.unit ?? ""}`;
402
+ }
@@ -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
+ }