@bugmole/cli 0.4.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.
- package/.bugmole.env.example +20 -0
- package/LICENSE +7 -0
- package/README.md +293 -0
- package/TESTING.md +117 -0
- package/bugmole.config.yaml +39 -0
- package/package.json +85 -0
- package/scripts/billing/paypal-setup.mjs +121 -0
- package/scripts/bugmole-continue.ts +318 -0
- package/scripts/bugmole-init.ts +188 -0
- package/scripts/bugmole.cjs +17 -0
- package/scripts/bugmole.test.ts +344 -0
- package/scripts/bugmole.ts +657 -0
- package/scripts/ensure-maestro.cjs +79 -0
- package/scripts/ios-tunnel-keeper.sh +45 -0
- package/scripts/ios-wda-keeper.sh +66 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -0
- package/scripts/ui-parity-diff.py +65 -0
- package/scripts/ui-parity-requirements.txt +1 -0
- package/scripts/verify-manage-to-plans.mts +194 -0
- package/spec/app-ui-audit.schema.json +176 -0
- package/spec/blockers.yaml +79 -0
- package/spec/bugs.index.json +42 -0
- package/spec/design-dna.schema.json +38 -0
- package/spec/domain_rules.yaml +24 -0
- package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
- package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
- package/spec/journey_graph.yaml +126 -0
- package/spec/journeys.graph.json +2618 -0
- package/spec/plans/dashboard-smoke.flow.yaml +20 -0
- package/spec/plans/example.flow.yaml +99 -0
- package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
- package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
- package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
- package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
- package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
- package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
- package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
- package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
- package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
- package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
- package/spec/plans/login.flow.yaml +22 -0
- package/spec/plans/owner-operations.flow.yaml +20 -0
- package/spec/project_config.yaml +55 -0
- package/spec/roles.yaml +30 -0
- package/spec/schema.md +394 -0
- package/spec/test-case-results.schema.json +62 -0
- package/spec/test-cases.schema.json +85 -0
- package/spec/ui-parity-audit.schema.json +194 -0
- package/spec/ui-reverse-engineering.schema.json +94 -0
- package/src/billing/plan-catalog.test.ts +46 -0
- package/src/billing/plan-catalog.ts +199 -0
- package/src/integrations/aws-sigv4.test.ts +42 -0
- package/src/integrations/aws-sigv4.ts +72 -0
- package/src/integrations/device-farm.ts +155 -0
- package/src/integrations/github-app.test.ts +57 -0
- package/src/integrations/github-app.ts +143 -0
- package/src/integrations/gitlab.ts +81 -0
- package/src/integrations/temp-email.test.ts +123 -0
- package/src/integrations/temp-email.ts +175 -0
- package/src/integrations/testflight-feedback.test.ts +51 -0
- package/src/integrations/testflight-feedback.ts +173 -0
- package/src/integrations/webdriver-client.ts +131 -0
- package/src/mcp/server.test.ts +1220 -0
- package/src/mcp/server.ts +3064 -0
- package/src/mcp/write-test-cases.test.ts +287 -0
- package/src/registry/api-key-client.ts +39 -0
- package/src/registry/control-plane-client.ts +212 -0
- package/src/registry/migrations/0001_registry.sql +47 -0
- package/src/registry/migrations/0002_device_authorizations.sql +23 -0
- package/src/registry/migrations/0003_project_environments.sql +25 -0
- package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
- package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
- package/src/registry/migrations/0006_workspaces.sql +36 -0
- package/src/registry/migrations/0007_project_apps.sql +26 -0
- package/src/registry/migrations/0008_agent_tasks.sql +30 -0
- package/src/registry/migrations/0009_journey_revisions.sql +17 -0
- package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
- package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
- package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
- package/src/registry/migrations/0013_device_sessions.sql +22 -0
- package/src/registry/migrations/0014_agent_task_step.sql +1 -0
- package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
- package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
- package/src/registry/migrations/0017_run_targets.sql +7 -0
- package/src/registry/migrations/0018_run_fix_from.sql +4 -0
- package/src/registry/migrations/0019_workspace_flags.sql +9 -0
- package/src/registry/migrations/0020_orgs.sql +40 -0
- package/src/registry/migrations/0021_billing_core.sql +58 -0
- package/src/registry/migrations/0022_cloud_runners.sql +19 -0
- package/src/registry/migrations/0023_signup.sql +4 -0
- package/src/registry/migrations/0024_billing.sql +67 -0
- package/src/registry/migrations/0025_notifications.sql +47 -0
- package/src/registry/migrations/0026_repo_bindings.sql +28 -0
- package/src/registry/migrations/0027_feedback.sql +29 -0
- package/src/registry/migrations/0028_devices.sql +48 -0
- package/src/registry/migrations/0029_sso.sql +31 -0
- package/src/registry/migrations/0030_workspace_domains.sql +18 -0
- package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
- package/src/registry/migrations/0032_personas.sql +15 -0
- package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
- package/src/registry/task-scheduling.test.ts +100 -0
- package/src/registry/task-scheduling.ts +80 -0
- package/src/registry-worker/ai/platform-model.ts +77 -0
- package/src/registry-worker/ai/routes.test.ts +88 -0
- package/src/registry-worker/ai/routes.ts +90 -0
- package/src/registry-worker/artifacts.test.ts +98 -0
- package/src/registry-worker/artifacts.ts +85 -0
- package/src/registry-worker/billing/billing-core.test.ts +175 -0
- package/src/registry-worker/billing/checkout-routes.ts +209 -0
- package/src/registry-worker/billing/enforcement.ts +69 -0
- package/src/registry-worker/billing/entitlements.ts +108 -0
- package/src/registry-worker/billing/ledger.ts +186 -0
- package/src/registry-worker/billing/paypal/api.ts +259 -0
- package/src/registry-worker/billing/paypal/client.ts +91 -0
- package/src/registry-worker/billing/paypal/provider.ts +143 -0
- package/src/registry-worker/billing/paypal.test.ts +466 -0
- package/src/registry-worker/billing/provider.ts +114 -0
- package/src/registry-worker/billing/routes.ts +66 -0
- package/src/registry-worker/billing/subscriptions.ts +780 -0
- package/src/registry-worker/billing/thresholds.ts +107 -0
- package/src/registry-worker/core.ts +308 -0
- package/src/registry-worker/devices/devices.test.ts +185 -0
- package/src/registry-worker/devices/policy.ts +71 -0
- package/src/registry-worker/devices/routes.ts +453 -0
- package/src/registry-worker/domains/domains.test.ts +210 -0
- package/src/registry-worker/domains/routes.ts +139 -0
- package/src/registry-worker/domains.ts +88 -0
- package/src/registry-worker/email/sender.ts +75 -0
- package/src/registry-worker/env.d.ts +14716 -0
- package/src/registry-worker/features.ts +20 -0
- package/src/registry-worker/feedback/feedback.test.ts +230 -0
- package/src/registry-worker/feedback/format.ts +148 -0
- package/src/registry-worker/feedback/routes.ts +386 -0
- package/src/registry-worker/flags.ts +39 -0
- package/src/registry-worker/github/checks.test.ts +177 -0
- package/src/registry-worker/github/checks.ts +374 -0
- package/src/registry-worker/gitlab/checks.test.ts +141 -0
- package/src/registry-worker/gitlab/checks.ts +349 -0
- package/src/registry-worker/hooks.ts +54 -0
- package/src/registry-worker/index.ts +2077 -0
- package/src/registry-worker/jobs/index.ts +29 -0
- package/src/registry-worker/jobs/retention.ts +68 -0
- package/src/registry-worker/mcp/mcp.test.ts +355 -0
- package/src/registry-worker/mcp/routes.ts +215 -0
- package/src/registry-worker/mcp/token.ts +126 -0
- package/src/registry-worker/mcp/tools.ts +563 -0
- package/src/registry-worker/notifications/alerts.ts +212 -0
- package/src/registry-worker/notifications/notifications.test.ts +298 -0
- package/src/registry-worker/notifications/outbox.ts +83 -0
- package/src/registry-worker/notifications/routes.ts +280 -0
- package/src/registry-worker/notifications/secrets.ts +49 -0
- package/src/registry-worker/notifications/slack.ts +96 -0
- package/src/registry-worker/notifications/teams.ts +46 -0
- package/src/registry-worker/org/audit.ts +116 -0
- package/src/registry-worker/org/routes.test.ts +163 -0
- package/src/registry-worker/org/routes.ts +302 -0
- package/src/registry-worker/personas/personas.test.ts +78 -0
- package/src/registry-worker/personas/routes.ts +100 -0
- package/src/registry-worker/repo-triggers.ts +20 -0
- package/src/registry-worker/routes/index.ts +74 -0
- package/src/registry-worker/run-events.ts +24 -0
- package/src/registry-worker/runner/dispatch.ts +219 -0
- package/src/registry-worker/runner/jobs.ts +43 -0
- package/src/registry-worker/runner/metering.ts +82 -0
- package/src/registry-worker/runner/policy.ts +59 -0
- package/src/registry-worker/runner/routes.ts +171 -0
- package/src/registry-worker/runner/runner.test.ts +358 -0
- package/src/registry-worker/runner/tokens.ts +93 -0
- package/src/registry-worker/runs.test.ts +60 -0
- package/src/registry-worker/signup/policy.ts +57 -0
- package/src/registry-worker/signup/routes.ts +106 -0
- package/src/registry-worker/signup/signup.test.ts +81 -0
- package/src/registry-worker/sso/aegis.ts +141 -0
- package/src/registry-worker/sso/membership.ts +157 -0
- package/src/registry-worker/sso/routes.ts +458 -0
- package/src/registry-worker/sso/sso.test.ts +344 -0
- package/src/registry-worker/testing/d1-shim.ts +180 -0
- package/src/registry-worker/testing/harness.ts +137 -0
- package/src/runner-worker/index.ts +108 -0
- package/src/runtime/ai-analysis.ts +97 -0
- package/src/runtime/ai-exploration.test.ts +32 -0
- package/src/runtime/ai-exploration.ts +69 -0
- package/src/runtime/ai-repair.ts +74 -0
- package/src/runtime/ai-work.test.ts +99 -0
- package/src/runtime/android-screen-record.test.ts +75 -0
- package/src/runtime/android-screen-record.ts +192 -0
- package/src/runtime/app-understanding.test.ts +123 -0
- package/src/runtime/app-understanding.ts +201 -0
- package/src/runtime/appium-driver.test.ts +179 -0
- package/src/runtime/appium-driver.ts +295 -0
- package/src/runtime/blocker-resolution.test.ts +113 -0
- package/src/runtime/blocker-resolution.ts +111 -0
- package/src/runtime/browser-matrix.integration.test.ts +212 -0
- package/src/runtime/browser-matrix.test.ts +143 -0
- package/src/runtime/browser-matrix.ts +200 -0
- package/src/runtime/canonical-flow.test.ts +52 -0
- package/src/runtime/config-validate.ts +185 -0
- package/src/runtime/continuous-execution.ts +291 -0
- package/src/runtime/cursor-applescript.ts +573 -0
- package/src/runtime/cursor-cli-driver.test.ts +78 -0
- package/src/runtime/cursor-cli-driver.ts +156 -0
- package/src/runtime/cursor-driver-example.ts +117 -0
- package/src/runtime/cursor-driver-index.ts +65 -0
- package/src/runtime/cursor-driver-init.ts +277 -0
- package/src/runtime/cursor-driver-run.test.ts +15 -0
- package/src/runtime/cursor-driver-run.ts +323 -0
- package/src/runtime/cursor-driver.ts +332 -0
- package/src/runtime/cursor-llm-example.ts +90 -0
- package/src/runtime/cursor-llm.ts +206 -0
- package/src/runtime/cursor-mcp-monitor.ts +386 -0
- package/src/runtime/device-clouds/browserstack.ts +73 -0
- package/src/runtime/device-clouds/device-farm.ts +52 -0
- package/src/runtime/device-clouds/index.ts +92 -0
- package/src/runtime/device-clouds/kobiton.ts +70 -0
- package/src/runtime/device-clouds/targets.ts +44 -0
- package/src/runtime/device-clouds/types.ts +62 -0
- package/src/runtime/diff-proposal.ts +84 -0
- package/src/runtime/discovery-task.test.ts +29 -0
- package/src/runtime/discovery-task.ts +284 -0
- package/src/runtime/driver-recovery.ts +69 -0
- package/src/runtime/driver.ts +79 -0
- package/src/runtime/environment.test.ts +104 -0
- package/src/runtime/environment.ts +137 -0
- package/src/runtime/executor.test.ts +509 -0
- package/src/runtime/executor.ts +921 -0
- package/src/runtime/explorer.test.ts +101 -0
- package/src/runtime/explorer.ts +1013 -0
- package/src/runtime/failure-analysis.test.ts +111 -0
- package/src/runtime/failure-analysis.ts +272 -0
- package/src/runtime/fixtures/fake-maestro.sh +36 -0
- package/src/runtime/flow-language.test.ts +268 -0
- package/src/runtime/flow-language.ts +414 -0
- package/src/runtime/init-wizard.ts +354 -0
- package/src/runtime/ios-screen-record.test.ts +68 -0
- package/src/runtime/ios-screen-record.ts +155 -0
- package/src/runtime/journey-editor.ts +452 -0
- package/src/runtime/journey-evidence.test.ts +161 -0
- package/src/runtime/journey-evidence.ts +180 -0
- package/src/runtime/journey-graph.test.ts +257 -0
- package/src/runtime/journey-graph.ts +170 -0
- package/src/runtime/legacy-names.ts +32 -0
- package/src/runtime/llm-example.ts +105 -0
- package/src/runtime/llm.ts +527 -0
- package/src/runtime/local-browser.test.ts +45 -0
- package/src/runtime/local-browser.ts +48 -0
- package/src/runtime/local-registry-stub.test.ts +325 -0
- package/src/runtime/local-registry-stub.ts +803 -0
- package/src/runtime/maestro-driver.test.ts +84 -0
- package/src/runtime/maestro-driver.ts +209 -0
- package/src/runtime/mole-voice.ts +21 -0
- package/src/runtime/nav-crawl.test.ts +100 -0
- package/src/runtime/nav-crawl.ts +153 -0
- package/src/runtime/pipeline.test.ts +405 -0
- package/src/runtime/pipeline.ts +833 -0
- package/src/runtime/planner.test.ts +37 -0
- package/src/runtime/planner.ts +274 -0
- package/src/runtime/platform-ai.ts +76 -0
- package/src/runtime/playwright-driver.test.ts +93 -0
- package/src/runtime/playwright-driver.ts +620 -0
- package/src/runtime/project-spec.ts +140 -0
- package/src/runtime/record-run-verdicts.ts +68 -0
- package/src/runtime/reporter.test.ts +56 -0
- package/src/runtime/reporter.ts +158 -0
- package/src/runtime/reset.test.ts +44 -0
- package/src/runtime/reset.ts +61 -0
- package/src/runtime/reviewer.test.ts +73 -0
- package/src/runtime/reviewer.ts +158 -0
- package/src/runtime/run-job.ts +136 -0
- package/src/runtime/run-once.test.ts +207 -0
- package/src/runtime/run-once.ts +168 -0
- package/src/runtime/run.ts +132 -0
- package/src/runtime/screen-recording.ts +34 -0
- package/src/runtime/serve-gateway.test.ts +74 -0
- package/src/runtime/serve-gateway.ts +164 -0
- package/src/runtime/serve-worker.test.ts +23 -0
- package/src/runtime/serve-worker.ts +278 -0
- package/src/runtime/site-discovery.test.ts +168 -0
- package/src/runtime/site-discovery.ts +308 -0
- package/src/runtime/target-runner.ts +144 -0
- package/src/runtime/test-case-verdicts.test.ts +94 -0
- package/src/runtime/test-case-verdicts.ts +120 -0
- package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
- package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
- package/src/runtime/ui-reverse-engineering/types.ts +197 -0
- package/src/runtime/web-suite.test.ts +97 -0
- package/src/runtime/web-suite.ts +176 -0
- package/src/storage/create-object-store.ts +144 -0
- package/src/storage/keys.ts +34 -0
- package/src/storage/local-artifact-server.test.ts +314 -0
- package/src/storage/local-artifact-server.ts +357 -0
- package/src/storage/object-store.test.ts +28 -0
- package/src/storage/object-store.ts +101 -0
- package/src/storage/registry-object-store.ts +88 -0
- package/src/storage/remote-object-store.ts +104 -0
- package/src/storage/storage-directory.test.ts +43 -0
- package/src/storage/storage-directory.ts +24 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Loads the route and job modules added for plans, billing and integrations.
|
|
2
|
+
// Each registers itself with routes/index.ts or jobs/index.ts on import.
|
|
3
|
+
import "./org/routes.js";
|
|
4
|
+
import "./billing/routes.js";
|
|
5
|
+
import "./jobs/retention.js";
|
|
6
|
+
import "./ai/routes.js";
|
|
7
|
+
import "./runner/routes.js";
|
|
8
|
+
import "./signup/routes.js";
|
|
9
|
+
import "./billing/checkout-routes.js";
|
|
10
|
+
import "./notifications/outbox.js";
|
|
11
|
+
import "./notifications/alerts.js";
|
|
12
|
+
import "./notifications/routes.js";
|
|
13
|
+
import "./github/checks.js";
|
|
14
|
+
import "./gitlab/checks.js";
|
|
15
|
+
import "./personas/routes.js";
|
|
16
|
+
import "./feedback/routes.js";
|
|
17
|
+
import "./devices/routes.js";
|
|
18
|
+
import "./domains/routes.js";
|
|
19
|
+
import "./sso/routes.js";
|
|
20
|
+
import "./mcp/routes.js";
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
4
|
+
import type { Env } from "../core.js";
|
|
5
|
+
import { call, createTestEnv, seedProject } from "../testing/harness.js";
|
|
6
|
+
import { drainOutbox } from "../notifications/outbox.js";
|
|
7
|
+
import { cleanConsole, fingerprintOf, formatIssue, quietMentions, redactSecrets, redactUrl } from "./format.js";
|
|
8
|
+
import { RATE_LIMIT_PER_HOUR } from "./routes.js";
|
|
9
|
+
|
|
10
|
+
const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
11
|
+
const PNG = `data:image/png;base64,${Buffer.from("fake-png-bytes").toString("base64")}`;
|
|
12
|
+
|
|
13
|
+
function fakeGithub() {
|
|
14
|
+
const state = { issues: [] as any[], comments: [] as Array<{ issue: number; body: string }>, labels: new Set<string>(), fail: 0 };
|
|
15
|
+
const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
16
|
+
const url = new URL(String(input));
|
|
17
|
+
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
|
18
|
+
if (url.pathname.endsWith("/access_tokens")) return Response.json({ token: "ghs_x", expires_at: new Date(Date.now() + 3_600_000).toISOString() }, { status: 201 });
|
|
19
|
+
if (state.fail) return new Response("down", { status: state.fail });
|
|
20
|
+
const label = url.pathname.match(/\/labels\/([^/]+)$/);
|
|
21
|
+
if (label) return state.labels.has(decodeURIComponent(label[1])) ? Response.json({}) : new Response("", { status: 404 });
|
|
22
|
+
if (url.pathname.endsWith("/labels")) {
|
|
23
|
+
state.labels.add(body.name);
|
|
24
|
+
return Response.json(body, { status: 201 });
|
|
25
|
+
}
|
|
26
|
+
if (url.pathname.endsWith("/issues")) {
|
|
27
|
+
const number = state.issues.length + 1;
|
|
28
|
+
state.issues.push({ number, ...body });
|
|
29
|
+
return Response.json({ number, html_url: `https://github.com/theaiinc/bugmole/issues/${number}` }, { status: 201 });
|
|
30
|
+
}
|
|
31
|
+
const comment = url.pathname.match(/\/issues\/(\d+)\/comments$/);
|
|
32
|
+
if (comment) {
|
|
33
|
+
state.comments.push({ issue: Number(comment[1]), body: body.body });
|
|
34
|
+
return Response.json({ id: 1 }, { status: 201 });
|
|
35
|
+
}
|
|
36
|
+
return new Response("not found", { status: 404 });
|
|
37
|
+
}) as unknown as typeof fetch;
|
|
38
|
+
return { state, doFetch };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function feedbackEnv(gh: ReturnType<typeof fakeGithub>, extra: Record<string, unknown> = {}) {
|
|
42
|
+
return createTestEnv({
|
|
43
|
+
FEATURE_FEEDBACK: "on",
|
|
44
|
+
GITHUB_APP_ID: "1",
|
|
45
|
+
GITHUB_APP_PRIVATE_KEY: privateKey,
|
|
46
|
+
GITHUB_FETCH: gh.doFetch,
|
|
47
|
+
BUGMOLE_FEEDBACK_REPO: "theaiinc/bugmole",
|
|
48
|
+
BUGMOLE_FEEDBACK_INSTALLATION_ID: "5",
|
|
49
|
+
SECRETS_ENCRYPTION_KEY: "k",
|
|
50
|
+
REGISTRY_PUBLIC_URL: "https://registry.test",
|
|
51
|
+
DASHBOARD_URL: "https://dash.test",
|
|
52
|
+
PLATFORM_STAFF_IDS: "user_staff",
|
|
53
|
+
PLATFORM_STAFF_EMAILS: "staff@bugmole.test",
|
|
54
|
+
...extra,
|
|
55
|
+
} as Partial<Env>);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const bug = (description: string, extra: Record<string, unknown> = {}) => ({
|
|
59
|
+
kind: "bug",
|
|
60
|
+
page: "https://dash.test/runs?project=shop&token=abc",
|
|
61
|
+
payload: {
|
|
62
|
+
description,
|
|
63
|
+
expectation: "It should save",
|
|
64
|
+
screenshot: PNG,
|
|
65
|
+
consoleLogs: [
|
|
66
|
+
{ level: "log", message: "boot" },
|
|
67
|
+
{ level: "error", message: "TypeError: x is undefined (Authorization: Bearer abcdefghijklmnop)" },
|
|
68
|
+
],
|
|
69
|
+
networkLogs: [{ url: "https://registry.test/api/runs?key=bm_live_secret123456", duration: 42.4 }], // gitleaks:allow (fake value for the redaction test)
|
|
70
|
+
environment: { url: "https://dash.test/runs?secret=1", userAgent: "Mozilla/5.0", windowWidth: 1440, windowHeight: 900 },
|
|
71
|
+
...extra,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("secrets are scrubbed from logs, links and free text", () => {
|
|
76
|
+
assert.doesNotMatch(redactSecrets("Authorization: Bearer abc.def.ghi-123"), /abc\.def/);
|
|
77
|
+
assert.equal(redactSecrets("key bm_live_key_abc_1234567890 and xoxb-1234-5678-abcdefgh"), "key [redacted token] and [redacted token]");
|
|
78
|
+
assert.equal(redactSecrets("password=hunter22 token: 'abc123'"), "password=[redacted] token: '[redacted]'");
|
|
79
|
+
// nosemgrep: generic.secrets.security.detected-aws-access-key-id-value.detected-aws-access-key-id-value -- a fake key, so the redaction has something to catch.
|
|
80
|
+
assert.equal(redactSecrets("AKIAABCDEFGHIJKLMNOP"), "[redacted key]"); // gitleaks:allow (fake value for the redaction test)
|
|
81
|
+
assert.equal(redactUrl("https://dash.test/runs?token=abc#local-artifact-token=zzz"), "https://dash.test/runs?[redacted]");
|
|
82
|
+
assert.equal(cleanConsole([{ level: "error", message: "x".repeat(5000) }, { nope: true }])[0].message.length, 1000);
|
|
83
|
+
assert.equal(quietMentions("ping @octocat and me@example.com"), "ping @octocat and me@example.com");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the same problem gets the same fingerprint, whatever the ids", async () => {
|
|
87
|
+
const a = await fingerprintOf("bug", "Run run_8f3a2c1d9e failed at step 3", "https://dash.test/runs/abcdef0123456789abcd", [{ level: "error", message: "Timeout 30000ms" }]);
|
|
88
|
+
const b = await fingerprintOf("bug", "run run_77aa99bb11 failed at step 4", "https://dash.test/runs/0123456789abcdef0123", [{ level: "error", message: "Timeout 45000ms" }]);
|
|
89
|
+
const c = await fingerprintOf("bug", "Something else broke", "https://dash.test/runs", []);
|
|
90
|
+
assert.equal(a, b);
|
|
91
|
+
assert.notEqual(a, c);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("issue bodies carry context, fold logs away, and can't break out of their code blocks", () => {
|
|
95
|
+
const { title, body, labels } = formatIssue({
|
|
96
|
+
id: "fb_1", kind: "feedback", message: "Love it @everyone", rating: 4, category: "Speed", screenshotUrls: [],
|
|
97
|
+
console: [{ level: "log", message: "```\n## injected" }], network: [], fingerprint: "f", submittedAt: "now",
|
|
98
|
+
workspace: { id: "acme", name: "Acme", plan: "team" }, page: "https://dash.test/?x=1",
|
|
99
|
+
});
|
|
100
|
+
assert.equal(title, "[Feedback] ★★★★☆ Love it @everyone");
|
|
101
|
+
assert.deepEqual(labels, ["feedback", "feedback"]);
|
|
102
|
+
assert.match(body, /Workspace:\*\* Acme \(`acme`, team plan\)/);
|
|
103
|
+
assert.match(body, /<details><summary>Console \(1\)<\/summary>/);
|
|
104
|
+
assert.match(body, /````\n\[log\] ```\n## injected\n````/);
|
|
105
|
+
assert.match(body, /Page:\*\* https:\/\/dash\.test\/\?\[redacted\]/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a bug report is stored scrubbed, and filed once as a GitHub issue with a screenshot GitHub can load", async () => {
|
|
109
|
+
const gh = fakeGithub();
|
|
110
|
+
const { env, db, artifacts, workspaceId, projectId, owner } = await (async () => {
|
|
111
|
+
const setup = feedbackEnv(gh);
|
|
112
|
+
return { ...setup, ...(await seedProject(setup.env)) };
|
|
113
|
+
})();
|
|
114
|
+
const sent = await call(env, "POST", "/api/feedback", { as: owner, body: { ...bug("Saving the plan fails"), workspaceId, projectId } });
|
|
115
|
+
assert.equal(sent.status, 201, JSON.stringify(sent.json));
|
|
116
|
+
const id = sent.json.id;
|
|
117
|
+
const row = db.db.prepare("SELECT data_json AS data, page, screenshot_keys_json AS keys, plan FROM feedback_reports WHERE id = ?").get(id) as any;
|
|
118
|
+
assert.ok(!row.data.includes("abcdefghijklmnop"), "bearer token removed");
|
|
119
|
+
assert.ok(!row.data.includes("bm_live_secret"), "query string removed");
|
|
120
|
+
assert.equal(row.page, "https://dash.test/runs?[redacted]");
|
|
121
|
+
assert.equal(row.plan, "free");
|
|
122
|
+
assert.ok(artifacts.objects.has(`feedback/${id}/0`));
|
|
123
|
+
|
|
124
|
+
await drainOutbox(env);
|
|
125
|
+
assert.equal(gh.state.issues.length, 1);
|
|
126
|
+
const issue = gh.state.issues[0];
|
|
127
|
+
assert.deepEqual(issue.labels, ["feedback", "bug"]);
|
|
128
|
+
assert.deepEqual([...gh.state.labels].sort(), ["bug", "feedback"]);
|
|
129
|
+
assert.equal(issue.title, "[Bug] Saving the plan fails");
|
|
130
|
+
const image = issue.body.match(/!\[screenshot\]\((https:\/\/registry\.test[^)]+)\)/)?.[1];
|
|
131
|
+
assert.ok(image);
|
|
132
|
+
const fetched = await call(env, "GET", new URL(image).pathname + new URL(image).search);
|
|
133
|
+
assert.equal(fetched.status, 200);
|
|
134
|
+
assert.equal(fetched.headers.get("content-type"), "image/png");
|
|
135
|
+
const tampered = new URL(image);
|
|
136
|
+
tampered.searchParams.set("expires", String(Date.now() + 999_999_999));
|
|
137
|
+
assert.equal((await call(env, "GET", tampered.pathname + tampered.search)).status, 404);
|
|
138
|
+
assert.equal((await call(env, "GET", `/api/feedback/${id}/images/1${new URL(image).search}`)).status, 404);
|
|
139
|
+
|
|
140
|
+
const reports = await call(env, "GET", "/api/feedback/mine", { as: owner });
|
|
141
|
+
assert.equal(reports.json.reports[0].status, "open");
|
|
142
|
+
assert.equal(reports.json.reports[0].issueUrl, undefined, "reporters don't get the internal issue link");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("the same problem again comments on the open issue; closing it tells every reporter", async () => {
|
|
146
|
+
const gh = fakeGithub();
|
|
147
|
+
const setup = feedbackEnv(gh);
|
|
148
|
+
const { owner, workspaceId } = await seedProject(setup.env);
|
|
149
|
+
const { env, email } = setup;
|
|
150
|
+
await call(env, "GET", "/api/workspaces", { as: owner, headers: { "x-aegis-email": "owner@acme.test", "x-aegis-email-verified": "true" } });
|
|
151
|
+
await call(env, "GET", "/api/workspaces", { as: "user_two", headers: { "x-aegis-email": "two@acme.test", "x-aegis-email-verified": "true" } });
|
|
152
|
+
await call(env, "POST", "/api/feedback", { as: owner, body: bug("Run run_111aaa222b froze", { screenshot: undefined }) });
|
|
153
|
+
await call(env, "POST", "/api/feedback", { as: "user_two", body: bug("run run_999fff000c froze", { screenshot: undefined }) });
|
|
154
|
+
await drainOutbox(env);
|
|
155
|
+
await drainOutbox(env, new Date(Date.now() + 60_000));
|
|
156
|
+
assert.equal(gh.state.issues.length, 1);
|
|
157
|
+
assert.equal(gh.state.comments.length, 1);
|
|
158
|
+
assert.match(gh.state.comments[0].body, /Reported again/);
|
|
159
|
+
|
|
160
|
+
const service = { "x-aegis-id": "bugmole-dashboard-service", "x-aegis-secret": "test-service-secret" };
|
|
161
|
+
assert.equal((await call(env, "POST", "/api/feedback/issue-state", { as: owner, body: { repo: "theaiinc/bugmole", issueNumber: 1, state: "closed" } })).status, 403);
|
|
162
|
+
const closed = await call(env, "POST", "/api/feedback/issue-state", { headers: service, body: { repo: "theaiinc/bugmole", issueNumber: 1, state: "closed" } });
|
|
163
|
+
assert.equal(closed.json.updated, 2);
|
|
164
|
+
await drainOutbox(env);
|
|
165
|
+
const notices = email.sent.filter((message) => /fixed or closed/.test(message.subject)).map((message) => message.to[0].email).sort();
|
|
166
|
+
assert.deepEqual(notices, ["owner@acme.test", "two@acme.test"]);
|
|
167
|
+
const mine = await call(env, "GET", "/api/feedback/mine", { as: "user_two" });
|
|
168
|
+
assert.equal(mine.json.reports[0].status, "closed", "a duplicate shows its original's status");
|
|
169
|
+
assert.ok(workspaceId);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("feedback is validated, limited per person, and only for signed-in people", async () => {
|
|
173
|
+
const gh = fakeGithub();
|
|
174
|
+
const setup = feedbackEnv(gh);
|
|
175
|
+
const { env } = setup;
|
|
176
|
+
const { owner, projectId } = await seedProject(env);
|
|
177
|
+
const send = (body: unknown, as = owner) => call(env, "POST", "/api/feedback", { as, body });
|
|
178
|
+
assert.equal((await send({ kind: "praise", payload: {} })).status, 422);
|
|
179
|
+
assert.equal((await send({ kind: "bug", payload: { description: " " } })).status, 422);
|
|
180
|
+
assert.equal((await send({ kind: "feedback", payload: { comment: "ok", rating: 9 } })).status, 422);
|
|
181
|
+
assert.equal((await send({ kind: "bug", payload: { description: "x", screenshot: "data:text/html;base64,PGgxPg==" } })).status, 422);
|
|
182
|
+
const key = await call(env, "POST", "/api/api-keys", { as: owner, body: { name: "ci", projectIds: [projectId] } });
|
|
183
|
+
assert.equal((await call(env, "POST", "/api/feedback", { token: key.json.token, body: { kind: "bug", payload: { description: "x" } } })).status, 403);
|
|
184
|
+
|
|
185
|
+
// Context the reporter can't see isn't recorded.
|
|
186
|
+
const sneaky = await send({ kind: "feedback", payload: { comment: "hi", rating: 5 }, workspaceId: "someone-else", projectId: "theirs" });
|
|
187
|
+
const row = setup.db.db.prepare("SELECT workspace_id AS w, project_id AS p FROM feedback_reports WHERE id = ?").get(sneaky.json.id) as any;
|
|
188
|
+
assert.deepEqual({ ...row }, { w: null, p: null });
|
|
189
|
+
|
|
190
|
+
for (let index = 1; index < RATE_LIMIT_PER_HOUR; index++) {
|
|
191
|
+
assert.equal((await send({ kind: "feedback", payload: { comment: `note ${index}`, rating: 4 } })).status, 201);
|
|
192
|
+
}
|
|
193
|
+
assert.equal((await send({ kind: "feedback", payload: { comment: "one more", rating: 4 } })).status, 429);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("only platform staff see the triage list", async () => {
|
|
197
|
+
const gh = fakeGithub();
|
|
198
|
+
const { env } = feedbackEnv(gh);
|
|
199
|
+
await call(env, "POST", "/api/feedback", { as: "user_a", body: { kind: "feedback", payload: { comment: "Nice", rating: 5 } } });
|
|
200
|
+
assert.equal((await call(env, "GET", "/api/admin/feedback", { as: "user_a" })).status, 404);
|
|
201
|
+
const list = await call(env, "GET", "/api/admin/feedback?kind=feedback", { as: "user_staff" });
|
|
202
|
+
assert.equal(list.json.reports.length, 1);
|
|
203
|
+
|
|
204
|
+
// Staff can also be listed by email, but only once Aegis has verified it.
|
|
205
|
+
const byEmail = (verified: string) => call(env, "GET", "/api/admin/feedback", {
|
|
206
|
+
as: "user_other",
|
|
207
|
+
headers: { "x-aegis-email": "Staff@bugmole.test", "x-aegis-email-verified": verified },
|
|
208
|
+
});
|
|
209
|
+
assert.equal((await byEmail("true")).status, 200);
|
|
210
|
+
assert.equal((await byEmail("false")).status, 404);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("a report is kept even when screenshots can't be stored", async () => {
|
|
214
|
+
const gh = fakeGithub();
|
|
215
|
+
const { env, db } = feedbackEnv(gh, { ARTIFACTS: undefined });
|
|
216
|
+
const sent = await call(env, "POST", "/api/feedback", { as: "user_a", body: bug("Button does nothing") });
|
|
217
|
+
assert.equal(sent.status, 201);
|
|
218
|
+
const row = db.db.prepare("SELECT data_json AS data, screenshot_keys_json AS keys FROM feedback_reports WHERE id = ?").get(sent.json.id) as any;
|
|
219
|
+
assert.equal(JSON.parse(row.data).imagesDropped, 1);
|
|
220
|
+
assert.equal(row.keys, "[]");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a missing GitHub setup keeps the report and stops retrying", async () => {
|
|
224
|
+
const gh = fakeGithub();
|
|
225
|
+
const { env, db } = feedbackEnv(gh, { BUGMOLE_FEEDBACK_REPO: "" });
|
|
226
|
+
const sent = await call(env, "POST", "/api/feedback", { as: "user_a", body: { kind: "feedback", payload: { comment: "Nice", rating: 5 } } });
|
|
227
|
+
await drainOutbox(env);
|
|
228
|
+
assert.equal((db.db.prepare("SELECT status FROM outbox").get() as any).status, "dead");
|
|
229
|
+
assert.equal((db.db.prepare("SELECT status FROM feedback_reports WHERE id = ?").get(sent.json.id) as any).status, "new");
|
|
230
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Turning a report into something safe to store and useful on GitHub.
|
|
2
|
+
// Issue titles, labels and the metadata footer follow @theaiinc/feedback-gh-issue's
|
|
3
|
+
// conventions (used by realistiz-dd), with the logs and context Bugmole has.
|
|
4
|
+
|
|
5
|
+
export type ConsoleEntry = { level: string; message: string; timestamp?: number };
|
|
6
|
+
export type NetworkEntry = { url: string; duration?: number; startTime?: number };
|
|
7
|
+
|
|
8
|
+
const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
|
9
|
+
[/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [redacted]"],
|
|
10
|
+
[/\b(bm_live|vk_live|vrt|ghs|gho|ghp|ghu|github_pat)_[A-Za-z0-9_.-]{8,}/g, "[redacted token]"],
|
|
11
|
+
[/\bxox[abposr]-[A-Za-z0-9-]{8,}/g, "[redacted token]"],
|
|
12
|
+
[/\bsk-[A-Za-z0-9_-]{16,}/g, "[redacted key]"],
|
|
13
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, "[redacted key]"],
|
|
14
|
+
[/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, "[redacted jwt]"],
|
|
15
|
+
[/((?:password|passwd|secret|token|api[_-]?key|access[_-]?key|authorization|session|code)["']?\s*[:=]\s*["']?)[^\s"'&,;}]+/gi, "$1[redacted]"],
|
|
16
|
+
[/#local-artifact-token=[^&\s]+/g, "#local-artifact-token=[redacted]"],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Removes things that look like credentials from text people didn't mean to share. */
|
|
20
|
+
export function redactSecrets(text: string): string {
|
|
21
|
+
let result = text;
|
|
22
|
+
for (const [pattern, replacement] of SECRET_PATTERNS) result = result.replace(pattern, replacement);
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Query strings often carry tokens; keep the path. */
|
|
27
|
+
export function redactUrl(value: string): string {
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(value);
|
|
30
|
+
if (url.search) url.search = "?[redacted]";
|
|
31
|
+
url.hash = "";
|
|
32
|
+
return redactSecrets(url.toString());
|
|
33
|
+
} catch {
|
|
34
|
+
return redactSecrets(value.split("?")[0]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clip(text: string, max: number): string {
|
|
39
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function cleanConsole(entries: unknown, limit = 50): ConsoleEntry[] {
|
|
43
|
+
if (!Array.isArray(entries)) return [];
|
|
44
|
+
return entries.slice(-limit).flatMap((entry) => {
|
|
45
|
+
const item = entry as Partial<ConsoleEntry>;
|
|
46
|
+
if (typeof item?.message !== "string") return [];
|
|
47
|
+
return [{
|
|
48
|
+
level: typeof item.level === "string" ? item.level.slice(0, 10) : "log",
|
|
49
|
+
message: clip(redactSecrets(item.message), 1000),
|
|
50
|
+
...(typeof item.timestamp === "number" ? { timestamp: item.timestamp } : {}),
|
|
51
|
+
}];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function cleanNetwork(entries: unknown, limit = 50): NetworkEntry[] {
|
|
56
|
+
if (!Array.isArray(entries)) return [];
|
|
57
|
+
return entries.slice(-limit).flatMap((entry) => {
|
|
58
|
+
const item = entry as Partial<NetworkEntry>;
|
|
59
|
+
if (typeof item?.url !== "string") return [];
|
|
60
|
+
return [{
|
|
61
|
+
url: clip(redactUrl(item.url), 300),
|
|
62
|
+
...(typeof item.duration === "number" ? { duration: Math.round(item.duration) } : {}),
|
|
63
|
+
}];
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalize(text: string): string {
|
|
68
|
+
return text.toLowerCase().replace(/[0-9a-f]{8,}/g, "#").replace(/\d+/g, "#").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Same kind, same words (ignoring ids and numbers), same page, same first error: the same problem. */
|
|
72
|
+
export async function fingerprintOf(kind: string, message: string, page: string | null, consoleEntries: ConsoleEntry[]): Promise<string> {
|
|
73
|
+
let path = "";
|
|
74
|
+
try {
|
|
75
|
+
path = page ? new URL(page).pathname.replace(/\/[0-9a-z_-]{16,}/gi, "/:id") : "";
|
|
76
|
+
} catch {
|
|
77
|
+
path = "";
|
|
78
|
+
}
|
|
79
|
+
const firstError = consoleEntries.find((entry) => entry.level === "error")?.message ?? "";
|
|
80
|
+
const source = [kind, normalize(message), path, normalize(firstError)].join("|");
|
|
81
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source));
|
|
82
|
+
return [...new Uint8Array(digest)].slice(0, 12).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type IssueReport = {
|
|
86
|
+
id: string;
|
|
87
|
+
kind: "bug" | "feedback";
|
|
88
|
+
message: string;
|
|
89
|
+
expectation?: string;
|
|
90
|
+
rating?: number;
|
|
91
|
+
category?: string;
|
|
92
|
+
contact?: string;
|
|
93
|
+
page?: string | null;
|
|
94
|
+
workspace?: { id: string; name: string; plan: string } | null;
|
|
95
|
+
runUrl?: string | null;
|
|
96
|
+
environment?: Record<string, unknown> | null;
|
|
97
|
+
screenshotUrls: string[];
|
|
98
|
+
console: ConsoleEntry[];
|
|
99
|
+
network: NetworkEntry[];
|
|
100
|
+
fingerprint: string;
|
|
101
|
+
submittedAt: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Markdown can't break out of a fenced block made of more backticks than it contains. */
|
|
105
|
+
function fence(text: string): string {
|
|
106
|
+
const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((match) => match[0].length));
|
|
107
|
+
const ticks = "`".repeat(longest + 1);
|
|
108
|
+
return `${ticks}\n${text}\n${ticks}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Reporter text can't ping people or teams on GitHub. */
|
|
112
|
+
export function quietMentions(text: string): string {
|
|
113
|
+
return text.replace(/(^|[^\w`])@(?=[A-Za-z0-9])/g, "$1@\u200b");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function oneLine(text: string): string {
|
|
117
|
+
return text.replace(/\s+/g, " ").trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function formatIssue(report: IssueReport): { title: string; body: string; labels: string[] } {
|
|
121
|
+
const title = report.kind === "bug"
|
|
122
|
+
? `[Bug] ${clip(oneLine(quietMentions(report.message)), 80)}`
|
|
123
|
+
: `[Feedback] ${"★".repeat(report.rating ?? 0)}${"☆".repeat(5 - (report.rating ?? 0))} ${clip(oneLine(quietMentions(report.message)), 60)}`;
|
|
124
|
+
const env = report.environment ?? {};
|
|
125
|
+
const lines: string[] = [];
|
|
126
|
+
if (report.kind === "bug") {
|
|
127
|
+
lines.push("### What happened", quietMentions(report.message));
|
|
128
|
+
if (report.expectation) lines.push("", "### Expected", quietMentions(report.expectation));
|
|
129
|
+
} else {
|
|
130
|
+
lines.push(`**Rating:** ${report.rating}/5${report.category ? ` · **Topic:** ${quietMentions(report.category)}` : ""}`, "", quietMentions(report.message));
|
|
131
|
+
}
|
|
132
|
+
for (const url of report.screenshotUrls) lines.push("", ``);
|
|
133
|
+
lines.push("", "### Context");
|
|
134
|
+
if (report.workspace) lines.push(`- **Workspace:** ${report.workspace.name} (\`${report.workspace.id}\`, ${report.workspace.plan} plan)`);
|
|
135
|
+
if (report.page) lines.push(`- **Page:** ${redactUrl(report.page)}`);
|
|
136
|
+
if (report.runUrl) lines.push(`- **Run:** ${report.runUrl}`);
|
|
137
|
+
if (typeof env.userAgent === "string") lines.push(`- **Browser:** ${oneLine(env.userAgent).slice(0, 200)}`);
|
|
138
|
+
if (typeof env.windowWidth === "number") lines.push(`- **Window:** ${env.windowWidth}×${env.windowHeight}`);
|
|
139
|
+
if (report.contact) lines.push(`- **Contact (shared by the reporter):** ${quietMentions(oneLine(report.contact)).slice(0, 200)}`);
|
|
140
|
+
if (report.console.length) {
|
|
141
|
+
lines.push("", `<details><summary>Console (${report.console.length})</summary>`, "", fence(report.console.map((entry) => `[${entry.level}] ${entry.message}`).join("\n")), "", "</details>");
|
|
142
|
+
}
|
|
143
|
+
if (report.network.length) {
|
|
144
|
+
lines.push("", `<details><summary>Network (${report.network.length})</summary>`, "", fence(report.network.map((entry) => `${entry.duration ?? "?"}ms ${entry.url}`).join("\n")), "", "</details>");
|
|
145
|
+
}
|
|
146
|
+
lines.push("", "---", `<sub>Sent from the Bugmole dashboard · report \`${report.id}\` · ${report.submittedAt} · fingerprint \`${report.fingerprint}\`</sub>`);
|
|
147
|
+
return { title, body: lines.join("\n"), labels: ["feedback", report.kind] };
|
|
148
|
+
}
|