@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,374 @@
|
|
|
1
|
+
// GitHub PR checks: a pull request (or a successful deployment) starts the
|
|
2
|
+
// project's plans, and each run reports back as a check run on the commit.
|
|
3
|
+
//
|
|
4
|
+
// The dashboard receives GitHub's webhooks (it holds the webhook secret) and
|
|
5
|
+
// forwards the few fields that matter here. Check runs are created and
|
|
6
|
+
// updated from the outbox, so GitHub being slow never slows a run down.
|
|
7
|
+
import { createCheckRun, githubAppConfigured, GithubAppError, updateCheckRun, type CheckRunUpdate, type GithubAppEnv } from "../../integrations/github-app.js";
|
|
8
|
+
import type { Env, Principal } from "../core.js";
|
|
9
|
+
import { memberOfProject, newId, requestJson, response } from "../core.js";
|
|
10
|
+
import { getEntitlements, workspaceOfProject } from "../billing/entitlements.js";
|
|
11
|
+
import { flagOn } from "../flags.js";
|
|
12
|
+
import { runChangedHooks, runService, type InternalRunFields } from "../hooks.js";
|
|
13
|
+
import { auditProject } from "../org/audit.js";
|
|
14
|
+
import { enqueue, PermanentDeliveryError, registerOutboxHandler } from "../notifications/outbox.js";
|
|
15
|
+
import { detachedContext, matchPath, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
|
|
16
|
+
import { previewUrl } from "../repo-triggers.js";
|
|
17
|
+
import { digging, molesFound, NO_BUGS } from "../../runtime/mole-voice.js";
|
|
18
|
+
|
|
19
|
+
export const DASHBOARD_SERVICE_ID = "bugmole-dashboard-service";
|
|
20
|
+
const TRIGGERS = new Set(["pull_request", "deployment"]);
|
|
21
|
+
const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
22
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
23
|
+
|
|
24
|
+
export type GithubEvent =
|
|
25
|
+
| { type: "pull_request"; installationId: number; repo: string; sha: string; prNumber: number; branch?: string }
|
|
26
|
+
| { type: "deployment"; installationId: number; repo: string; sha: string; url: string; environment?: string; prNumber?: number }
|
|
27
|
+
| { type: "check_rerun"; installationId: number; repo: string; checkRunId: number };
|
|
28
|
+
|
|
29
|
+
type Binding = {
|
|
30
|
+
id: string;
|
|
31
|
+
project_id: string;
|
|
32
|
+
installation_id: number;
|
|
33
|
+
repo_full_name: string;
|
|
34
|
+
plan_ids_json: string | null;
|
|
35
|
+
environment_id: string | null;
|
|
36
|
+
trigger: "pull_request" | "deployment";
|
|
37
|
+
base_url_template: string | null;
|
|
38
|
+
execution_mode: "self" | "cloud";
|
|
39
|
+
created_by: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function github(env: Env): GithubAppEnv {
|
|
43
|
+
return env as Env & GithubAppEnv;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function dashboard(env: Env): string {
|
|
47
|
+
return (env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/$/, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { previewUrl };
|
|
51
|
+
|
|
52
|
+
/** GitLab's merge request checks share this gate — one PR/MR checks feature regardless of provider. */
|
|
53
|
+
export async function prChecksOn(env: Env, projectId: string): Promise<boolean> {
|
|
54
|
+
const workspaceId = await workspaceOfProject(env, projectId);
|
|
55
|
+
if (!workspaceId || !(await flagOn(env, workspaceId, "pr_checks"))) return false;
|
|
56
|
+
return (await getEntitlements(env, workspaceId)).features.prChecks;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The binding's installation must still be connected to its project and cover the repo. */
|
|
60
|
+
async function installationCovers(env: Env, projectId: string, installationId: number, repo: string): Promise<boolean> {
|
|
61
|
+
const row = await env.DB.prepare(`
|
|
62
|
+
SELECT repository_selection AS selection, repositories_json AS repos FROM github_installations
|
|
63
|
+
WHERE project_id = ? AND installation_id = ? AND revoked_at IS NULL
|
|
64
|
+
`).bind(projectId, installationId).first<{ selection: string; repos: string }>();
|
|
65
|
+
if (!row) return false;
|
|
66
|
+
if (row.selection === "all") return true;
|
|
67
|
+
const [owner, name] = repo.toLowerCase().split("/");
|
|
68
|
+
const repos = JSON.parse(row.repos || "[]") as Array<{ owner: string; repo: string }>;
|
|
69
|
+
return repos.some((item) => item.owner.toLowerCase() === owner && item.repo.toLowerCase() === name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function publishedPlans(env: Env, projectId: string, only: string[] | null): Promise<string[]> {
|
|
73
|
+
const rows = await env.DB.prepare("SELECT plan_id AS planId FROM plan_drafts WHERE project_id = ? ORDER BY plan_id").bind(projectId).all<{ planId: string }>();
|
|
74
|
+
const all = rows.results.map((row) => row.planId);
|
|
75
|
+
return only ? all.filter((planId) => only.includes(planId)) : all;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function startRun(env: Env, binding: Binding, planId: string, idempotencyKey: string, internal: InternalRunFields): Promise<string | null> {
|
|
79
|
+
if (!runService.create) throw new Error("Run creation isn't wired");
|
|
80
|
+
const principal: Principal = { aegisId: binding.created_by, projectIds: [], scopes: ["*"] };
|
|
81
|
+
const request = new Request("https://registry.internal/github", {
|
|
82
|
+
method: "POST",
|
|
83
|
+
body: JSON.stringify({ planId, idempotencyKey, executionMode: binding.execution_mode, ...(binding.environment_id ? { environmentId: binding.environment_id } : {}) }),
|
|
84
|
+
});
|
|
85
|
+
const created = await runService.create(request, env, principal, binding.project_id, detachedContext(), internal);
|
|
86
|
+
const body = await created.json().catch(() => ({})) as { run?: { id: string }; error?: string };
|
|
87
|
+
if (!created.ok || !body.run) {
|
|
88
|
+
console.warn(`[github] couldn't start ${planId} for ${binding.repo_full_name}: ${body.error ?? created.status}`);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
// A new run (201) gets a check; a repeat delivery (200) already has one.
|
|
92
|
+
if (created.status === 201) await enqueue(env, "github.check.create", { runId: body.run.id }, { dedupeKey: `gh-check-create:${body.run.id}` });
|
|
93
|
+
return body.run.id;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function handleGithubEvent(env: Env, event: GithubEvent): Promise<{ started: string[] }> {
|
|
97
|
+
const started: string[] = [];
|
|
98
|
+
if (event.type === "check_rerun") {
|
|
99
|
+
const run = await env.DB.prepare(`
|
|
100
|
+
SELECT r.plan_id AS planId, r.project_id AS projectId, r.commit_sha AS sha, r.pr_number AS prNumber,
|
|
101
|
+
r.environment_base_url AS baseUrl, r.trigger_source AS trigger, r.repo_full_name AS repo
|
|
102
|
+
FROM runs r WHERE r.check_run_id = ? AND r.github_installation_id = ?
|
|
103
|
+
`).bind(event.checkRunId, event.installationId).first<{ planId: string; projectId: string; sha: string; prNumber: number | null; baseUrl: string | null; trigger: string; repo: string }>();
|
|
104
|
+
if (!run || run.repo.toLowerCase() !== event.repo.toLowerCase()) return { started };
|
|
105
|
+
const binding = await env.DB.prepare("SELECT * FROM project_repo_bindings WHERE project_id = ? AND lower(repo_full_name) = lower(?) AND trigger = ? AND enabled = 1")
|
|
106
|
+
.bind(run.projectId, run.repo, run.trigger).first<Binding>();
|
|
107
|
+
if (!binding || !(await prChecksOn(env, binding.project_id))) return { started };
|
|
108
|
+
const id = await startRun(env, binding, run.planId, `gh:rerun:${event.checkRunId}:${newId("r")}`, {
|
|
109
|
+
triggerSource: run.trigger, repoFullName: binding.repo_full_name, commitSha: run.sha,
|
|
110
|
+
prNumber: run.prNumber ?? undefined, githubInstallationId: event.installationId, environmentBaseUrl: run.baseUrl ?? undefined,
|
|
111
|
+
});
|
|
112
|
+
if (id) started.push(id);
|
|
113
|
+
return { started };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const bindings = await env.DB.prepare(`
|
|
117
|
+
SELECT * FROM project_repo_bindings WHERE lower(repo_full_name) = lower(?) AND installation_id = ? AND trigger = ? AND enabled = 1
|
|
118
|
+
`).bind(event.repo, event.installationId, event.type).all<Binding>();
|
|
119
|
+
for (const binding of bindings.results) {
|
|
120
|
+
if (!(await prChecksOn(env, binding.project_id))) continue;
|
|
121
|
+
if (!(await installationCovers(env, binding.project_id, binding.installation_id, binding.repo_full_name))) continue;
|
|
122
|
+
let baseUrl: string | undefined;
|
|
123
|
+
if (event.type === "deployment") {
|
|
124
|
+
baseUrl = binding.base_url_template ? previewUrl(binding.base_url_template, { sha: event.sha, number: event.prNumber }) ?? event.url : event.url;
|
|
125
|
+
} else if (binding.base_url_template) {
|
|
126
|
+
const url = previewUrl(binding.base_url_template, { number: event.prNumber, sha: event.sha, branch: event.branch });
|
|
127
|
+
if (!url) continue;
|
|
128
|
+
baseUrl = url;
|
|
129
|
+
}
|
|
130
|
+
const plans = await publishedPlans(env, binding.project_id, binding.plan_ids_json ? JSON.parse(binding.plan_ids_json) as string[] : null);
|
|
131
|
+
for (const planId of plans) {
|
|
132
|
+
const id = await startRun(env, binding, planId, `gh:${binding.id}:${event.sha}:${planId}`, {
|
|
133
|
+
triggerSource: binding.trigger,
|
|
134
|
+
repoFullName: binding.repo_full_name,
|
|
135
|
+
commitSha: event.sha,
|
|
136
|
+
prNumber: event.prNumber,
|
|
137
|
+
githubInstallationId: binding.installation_id,
|
|
138
|
+
environmentBaseUrl: baseUrl,
|
|
139
|
+
});
|
|
140
|
+
if (id) started.push(id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { started };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Check runs ------------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
type RunForCheck = {
|
|
149
|
+
id: string;
|
|
150
|
+
projectId: string;
|
|
151
|
+
planId: string;
|
|
152
|
+
status: string;
|
|
153
|
+
repo: string | null;
|
|
154
|
+
sha: string | null;
|
|
155
|
+
installationId: number | null;
|
|
156
|
+
checkRunId: number | null;
|
|
157
|
+
passed: number | null;
|
|
158
|
+
failed: number | null;
|
|
159
|
+
error: string | null;
|
|
160
|
+
baseUrl: string | null;
|
|
161
|
+
startedAt: string | null;
|
|
162
|
+
completedAt: string | null;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
async function runForCheck(env: Env, runId: string): Promise<RunForCheck | null> {
|
|
166
|
+
return env.DB.prepare(`
|
|
167
|
+
SELECT id, project_id AS projectId, plan_id AS planId, status, repo_full_name AS repo, commit_sha AS sha,
|
|
168
|
+
github_installation_id AS installationId, check_run_id AS checkRunId, steps_passed AS passed,
|
|
169
|
+
steps_failed AS failed, error_message AS error, environment_base_url AS baseUrl,
|
|
170
|
+
started_at AS startedAt, completed_at AS completedAt
|
|
171
|
+
FROM runs WHERE id = ?
|
|
172
|
+
`).bind(runId).first<RunForCheck>();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function iso(value: string | null): string | undefined {
|
|
176
|
+
if (!value) return undefined;
|
|
177
|
+
return /[zZ]$/.test(value) ? value : `${value.replace(" ", "T")}Z`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function completedTitle(title: string, conclusion: "success" | "cancelled" | "failure", failed: number): string {
|
|
181
|
+
if (conclusion === "cancelled") return `${title} was cancelled`;
|
|
182
|
+
// A failure with no failed step (the run couldn't start) found no bugs.
|
|
183
|
+
if (conclusion === "failure" && failed === 0) return `${title} failed`;
|
|
184
|
+
return conclusion === "success" ? `${title} passed: ${NO_BUGS.toLowerCase()}` : `${title}: ${molesFound(failed).replace(/^Our/, "our")}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function checkUpdateFor(run: RunForCheck, detailsUrl: string): CheckRunUpdate {
|
|
188
|
+
const title = `Bugmole: ${run.planId}`;
|
|
189
|
+
if (run.status === "queued") return { status: "queued", details_url: detailsUrl, output: { title, summary: "Waiting for a runner." } };
|
|
190
|
+
if (run.status === "running") {
|
|
191
|
+
return { status: "in_progress", started_at: iso(run.startedAt), details_url: detailsUrl, output: { title, summary: digging(run.baseUrl) } };
|
|
192
|
+
}
|
|
193
|
+
const conclusion = run.status === "success" ? "success" : run.status === "cancelled" ? "cancelled" : "failure";
|
|
194
|
+
const summary = [
|
|
195
|
+
`${run.passed ?? 0} step${run.passed === 1 ? "" : "s"} passed, ${run.failed ?? 0} failed${run.baseUrl ? ` on ${run.baseUrl}` : ""}.`,
|
|
196
|
+
run.error ? `\n\n> ${run.error.slice(0, 500).replaceAll("\n", "\n> ")}` : "",
|
|
197
|
+
`\n\n[Open the run in Bugmole](${detailsUrl})`,
|
|
198
|
+
].join("");
|
|
199
|
+
return {
|
|
200
|
+
status: "completed",
|
|
201
|
+
conclusion,
|
|
202
|
+
completed_at: iso(run.completedAt) ?? new Date().toISOString(),
|
|
203
|
+
details_url: detailsUrl,
|
|
204
|
+
output: { title: completedTitle(title, conclusion, run.failed ?? 0), summary },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function detailsUrl(env: Env, run: RunForCheck): string {
|
|
209
|
+
return `${dashboard(env)}/runs?${new URLSearchParams({ project: run.projectId, run: run.id })}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function deliver(work: () => Promise<unknown>): Promise<void> {
|
|
213
|
+
try {
|
|
214
|
+
await work();
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// A removed installation or repo (403/404/422) won't start working on a retry.
|
|
217
|
+
if (error instanceof GithubAppError && [401, 403, 404, 422].includes(error.status)) throw new PermanentDeliveryError(error.message);
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
registerOutboxHandler("github.check.create", async (env, payload: { runId: string }) => {
|
|
223
|
+
const run = await runForCheck(env, payload.runId);
|
|
224
|
+
if (!run?.repo || !run.sha || !run.installationId || run.checkRunId) return;
|
|
225
|
+
if (!githubAppConfigured(github(env))) throw new PermanentDeliveryError("The GitHub App isn't configured on the registry");
|
|
226
|
+
const url = detailsUrl(env, run);
|
|
227
|
+
await deliver(async () => {
|
|
228
|
+
const created = await createCheckRun(github(env), run.installationId!, run.repo!, { name: `Bugmole: ${run.planId}`, head_sha: run.sha!, external_id: run.id, ...checkUpdateFor(run, url) });
|
|
229
|
+
await env.DB.prepare("UPDATE runs SET check_run_id = ? WHERE id = ?").bind(created.id, run.id).run();
|
|
230
|
+
});
|
|
231
|
+
// The run may have moved on while the check was being created.
|
|
232
|
+
if (run.status !== "queued") await enqueue(env, "github.check.update", { runId: run.id });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
registerOutboxHandler("github.check.update", async (env, payload: { runId: string }) => {
|
|
236
|
+
const run = await runForCheck(env, payload.runId);
|
|
237
|
+
if (!run?.repo || !run.installationId) return;
|
|
238
|
+
// Created from the other handler, which queues this again once it has an id.
|
|
239
|
+
if (!run.checkRunId) return;
|
|
240
|
+
await deliver(() => updateCheckRun(github(env), run.installationId!, run.repo!, run.checkRunId!, checkUpdateFor(run, detailsUrl(env, run))));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
runChangedHooks.push(async (env, event) => {
|
|
244
|
+
const run = await env.DB.prepare("SELECT repo_full_name AS repo FROM runs WHERE id = ?").bind(event.runId).first<{ repo: string | null }>();
|
|
245
|
+
if (!run?.repo) return;
|
|
246
|
+
// One update per state; the handler always sends the run's latest state.
|
|
247
|
+
await enqueue(env, "github.check.update", { runId: event.runId }, { dedupeKey: `gh-check:${event.runId}:${event.status}` });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// --- Routes ----------------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
function isDashboardService(principal: Principal): boolean {
|
|
253
|
+
return principal.aegisId === DASHBOARD_SERVICE_ID && principal.scopes.includes("*") && !principal.apiKeyId && !principal.runner;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function validRepo(repo: string): boolean {
|
|
257
|
+
return REPO.test(repo) && !repo.split("/").some((part) => part === "." || part === "..");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function parseGithubEvent(body: Record<string, unknown> | null): GithubEvent | null {
|
|
261
|
+
if (!body) return null;
|
|
262
|
+
const installationId = Number(body.installationId);
|
|
263
|
+
const repo = typeof body.repo === "string" ? body.repo : "";
|
|
264
|
+
if (!Number.isInteger(installationId) || installationId <= 0 || !validRepo(repo)) return null;
|
|
265
|
+
if (body.type === "check_rerun") {
|
|
266
|
+
const checkRunId = Number(body.checkRunId);
|
|
267
|
+
return Number.isInteger(checkRunId) && checkRunId > 0 ? { type: "check_rerun", installationId, repo, checkRunId } : null;
|
|
268
|
+
}
|
|
269
|
+
const sha = typeof body.sha === "string" ? body.sha.toLowerCase() : "";
|
|
270
|
+
if (!SHA.test(sha)) return null;
|
|
271
|
+
const prNumber = body.prNumber === undefined || body.prNumber === null ? undefined : Number(body.prNumber);
|
|
272
|
+
if (prNumber !== undefined && (!Number.isInteger(prNumber) || prNumber <= 0)) return null;
|
|
273
|
+
if (body.type === "pull_request") {
|
|
274
|
+
if (prNumber === undefined) return null;
|
|
275
|
+
return { type: "pull_request", installationId, repo, sha, prNumber, ...(typeof body.branch === "string" ? { branch: body.branch } : {}) };
|
|
276
|
+
}
|
|
277
|
+
if (body.type === "deployment") {
|
|
278
|
+
const url = typeof body.url === "string" ? body.url : "";
|
|
279
|
+
try {
|
|
280
|
+
const parsed = new URL(url);
|
|
281
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
return { type: "deployment", installationId, repo, sha, url, ...(prNumber ? { prNumber } : {}), ...(typeof body.environment === "string" ? { environment: body.environment } : {}) };
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function listBindings({ env, principal }: AuthedRouteContext, projectId: string): Promise<Response> {
|
|
291
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
292
|
+
const rows = await env.DB.prepare(`
|
|
293
|
+
SELECT id, installation_id AS installationId, repo_full_name AS repo, plan_ids_json AS planIds, environment_id AS environmentId,
|
|
294
|
+
trigger, base_url_template AS baseUrlTemplate, execution_mode AS executionMode, enabled, created_at AS createdAt
|
|
295
|
+
FROM project_repo_bindings WHERE project_id = ? ORDER BY created_at ASC
|
|
296
|
+
`).bind(projectId).all<Record<string, unknown> & { planIds: string | null; enabled: number }>();
|
|
297
|
+
return response({
|
|
298
|
+
available: await prChecksOn(env, projectId),
|
|
299
|
+
configured: githubAppConfigured(github(env)),
|
|
300
|
+
bindings: rows.results.map((row) => ({ ...row, planIds: row.planIds ? JSON.parse(row.planIds) : null, enabled: Boolean(row.enabled) })),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function saveBinding({ env, principal, request }: AuthedRouteContext, projectId: string, bindingId?: string): Promise<Response> {
|
|
305
|
+
if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
306
|
+
if (request.method === "DELETE") {
|
|
307
|
+
const deleted = await env.DB.prepare("DELETE FROM project_repo_bindings WHERE id = ? AND project_id = ?").bind(bindingId, projectId).run();
|
|
308
|
+
if ((deleted.meta?.changes ?? 0) === 0) return response({ error: "Not found" }, 404);
|
|
309
|
+
await auditProject(env, principal, projectId, { action: "github.checks_removed", targetType: "repo_binding", targetId: bindingId }, request);
|
|
310
|
+
return response({ deleted: true });
|
|
311
|
+
}
|
|
312
|
+
if (!(await prChecksOn(env, projectId))) {
|
|
313
|
+
return response({ error: "Pull request checks are included in Team and Business.", code: "plan_feature", feature: "prChecks", upgradeUrl: "/billing" }, 402);
|
|
314
|
+
}
|
|
315
|
+
const body = await requestJson(request);
|
|
316
|
+
const repo = typeof body?.repo === "string" ? body.repo.trim() : "";
|
|
317
|
+
const installationId = Number(body?.installationId);
|
|
318
|
+
const trigger = body?.trigger ?? "pull_request";
|
|
319
|
+
const planIds = body?.planIds === null || body?.planIds === undefined ? null : body.planIds;
|
|
320
|
+
const template = typeof body?.baseUrlTemplate === "string" && body.baseUrlTemplate.trim() ? body.baseUrlTemplate.trim() : null;
|
|
321
|
+
const executionMode = body?.executionMode ?? "cloud";
|
|
322
|
+
const environmentId = typeof body?.environmentId === "string" && body.environmentId ? body.environmentId : null;
|
|
323
|
+
const enabled = body?.enabled === undefined ? true : body.enabled === true;
|
|
324
|
+
if (!validRepo(repo) || !Number.isInteger(installationId)) return response({ error: "repo (owner/name) and installationId are required" }, 422);
|
|
325
|
+
if (typeof trigger !== "string" || !TRIGGERS.has(trigger)) return response({ error: "trigger must be pull_request or deployment" }, 422);
|
|
326
|
+
if (executionMode !== "cloud" && executionMode !== "self") return response({ error: "executionMode must be cloud or self" }, 422);
|
|
327
|
+
if (planIds !== null && (!Array.isArray(planIds) || !planIds.every((id) => typeof id === "string"))) return response({ error: "planIds must be a list of plan ids, or null for all" }, 422);
|
|
328
|
+
if (template && !previewUrl(template, { number: 1, sha: "0".repeat(40), branch: "main" })) {
|
|
329
|
+
return response({ error: "The preview URL template must be an http(s) address; it can use {number}, {sha}, {short_sha} and {branch}" }, 422);
|
|
330
|
+
}
|
|
331
|
+
if (!(await installationCovers(env, projectId, installationId, repo))) {
|
|
332
|
+
return response({ error: "Connect that repository to this project through the GitHub App first" }, 422);
|
|
333
|
+
}
|
|
334
|
+
if (environmentId) {
|
|
335
|
+
const environment = await env.DB.prepare("SELECT id FROM project_environments WHERE project_id = ? AND id = ?").bind(projectId, environmentId).first();
|
|
336
|
+
if (!environment) return response({ error: "Environment not found in this project" }, 422);
|
|
337
|
+
}
|
|
338
|
+
const id = bindingId ?? newId("bind");
|
|
339
|
+
if (bindingId) {
|
|
340
|
+
const updated = await env.DB.prepare(`
|
|
341
|
+
UPDATE project_repo_bindings SET repo_full_name = ?, installation_id = ?, trigger = ?, plan_ids_json = ?, base_url_template = ?,
|
|
342
|
+
execution_mode = ?, environment_id = ?, enabled = ?
|
|
343
|
+
WHERE id = ? AND project_id = ?
|
|
344
|
+
`).bind(repo, installationId, trigger, planIds ? JSON.stringify(planIds) : null, template, executionMode, environmentId, enabled ? 1 : 0, bindingId, projectId).run();
|
|
345
|
+
if ((updated.meta?.changes ?? 0) === 0) return response({ error: "Not found" }, 404);
|
|
346
|
+
} else {
|
|
347
|
+
try {
|
|
348
|
+
await env.DB.prepare(`
|
|
349
|
+
INSERT INTO project_repo_bindings (id, project_id, installation_id, repo_full_name, plan_ids_json, environment_id, trigger, base_url_template, execution_mode, enabled, created_by)
|
|
350
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
351
|
+
`).bind(id, projectId, installationId, repo, planIds ? JSON.stringify(planIds) : null, environmentId, trigger, template, executionMode, enabled ? 1 : 0, principal.aegisId).run();
|
|
352
|
+
} catch {
|
|
353
|
+
return response({ error: "This repository already runs checks for that trigger" }, 409);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
await auditProject(env, principal, projectId, { action: bindingId ? "github.checks_updated" : "github.checks_added", targetType: "repo_binding", targetId: id, data: { repo, trigger } }, request);
|
|
357
|
+
return response({ binding: { id, repo, installationId, trigger, planIds, baseUrlTemplate: template, executionMode, environmentId, enabled } }, bindingId ? 200 : 201);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
registerRoutes(async (context) => {
|
|
361
|
+
const { request, url, env, principal } = context;
|
|
362
|
+
if (url.pathname === "/api/github/events" && request.method === "POST") {
|
|
363
|
+
if (!isDashboardService(principal)) return response({ error: "Not allowed" }, 403);
|
|
364
|
+
const event = parseGithubEvent(await requestJson(request));
|
|
365
|
+
if (!event) return response({ error: "Unrecognized event" }, 422);
|
|
366
|
+
return response(await handleGithubEvent(env, event));
|
|
367
|
+
}
|
|
368
|
+
let params = matchPath(url.pathname, "/api/projects/:projectId/repo-bindings");
|
|
369
|
+
if (params && request.method === "GET") return listBindings(context, params.projectId);
|
|
370
|
+
if (params && request.method === "POST") return saveBinding(context, params.projectId);
|
|
371
|
+
params = matchPath(url.pathname, "/api/projects/:projectId/repo-bindings/:bindingId");
|
|
372
|
+
if (params && (request.method === "PATCH" || request.method === "DELETE")) return saveBinding(context, params.projectId, params.bindingId);
|
|
373
|
+
return null;
|
|
374
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { Env } from "../core.js";
|
|
4
|
+
import { call, createTestEnv, seedPlan, seedProject } from "../testing/harness.js";
|
|
5
|
+
import { drainOutbox } from "../notifications/outbox.js";
|
|
6
|
+
import { parseMergeRequestEvent } from "./checks.js";
|
|
7
|
+
|
|
8
|
+
const SHA = "b".repeat(40);
|
|
9
|
+
|
|
10
|
+
function fakeGitlab() {
|
|
11
|
+
const state = { statuses: [] as Array<{ path: string; body: any }>, projects: new Map<string, { id: number; path_with_namespace: string }>(), failWith: 0 };
|
|
12
|
+
state.projects.set("acme/shop", { id: 555, path_with_namespace: "acme/shop" });
|
|
13
|
+
const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
14
|
+
const url = new URL(String(input));
|
|
15
|
+
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
|
16
|
+
const projectMatch = url.pathname.match(/^\/api\/v4\/projects\/([^/]+)$/);
|
|
17
|
+
if (projectMatch) {
|
|
18
|
+
const project = state.projects.get(decodeURIComponent(projectMatch[1]));
|
|
19
|
+
return project ? Response.json(project) : new Response("not found", { status: 404 });
|
|
20
|
+
}
|
|
21
|
+
if (state.failWith) return new Response("nope", { status: state.failWith });
|
|
22
|
+
const statusMatch = url.pathname.match(/^\/api\/v4\/projects\/(\d+)\/statuses\/([0-9a-f]+)$/);
|
|
23
|
+
if (init?.method === "POST" && statusMatch) {
|
|
24
|
+
state.statuses.push({ path: url.pathname, body });
|
|
25
|
+
return Response.json({ id: state.statuses.length });
|
|
26
|
+
}
|
|
27
|
+
return new Response("not found", { status: 404 });
|
|
28
|
+
}) as unknown as typeof fetch;
|
|
29
|
+
return { state, doFetch };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function setup(gl: ReturnType<typeof fakeGitlab>, plan = "team") {
|
|
33
|
+
const created = createTestEnv({ FEATURE_PR_CHECKS: "on", GITLAB_FETCH: gl.doFetch, DASHBOARD_URL: "https://dash.test", SECRETS_ENCRYPTION_KEY: "test-secrets-key-32-bytes-long!!" } as Partial<Env>);
|
|
34
|
+
const project = await seedProject(created.env);
|
|
35
|
+
created.db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, ?)").run(project.workspaceId, plan);
|
|
36
|
+
await seedPlan(created.env, project.projectId, project.owner, "checkout");
|
|
37
|
+
return { ...created, ...project };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mrPayload(action: string, extra: Record<string, unknown> = {}) {
|
|
41
|
+
return {
|
|
42
|
+
object_kind: "merge_request",
|
|
43
|
+
project: { id: 555, path_with_namespace: "acme/shop" },
|
|
44
|
+
object_attributes: { iid: 7, action, source_branch: "feature", last_commit: { id: SHA }, ...extra },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("parseMergeRequestEvent only accepts a well-formed Merge Request Hook", () => {
|
|
49
|
+
assert.deepEqual(parseMergeRequestEvent(mrPayload("open")), { gitlabProjectId: 555, action: "open", hasCommit: false, iid: 7, sha: SHA, branch: "feature" });
|
|
50
|
+
assert.equal(parseMergeRequestEvent(mrPayload("update", { oldrev: "a".repeat(40) }))?.hasCommit, true);
|
|
51
|
+
assert.equal(parseMergeRequestEvent({ object_kind: "push" }), null);
|
|
52
|
+
assert.equal(parseMergeRequestEvent({ object_kind: "merge_request", project: { id: 555 }, object_attributes: { iid: 7, action: "open", last_commit: { id: "short" } } }), null);
|
|
53
|
+
assert.equal(parseMergeRequestEvent({ object_kind: "merge_request", project: { id: "not-a-number" }, object_attributes: { iid: 7, action: "open", last_commit: { id: SHA } } }), null);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("connecting a project verifies the token, then a merge request runs the chosen plans and reports statuses", async () => {
|
|
57
|
+
const gl = fakeGitlab();
|
|
58
|
+
const { env, db, projectId, owner } = await setup(gl);
|
|
59
|
+
const bind = await call(env, "POST", `/api/projects/${projectId}/gitlab-bindings`, {
|
|
60
|
+
as: owner,
|
|
61
|
+
body: { projectPath: "acme/shop", accessToken: "glpat-token", planIds: ["checkout"], baseUrlTemplate: "https://pr-{number}.preview.acme.test", executionMode: "self" },
|
|
62
|
+
});
|
|
63
|
+
assert.equal(bind.status, 201, JSON.stringify(bind.json));
|
|
64
|
+
assert.ok(bind.json.webhookSecret, "the secret is returned once, at creation");
|
|
65
|
+
assert.match(bind.json.webhookUrl, /\/api\/gitlab\/webhook\?binding=/);
|
|
66
|
+
const bindingId = bind.json.binding.id;
|
|
67
|
+
const secret = bind.json.webhookSecret as string;
|
|
68
|
+
|
|
69
|
+
const webhook = (body: unknown, token = secret) => call(env, "POST", `/api/gitlab/webhook?binding=${bindingId}`, { headers: { "x-gitlab-token": token }, body });
|
|
70
|
+
|
|
71
|
+
assert.equal((await webhook(mrPayload("open"), "wrong-token")).status, 401);
|
|
72
|
+
assert.equal((await webhook({ ...mrPayload("open"), project: { id: 999, path_with_namespace: "other/repo" } })).json.started.length, 0, "a different project id is ignored");
|
|
73
|
+
|
|
74
|
+
const started = await webhook(mrPayload("open"));
|
|
75
|
+
assert.equal(started.json.started.length, 1, "only the chosen plan");
|
|
76
|
+
const again = await webhook(mrPayload("open"));
|
|
77
|
+
assert.deepEqual(again.json.started, started.json.started, "a redelivered webhook doesn't start a second run");
|
|
78
|
+
const runId = started.json.started[0];
|
|
79
|
+
const run = db.db.prepare("SELECT environment_base_url AS url, commit_sha AS sha, pr_number AS pr, execution_mode AS mode, gitlab_binding_id AS bindingId FROM runs WHERE id = ?").get(runId) as any;
|
|
80
|
+
assert.deepEqual({ ...run }, { url: "https://pr-7.preview.acme.test/", sha: SHA, pr: 7, mode: "self", bindingId });
|
|
81
|
+
|
|
82
|
+
await drainOutbox(env);
|
|
83
|
+
assert.equal(gl.state.statuses.length, 1);
|
|
84
|
+
assert.deepEqual(gl.state.statuses[0].body, { state: "pending", name: "Bugmole: checkout", description: "Waiting for a runner.", target_url: `https://dash.test/runs?project=shop&run=${runId}` });
|
|
85
|
+
|
|
86
|
+
await call(env, "POST", `/api/runs/${runId}/claim`, { as: owner, body: { workerId: "w1" } });
|
|
87
|
+
await drainOutbox(env);
|
|
88
|
+
assert.equal(gl.state.statuses.at(-1)!.body.state, "running");
|
|
89
|
+
|
|
90
|
+
await call(env, "POST", `/api/runs/${runId}`, { as: owner, body: { workerId: "w1", status: "error", stepsPassed: 2, stepsFailed: 1, errorMessage: "Payment never appeared" } });
|
|
91
|
+
await drainOutbox(env);
|
|
92
|
+
const finalStatus = gl.state.statuses.at(-1)!.body;
|
|
93
|
+
assert.equal(finalStatus.state, "failed");
|
|
94
|
+
assert.match(finalStatus.description, /2 step\(s\) passed, 1 failed on https:\/\/pr-7\.preview\.acme\.test\//);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("merge request checks need a paid plan, a real token, and only project managers to set them up", async () => {
|
|
98
|
+
const gl = fakeGitlab();
|
|
99
|
+
const free = await setup(gl, "free");
|
|
100
|
+
const refused = await call(free.env, "POST", `/api/projects/${free.projectId}/gitlab-bindings`, { as: free.owner, body: { projectPath: "acme/shop", accessToken: "x" } });
|
|
101
|
+
assert.equal(refused.status, 402);
|
|
102
|
+
|
|
103
|
+
const { env, projectId, owner } = await setup(gl);
|
|
104
|
+
const add = (body: Record<string, unknown>, as = owner) => call(env, "POST", `/api/projects/${projectId}/gitlab-bindings`, { as, body });
|
|
105
|
+
assert.equal((await add({ projectPath: "acme/missing", accessToken: "glpat-token" })).status, 422, "GitLab doesn't know that project");
|
|
106
|
+
assert.equal((await add({ projectPath: "acme/shop", accessToken: "glpat-token", baseUrlTemplate: "ftp://{number}" })).status, 422);
|
|
107
|
+
assert.equal((await add({ projectPath: "acme/shop", accessToken: "glpat-token" }, "user_stranger")).status, 403);
|
|
108
|
+
const ok = await add({ projectPath: "acme/shop", accessToken: "glpat-token", executionMode: "self" });
|
|
109
|
+
assert.equal(ok.status, 201);
|
|
110
|
+
assert.equal((await add({ projectPath: "acme/shop", accessToken: "glpat-token" })).status, 409, "already connected for this trigger");
|
|
111
|
+
|
|
112
|
+
const list = await call(env, "GET", `/api/projects/${projectId}/gitlab-bindings`, { as: owner });
|
|
113
|
+
assert.equal(list.json.bindings.length, 1);
|
|
114
|
+
assert.equal(list.json.available, true);
|
|
115
|
+
assert.equal((await call(env, "DELETE", `/api/projects/${projectId}/gitlab-bindings/${ok.json.binding.id}`, { as: owner })).status, 200);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("rotating the webhook secret invalidates the old one", async () => {
|
|
119
|
+
const gl = fakeGitlab();
|
|
120
|
+
const { env, projectId, owner } = await setup(gl);
|
|
121
|
+
const bind = await call(env, "POST", `/api/projects/${projectId}/gitlab-bindings`, { as: owner, body: { projectPath: "acme/shop", accessToken: "glpat-token", executionMode: "self" } });
|
|
122
|
+
const bindingId = bind.json.binding.id;
|
|
123
|
+
const oldSecret = bind.json.webhookSecret as string;
|
|
124
|
+
const rotated = await call(env, "POST", `/api/projects/${projectId}/gitlab-bindings/${bindingId}/rotate-webhook-secret`, { as: owner });
|
|
125
|
+
assert.equal(rotated.status, 200);
|
|
126
|
+
assert.notEqual(rotated.json.webhookSecret, oldSecret);
|
|
127
|
+
const webhook = (token: string) => call(env, "POST", `/api/gitlab/webhook?binding=${bindingId}`, { headers: { "x-gitlab-token": token }, body: mrPayload("open") });
|
|
128
|
+
assert.equal((await webhook(oldSecret)).status, 401);
|
|
129
|
+
assert.equal((await webhook(rotated.json.webhookSecret)).json.started.length, 1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a status GitLab won't accept is dropped, not retried forever", async () => {
|
|
133
|
+
const gl = fakeGitlab();
|
|
134
|
+
const { env, db, projectId, owner } = await setup(gl);
|
|
135
|
+
const bind = await call(env, "POST", `/api/projects/${projectId}/gitlab-bindings`, { as: owner, body: { projectPath: "acme/shop", accessToken: "glpat-token", executionMode: "self" } });
|
|
136
|
+
gl.state.failWith = 404;
|
|
137
|
+
await call(env, "POST", `/api/gitlab/webhook?binding=${bind.json.binding.id}`, { headers: { "x-gitlab-token": bind.json.webhookSecret }, body: mrPayload("open") });
|
|
138
|
+
await drainOutbox(env);
|
|
139
|
+
const dead = db.db.prepare("SELECT COUNT(*) AS c FROM outbox WHERE kind = 'gitlab.status.update' AND status = 'dead'").get() as any;
|
|
140
|
+
assert.equal(dead.c, 1);
|
|
141
|
+
});
|