@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,386 @@
|
|
|
1
|
+
// In-product bug reports and feedback: stored in the registry (screenshots in
|
|
2
|
+
// the artifacts bucket), and filed as GitHub issues on the product repo from
|
|
3
|
+
// the outbox. A repeat of the same problem within a week comments on the
|
|
4
|
+
// existing issue instead of opening another.
|
|
5
|
+
import { githubAppConfigured, githubRequest, GithubAppError, type GithubAppEnv } from "../../integrations/github-app.js";
|
|
6
|
+
import type { Env, Principal } from "../core.js";
|
|
7
|
+
import { constantTimeEqual, newId, requestJson, response, sha256 } from "../core.js";
|
|
8
|
+
import { getWorkspacePlan } from "../billing/entitlements.js";
|
|
9
|
+
import { flagOn } from "../flags.js";
|
|
10
|
+
import { recordAudit } from "../org/audit.js";
|
|
11
|
+
import { drainSoon, enqueue, PermanentDeliveryError, registerOutboxHandler } from "../notifications/outbox.js";
|
|
12
|
+
import { matchPath, registerPublicRoutes, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
|
|
13
|
+
import { cleanConsole, cleanNetwork, fingerprintOf, formatIssue, redactSecrets, redactUrl, type IssueReport } from "./format.js";
|
|
14
|
+
|
|
15
|
+
export type FeedbackEnv = GithubAppEnv & {
|
|
16
|
+
/** The product repository issues are filed on, e.g. "theaiinc/bugmole". */
|
|
17
|
+
BUGMOLE_FEEDBACK_REPO?: string;
|
|
18
|
+
/** The Bugmole GitHub App's installation id on that repository. */
|
|
19
|
+
BUGMOLE_FEEDBACK_INSTALLATION_ID?: string;
|
|
20
|
+
/** Comma-separated Aegis ids allowed to triage reports. */
|
|
21
|
+
PLATFORM_STAFF_IDS?: string;
|
|
22
|
+
/** Comma-separated emails allowed to triage reports, once Aegis has verified them. */
|
|
23
|
+
PLATFORM_STAFF_EMAILS?: string;
|
|
24
|
+
SECRETS_ENCRYPTION_KEY?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const RATE_LIMIT_PER_HOUR = 10;
|
|
28
|
+
export const IP_LIMIT_PER_HOUR = 30;
|
|
29
|
+
const MAX_TEXT = 5_000;
|
|
30
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
31
|
+
const MAX_IMAGES = 4;
|
|
32
|
+
const DUPLICATE_WINDOW_MS = 7 * 86_400_000;
|
|
33
|
+
const SCREENSHOT_LINK_TTL_MS = 180 * 86_400_000;
|
|
34
|
+
|
|
35
|
+
function feedbackEnv(env: Env): Env & FeedbackEnv {
|
|
36
|
+
return env as Env & FeedbackEnv;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function text(value: unknown, max = MAX_TEXT): string {
|
|
40
|
+
return typeof value === "string" ? value.trim().slice(0, max) : "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isPerson(principal: Principal): boolean {
|
|
44
|
+
// Reports come from people signed in on the dashboard, not keys or runners.
|
|
45
|
+
return principal.scopes.includes("*") && !principal.apiKeyId && !principal.runner && principal.aegisId !== "bugmole-dashboard-service";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function listOf(value: string | undefined): string[] {
|
|
49
|
+
return (value ?? "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function staff(env: Env, principal: Principal): boolean {
|
|
53
|
+
if (!isPerson(principal)) return false;
|
|
54
|
+
if (listOf(feedbackEnv(env).PLATFORM_STAFF_IDS).includes(principal.aegisId.toLowerCase())) return true;
|
|
55
|
+
return Boolean(principal.email && principal.emailVerified
|
|
56
|
+
&& listOf(feedbackEnv(env).PLATFORM_STAFF_EMAILS).includes(principal.email.toLowerCase()));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type DecodedImage = { bytes: Uint8Array; type: string };
|
|
60
|
+
|
|
61
|
+
export function decodeImage(value: unknown): DecodedImage | null {
|
|
62
|
+
if (typeof value !== "string") return null;
|
|
63
|
+
const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/.exec(value);
|
|
64
|
+
if (!match) return null;
|
|
65
|
+
if ((match[2].length * 3) / 4 > MAX_IMAGE_BYTES) return null;
|
|
66
|
+
try {
|
|
67
|
+
return { type: match[1], bytes: Uint8Array.from(atob(match[2]), (char) => char.charCodeAt(0)) };
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function screenshotSignature(env: Env, key: string, expires: number): Promise<string> {
|
|
74
|
+
const secret = feedbackEnv(env).SECRETS_ENCRYPTION_KEY;
|
|
75
|
+
if (!secret) throw new Error("SECRETS_ENCRYPTION_KEY is not configured");
|
|
76
|
+
return (await sha256(`feedback-image:${secret}:${key}:${expires}`)).slice(0, 40);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A link GitHub can load (it proxies images), without making the bucket public. */
|
|
80
|
+
export async function signedScreenshotUrl(env: Env, reportId: string, index: number, now = Date.now()): Promise<string> {
|
|
81
|
+
const expires = now + SCREENSHOT_LINK_TTL_MS;
|
|
82
|
+
const signature = await screenshotSignature(env, `${reportId}/${index}`, expires);
|
|
83
|
+
const base = (feedbackEnv(env).REGISTRY_PUBLIC_URL || "https://api.bugmole.com").replace(/\/$/, "");
|
|
84
|
+
return `${base}/api/feedback/${encodeURIComponent(reportId)}/images/${index}?${new URLSearchParams({ expires: String(expires), sig: signature })}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function submit({ env, principal, request, ctx }: AuthedRouteContext): Promise<Response> {
|
|
88
|
+
if (!isPerson(principal)) return response({ error: "Sign in on the dashboard to send feedback" }, 403);
|
|
89
|
+
const body = await requestJson(request);
|
|
90
|
+
const kind = body?.kind;
|
|
91
|
+
if (kind !== "bug" && kind !== "feedback") return response({ error: "kind must be bug or feedback" }, 422);
|
|
92
|
+
const payload = (body?.payload ?? {}) as Record<string, unknown>;
|
|
93
|
+
const message = text(kind === "bug" ? payload.description : payload.comment);
|
|
94
|
+
if (!message) return response({ error: kind === "bug" ? "Describe what happened" : "Write a comment" }, 422);
|
|
95
|
+
const rating = kind === "feedback" ? Number(payload.rating) : null;
|
|
96
|
+
if (kind === "feedback" && (!Number.isInteger(rating) || rating! < 1 || rating! > 5)) return response({ error: "rating must be 1 to 5" }, 422);
|
|
97
|
+
|
|
98
|
+
const ip = (request.headers.get("x-forwarded-for") ?? "").split(",")[0].trim() || null;
|
|
99
|
+
const hourAgo = new Date(Date.now() - 3_600_000).toISOString().replace("T", " ").slice(0, 19);
|
|
100
|
+
const recent = await env.DB.prepare(`
|
|
101
|
+
SELECT SUM(CASE WHEN aegis_id = ? THEN 1 ELSE 0 END) AS mine, SUM(CASE WHEN ip IS NOT NULL AND ip = ? THEN 1 ELSE 0 END) AS fromIp
|
|
102
|
+
FROM feedback_reports WHERE created_at > ?
|
|
103
|
+
`).bind(principal.aegisId, ip, hourAgo).first<{ mine: number | null; fromIp: number | null }>();
|
|
104
|
+
if ((recent?.mine ?? 0) >= RATE_LIMIT_PER_HOUR || (ip && (recent?.fromIp ?? 0) >= IP_LIMIT_PER_HOUR)) {
|
|
105
|
+
return response({ error: "Thanks — that's a lot of reports in an hour. Please try again a bit later." }, 429);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const images = (kind === "bug" ? [payload.screenshot] : Array.isArray(payload.images) ? payload.images : []).filter(Boolean).slice(0, MAX_IMAGES);
|
|
109
|
+
const decoded = images.map(decodeImage);
|
|
110
|
+
if (decoded.some((image) => image === null)) return response({ error: "Images must be PNG, JPEG, WebP or GIF, up to 5 MB each" }, 422);
|
|
111
|
+
|
|
112
|
+
const workspaceId = text(body?.workspaceId, 100) || null;
|
|
113
|
+
const projectId = text(body?.projectId, 100) || null;
|
|
114
|
+
const runId = text(body?.runId, 100) || null;
|
|
115
|
+
// Context is only recorded for things the reporter can actually see.
|
|
116
|
+
const workspace = workspaceId
|
|
117
|
+
? await env.DB.prepare(`
|
|
118
|
+
SELECT w.id, w.name FROM workspaces w JOIN workspace_memberships m ON m.workspace_id = w.id
|
|
119
|
+
WHERE w.id = ? AND m.aegis_id = ? AND m.status = 'active'
|
|
120
|
+
`).bind(workspaceId, principal.aegisId).first<{ id: string; name: string }>()
|
|
121
|
+
: null;
|
|
122
|
+
const project = projectId && workspace
|
|
123
|
+
? await env.DB.prepare("SELECT id FROM projects WHERE id = ? AND workspace_id = ?").bind(projectId, workspace.id).first<{ id: string }>()
|
|
124
|
+
: null;
|
|
125
|
+
const run = runId && project
|
|
126
|
+
? await env.DB.prepare("SELECT id FROM runs WHERE id = ? AND project_id = ?").bind(runId, project.id).first<{ id: string }>()
|
|
127
|
+
: null;
|
|
128
|
+
const plan = workspace ? (await getWorkspacePlan(env, workspace.id)).plan : null;
|
|
129
|
+
|
|
130
|
+
const consoleLogs = cleanConsole(payload.consoleLogs);
|
|
131
|
+
const networkLogs = cleanNetwork(payload.networkLogs);
|
|
132
|
+
const page = text(body?.page, 2_000) ? redactUrl(text(body?.page, 2_000)) : null;
|
|
133
|
+
const environment = payload.environment && typeof payload.environment === "object" ? payload.environment as Record<string, unknown> : null;
|
|
134
|
+
const id = newId("fb");
|
|
135
|
+
const fingerprint = await fingerprintOf(kind, message, page, consoleLogs);
|
|
136
|
+
const screenshotKeys: string[] = [];
|
|
137
|
+
// Without a bucket the report still matters more than its pictures.
|
|
138
|
+
const imagesDropped = decoded.length > 0 && !env.ARTIFACTS;
|
|
139
|
+
if (decoded.length && env.ARTIFACTS) {
|
|
140
|
+
for (const [index, image] of decoded.entries()) {
|
|
141
|
+
const key = `feedback/${id}/${index}`;
|
|
142
|
+
await env.ARTIFACTS.put(key, image!.bytes, { httpMetadata: { contentType: image!.type } });
|
|
143
|
+
screenshotKeys.push(key);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const data = {
|
|
147
|
+
...(imagesDropped ? { imagesDropped: decoded.length } : {}),
|
|
148
|
+
expectation: kind === "bug" ? redactSecrets(text(payload.expectation)) || undefined : undefined,
|
|
149
|
+
console: consoleLogs,
|
|
150
|
+
network: networkLogs,
|
|
151
|
+
environment: environment ? {
|
|
152
|
+
url: typeof environment.url === "string" ? redactUrl(environment.url) : undefined,
|
|
153
|
+
userAgent: text(environment.userAgent, 300) || undefined,
|
|
154
|
+
platform: text(environment.platform, 50) || undefined,
|
|
155
|
+
language: text(environment.language, 20) || undefined,
|
|
156
|
+
windowWidth: Number(environment.windowWidth) || undefined,
|
|
157
|
+
windowHeight: Number(environment.windowHeight) || undefined,
|
|
158
|
+
} : undefined,
|
|
159
|
+
annotations: Array.isArray(payload.annotations)
|
|
160
|
+
? (payload.annotations as Array<Record<string, unknown>>).slice(0, 20).map((note) => ({ number: Number(note.number) || 0, note: text(note.note, 500) || undefined }))
|
|
161
|
+
: undefined,
|
|
162
|
+
};
|
|
163
|
+
await env.DB.prepare(`
|
|
164
|
+
INSERT INTO feedback_reports (id, aegis_id, workspace_id, project_id, run_id, kind, rating, category, message, page, plan, contact, data_json, screenshot_keys_json, fingerprint, ip)
|
|
165
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
166
|
+
`).bind(
|
|
167
|
+
id, principal.aegisId, workspace?.id ?? null, project?.id ?? null, run?.id ?? null, kind, rating,
|
|
168
|
+
kind === "feedback" ? text(payload.category, 100) || null : null,
|
|
169
|
+
redactSecrets(message), page, plan,
|
|
170
|
+
kind === "feedback" ? redactSecrets(text(payload.contactInfo, 300)) || null : null,
|
|
171
|
+
JSON.stringify(data), JSON.stringify(screenshotKeys), fingerprint, ip,
|
|
172
|
+
).run();
|
|
173
|
+
if (workspace) {
|
|
174
|
+
await recordAudit(env, { type: "user", id: principal.aegisId }, { workspaceId: workspace.id, projectId: project?.id ?? null, action: "feedback.submitted", targetType: "feedback", targetId: id, data: { kind } }, request);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (await flagOn(env, workspace?.id ?? null, "feedback")) {
|
|
178
|
+
const since = new Date(Date.now() - DUPLICATE_WINDOW_MS).toISOString().replace("T", " ").slice(0, 19);
|
|
179
|
+
const original = await env.DB.prepare(`
|
|
180
|
+
SELECT id, github_issue_number AS issue FROM feedback_reports
|
|
181
|
+
WHERE fingerprint = ? AND id != ? AND duplicate_of IS NULL AND created_at > ? AND status != 'closed'
|
|
182
|
+
ORDER BY created_at ASC LIMIT 1
|
|
183
|
+
`).bind(fingerprint, id, since).first<{ id: string; issue: number | null }>();
|
|
184
|
+
if (original) {
|
|
185
|
+
await env.DB.prepare("UPDATE feedback_reports SET duplicate_of = ? WHERE id = ?").bind(original.id, id).run();
|
|
186
|
+
await enqueue(env, "feedback.issue.comment", { reportId: id, originalId: original.id }, { dedupeKey: `feedback-comment:${id}` });
|
|
187
|
+
} else {
|
|
188
|
+
await enqueue(env, "feedback.issue.create", { reportId: id }, { dedupeKey: `feedback-issue:${id}` });
|
|
189
|
+
}
|
|
190
|
+
drainSoon(env, ctx);
|
|
191
|
+
}
|
|
192
|
+
return response({ id }, 201);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function mine({ env, principal }: AuthedRouteContext): Promise<Response> {
|
|
196
|
+
if (!isPerson(principal)) return response({ error: "Not allowed" }, 403);
|
|
197
|
+
const rows = await env.DB.prepare(`
|
|
198
|
+
SELECT r.id, r.kind, r.message, r.created_at AS createdAt,
|
|
199
|
+
COALESCE(o.status, r.status) AS status, COALESCE(o.closed_at, r.closed_at) AS closedAt
|
|
200
|
+
FROM feedback_reports r LEFT JOIN feedback_reports o ON o.id = r.duplicate_of
|
|
201
|
+
WHERE r.aegis_id = ? ORDER BY r.created_at DESC LIMIT 50
|
|
202
|
+
`).bind(principal.aegisId).all();
|
|
203
|
+
return response({ reports: rows.results });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function triage({ env, principal, url }: AuthedRouteContext): Promise<Response> {
|
|
207
|
+
if (!staff(env, principal)) return response({ error: "Not found" }, 404);
|
|
208
|
+
const status = url.searchParams.get("status");
|
|
209
|
+
const kind = url.searchParams.get("kind");
|
|
210
|
+
const rows = await env.DB.prepare(`
|
|
211
|
+
SELECT r.id, r.kind, r.rating, r.category, r.message, r.page, r.plan, r.status, r.duplicate_of AS duplicateOf,
|
|
212
|
+
r.github_issue_url AS issueUrl, r.created_at AS createdAt, w.name AS workspaceName
|
|
213
|
+
FROM feedback_reports r LEFT JOIN workspaces w ON w.id = r.workspace_id
|
|
214
|
+
WHERE (? IS NULL OR r.status = ?) AND (? IS NULL OR r.kind = ?)
|
|
215
|
+
ORDER BY r.created_at DESC LIMIT 200
|
|
216
|
+
`).bind(status, status, kind, kind).all();
|
|
217
|
+
return response({ reports: rows.results });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function issueState({ env, principal, request }: AuthedRouteContext): Promise<Response> {
|
|
221
|
+
if (principal.aegisId !== "bugmole-dashboard-service" || !principal.scopes.includes("*")) return response({ error: "Not allowed" }, 403);
|
|
222
|
+
const body = await requestJson(request);
|
|
223
|
+
const repo = text(body?.repo, 200).toLowerCase();
|
|
224
|
+
const issueNumber = Number(body?.issueNumber);
|
|
225
|
+
const state = body?.state === "closed" ? "closed" : body?.state === "open" ? "open" : null;
|
|
226
|
+
const configured = (feedbackEnv(env).BUGMOLE_FEEDBACK_REPO ?? "").toLowerCase();
|
|
227
|
+
if (!state || !Number.isInteger(issueNumber) || !configured || repo !== configured) return response({ updated: 0 });
|
|
228
|
+
const rows = await env.DB.prepare("SELECT id, aegis_id AS aegisId, status, kind, message, duplicate_of AS duplicateOf FROM feedback_reports WHERE github_issue_number = ?")
|
|
229
|
+
.bind(issueNumber).all<{ id: string; aegisId: string; status: string; kind: string; message: string; duplicateOf: string | null }>();
|
|
230
|
+
for (const report of rows.results) {
|
|
231
|
+
const next = state === "closed" ? "closed" : "open";
|
|
232
|
+
if (report.status === next) continue;
|
|
233
|
+
await env.DB.prepare("UPDATE feedback_reports SET status = ?, closed_at = ? WHERE id = ?")
|
|
234
|
+
.bind(next, next === "closed" ? new Date().toISOString() : null, report.id).run();
|
|
235
|
+
if (next === "closed" && !report.duplicateOf) {
|
|
236
|
+
// Everyone who reported it (the original and its duplicates) hears it was dealt with.
|
|
237
|
+
const people = await env.DB.prepare(`
|
|
238
|
+
SELECT DISTINCT u.email FROM feedback_reports r JOIN aegis_users u ON u.id = r.aegis_id
|
|
239
|
+
WHERE (r.id = ? OR r.duplicate_of = ?) AND u.email IS NOT NULL
|
|
240
|
+
`).bind(report.id, report.id).all<{ email: string }>();
|
|
241
|
+
for (const person of people.results) {
|
|
242
|
+
await enqueue(env, "email.message", {
|
|
243
|
+
to: [{ email: person.email }],
|
|
244
|
+
subject: report.kind === "bug" ? "The bug you reported has been fixed or closed" : "Thanks — we've acted on your feedback",
|
|
245
|
+
text: `You wrote: "${report.message.slice(0, 200)}"\n\nWe've closed it. If the problem is still there, report it again from the dashboard.`,
|
|
246
|
+
html: `<p>You wrote: “${report.message.slice(0, 200).replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`)}”</p><p>We've closed it. If the problem is still there, report it again from the dashboard.</p>`,
|
|
247
|
+
tag: "feedback-closed",
|
|
248
|
+
}, { dedupeKey: `feedback-closed:${report.id}:${person.email}` });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return response({ updated: rows.results.length });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
registerRoutes(async (context) => {
|
|
256
|
+
const { request, url } = context;
|
|
257
|
+
if (url.pathname === "/api/feedback" && request.method === "POST") return submit(context);
|
|
258
|
+
if (url.pathname === "/api/feedback/mine" && request.method === "GET") return mine(context);
|
|
259
|
+
if (url.pathname === "/api/admin/feedback" && request.method === "GET") return triage(context);
|
|
260
|
+
if (url.pathname === "/api/feedback/issue-state" && request.method === "POST") return issueState(context);
|
|
261
|
+
return null;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// GitHub fetches screenshots from the issue body; the signature is the only key.
|
|
265
|
+
registerPublicRoutes(async ({ request, env, url }) => {
|
|
266
|
+
const params = matchPath(url.pathname, "/api/feedback/:reportId/images/:index");
|
|
267
|
+
if (!params || request.method !== "GET") return null;
|
|
268
|
+
const expires = Number(url.searchParams.get("expires"));
|
|
269
|
+
const signature = url.searchParams.get("sig") ?? "";
|
|
270
|
+
const key = `${params.reportId}/${params.index}`;
|
|
271
|
+
if (!Number.isFinite(expires) || expires < Date.now() || !feedbackEnv(env).SECRETS_ENCRYPTION_KEY
|
|
272
|
+
|| !constantTimeEqual(signature, await screenshotSignature(env, key, expires))) {
|
|
273
|
+
return new Response("Not found", { status: 404 });
|
|
274
|
+
}
|
|
275
|
+
const object = await env.ARTIFACTS?.get(`feedback/${key}`);
|
|
276
|
+
if (!object) return new Response("Not found", { status: 404 });
|
|
277
|
+
return new Response(object.body, {
|
|
278
|
+
headers: {
|
|
279
|
+
"content-type": object.httpMetadata?.contentType ?? "image/png",
|
|
280
|
+
"cache-control": "private, max-age=86400",
|
|
281
|
+
"x-content-type-options": "nosniff",
|
|
282
|
+
"content-security-policy": "default-src 'none'",
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// --- Filing issues ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
function target(env: Env): { repo: string; installationId: number } {
|
|
290
|
+
const repo = feedbackEnv(env).BUGMOLE_FEEDBACK_REPO;
|
|
291
|
+
const installationId = Number(feedbackEnv(env).BUGMOLE_FEEDBACK_INSTALLATION_ID);
|
|
292
|
+
if (!repo || !Number.isInteger(installationId) || !githubAppConfigured(feedbackEnv(env))) {
|
|
293
|
+
throw new PermanentDeliveryError("Feedback issues aren't configured (BUGMOLE_FEEDBACK_REPO, BUGMOLE_FEEDBACK_INSTALLATION_ID, GitHub App)");
|
|
294
|
+
}
|
|
295
|
+
return { repo, installationId };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const LABELS = [
|
|
299
|
+
{ name: "feedback", color: "7057ff", description: "Sent from the Bugmole dashboard" },
|
|
300
|
+
{ name: "bug", color: "d73a4a", description: "Something isn't working" },
|
|
301
|
+
];
|
|
302
|
+
const labelsReady = new Set<string>();
|
|
303
|
+
|
|
304
|
+
async function ensureLabels(env: Env, repo: string, installationId: number): Promise<void> {
|
|
305
|
+
if (labelsReady.has(repo)) return;
|
|
306
|
+
for (const label of LABELS) {
|
|
307
|
+
try {
|
|
308
|
+
await githubRequest(feedbackEnv(env), installationId, "GET", `/repos/${repo}/labels/${encodeURIComponent(label.name)}`);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (!(error instanceof GithubAppError) || error.status !== 404) throw error;
|
|
311
|
+
await githubRequest(feedbackEnv(env), installationId, "POST", `/repos/${repo}/labels`, label).catch((reason) => {
|
|
312
|
+
// Someone else created it in the meantime.
|
|
313
|
+
if (!(reason instanceof GithubAppError) || reason.status !== 422) throw reason;
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
labelsReady.add(repo);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function issueReport(env: Env, reportId: string): Promise<IssueReport | null> {
|
|
321
|
+
const row = await env.DB.prepare(`
|
|
322
|
+
SELECT r.*, w.name AS workspace_name FROM feedback_reports r LEFT JOIN workspaces w ON w.id = r.workspace_id WHERE r.id = ?
|
|
323
|
+
`).bind(reportId).first<Record<string, any>>();
|
|
324
|
+
if (!row) return null;
|
|
325
|
+
const data = JSON.parse(row.data_json || "{}");
|
|
326
|
+
const keys = JSON.parse(row.screenshot_keys_json || "[]") as string[];
|
|
327
|
+
const dashboard = (env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/$/, "");
|
|
328
|
+
return {
|
|
329
|
+
id: row.id,
|
|
330
|
+
kind: row.kind,
|
|
331
|
+
message: row.message,
|
|
332
|
+
expectation: data.expectation,
|
|
333
|
+
rating: row.rating ?? undefined,
|
|
334
|
+
category: row.category ?? undefined,
|
|
335
|
+
contact: row.contact ?? undefined,
|
|
336
|
+
page: row.page,
|
|
337
|
+
workspace: row.workspace_id ? { id: row.workspace_id, name: row.workspace_name ?? row.workspace_id, plan: row.plan ?? "free" } : null,
|
|
338
|
+
runUrl: row.run_id ? `${dashboard}/runs?${new URLSearchParams({ project: row.project_id, run: row.run_id })}` : null,
|
|
339
|
+
environment: data.environment ?? null,
|
|
340
|
+
screenshotUrls: await Promise.all(keys.map((_, index) => signedScreenshotUrl(env, row.id, index))),
|
|
341
|
+
console: data.console ?? [],
|
|
342
|
+
network: data.network ?? [],
|
|
343
|
+
fingerprint: row.fingerprint,
|
|
344
|
+
submittedAt: String(row.created_at),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function permanent(error: unknown): never {
|
|
349
|
+
if (error instanceof GithubAppError && [401, 403, 404, 410, 422].includes(error.status)) throw new PermanentDeliveryError(error.message);
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
registerOutboxHandler("feedback.issue.create", async (env, payload: { reportId: string }) => {
|
|
354
|
+
const report = await issueReport(env, payload.reportId);
|
|
355
|
+
const existing = await env.DB.prepare("SELECT github_issue_number AS issue FROM feedback_reports WHERE id = ?").bind(payload.reportId).first<{ issue: number | null }>();
|
|
356
|
+
if (!report || existing?.issue) return;
|
|
357
|
+
const { repo, installationId } = target(env);
|
|
358
|
+
try {
|
|
359
|
+
await ensureLabels(env, repo, installationId);
|
|
360
|
+
const issue = formatIssue(report);
|
|
361
|
+
const created = await githubRequest<{ number: number; html_url: string }>(feedbackEnv(env), installationId, "POST", `/repos/${repo}/issues`, issue);
|
|
362
|
+
await env.DB.prepare("UPDATE feedback_reports SET github_issue_number = ?, github_issue_url = ?, status = 'open' WHERE id = ?")
|
|
363
|
+
.bind(created.number, created.html_url, payload.reportId).run();
|
|
364
|
+
} catch (error) {
|
|
365
|
+
permanent(error);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
registerOutboxHandler("feedback.issue.comment", async (env, payload: { reportId: string; originalId: string }) => {
|
|
370
|
+
const original = await env.DB.prepare("SELECT github_issue_number AS issue FROM feedback_reports WHERE id = ?").bind(payload.originalId).first<{ issue: number | null }>();
|
|
371
|
+
// The original's issue may not exist yet; retry until it does.
|
|
372
|
+
if (!original?.issue) throw new Error("The original report's issue isn't filed yet");
|
|
373
|
+
const report = await issueReport(env, payload.reportId);
|
|
374
|
+
if (!report) return;
|
|
375
|
+
const { repo, installationId } = target(env);
|
|
376
|
+
const { body } = formatIssue(report);
|
|
377
|
+
try {
|
|
378
|
+
await githubRequest(feedbackEnv(env), installationId, "POST", `/repos/${repo}/issues/${original.issue}/comments`, {
|
|
379
|
+
body: `**Reported again** (same problem, report \`${report.id}\`)\n\n${body}`,
|
|
380
|
+
});
|
|
381
|
+
await env.DB.prepare("UPDATE feedback_reports SET github_issue_number = ?, status = 'open' WHERE id = ?").bind(original.issue, payload.reportId).run();
|
|
382
|
+
} catch (error) {
|
|
383
|
+
permanent(error);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Feature flags for the gap-closing rollout. A global var turns a feature on
|
|
2
|
+
// for everyone ("on"), or leaves it to per-workspace opt-in ("beta", the
|
|
3
|
+
// default); "off" disables it even for opted-in workspaces.
|
|
4
|
+
|
|
5
|
+
export const FEATURES = [
|
|
6
|
+
"entitlements",
|
|
7
|
+
"cloud_runners",
|
|
8
|
+
"billing",
|
|
9
|
+
"pr_checks",
|
|
10
|
+
"slack",
|
|
11
|
+
"teams",
|
|
12
|
+
"device_clouds",
|
|
13
|
+
"sso",
|
|
14
|
+
"feedback",
|
|
15
|
+
"mcp",
|
|
16
|
+
] as const;
|
|
17
|
+
export type Feature = (typeof FEATURES)[number];
|
|
18
|
+
|
|
19
|
+
export type FlagEnv = {
|
|
20
|
+
DB: D1Database;
|
|
21
|
+
[key: `FEATURE_${string}`]: string | undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type FlagMode = "on" | "beta" | "off" | "shadow";
|
|
25
|
+
|
|
26
|
+
export function flagMode(env: FlagEnv, feature: Feature): FlagMode {
|
|
27
|
+
const raw = (env[`FEATURE_${feature.toUpperCase()}`] ?? "beta").trim().toLowerCase();
|
|
28
|
+
return raw === "on" || raw === "off" || raw === "shadow" ? raw : "beta";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function flagOn(env: FlagEnv, workspaceId: string | null | undefined, feature: Feature): Promise<boolean> {
|
|
32
|
+
const mode = flagMode(env, feature);
|
|
33
|
+
if (mode === "on") return true;
|
|
34
|
+
if (mode === "off" || mode === "shadow" || !workspaceId) return false;
|
|
35
|
+
const row = await env.DB.prepare(
|
|
36
|
+
"SELECT enabled FROM workspace_flags WHERE workspace_id = ? AND flag = ?",
|
|
37
|
+
).bind(workspaceId, feature).first<{ enabled: number }>();
|
|
38
|
+
return row?.enabled === 1;
|
|
39
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
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, seedPlan, seedProject } from "../testing/harness.js";
|
|
6
|
+
import { drainOutbox } from "../notifications/outbox.js";
|
|
7
|
+
import { parseGithubEvent, previewUrl } from "./checks.js";
|
|
8
|
+
|
|
9
|
+
const SHA = "a".repeat(40);
|
|
10
|
+
const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ type: "pkcs1", format: "pem" }).toString();
|
|
11
|
+
|
|
12
|
+
function fakeGithub() {
|
|
13
|
+
const state = { checks: new Map<number, any>(), calls: [] as Array<{ method: string; path: string; body: any }>, next: 100, failWith: 0 };
|
|
14
|
+
const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
15
|
+
const url = new URL(String(input));
|
|
16
|
+
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
|
17
|
+
state.calls.push({ method: init?.method ?? "GET", path: url.pathname, body });
|
|
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.failWith) return new Response("nope", { status: state.failWith });
|
|
20
|
+
if (init?.method === "POST" && url.pathname.endsWith("/check-runs")) {
|
|
21
|
+
const id = state.next++;
|
|
22
|
+
state.checks.set(id, { ...body });
|
|
23
|
+
return Response.json({ id, html_url: `https://github.com/check/${id}` }, { status: 201 });
|
|
24
|
+
}
|
|
25
|
+
const match = url.pathname.match(/\/check-runs\/(\d+)$/);
|
|
26
|
+
if (init?.method === "PATCH" && match) {
|
|
27
|
+
Object.assign(state.checks.get(Number(match[1])), body);
|
|
28
|
+
return Response.json({ id: Number(match[1]) });
|
|
29
|
+
}
|
|
30
|
+
return new Response("not found", { status: 404 });
|
|
31
|
+
}) as unknown as typeof fetch;
|
|
32
|
+
return { state, doFetch };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function setup(gh: ReturnType<typeof fakeGithub>, plan = "team") {
|
|
36
|
+
const created = createTestEnv({
|
|
37
|
+
FEATURE_PR_CHECKS: "on",
|
|
38
|
+
GITHUB_APP_ID: "1",
|
|
39
|
+
GITHUB_APP_PRIVATE_KEY: privateKey,
|
|
40
|
+
GITHUB_FETCH: gh.doFetch,
|
|
41
|
+
DASHBOARD_URL: "https://dash.test",
|
|
42
|
+
} as Partial<Env>);
|
|
43
|
+
const project = await seedProject(created.env);
|
|
44
|
+
created.db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, ?)").run(project.workspaceId, plan);
|
|
45
|
+
await seedPlan(created.env, project.projectId, project.owner, "checkout");
|
|
46
|
+
await seedPlan(created.env, project.projectId, project.owner, "signup");
|
|
47
|
+
await call(created.env, "POST", `/api/projects/${project.projectId}/github-installations`, {
|
|
48
|
+
as: project.owner,
|
|
49
|
+
body: { installationId: 42, accountLogin: "acme", accountType: "Organization", repositorySelection: "selected", repositories: [{ owner: "acme", repo: "shop" }] },
|
|
50
|
+
});
|
|
51
|
+
const event = (body: Record<string, unknown>) => call(created.env, "POST", "/api/github/events", {
|
|
52
|
+
headers: { "x-aegis-id": "bugmole-dashboard-service", "x-aegis-secret": "test-service-secret" },
|
|
53
|
+
body,
|
|
54
|
+
});
|
|
55
|
+
return { ...created, ...project, event };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("preview URLs and forwarded events are checked before use", () => {
|
|
59
|
+
assert.equal(previewUrl("https://pr-{number}.preview.acme.test", { number: 12, sha: SHA }), "https://pr-12.preview.acme.test/");
|
|
60
|
+
assert.equal(previewUrl("https://{branch}--shop.pages.dev/", { sha: SHA, branch: "Feature/Checkout Flow" }), "https://feature-checkout-flow--shop.pages.dev/");
|
|
61
|
+
assert.equal(previewUrl("https://x.test/{short_sha}", { sha: SHA }), "https://x.test/aaaaaaa");
|
|
62
|
+
assert.equal(previewUrl("https://pr-{number}.test", { sha: SHA }), null);
|
|
63
|
+
assert.equal(previewUrl("javascript:{sha}", { sha: SHA }), null);
|
|
64
|
+
|
|
65
|
+
assert.deepEqual(parseGithubEvent({ type: "pull_request", installationId: 42, repo: "acme/shop", sha: SHA.toUpperCase(), prNumber: 7, branch: "main" }),
|
|
66
|
+
{ type: "pull_request", installationId: 42, repo: "acme/shop", sha: SHA, prNumber: 7, branch: "main" });
|
|
67
|
+
assert.equal(parseGithubEvent({ type: "pull_request", installationId: 42, repo: "acme/shop", sha: "short", prNumber: 7 }), null);
|
|
68
|
+
assert.equal(parseGithubEvent({ type: "pull_request", installationId: 42, repo: "../etc", sha: SHA, prNumber: 7 }), null);
|
|
69
|
+
assert.equal(parseGithubEvent({ type: "deployment", installationId: 42, repo: "acme/shop", sha: SHA, url: "file:///etc/passwd" }), null);
|
|
70
|
+
assert.equal(parseGithubEvent({ type: "push", installationId: 42, repo: "acme/shop", sha: SHA }), null);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("only the dashboard's service identity can forward GitHub events", async () => {
|
|
74
|
+
const gh = fakeGithub();
|
|
75
|
+
const { env, owner } = await setup(gh);
|
|
76
|
+
const body = { type: "pull_request", installationId: 42, repo: "acme/shop", sha: SHA, prNumber: 7 };
|
|
77
|
+
assert.equal((await call(env, "POST", "/api/github/events", { as: owner, body })).status, 403);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a pull request runs the chosen plans against its preview and reports each as a check", async () => {
|
|
81
|
+
const gh = fakeGithub();
|
|
82
|
+
const { env, db, projectId, owner, event } = await setup(gh);
|
|
83
|
+
const bind = await call(env, "POST", `/api/projects/${projectId}/repo-bindings`, {
|
|
84
|
+
as: owner,
|
|
85
|
+
body: { repo: "acme/shop", installationId: 42, planIds: ["checkout"], baseUrlTemplate: "https://pr-{number}.preview.acme.test", executionMode: "self" },
|
|
86
|
+
});
|
|
87
|
+
assert.equal(bind.status, 201, JSON.stringify(bind.json));
|
|
88
|
+
|
|
89
|
+
const started = await event({ type: "pull_request", installationId: 42, repo: "acme/shop", sha: SHA, prNumber: 7, branch: "feature" });
|
|
90
|
+
assert.equal(started.json.started.length, 1, "only the chosen plan");
|
|
91
|
+
const again = await event({ type: "pull_request", installationId: 42, repo: "acme/shop", sha: SHA, prNumber: 7 });
|
|
92
|
+
assert.deepEqual(again.json.started, started.json.started, "a redelivered webhook doesn't start a second run");
|
|
93
|
+
const runId = started.json.started[0];
|
|
94
|
+
const run = db.db.prepare("SELECT environment_base_url AS url, commit_sha AS sha, pr_number AS pr, execution_mode AS mode FROM runs WHERE id = ?").get(runId) as any;
|
|
95
|
+
assert.deepEqual({ ...run }, { url: "https://pr-7.preview.acme.test/", sha: SHA, pr: 7, mode: "self" });
|
|
96
|
+
|
|
97
|
+
await drainOutbox(env);
|
|
98
|
+
const [checkId] = [...gh.state.checks.keys()];
|
|
99
|
+
assert.equal(gh.state.checks.size, 1);
|
|
100
|
+
assert.deepEqual({ name: gh.state.checks.get(checkId).name, sha: gh.state.checks.get(checkId).head_sha, status: gh.state.checks.get(checkId).status },
|
|
101
|
+
{ name: "Bugmole: checkout", sha: SHA, status: "queued" });
|
|
102
|
+
assert.match(gh.state.checks.get(checkId).details_url, /dash\.test\/runs\?project=shop&run=/);
|
|
103
|
+
const create = gh.state.calls.find((c) => c.path.endsWith("/check-runs"))!;
|
|
104
|
+
assert.equal(create.path, "/repos/acme/shop/check-runs");
|
|
105
|
+
|
|
106
|
+
await call(env, "POST", `/api/runs/${runId}/claim`, { as: owner, body: { workerId: "w1" } });
|
|
107
|
+
await drainOutbox(env);
|
|
108
|
+
assert.equal(gh.state.checks.get(checkId).status, "in_progress");
|
|
109
|
+
await call(env, "POST", `/api/runs/${runId}`, { as: owner, body: { workerId: "w1", status: "error", stepsPassed: 2, stepsFailed: 1, errorMessage: "Payment never appeared" } });
|
|
110
|
+
await drainOutbox(env);
|
|
111
|
+
const done = gh.state.checks.get(checkId);
|
|
112
|
+
assert.equal(done.status, "completed");
|
|
113
|
+
assert.equal(done.conclusion, "failure");
|
|
114
|
+
assert.match(done.output.summary, /2 steps passed, 1 failed on https:\/\/pr-7\.preview\.acme\.test\//);
|
|
115
|
+
assert.match(done.output.summary, /> Payment never appeared/);
|
|
116
|
+
|
|
117
|
+
// "Re-run" on GitHub starts a fresh run against the same commit and preview.
|
|
118
|
+
const rerun = await event({ type: "check_rerun", installationId: 42, repo: "acme/shop", checkRunId: checkId });
|
|
119
|
+
assert.equal(rerun.json.started.length, 1);
|
|
120
|
+
assert.notEqual(rerun.json.started[0], runId);
|
|
121
|
+
const second = db.db.prepare("SELECT environment_base_url AS url, commit_sha AS sha FROM runs WHERE id = ?").get(rerun.json.started[0]) as any;
|
|
122
|
+
assert.deepEqual({ ...second }, { url: "https://pr-7.preview.acme.test/", sha: SHA });
|
|
123
|
+
await drainOutbox(env);
|
|
124
|
+
assert.equal(gh.state.checks.size, 2);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a successful deployment runs every plan against the deployed URL", async () => {
|
|
128
|
+
const gh = fakeGithub();
|
|
129
|
+
const { env, db, projectId, owner, event } = await setup(gh);
|
|
130
|
+
await call(env, "POST", `/api/projects/${projectId}/repo-bindings`, { as: owner, body: { repo: "acme/shop", installationId: 42, trigger: "deployment", executionMode: "self" } });
|
|
131
|
+
assert.equal((await event({ type: "pull_request", installationId: 42, repo: "acme/shop", sha: SHA, prNumber: 3 })).json.started.length, 0, "not bound to pull requests");
|
|
132
|
+
const started = await event({ type: "deployment", installationId: 42, repo: "acme/shop", sha: SHA, url: "https://shop-git-abc.vercel.app" });
|
|
133
|
+
assert.equal(started.json.started.length, 2);
|
|
134
|
+
const urls = db.db.prepare("SELECT DISTINCT environment_base_url AS url FROM runs").all() as any[];
|
|
135
|
+
assert.deepEqual(urls.map((row) => row.url), ["https://shop-git-abc.vercel.app"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("checks need a paid plan, a connected repository, and managers to set them up", async () => {
|
|
139
|
+
const gh = fakeGithub();
|
|
140
|
+
const free = await setup(gh, "free");
|
|
141
|
+
const refused = await call(free.env, "POST", `/api/projects/${free.projectId}/repo-bindings`, { as: free.owner, body: { repo: "acme/shop", installationId: 42 } });
|
|
142
|
+
assert.equal(refused.status, 402);
|
|
143
|
+
|
|
144
|
+
const { env, projectId, owner, event, db } = await setup(gh);
|
|
145
|
+
const add = (body: Record<string, unknown>, as = owner) => call(env, "POST", `/api/projects/${projectId}/repo-bindings`, { as, body });
|
|
146
|
+
assert.equal((await add({ repo: "acme/other", installationId: 42 })).status, 422, "repository not connected");
|
|
147
|
+
assert.equal((await add({ repo: "acme/shop", installationId: 99 })).status, 422, "someone else's installation");
|
|
148
|
+
assert.equal((await add({ repo: "acme/shop", installationId: 42, baseUrlTemplate: "ftp://{number}" })).status, 422);
|
|
149
|
+
assert.equal((await add({ repo: "acme/shop", installationId: 42 }, "user_stranger")).status, 403);
|
|
150
|
+
const ok = await add({ repo: "acme/shop", installationId: 42, executionMode: "self" });
|
|
151
|
+
assert.equal(ok.status, 201);
|
|
152
|
+
assert.equal((await add({ repo: "acme/shop", installationId: 42 })).status, 409);
|
|
153
|
+
|
|
154
|
+
// Downgraded to Free: events are ignored rather than starting runs nobody paid for.
|
|
155
|
+
db.db.prepare("UPDATE workspace_plans SET plan = 'free'").run();
|
|
156
|
+
assert.equal((await event({ type: "pull_request", installationId: 42, repo: "acme/shop", sha: SHA, prNumber: 1 })).json.started.length, 0);
|
|
157
|
+
db.db.prepare("UPDATE workspace_plans SET plan = 'team'").run();
|
|
158
|
+
// The installation is removed from GitHub: nothing starts.
|
|
159
|
+
db.db.prepare("UPDATE github_installations SET revoked_at = CURRENT_TIMESTAMP").run();
|
|
160
|
+
assert.equal((await event({ type: "pull_request", installationId: 42, repo: "acme/shop", sha: SHA, prNumber: 1 })).json.started.length, 0);
|
|
161
|
+
|
|
162
|
+
const list = await call(env, "GET", `/api/projects/${projectId}/repo-bindings`, { as: owner });
|
|
163
|
+
assert.equal(list.json.bindings.length, 1);
|
|
164
|
+
assert.equal(list.json.configured, true);
|
|
165
|
+
assert.equal((await call(env, "DELETE", `/api/projects/${projectId}/repo-bindings/${ok.json.binding.id}`, { as: owner })).status, 200);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a check GitHub won't accept is dropped, not retried forever", async () => {
|
|
169
|
+
const gh = fakeGithub();
|
|
170
|
+
const { env, db, projectId, owner, event } = await setup(gh);
|
|
171
|
+
await call(env, "POST", `/api/projects/${projectId}/repo-bindings`, { as: owner, body: { repo: "acme/shop", installationId: 42, executionMode: "self" } });
|
|
172
|
+
gh.state.failWith = 404;
|
|
173
|
+
await event({ type: "pull_request", installationId: 42, repo: "acme/shop", sha: SHA, prNumber: 9 });
|
|
174
|
+
await drainOutbox(env);
|
|
175
|
+
const dead = db.db.prepare("SELECT COUNT(*) AS c FROM outbox WHERE kind = 'github.check.create' AND status = 'dead'").get() as any;
|
|
176
|
+
assert.equal(dead.c, 2);
|
|
177
|
+
});
|