mergen-server 1.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/LICENSE +57 -0
- package/README.md +96 -0
- package/dist/__stubs__/calibration.js +471 -0
- package/dist/__stubs__/causal.js +601 -0
- package/dist/__stubs__/closed-source.js +150 -0
- package/dist/__stubs__/license.js +35 -0
- package/dist/__stubs__/plans.js +197 -0
- package/dist/app.js +467 -0
- package/dist/cli.js +250 -0
- package/dist/commands/agent-identity.js +79 -0
- package/dist/commands/gate.js +107 -0
- package/dist/commands/github.js +632 -0
- package/dist/commands/incident.js +644 -0
- package/dist/commands/policy.js +710 -0
- package/dist/commands/scan.js +243 -0
- package/dist/commands/setup.js +1218 -0
- package/dist/commands/shared.js +109 -0
- package/dist/commands/team.js +809 -0
- package/dist/datadog/blame-attribution.js +228 -0
- package/dist/datadog/client.js +159 -0
- package/dist/datadog/compactor.js +169 -0
- package/dist/datadog/fingerprinter.js +34 -0
- package/dist/datadog/incident-state.js +21 -0
- package/dist/datadog/line-matcher.js +56 -0
- package/dist/datadog/memory-store.js +399 -0
- package/dist/datadog/otel-trace.js +67 -0
- package/dist/index.js +505 -0
- package/dist/intelligence/action-risk.js +132 -0
- package/dist/intelligence/activity-feed.js +40 -0
- package/dist/intelligence/agent-identity.js +115 -0
- package/dist/intelligence/agent-pipeline.js +329 -0
- package/dist/intelligence/agent-profiles.js +81 -0
- package/dist/intelligence/ai-commit.js +16 -0
- package/dist/intelligence/approval-analytics.js +42 -0
- package/dist/intelligence/approval-events.js +5 -0
- package/dist/intelligence/arch-boundaries.js +94 -0
- package/dist/intelligence/arch-graph.js +117 -0
- package/dist/intelligence/autonomy.js +397 -0
- package/dist/intelligence/azure-broker.js +57 -0
- package/dist/intelligence/azure-proxy-sessions.js +49 -0
- package/dist/intelligence/baseline.js +1 -0
- package/dist/intelligence/behavior-baseline.js +134 -0
- package/dist/intelligence/billing.js +1 -0
- package/dist/intelligence/blast-radius.js +224 -0
- package/dist/intelligence/bypass.js +157 -0
- package/dist/intelligence/calibration-classifier.js +77 -0
- package/dist/intelligence/calibration-git-sync.js +74 -0
- package/dist/intelligence/calibration.js +1 -0
- package/dist/intelligence/cascade-detector.js +58 -0
- package/dist/intelligence/case-study-generator.js +126 -0
- package/dist/intelligence/causal-graph.js +126 -0
- package/dist/intelligence/causal.js +1 -0
- package/dist/intelligence/change-risk.js +102 -0
- package/dist/intelligence/compliance-report.js +134 -0
- package/dist/intelligence/confidence-report.js +85 -0
- package/dist/intelligence/corpus-to-policy.js +140 -0
- package/dist/intelligence/credential-broker.js +112 -0
- package/dist/intelligence/credential-misuse.js +39 -0
- package/dist/intelligence/debug-sessions.js +108 -0
- package/dist/intelligence/decision-escalation.js +36 -0
- package/dist/intelligence/decision-memory.js +47 -0
- package/dist/intelligence/decision-similarity.js +85 -0
- package/dist/intelligence/default-safety-tests.js +53 -0
- package/dist/intelligence/degradation-watcher.js +108 -0
- package/dist/intelligence/detector-plugins.js +70 -0
- package/dist/intelligence/detectors.js +1 -0
- package/dist/intelligence/device-auth.js +138 -0
- package/dist/intelligence/diff-size.js +42 -0
- package/dist/intelligence/ebpf-verify.js +45 -0
- package/dist/intelligence/enterprise-policy-engine.js +1122 -0
- package/dist/intelligence/error-fingerprint.js +1 -0
- package/dist/intelligence/execution-gate.js +100 -0
- package/dist/intelligence/execution-mode.js +22 -0
- package/dist/intelligence/gate-analytics.js +256 -0
- package/dist/intelligence/gate-decision.js +64 -0
- package/dist/intelligence/gcp-broker.js +50 -0
- package/dist/intelligence/git-adr-sync.js +207 -0
- package/dist/intelligence/hitl-hold.js +460 -0
- package/dist/intelligence/hypothesis-history.js +93 -0
- package/dist/intelligence/idp-identity.js +83 -0
- package/dist/intelligence/impl-critic.js +216 -0
- package/dist/intelligence/incident-autopilot.js +622 -0
- package/dist/intelligence/incident-replay.js +162 -0
- package/dist/intelligence/incident-result-cache.js +33 -0
- package/dist/intelligence/incident-similarity.js +58 -0
- package/dist/intelligence/infra-detectors.js +312 -0
- package/dist/intelligence/license.js +185 -0
- package/dist/intelligence/llm-spokesperson.js +62 -0
- package/dist/intelligence/mcp-prompts.js +320 -0
- package/dist/intelligence/mcp-resources.js +171 -0
- package/dist/intelligence/normalize.js +108 -0
- package/dist/intelligence/notifications.js +83 -0
- package/dist/intelligence/oidc-issuer.js +87 -0
- package/dist/intelligence/override-corpus.js +597 -0
- package/dist/intelligence/planning-gate.js +84 -0
- package/dist/intelligence/plans.js +223 -0
- package/dist/intelligence/platt-scaling.js +164 -0
- package/dist/intelligence/policy-proposals.js +84 -0
- package/dist/intelligence/policy-suggester.js +161 -0
- package/dist/intelligence/policy-sync.js +103 -0
- package/dist/intelligence/policy-test-runner.js +35 -0
- package/dist/intelligence/postmortem-parser.js +181 -0
- package/dist/intelligence/postmortem-retrieval.js +224 -0
- package/dist/intelligence/postmortem-store.js +596 -0
- package/dist/intelligence/pr-commenter.js +81 -0
- package/dist/intelligence/pr-policy-gate.js +38 -0
- package/dist/intelligence/pr-shadow-analyzer.js +229 -0
- package/dist/intelligence/process-tree.js +108 -0
- package/dist/intelligence/prompts.js +1 -0
- package/dist/intelligence/replay.js +38 -0
- package/dist/intelligence/repro-steps.js +1 -0
- package/dist/intelligence/resource-extractors.js +0 -0
- package/dist/intelligence/resource-file-context.js +138 -0
- package/dist/intelligence/risk-score.js +22 -0
- package/dist/intelligence/rollback.js +137 -0
- package/dist/intelligence/runbook-updater.js +143 -0
- package/dist/intelligence/sandbox.js +194 -0
- package/dist/intelligence/script-trust.js +104 -0
- package/dist/intelligence/session-metrics.js +67 -0
- package/dist/intelligence/session-threat-tracker.js +231 -0
- package/dist/intelligence/shadow-digest-cron.js +81 -0
- package/dist/intelligence/shadow-log.js +284 -0
- package/dist/intelligence/shell-ast.js +145 -0
- package/dist/intelligence/siem-forward.js +104 -0
- package/dist/intelligence/slack-digest.js +151 -0
- package/dist/intelligence/slack-override-loop.js +217 -0
- package/dist/intelligence/slack-routing.js +67 -0
- package/dist/intelligence/slack.js +900 -0
- package/dist/intelligence/sql-ast.js +31 -0
- package/dist/intelligence/team.js +1 -0
- package/dist/intelligence/telemetry.js +76 -0
- package/dist/intelligence/threshold-optimizer.js +75 -0
- package/dist/intelligence/token-budget.js +66 -0
- package/dist/intelligence/tool-guard.js +708 -0
- package/dist/intelligence/tool-manifest 2.js +74 -0
- package/dist/intelligence/tool-manifest.js +125 -0
- package/dist/intelligence/tools-analysis.js +857 -0
- package/dist/intelligence/tools-arch.js +152 -0
- package/dist/intelligence/tools-autonomy.js +495 -0
- package/dist/intelligence/tools-blast-radius.js +140 -0
- package/dist/intelligence/tools-browser.js +431 -0
- package/dist/intelligence/tools-change-timeline.js +154 -0
- package/dist/intelligence/tools-credentials.js +117 -0
- package/dist/intelligence/tools-datadog.js +233 -0
- package/dist/intelligence/tools-debug-sessions.js +209 -0
- package/dist/intelligence/tools-discovery.js +284 -0
- package/dist/intelligence/tools-infra.js +612 -0
- package/dist/intelligence/tools-intent.js +128 -0
- package/dist/intelligence/tools-memory.js +514 -0
- package/dist/intelligence/tools-runbook.js +951 -0
- package/dist/intelligence/tools-sessions.js +107 -0
- package/dist/intelligence/tools-state.js +232 -0
- package/dist/intelligence/tools-utility.js +430 -0
- package/dist/intelligence/tools-validate.js +215 -0
- package/dist/intelligence/tools.js +1 -0
- package/dist/intelligence/trace-context.js +11 -0
- package/dist/intelligence/unclassified-clusters.js +87 -0
- package/dist/intelligence/usage.js +195 -0
- package/dist/routes/active-authors.js +63 -0
- package/dist/routes/activity-feed.js +21 -0
- package/dist/routes/adr.js +117 -0
- package/dist/routes/agent-activity.js +184 -0
- package/dist/routes/agent-blunders.js +163 -0
- package/dist/routes/agents.js +175 -0
- package/dist/routes/api-keys.js +65 -0
- package/dist/routes/arch.js +75 -0
- package/dist/routes/audit-export.js +93 -0
- package/dist/routes/billing-dashboard.js +158 -0
- package/dist/routes/billing-outcome.js +77 -0
- package/dist/routes/calibration.js +305 -0
- package/dist/routes/ci-gate-history.js +20 -0
- package/dist/routes/ci-gate.js +232 -0
- package/dist/routes/ci.js +293 -0
- package/dist/routes/compliance-report.js +24 -0
- package/dist/routes/confidence.js +30 -0
- package/dist/routes/credentials.js +119 -0
- package/dist/routes/dashboard.js +1407 -0
- package/dist/routes/decision-memory.js +42 -0
- package/dist/routes/demo.js +844 -0
- package/dist/routes/device-auth-stub.js +173 -0
- package/dist/routes/explain-why.js +39 -0
- package/dist/routes/gate-analytics.js +213 -0
- package/dist/routes/gate.js +28 -0
- package/dist/routes/github-webhook.js +364 -0
- package/dist/routes/habituation.js +30 -0
- package/dist/routes/health-integrations.js +243 -0
- package/dist/routes/heartbeats.js +45 -0
- package/dist/routes/hitl.js +364 -0
- package/dist/routes/impact-report.js +645 -0
- package/dist/routes/incident-webhook.js +63 -0
- package/dist/routes/incidents.js +366 -0
- package/dist/routes/layers.js +62 -0
- package/dist/routes/license.js +187 -0
- package/dist/routes/oidc-issuer.js +30 -0
- package/dist/routes/onboarding.js +170 -0
- package/dist/routes/otel.js +67 -0
- package/dist/routes/otlp-receiver.js +179 -0
- package/dist/routes/overrides.js +241 -0
- package/dist/routes/pagerduty.js +258 -0
- package/dist/routes/policies.js +809 -0
- package/dist/routes/policy-nl.js +169 -0
- package/dist/routes/postmortem.js +102 -0
- package/dist/routes/pr-shadow.js +47 -0
- package/dist/routes/rbac.js +61 -0
- package/dist/routes/replay.js +17 -0
- package/dist/routes/risk-report.js +164 -0
- package/dist/routes/runbooks.js +125 -0
- package/dist/routes/safety-test.js +174 -0
- package/dist/routes/sdk.js +222 -0
- package/dist/routes/sensor.js +819 -0
- package/dist/routes/sentry.js +140 -0
- package/dist/routes/sessions.js +43 -0
- package/dist/routes/setup-ui.js +594 -0
- package/dist/routes/shadow-report.js +107 -0
- package/dist/routes/slack-routing.js +171 -0
- package/dist/routes/team-usage.js +71 -0
- package/dist/routes/telemetry.js +28 -0
- package/dist/routes/tenants.js +199 -0
- package/dist/routes/tickets.js +167 -0
- package/dist/routes/validate.js +13 -0
- package/dist/routes/war-room.js +113 -0
- package/dist/scripts/check-arch.js +22 -0
- package/dist/seeds/community-corpus.js +176 -0
- package/dist/seeds/corpus.js +1224 -0
- package/dist/sensor/action-ledger.js +327 -0
- package/dist/sensor/adr-store.js +140 -0
- package/dist/sensor/agent-blunder-store.js +370 -0
- package/dist/sensor/agent-context-store.js +225 -0
- package/dist/sensor/agent-memory-store.js +231 -0
- package/dist/sensor/audit-fetch.js +23 -0
- package/dist/sensor/audit-log.js +273 -0
- package/dist/sensor/buffer-schemas.js +225 -0
- package/dist/sensor/buffer.js +599 -0
- package/dist/sensor/bypass-tracker.js +93 -0
- package/dist/sensor/ci-gate-history.js +70 -0
- package/dist/sensor/cloud-auth.js +183 -0
- package/dist/sensor/commit-context-store.js +235 -0
- package/dist/sensor/docker-log-stream.js +164 -0
- package/dist/sensor/docker-monitor.js +122 -0
- package/dist/sensor/extended-buffer.js +46 -0
- package/dist/sensor/feedback-token.js +41 -0
- package/dist/sensor/file-lock.js +6 -0
- package/dist/sensor/fs-watcher.js +106 -0
- package/dist/sensor/gate-heartbeat.js +122 -0
- package/dist/sensor/git-suspect.js +136 -0
- package/dist/sensor/habituation-store.js +77 -0
- package/dist/sensor/heartbeat-monitor.js +133 -0
- package/dist/sensor/incident-store.js +340 -0
- package/dist/sensor/infra-normalizer.js +513 -0
- package/dist/sensor/ingest.js +339 -0
- package/dist/sensor/jest-reporter.js +52 -0
- package/dist/sensor/k8s-events.js +132 -0
- package/dist/sensor/layer2-store.js +111 -0
- package/dist/sensor/layer3-store.js +230 -0
- package/dist/sensor/layer4-store.js +147 -0
- package/dist/sensor/logger.js +13 -0
- package/dist/sensor/otel-exporter.js +161 -0
- package/dist/sensor/paths.js +71 -0
- package/dist/sensor/policy-history.js +147 -0
- package/dist/sensor/pr-shadow-store.js +83 -0
- package/dist/sensor/process-watcher.js +162 -0
- package/dist/sensor/rbac.js +92 -0
- package/dist/sensor/redact.js +114 -0
- package/dist/sensor/redis-store.js +191 -0
- package/dist/sensor/route-reachability.js +64 -0
- package/dist/sensor/security-utils.js +18 -0
- package/dist/sensor/service-graph.js +170 -0
- package/dist/sensor/service-topology.js +227 -0
- package/dist/sensor/session-history.js +74 -0
- package/dist/sensor/session-persist.js +38 -0
- package/dist/sensor/shadow-promote.js +122 -0
- package/dist/sensor/sourcemap.js +281 -0
- package/dist/sensor/sqlite-store.js +205 -0
- package/dist/sensor/sso.js +164 -0
- package/dist/sensor/vitest-reporter.js +65 -0
- package/dist/sensor/watcher.js +48 -0
- package/dist/storage/interfaces.js +0 -0
- package/dist/storage/pg/pg-action-ledger.js +138 -0
- package/dist/storage/pg/pg-approval-store.js +107 -0
- package/dist/storage/pg/pg-blunder-store.js +193 -0
- package/dist/storage/pg/pg-ci-gate-history.js +83 -0
- package/dist/storage/pg/pg-client.js +24 -0
- package/dist/storage/pg/pg-event-store.js +63 -0
- package/dist/storage/pg/pg-incident-store.js +190 -0
- package/dist/storage/pg/pg-migrations.js +26 -0
- package/dist/storage/pg/pg-override-corpus.js +447 -0
- package/dist/storage/pg/pg-shadow-log.js +129 -0
- package/dist/storage/sqlite/sqlite-action-ledger.js +18 -0
- package/dist/storage/sqlite/sqlite-approval-store.js +37 -0
- package/dist/storage/sqlite/sqlite-blunder-store.js +27 -0
- package/dist/storage/sqlite/sqlite-ci-gate-history.js +19 -0
- package/dist/storage/sqlite/sqlite-event-store.js +29 -0
- package/dist/storage/sqlite/sqlite-incident-store.js +34 -0
- package/dist/storage/sqlite/sqlite-override-corpus.js +75 -0
- package/dist/storage/sqlite/sqlite-shadow-log.js +36 -0
- package/dist/storage/store-factory.js +55 -0
- package/dist/storage/store-registry.js +31 -0
- package/dist/update-checker.js +107 -0
- package/dist/workers/autopilot-worker.js +28 -0
- package/dist/workers/notification-worker.js +28 -0
- package/dist/workers/queues.js +58 -0
- package/dist/workers/validation-worker.js +24 -0
- package/dist/workers/worker-registry.js +18 -0
- package/package.json +123 -0
- package/sdk/mergen-inject.js +260 -0
- package/sdk/node.js +313 -0
- package/sdk/vite-plugin.ts +57 -0
- package/sdk/webpack-plugin.js +106 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { Router } from "express";
|
|
3
|
+
import { memoryStore } from "../datadog/memory-store.js";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import os from "os";
|
|
7
|
+
import { commitContextStore, extractLinkedIssues } from "../sensor/commit-context-store.js";
|
|
8
|
+
import { detectAiCommit } from "../intelligence/ai-commit.js";
|
|
9
|
+
import { analyzePRForShadow } from "../intelligence/pr-shadow-analyzer.js";
|
|
10
|
+
import { evaluatePRDiff } from "../intelligence/pr-policy-gate.js";
|
|
11
|
+
import { getActivePlanId } from "../intelligence/license.js";
|
|
12
|
+
import { getPlan } from "../intelligence/plans.js";
|
|
13
|
+
import { maybePostPRComment, hasMergenComment } from "../intelligence/pr-commenter.js";
|
|
14
|
+
import { recordHabituationEvent } from "../sensor/habituation-store.js";
|
|
15
|
+
import logger from "../sensor/logger.js";
|
|
16
|
+
function loadGitHubToken() {
|
|
17
|
+
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
|
|
18
|
+
try {
|
|
19
|
+
const cfgPath = path.join(os.homedir(), ".mergen", "config.json");
|
|
20
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
21
|
+
const gh = cfg.github ?? {};
|
|
22
|
+
if (typeof gh.token === "string" && gh.token) return gh.token;
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
async function fetchPRFilesWithPatches(repo, prNumber, token) {
|
|
28
|
+
const url = `https://api.github.com/repos/${repo}/pulls/${prNumber}/files?per_page=100`;
|
|
29
|
+
const res = await fetch(url, {
|
|
30
|
+
headers: {
|
|
31
|
+
Accept: "application/vnd.github+json",
|
|
32
|
+
Authorization: `Bearer ${token}`,
|
|
33
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
logger.warn({ repo, prNumber, status: res.status }, "github-webhook: failed to fetch PR files");
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
return data.map((f) => ({ filename: f.filename, patch: f.patch }));
|
|
42
|
+
}
|
|
43
|
+
async function fetchPRFiles(repo, prNumber, token) {
|
|
44
|
+
const files = await fetchPRFilesWithPatches(repo, prNumber, token);
|
|
45
|
+
return files.map((f) => f.filename);
|
|
46
|
+
}
|
|
47
|
+
async function maybeCreatePolicyGateCheckRun(gateResult, repo, headSha, token) {
|
|
48
|
+
if (!token) return;
|
|
49
|
+
if (gateResult.verdict === "pass") return;
|
|
50
|
+
const conclusion = gateResult.verdict === "block" ? "failure" : "neutral";
|
|
51
|
+
const matches = gateResult.verdict === "block" ? gateResult.blockMatches : gateResult.holdMatches;
|
|
52
|
+
const title = gateResult.verdict === "block" ? "\u{1F6AB} Policy violation: this PR introduces a destructive command/resource" : "\u26A0\uFE0F This PR introduces a change flagged for human review";
|
|
53
|
+
const summaryLines = matches.slice(0, 10).map((m) => `- \`${m.file}\`: \`${m.line}\` \u2014 ${m.reasons.join("; ")}`);
|
|
54
|
+
const body = {
|
|
55
|
+
name: "Mergen Policy Gate",
|
|
56
|
+
head_sha: headSha,
|
|
57
|
+
status: "completed",
|
|
58
|
+
conclusion,
|
|
59
|
+
output: {
|
|
60
|
+
title,
|
|
61
|
+
summary: `${matches.length} line(s) across this PR's diff matched enterprise policy rules.` + (gateResult.truncated ? ` (scan stopped at ${gateResult.linesScanned} lines \u2014 this PR is large enough that not every added line was checked.)` : ""),
|
|
62
|
+
text: summaryLines.join("\n")
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/check-runs`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: {
|
|
69
|
+
Accept: "application/vnd.github+json",
|
|
70
|
+
Authorization: `Bearer ${token}`,
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(body)
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
logger.warn({ repo, status: res.status }, "github-webhook: policy gate check run post failed (need checks:write scope)");
|
|
78
|
+
} else {
|
|
79
|
+
logger.info({ repo, conclusion }, "github-webhook: policy gate check run posted");
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
logger.warn({ err }, "github-webhook: policy gate check run error (non-fatal)");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function maybeCreateCheckRun(shadowResult, headSha, token) {
|
|
86
|
+
if (!token) return;
|
|
87
|
+
const HIGH_RELEVANCE = 0.7;
|
|
88
|
+
const isWarning = shadowResult.relevanceScore >= HIGH_RELEVANCE;
|
|
89
|
+
const summary = isWarning ? `Mergen found ${shadowResult.matchedIncidents} past incident(s) and ${shadowResult.matchedContexts} PR context(s) related to this change (relevance ${(shadowResult.relevanceScore * 100).toFixed(0)}%).` : `No high-relevance incidents found for this PR's changes.`;
|
|
90
|
+
const body = {
|
|
91
|
+
name: "Mergen Context Gate",
|
|
92
|
+
head_sha: headSha,
|
|
93
|
+
status: "completed",
|
|
94
|
+
conclusion: isWarning ? "action_required" : "success",
|
|
95
|
+
output: {
|
|
96
|
+
title: isWarning ? "\u26A0\uFE0F Related past incidents detected" : "\u2705 No related incidents",
|
|
97
|
+
summary,
|
|
98
|
+
text: shadowResult.wouldHaveComment ?? ""
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(`https://api.github.com/repos/${shadowResult.repo}/check-runs`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: {
|
|
105
|
+
Accept: "application/vnd.github+json",
|
|
106
|
+
Authorization: `Bearer ${token}`,
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify(body)
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
logger.warn(
|
|
114
|
+
{ repo: shadowResult.repo, pr: shadowResult.prNumber, status: res.status },
|
|
115
|
+
"github-webhook: check run post failed (need checks:write scope)"
|
|
116
|
+
);
|
|
117
|
+
} else {
|
|
118
|
+
logger.info(
|
|
119
|
+
{ repo: shadowResult.repo, pr: shadowResult.prNumber, conclusion: body.conclusion },
|
|
120
|
+
"github-webhook: check run posted"
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
logger.warn({ err }, "github-webhook: check run error (non-fatal)");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function loadWebhookSecret() {
|
|
128
|
+
if (process.env.GITHUB_WEBHOOK_SECRET) return process.env.GITHUB_WEBHOOK_SECRET;
|
|
129
|
+
try {
|
|
130
|
+
const secretFile = path.join(os.homedir(), ".mergen", "github-webhook-secret");
|
|
131
|
+
return fs.readFileSync(secretFile, "utf8").trim();
|
|
132
|
+
} catch {
|
|
133
|
+
return "";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const WEBHOOK_SECRET = loadWebhookSecret();
|
|
137
|
+
const REQUIRE_SECRET = process.env.MERGEN_REQUIRE_GITHUB_WEBHOOK_SECRET === "true";
|
|
138
|
+
if (WEBHOOK_SECRET) {
|
|
139
|
+
logger.info("github-webhook: secret loaded (verification enabled)");
|
|
140
|
+
} else if (REQUIRE_SECRET) {
|
|
141
|
+
logger.error(
|
|
142
|
+
"github-webhook: MERGEN_REQUIRE_GITHUB_WEBHOOK_SECRET=true but GITHUB_WEBHOOK_SECRET is not set \u2014 all webhook requests will be rejected until a secret is configured. Set GITHUB_WEBHOOK_SECRET to the secret configured in your GitHub webhook settings."
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
logger.warn(
|
|
146
|
+
"github-webhook: GITHUB_WEBHOOK_SECRET is not set \u2014 signature verification is DISABLED. Any caller can inject fake PR/push events and poison the commit context corpus. Set GITHUB_WEBHOOK_SECRET to the secret configured in your GitHub webhook settings, or set MERGEN_REQUIRE_GITHUB_WEBHOOK_SECRET=true to reject all webhook requests instead of accepting them unverified."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
function verifySignature(rawBody, signature) {
|
|
150
|
+
if (!WEBHOOK_SECRET) return !REQUIRE_SECRET;
|
|
151
|
+
if (!signature) return false;
|
|
152
|
+
const expected = "sha256=" + crypto.createHmac("sha256", WEBHOOK_SECRET).update(rawBody).digest("hex");
|
|
153
|
+
try {
|
|
154
|
+
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const _pendingApprovers = /* @__PURE__ */ new Map();
|
|
160
|
+
function createGitHubWebhookRouter() {
|
|
161
|
+
const router = Router();
|
|
162
|
+
router.post("/webhooks/github", (req, res) => {
|
|
163
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
164
|
+
const chunks = [];
|
|
165
|
+
let totalBytes = 0;
|
|
166
|
+
req.on("data", (chunk) => {
|
|
167
|
+
totalBytes += chunk.length;
|
|
168
|
+
if (totalBytes > MAX_BODY_BYTES) {
|
|
169
|
+
res.status(413).json({ error: "payload too large" });
|
|
170
|
+
req.destroy();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
chunks.push(chunk);
|
|
174
|
+
});
|
|
175
|
+
req.on("end", () => {
|
|
176
|
+
if (res.headersSent) return;
|
|
177
|
+
const rawBody = Buffer.concat(chunks);
|
|
178
|
+
const signature = req.headers["x-hub-signature-256"];
|
|
179
|
+
const event = req.headers["x-github-event"];
|
|
180
|
+
if (!verifySignature(rawBody, signature)) {
|
|
181
|
+
res.status(401).json({ error: "invalid x-hub-signature-256" });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
let payload;
|
|
185
|
+
try {
|
|
186
|
+
payload = JSON.parse(rawBody.toString("utf8"));
|
|
187
|
+
} catch {
|
|
188
|
+
res.status(400).json({ error: "malformed JSON" });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
res.json({ status: "accepted", event });
|
|
192
|
+
void handleEvent(event ?? "", payload).catch((err) => {
|
|
193
|
+
logger.warn({ err, event }, "github-webhook: handler error");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
return router;
|
|
198
|
+
}
|
|
199
|
+
async function handleEvent(event, payload) {
|
|
200
|
+
if (event === "pull_request_review") {
|
|
201
|
+
handleReviewEvent(payload);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (event === "pull_request") {
|
|
205
|
+
const prPayload = payload;
|
|
206
|
+
const { action, pull_request: pr, repository } = prPayload ?? {};
|
|
207
|
+
if (action === "opened" || action === "synchronize" || action === "ready_for_review") {
|
|
208
|
+
const headSha = pr?.head?.sha ?? "";
|
|
209
|
+
const repoFullName = repository?.full_name ?? "";
|
|
210
|
+
const ghToken = loadGitHubToken();
|
|
211
|
+
const changedFilesWithPatches = ghToken ? await fetchPRFilesWithPatches(repoFullName, pr?.number ?? 0, ghToken).catch(() => []) : [];
|
|
212
|
+
const changedFiles = changedFilesWithPatches.map((f) => f.filename);
|
|
213
|
+
void analyzePRForShadow({
|
|
214
|
+
repo: repoFullName,
|
|
215
|
+
prNumber: pr?.number ?? 0,
|
|
216
|
+
prTitle: pr?.title ?? "",
|
|
217
|
+
prBody: pr?.body ?? null,
|
|
218
|
+
author: pr?.user?.login ?? "",
|
|
219
|
+
branch: pr?.head?.ref ?? "",
|
|
220
|
+
action,
|
|
221
|
+
changedFiles
|
|
222
|
+
}).then(async (shadowResult) => {
|
|
223
|
+
await maybePostPRComment(shadowResult);
|
|
224
|
+
await maybeCreateCheckRun(shadowResult, headSha, ghToken);
|
|
225
|
+
}).catch((err) => {
|
|
226
|
+
logger.warn({ err }, "pr-shadow/comment/checkrun: error (non-fatal)");
|
|
227
|
+
});
|
|
228
|
+
if (getPlan(getActivePlanId()).capabilities.ciGate) {
|
|
229
|
+
const serviceName = repoFullName.split("/").pop() ?? repoFullName;
|
|
230
|
+
const gateResult = evaluatePRDiff(changedFilesWithPatches, serviceName);
|
|
231
|
+
if (gateResult.verdict !== "pass") {
|
|
232
|
+
logger.warn(
|
|
233
|
+
{ repo: repoFullName, pr: pr?.number, verdict: gateResult.verdict, blockCount: gateResult.blockMatches.length, holdCount: gateResult.holdMatches.length },
|
|
234
|
+
"github-webhook: PR policy gate found matches"
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
void maybeCreatePolicyGateCheckRun(gateResult, repoFullName, headSha, ghToken).catch((err) => {
|
|
238
|
+
logger.warn({ err }, "pr-policy-gate: check-run post error (non-fatal)");
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
await handlePREvent(prPayload);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (event === "push") {
|
|
246
|
+
handlePushEvent(payload);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function handleReviewEvent(payload) {
|
|
251
|
+
if (payload?.review?.state !== "approved") return;
|
|
252
|
+
const repo = payload?.repository?.full_name ?? "";
|
|
253
|
+
const prNum = payload?.pull_request?.number;
|
|
254
|
+
if (!repo || prNum == null) return;
|
|
255
|
+
const key = `${repo}#${prNum}`;
|
|
256
|
+
if (!_pendingApprovers.has(key)) _pendingApprovers.set(key, /* @__PURE__ */ new Set());
|
|
257
|
+
_pendingApprovers.get(key).add(payload.review.user.login);
|
|
258
|
+
logger.debug({ repo, prNum, approver: payload.review.user.login }, "github-webhook: approval captured");
|
|
259
|
+
if (hasMergenComment(key)) {
|
|
260
|
+
recordHabituationEvent({
|
|
261
|
+
recordedAt: Date.now(),
|
|
262
|
+
eventType: "pr_review_submitted",
|
|
263
|
+
actor: payload.review.user.login,
|
|
264
|
+
repo,
|
|
265
|
+
prNumber: prNum
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function handlePREvent(payload) {
|
|
270
|
+
const { action, pull_request: pr, repository } = payload ?? {};
|
|
271
|
+
if (!pr || !repository) return;
|
|
272
|
+
const repo = repository.full_name ?? "";
|
|
273
|
+
const prKey = `${repo}#${pr.number}`;
|
|
274
|
+
if (action !== "closed" || !pr.merged) {
|
|
275
|
+
if ((action === "review_requested" || action === "review_request_removed") && pr.requested_reviewers) {
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const mergedAt = pr.merged_at ? new Date(pr.merged_at).getTime() : Date.now();
|
|
280
|
+
const sha = pr.merge_commit_sha ?? pr.head.sha;
|
|
281
|
+
const approverSet = _pendingApprovers.get(prKey) ?? /* @__PURE__ */ new Set();
|
|
282
|
+
_pendingApprovers.delete(prKey);
|
|
283
|
+
const prText = [pr.title, pr.body ?? ""].join(" ");
|
|
284
|
+
const aiSignal = detectAiCommit(prText, pr.user.login);
|
|
285
|
+
const linkedIssues = extractLinkedIssues(pr.body ?? "");
|
|
286
|
+
const ctx = {
|
|
287
|
+
sha,
|
|
288
|
+
repo,
|
|
289
|
+
branch: pr.base.ref,
|
|
290
|
+
prNumber: pr.number,
|
|
291
|
+
prTitle: pr.title,
|
|
292
|
+
prBody: pr.body,
|
|
293
|
+
author: pr.user.login,
|
|
294
|
+
approvers: [...approverSet],
|
|
295
|
+
linkedIssues,
|
|
296
|
+
aiGenerated: aiSignal.detected,
|
|
297
|
+
aiTool: aiSignal.tool,
|
|
298
|
+
filesChanged: [],
|
|
299
|
+
// populated below if file list is available in payload
|
|
300
|
+
capturedAt: Date.now(),
|
|
301
|
+
mergedAt
|
|
302
|
+
};
|
|
303
|
+
commitContextStore.upsert(ctx);
|
|
304
|
+
const ghToken = loadGitHubToken();
|
|
305
|
+
if (ghToken) {
|
|
306
|
+
void fetchPRFiles(repo, pr.number, ghToken).then((files) => {
|
|
307
|
+
if (files.length > 0) {
|
|
308
|
+
commitContextStore.upsert({ ...ctx, filesChanged: files });
|
|
309
|
+
logger.debug(
|
|
310
|
+
{ sha: sha.slice(0, 7), repo, pr: pr.number, files: files.length },
|
|
311
|
+
"github-webhook: PR files populated"
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}).catch((err) => {
|
|
315
|
+
logger.warn({ err }, "github-webhook: fetchPRFiles failed (non-fatal)");
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
memoryStore.correlateGitHubPR({ prUrl: pr.html_url, prTitle: pr.title, prSha: sha, mergedAt });
|
|
319
|
+
logger.info(
|
|
320
|
+
{ sha: sha.slice(0, 7), repo, pr: pr.number, ai: aiSignal.detected, issues: linkedIssues.length },
|
|
321
|
+
"github-webhook: PR context captured"
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
function handlePushEvent(payload) {
|
|
325
|
+
const { ref, after: sha, commits, repository, pusher } = payload ?? {};
|
|
326
|
+
if (!sha || !commits?.length || !repository) return;
|
|
327
|
+
const repo = repository.full_name ?? "";
|
|
328
|
+
const branch = ref?.replace("refs/heads/", "") ?? null;
|
|
329
|
+
for (const commit of commits.slice(0, 20)) {
|
|
330
|
+
const commitSha = commit.id;
|
|
331
|
+
const author = commit.author?.username ?? commit.author?.name ?? pusher?.name ?? "";
|
|
332
|
+
const message = commit.message ?? "";
|
|
333
|
+
const aiSignal = detectAiCommit(message, author);
|
|
334
|
+
const changedFiles = [
|
|
335
|
+
...commit.added ?? [],
|
|
336
|
+
...commit.modified ?? [],
|
|
337
|
+
...commit.removed ?? []
|
|
338
|
+
];
|
|
339
|
+
const ctx = {
|
|
340
|
+
sha: commitSha,
|
|
341
|
+
repo,
|
|
342
|
+
branch,
|
|
343
|
+
prNumber: null,
|
|
344
|
+
prTitle: message.split("\n")[0].slice(0, 200),
|
|
345
|
+
prBody: message,
|
|
346
|
+
author,
|
|
347
|
+
approvers: [],
|
|
348
|
+
linkedIssues: extractLinkedIssues(message),
|
|
349
|
+
aiGenerated: aiSignal.detected,
|
|
350
|
+
aiTool: aiSignal.tool,
|
|
351
|
+
filesChanged: changedFiles,
|
|
352
|
+
capturedAt: Date.now(),
|
|
353
|
+
mergedAt: null
|
|
354
|
+
};
|
|
355
|
+
commitContextStore.upsert(ctx);
|
|
356
|
+
}
|
|
357
|
+
logger.info(
|
|
358
|
+
{ sha: sha.slice(0, 7), repo, branch, commits: commits.length },
|
|
359
|
+
"github-webhook: push commits captured"
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
export {
|
|
363
|
+
createGitHubWebhookRouter
|
|
364
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { getHabituationEvents, getWeeklyHabituation } from "../sensor/habituation-store.js";
|
|
3
|
+
function createHabituationRouter() {
|
|
4
|
+
const router = Router();
|
|
5
|
+
router.get("/habituation", (req, res) => {
|
|
6
|
+
const windowWeeks = Math.min(26, Math.max(1, Number(req.query.weeks ?? 8)));
|
|
7
|
+
const weekly = getWeeklyHabituation(windowWeeks);
|
|
8
|
+
const all = getHabituationEvents();
|
|
9
|
+
const withComments = new Set(
|
|
10
|
+
all.filter((e) => e.eventType === "comment_posted").map((e) => e.actor)
|
|
11
|
+
).size;
|
|
12
|
+
const engaged = new Set(
|
|
13
|
+
all.filter((e) => e.eventType !== "comment_posted").map((e) => e.actor)
|
|
14
|
+
).size;
|
|
15
|
+
res.json({
|
|
16
|
+
ok: true,
|
|
17
|
+
windowWeeks,
|
|
18
|
+
totalPRsCommented: all.filter((e) => e.eventType === "comment_posted").length,
|
|
19
|
+
uniqueEngineers: new Set(all.map((e) => e.actor)).size,
|
|
20
|
+
engineersWithComments: withComments,
|
|
21
|
+
engagedEngineers: engaged,
|
|
22
|
+
habituationRate: withComments > 0 ? engaged / withComments : null,
|
|
23
|
+
weekly
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
return router;
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
createHabituationRouter
|
|
30
|
+
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { getAuditHealth } from "../sensor/audit-log.js";
|
|
6
|
+
import { getStaleErrors } from "../sensor/buffer.js";
|
|
7
|
+
import { testConnection as testDatadogConnection } from "../datadog/client.js";
|
|
8
|
+
async function checkSlack() {
|
|
9
|
+
const token = process.env.MERGEN_SLACK_BOT_TOKEN;
|
|
10
|
+
const channel = process.env.MERGEN_SLACK_CHANNEL;
|
|
11
|
+
if (!token) return {
|
|
12
|
+
id: "slack",
|
|
13
|
+
name: "Slack",
|
|
14
|
+
status: "missing",
|
|
15
|
+
detail: "MERGEN_SLACK_BOT_TOKEN not set \u2014 incident threads and PR comments disabled",
|
|
16
|
+
fix: "export MERGEN_SLACK_BOT_TOKEN=xoxb-...",
|
|
17
|
+
docsUrl: "https://api.slack.com/apps"
|
|
18
|
+
};
|
|
19
|
+
if (!channel) return {
|
|
20
|
+
id: "slack",
|
|
21
|
+
name: "Slack",
|
|
22
|
+
status: "warn",
|
|
23
|
+
detail: "Token set but MERGEN_SLACK_CHANNEL not configured",
|
|
24
|
+
fix: "export MERGEN_SLACK_CHANNEL=#incidents"
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
const r = await fetch("https://slack.com/api/auth.test", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
30
|
+
signal: AbortSignal.timeout(3e3)
|
|
31
|
+
});
|
|
32
|
+
const d = await r.json();
|
|
33
|
+
return d.ok ? { id: "slack", name: "Slack", status: "ok", detail: `Connected \u2014 workspace: ${d.team ?? "unknown"}, channel: ${channel}` } : { id: "slack", name: "Slack", status: "warn", detail: `Auth failed: ${d.error}`, fix: "Check MERGEN_SLACK_BOT_TOKEN \u2014 needs chat:write scope", docsUrl: "https://api.slack.com/apps" };
|
|
34
|
+
} catch {
|
|
35
|
+
return { id: "slack", name: "Slack", status: "warn", detail: "Token set but Slack API unreachable", fix: "Check network connectivity" };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function checkPagerDuty() {
|
|
39
|
+
const secret = process.env.MERGEN_PAGERDUTY_SECRET;
|
|
40
|
+
if (!secret) return {
|
|
41
|
+
id: "pagerduty",
|
|
42
|
+
name: "PagerDuty",
|
|
43
|
+
status: "missing",
|
|
44
|
+
detail: "MERGEN_PAGERDUTY_SECRET not set \u2014 webhook signature verification disabled",
|
|
45
|
+
fix: "export MERGEN_PAGERDUTY_SECRET=... # from PagerDuty webhook config",
|
|
46
|
+
docsUrl: "https://developer.pagerduty.com/docs/webhooks"
|
|
47
|
+
};
|
|
48
|
+
return { id: "pagerduty", name: "PagerDuty", status: "ok", detail: "Secret configured \u2014 signature verification enabled" };
|
|
49
|
+
}
|
|
50
|
+
async function checkGitHub() {
|
|
51
|
+
const token = process.env.GITHUB_TOKEN;
|
|
52
|
+
const secret = process.env.GITHUB_WEBHOOK_SECRET ?? (existsSync(join(homedir(), ".mergen", "github-webhook-secret")) ? "[file]" : null);
|
|
53
|
+
if (!token && !secret) return {
|
|
54
|
+
id: "github",
|
|
55
|
+
name: "GitHub",
|
|
56
|
+
status: "missing",
|
|
57
|
+
detail: "GITHUB_TOKEN and GITHUB_WEBHOOK_SECRET not set",
|
|
58
|
+
fix: "mergen-server connect github --repo owner/repo",
|
|
59
|
+
docsUrl: "https://github.com/settings/tokens"
|
|
60
|
+
};
|
|
61
|
+
if (token) {
|
|
62
|
+
try {
|
|
63
|
+
const r = await fetch("https://api.github.com/user", {
|
|
64
|
+
headers: { Authorization: `Bearer ${token}`, "User-Agent": "mergen" },
|
|
65
|
+
signal: AbortSignal.timeout(3e3)
|
|
66
|
+
});
|
|
67
|
+
const d = await r.json();
|
|
68
|
+
if (!r.ok) return { id: "github", name: "GitHub", status: "warn", detail: `Token auth failed: ${d.message}`, fix: "Check GITHUB_TOKEN at https://github.com/settings/tokens" };
|
|
69
|
+
return {
|
|
70
|
+
id: "github",
|
|
71
|
+
name: "GitHub",
|
|
72
|
+
status: "ok",
|
|
73
|
+
detail: `Connected \u2014 user: ${d.login}${secret ? ", webhook secret set" : ", no webhook secret"}`
|
|
74
|
+
};
|
|
75
|
+
} catch {
|
|
76
|
+
return { id: "github", name: "GitHub", status: "warn", detail: "Token set but GitHub API unreachable", fix: "Check network connectivity" };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { id: "github", name: "GitHub", status: "ok", detail: "Webhook secret configured (token not set \u2014 PR commenting disabled)" };
|
|
80
|
+
}
|
|
81
|
+
async function checkDatadog() {
|
|
82
|
+
const apiKey = process.env.DD_API_KEY;
|
|
83
|
+
const appKey = process.env.DD_APP_KEY;
|
|
84
|
+
if (!apiKey) return {
|
|
85
|
+
id: "datadog",
|
|
86
|
+
name: "Datadog",
|
|
87
|
+
status: "missing",
|
|
88
|
+
detail: "DD_API_KEY not set",
|
|
89
|
+
fix: "export DD_API_KEY=... # https://app.datadoghq.com/organization-settings/api-keys"
|
|
90
|
+
};
|
|
91
|
+
if (!appKey) return {
|
|
92
|
+
id: "datadog",
|
|
93
|
+
name: "Datadog",
|
|
94
|
+
status: "warn",
|
|
95
|
+
detail: "DD_API_KEY set but DD_APP_KEY missing",
|
|
96
|
+
fix: "export DD_APP_KEY=... # https://app.datadoghq.com/organization-settings/application-keys"
|
|
97
|
+
};
|
|
98
|
+
const site = process.env.DATADOG_SITE ?? process.env.DD_SITE ?? "datadoghq.com";
|
|
99
|
+
try {
|
|
100
|
+
await testDatadogConnection();
|
|
101
|
+
return { id: "datadog", name: "Datadog", status: "ok", detail: `Connected \u2014 site: ${site}` };
|
|
102
|
+
} catch (err) {
|
|
103
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
104
|
+
return { id: "datadog", name: "Datadog", status: "warn", detail, fix: "Run: mergen-server init to re-configure" };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function checkLinear() {
|
|
108
|
+
const apiKey = process.env.LINEAR_API_KEY;
|
|
109
|
+
const teamId = process.env.LINEAR_TEAM_ID;
|
|
110
|
+
if (!apiKey) return {
|
|
111
|
+
id: "linear",
|
|
112
|
+
name: "Linear",
|
|
113
|
+
status: "missing",
|
|
114
|
+
detail: "LINEAR_API_KEY not set \u2014 ticket creation disabled",
|
|
115
|
+
fix: "export LINEAR_API_KEY=lin_api_...",
|
|
116
|
+
docsUrl: "https://linear.app/settings/api"
|
|
117
|
+
};
|
|
118
|
+
if (!teamId) return {
|
|
119
|
+
id: "linear",
|
|
120
|
+
name: "Linear",
|
|
121
|
+
status: "warn",
|
|
122
|
+
detail: "LINEAR_API_KEY set but LINEAR_TEAM_ID missing",
|
|
123
|
+
fix: "export LINEAR_TEAM_ID=<team-id> # from https://linear.app/settings/api"
|
|
124
|
+
};
|
|
125
|
+
return { id: "linear", name: "Linear", status: "ok", detail: `Configured \u2014 team: ${teamId}` };
|
|
126
|
+
}
|
|
127
|
+
function checkJira() {
|
|
128
|
+
const baseUrl = process.env.JIRA_BASE_URL;
|
|
129
|
+
const email = process.env.JIRA_EMAIL;
|
|
130
|
+
const token = process.env.JIRA_API_TOKEN;
|
|
131
|
+
const projKey = process.env.JIRA_PROJECT_KEY;
|
|
132
|
+
if (!baseUrl) return {
|
|
133
|
+
id: "jira",
|
|
134
|
+
name: "Jira",
|
|
135
|
+
status: "missing",
|
|
136
|
+
detail: "JIRA_BASE_URL not set \u2014 ticket creation disabled",
|
|
137
|
+
fix: "export JIRA_BASE_URL=https://yourco.atlassian.net",
|
|
138
|
+
docsUrl: "https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/"
|
|
139
|
+
};
|
|
140
|
+
if (!email || !token) return {
|
|
141
|
+
id: "jira",
|
|
142
|
+
name: "Jira",
|
|
143
|
+
status: "warn",
|
|
144
|
+
detail: `JIRA_BASE_URL set but ${!email ? "JIRA_EMAIL" : "JIRA_API_TOKEN"} missing`,
|
|
145
|
+
fix: !email ? "export JIRA_EMAIL=you@company.com" : "export JIRA_API_TOKEN=..."
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
id: "jira",
|
|
149
|
+
name: "Jira",
|
|
150
|
+
status: "ok",
|
|
151
|
+
detail: `Configured \u2014 ${baseUrl}${projKey ? ` \xB7 project: ${projKey}` : " (JIRA_PROJECT_KEY not set \u2014 pass per request)"}`
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function checkSentry() {
|
|
155
|
+
const secret = process.env.MERGEN_SENTRY_SECRET;
|
|
156
|
+
if (!secret) return {
|
|
157
|
+
id: "sentry",
|
|
158
|
+
name: "Sentry",
|
|
159
|
+
status: "missing",
|
|
160
|
+
detail: "MERGEN_SENTRY_SECRET not set \u2014 Sentry webhooks accepted without verification",
|
|
161
|
+
fix: "export MERGEN_SENTRY_SECRET=... # from your Sentry project webhook settings"
|
|
162
|
+
};
|
|
163
|
+
return { id: "sentry", name: "Sentry", status: "ok", detail: "Webhook secret configured" };
|
|
164
|
+
}
|
|
165
|
+
function checkRedis() {
|
|
166
|
+
const url = process.env.MERGEN_REDIS_URL;
|
|
167
|
+
if (!url) return {
|
|
168
|
+
id: "redis",
|
|
169
|
+
name: "Redis",
|
|
170
|
+
status: "missing",
|
|
171
|
+
detail: "MERGEN_REDIS_URL not set \u2014 ring buffer is in-memory only (lost on restart)",
|
|
172
|
+
fix: "export MERGEN_REDIS_URL=redis://localhost:6379"
|
|
173
|
+
};
|
|
174
|
+
return { id: "redis", name: "Redis", status: "ok", detail: `Configured \u2014 ${url}` };
|
|
175
|
+
}
|
|
176
|
+
function checkAutopilot() {
|
|
177
|
+
const enabled = process.env.MERGEN_AUTOPILOT === "true";
|
|
178
|
+
const shadow = process.env.MERGEN_SHADOW_MODE === "true";
|
|
179
|
+
if (shadow) return {
|
|
180
|
+
id: "autopilot",
|
|
181
|
+
name: "Autopilot",
|
|
182
|
+
status: "warn",
|
|
183
|
+
detail: "Shadow mode: Mergen diagnoses but never executes fixes",
|
|
184
|
+
fix: "Set MERGEN_AUTOPILOT=true and remove MERGEN_SHADOW_MODE to enable autonomous execution"
|
|
185
|
+
};
|
|
186
|
+
if (enabled) return { id: "autopilot", name: "Autopilot", status: "ok", detail: "Autonomous execution enabled at \u226585% confidence" };
|
|
187
|
+
return {
|
|
188
|
+
id: "autopilot",
|
|
189
|
+
name: "Autopilot",
|
|
190
|
+
status: "missing",
|
|
191
|
+
detail: "MERGEN_AUTOPILOT not set \u2014 diagnosis-only mode",
|
|
192
|
+
fix: "export MERGEN_AUTOPILOT=true (run in MERGEN_SHADOW_MODE=true first for 30 days)"
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function createHealthIntegrationsRouter() {
|
|
196
|
+
const router = Router();
|
|
197
|
+
router.get("/health/integrations", async (_req, res) => {
|
|
198
|
+
const [slack, pagerduty, github, datadog] = await Promise.all([
|
|
199
|
+
checkSlack(),
|
|
200
|
+
checkPagerDuty(),
|
|
201
|
+
checkGitHub(),
|
|
202
|
+
checkDatadog()
|
|
203
|
+
]);
|
|
204
|
+
const staleErrors = getStaleErrors();
|
|
205
|
+
const staleCheck = staleErrors.length > 0 ? {
|
|
206
|
+
id: "stale_errors",
|
|
207
|
+
name: "Active Errors",
|
|
208
|
+
status: "warn",
|
|
209
|
+
detail: `${staleErrors.length} error pattern${staleErrors.length !== 1 ? "s" : ""} active for >1 hour without triage`,
|
|
210
|
+
fix: "Run: curl http://127.0.0.1:3000/health/integrations | jq .staleErrors"
|
|
211
|
+
} : { id: "stale_errors", name: "Active Errors", status: "ok", detail: "No untriaged errors older than 1 hour" };
|
|
212
|
+
const integrations = [
|
|
213
|
+
slack,
|
|
214
|
+
pagerduty,
|
|
215
|
+
github,
|
|
216
|
+
datadog,
|
|
217
|
+
checkLinear(),
|
|
218
|
+
checkJira(),
|
|
219
|
+
checkSentry(),
|
|
220
|
+
checkRedis(),
|
|
221
|
+
checkAutopilot(),
|
|
222
|
+
staleCheck
|
|
223
|
+
];
|
|
224
|
+
const ok = integrations.filter((i) => i.status === "ok").length;
|
|
225
|
+
const warn = integrations.filter((i) => i.status === "warn").length;
|
|
226
|
+
const missing = integrations.filter((i) => i.status === "missing").length;
|
|
227
|
+
res.json({
|
|
228
|
+
ok: true,
|
|
229
|
+
summary: { ok, warn, missing, total: integrations.length },
|
|
230
|
+
integrations,
|
|
231
|
+
staleErrors,
|
|
232
|
+
staleErrorCount: staleErrors.length
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
router.get("/audit-health", (_req, res) => {
|
|
236
|
+
const health = getAuditHealth();
|
|
237
|
+
res.status(health.ok ? 200 : 503).json(health);
|
|
238
|
+
});
|
|
239
|
+
return router;
|
|
240
|
+
}
|
|
241
|
+
export {
|
|
242
|
+
createHealthIntegrationsRouter
|
|
243
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { ping, getReport, getAllReports, removeHeartbeat } from "../sensor/heartbeat-monitor.js";
|
|
3
|
+
function createHeartbeatsRouter() {
|
|
4
|
+
const router = Router();
|
|
5
|
+
router.post("/heartbeat/:name", (req, res) => {
|
|
6
|
+
const { name } = req.params;
|
|
7
|
+
if (!/^[a-zA-Z0-9_-]{1,80}$/.test(name)) {
|
|
8
|
+
res.status(400).json({ ok: false, error: "name must be 1-80 alphanumeric, hyphen, or underscore characters" });
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const intervalSeconds = Math.max(60, parseInt(String(req.query["interval"] ?? req.body?.interval ?? "86400"), 10) || 86400);
|
|
12
|
+
const graceSeconds = req.query["grace"] !== void 0 || req.body?.grace !== void 0 ? Math.max(0, parseInt(String(req.query["grace"] ?? req.body?.grace ?? "0"), 10) || 0) : void 0;
|
|
13
|
+
const description = typeof (req.query["description"] ?? req.body?.description) === "string" ? String(req.query["description"] ?? req.body.description).slice(0, 200) : void 0;
|
|
14
|
+
const config = ping(name, intervalSeconds, graceSeconds, description);
|
|
15
|
+
const report = getReport(name);
|
|
16
|
+
res.json({ ok: true, heartbeat: report, _hint: `curl -s http://localhost:3000/heartbeat/${config.name}` });
|
|
17
|
+
});
|
|
18
|
+
router.get("/heartbeats", (_req, res) => {
|
|
19
|
+
const reports = getAllReports();
|
|
20
|
+
const missing = reports.filter((r) => r.status === "missing" || r.status === "never-pinged").length;
|
|
21
|
+
res.json({ ok: true, total: reports.length, missing, heartbeats: reports });
|
|
22
|
+
});
|
|
23
|
+
router.get("/heartbeat/:name", (req, res) => {
|
|
24
|
+
const { name } = req.params;
|
|
25
|
+
const report = getReport(name);
|
|
26
|
+
if (!report) {
|
|
27
|
+
res.status(404).json({ ok: false, error: `no heartbeat named '${name}'` });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
res.json({ ok: true, heartbeat: report });
|
|
31
|
+
});
|
|
32
|
+
router.delete("/heartbeat/:name", (req, res) => {
|
|
33
|
+
const { name } = req.params;
|
|
34
|
+
const removed = removeHeartbeat(name);
|
|
35
|
+
if (!removed) {
|
|
36
|
+
res.status(404).json({ ok: false, error: `no heartbeat named '${name}'` });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
res.json({ ok: true, removed: name });
|
|
40
|
+
});
|
|
41
|
+
return router;
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
createHeartbeatsRouter
|
|
45
|
+
};
|