FN-7427: add GitLab signal connector
Add GitLab webhook ingestion to Command Center Signals. - Register GitLab as a supported signal provider with shared-token verification.\n- Normalize GitLab issue and merge request webhooks into capped Command Center signals.\n- Update tests, UI labels, docs, and changeset coverage for the new connector.\n\nFiles changed:\n .changeset/fn-7427-gitlab-signals.md | 7 +\n docs/dashboard-guide.md | 2 +-\n docs/settings-reference.md | 1 +\n docs/signals-connectors.md | 52 ++++-\n .../core/src/__tests__/signals-analytics.test.ts | 28 ++-\n .../__tests__/CommandCenter.mobile-scroll.test.tsx | 5 +-\n .../__tests__/CommandCenter.tablet-layout.test.tsx | 5 +-\n .../command-center/areas/SignalsArea.tsx | 5 +-\n .../areas/__tests__/areas.github-signals.test.tsx | 12 +-\n .../register-command-center-routes.test.ts | 7 +-\n .../src/__tests__/register-signal-routes.test.ts | 227 ++++++++++++++++++++-\n .../dashboard/src/routes/register-signal-routes.ts | 8 +-\n packages/dashboard/src/signal-source.ts | 23 ++-\n packages/dashboard/src/signal-sources/gitlab.ts | 219 ++++++++++++++++++++\n 14 files changed, 574 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-7427 Fusion-Task-Lineage: cfe94cb7-e9d5-4bec-9fc5-4a7e45b8c95d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7427-gitlab-signals.md
Normal file
7
.changeset/fn-7427-gitlab-signals.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add GitLab as a Command Center Signals connector.
|
||||
category: feature
|
||||
dev: Adds GitLab webhook token verification and issue/MR signal normalization for Command Center incidents.
|
||||
@@ -960,7 +960,7 @@ Features:
|
||||
- **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown.
|
||||
<!-- FNXC:CommandCenter 2026-06-21-07:07: FN-6722 requires the GitHub area to expose a resolved-issue detail list from local task-store analytics only, with exact close timestamps flagged when reconciliation populated `sourceIssueClosedAt` and approximation called out otherwise. -->
|
||||
- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, a by-repository bar breakdown, and a **Resolved issues** detail list. Resolved rows include the Fusion task, repository, source issue number, optional issue link, resolved timestamp, and whether that timestamp is exact (`sourceIssueClosedAt`) or the documented `updatedAt` approximation; missing issue URLs render as plain text rather than empty anchors or click targets. The same resolved rows are available from the GitHub analytics payload as `resolved` and from the CSV export.
|
||||
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. Verified external connectors (`POST /api/signals/webhook`, `/sentry`, `/datadog`, and `/pagerduty`) create triage tasks and also write/resolve incidents, so Signals shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns from connector traffic. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. The companion `/api/command-center/signals/connectors` endpoint returns only per-provider configured booleans, allowing the empty state to distinguish "no connector configured" from "connector configured, awaiting signals" without exposing secrets.
|
||||
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. Verified external connectors (`POST /api/signals/gitlab`, `/webhook`, `/sentry`, `/datadog`, and `/pagerduty`) create triage tasks and also write/resolve incidents, so Signals shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns from connector traffic. GitLab supports GitLab.com and self-managed project/group issue and merge-request webhooks through the environment-only `FUSION_SIGNAL_GITLAB_SECRET` and `X-Gitlab-Token` header; no GitLab CLI or server-side link fetch is used. Signals adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. The companion `/api/command-center/signals/connectors` endpoint returns only per-provider configured booleans, allowing the empty state to distinguish "no connector configured" from "connector configured, awaiting signals" without exposing secrets.
|
||||
- **System** is the canonical system-telemetry destination. It reads local telemetry from `GET /api/system-stats` and, when multiple registered nodes exist, shows a node selector that can proxy the same system-stats payload through `GET /api/nodes/:id/system-stats` for remote nodes. It renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. Host memory uses OS-available memory (Node `process.availableMemory()` when available, with a flagged `freemem` fallback) so macOS inactive/cache pages are not reported as used. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
|
||||
- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences.
|
||||
- CSV exports are available from the analytics endpoints with `?format=csv`. The Workflows CSV includes one row per workflow plus a summary row; the Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
|
||||
|
||||
@@ -35,6 +35,7 @@ Command Center signal connectors are configured with process environment variabl
|
||||
| `FUSION_SIGNAL_SENTRY_SECRET` | Sentry | `POST /api/signals/sentry` | Verifies `Sentry-Hook-Signature` against Sentry issue webhook payloads. |
|
||||
| `FUSION_SIGNAL_DATADOG_SECRET` | Datadog | `POST /api/signals/datadog` | Verifies the custom `X-Datadog-Signature` HMAC header; optional `X-Datadog-Timestamp` bounds replay. |
|
||||
| `FUSION_SIGNAL_PAGERDUTY_SECRET` | PagerDuty | `POST /api/signals/pagerduty` | Verifies `X-PagerDuty-Signature` (`v1=<hex>`). |
|
||||
| `FUSION_SIGNAL_GITLAB_SECRET` | GitLab | `POST /api/signals/gitlab` | Verifies GitLab's `X-Gitlab-Token` secret-token header for GitLab.com or self-managed project/group issue and merge-request webhooks. |
|
||||
| `FUSION_MONITOR_INGEST_SECRET` | Monitor incidents API | `POST /api/monitor/incidents` | Separate bearer-token path for direct monitor ingestion; it is not used by `/api/signals/:provider`. |
|
||||
|
||||
See [Signals Connectors](./signals-connectors.md) for setup, signing, payload, and open/resolved mapping details.
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
# Signals Connectors
|
||||
|
||||
Fusion can receive signed external signals from Sentry, Datadog, PagerDuty, or a generic webhook at:
|
||||
Fusion can receive signed external signals from GitLab, Sentry, Datadog, PagerDuty, or a generic webhook at:
|
||||
|
||||
```text
|
||||
POST /api/signals/:provider
|
||||
```
|
||||
|
||||
Supported providers are `webhook`, `sentry`, `datadog`, and `pagerduty`. Every connector requires an HMAC signing secret configured in the Fusion dashboard process environment. Verified signals still create triage tasks, and they also write to the project-scoped `incidents` table so Command Center → Signals can show source, severity, and open/resolved status breakdowns.
|
||||
|
||||
Forward-looking GitLab webhook/system-hook signal planning is mapped in [GitLab Parity Inventory](./gitlab-parity-inventory.md); GitLab is not a Signals connector until a later implementation task defines signed verification and payload normalization.
|
||||
Supported providers are `webhook`, `gitlab`, `sentry`, `datadog`, and `pagerduty`. Every connector requires a verification secret configured in the Fusion dashboard process environment. Generic webhook, Sentry, Datadog, and PagerDuty use HMAC signatures; GitLab uses GitLab's `X-Gitlab-Token` secret-token header. Verified signals still create triage tasks, and they also write to the project-scoped `incidents` table so Command Center → Signals can show source, severity, and open/resolved status breakdowns.
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
@@ -21,7 +19,7 @@ Forward-looking GitLab webhook/system-hook signal planning is mapped in [GitLab
|
||||
## Security model
|
||||
|
||||
- Secrets are environment variables; do not commit them to source control.
|
||||
- HMAC verification uses the raw request body and constant-time comparison.
|
||||
- HMAC verification uses the raw request body and constant-time comparison where the provider supplies an HMAC signature. GitLab secret-token verification compares `X-Gitlab-Token` to `FUSION_SIGNAL_GITLAB_SECRET` with constant-time comparison.
|
||||
- Requests are capped at about 1 MB.
|
||||
- Replay protection rejects stale timestamps where the provider supplies one and rejects repeated delivery ids within the replay window.
|
||||
- Normalized `title`, `body`, `groupingKey`, `link`, and `meta` fields are capped by `signal-source.ts` before storage.
|
||||
@@ -128,13 +126,55 @@ Normalization:
|
||||
- `severity`: explicit `data.severity` when it is one of Fusion's normalized severities; otherwise high urgency maps to `critical` and other events map to `warning`.
|
||||
- Resolution: `event.event_type === "incident.resolved"` or `data.status === "resolved"` resolves the grouped incident; other incident events open/absorb it.
|
||||
|
||||
## GitLab
|
||||
|
||||
Set:
|
||||
|
||||
```bash
|
||||
export FUSION_SIGNAL_GITLAB_SECRET="gitlab-webhook-secret-token"
|
||||
```
|
||||
|
||||
Configure a GitLab project or group webhook with:
|
||||
|
||||
- **URL**: `https://<your-fusion-host>/api/signals/gitlab`
|
||||
- **Secret token**: the same value as `FUSION_SIGNAL_GITLAB_SECRET`
|
||||
- **Triggers**: Issue events and/or Merge request events
|
||||
|
||||
Fusion verifies GitLab's `X-Gitlab-Token` header. GitLab's webhook docs now recommend signing tokens for new webhooks, but this connector intentionally supports the documented secret-token compatibility path required by existing GitLab.com and self-managed GitLab installations. This task introduces no GitLab binary, CLI, download, or checksum-managed artifact.
|
||||
|
||||
Supported GitLab events:
|
||||
|
||||
- Project and group **Issue Hook** payloads with `object_kind`/`event_type` of `issue`.
|
||||
- Project and group **Merge Request Hook** payloads with `object_kind`/`event_type` of `merge_request`.
|
||||
- Work item issue payloads (`object_kind: "work_item"`) are treated as issue signals when they provide issue-shaped `object_attributes`.
|
||||
|
||||
Normalization:
|
||||
|
||||
- `source`: `gitlab`.
|
||||
- `groupingKey`: `gitlab:<project-or-group>:<issue|merge_request>:<iid>` using `project.path_with_namespace`, project id, group full path, or group id when available.
|
||||
- `externalId`: GitLab delivery headers such as `X-Gitlab-Event-UUID`, `Idempotency-Key`, `webhook-id`, or `X-Request-Id` when present; otherwise a stable fallback that includes grouping key, action, state, and timestamp so open and recovery events for the same IID are not deduped together.
|
||||
- `title`: `GitLab issue #<iid>: <title>` or `GitLab merge request !<iid>: <title>`.
|
||||
- `link`: `object_attributes.url` when safe, otherwise a project URL fallback. Fusion supports both GitLab.com and self-managed instance URLs and never fetches these links server-side.
|
||||
- `severity`: GitLab issue severity values such as `critical`, `high`, `medium`, and `low` map to Fusion's normalized severities; merge requests default to `info`.
|
||||
- Resolution: issue `action`/`state` of `close`/`closed` resolves the grouped signal; merge request `merge`/`merged` or `close`/`closed` resolves it; `open`, `update`, and `reopen` open/absorb it.
|
||||
|
||||
Non-actionable or unsupported GitLab events, including push/test/ping/system-hook-style payloads, are accepted as non-actionable and do not create tasks. Malformed actionable issue/MR payloads, such as missing `object_attributes.iid` or `object_attributes.title`, return a 4xx response and do not create tasks.
|
||||
|
||||
Relevant upstream evidence:
|
||||
|
||||
- GitLab upstream project: <https://gitlab.com/gitlab-org/gitlab>
|
||||
- GitLab webhook docs: <https://docs.gitlab.com/user/project/integrations/webhooks/>
|
||||
- GitLab webhook event reference: <https://docs.gitlab.com/user/project/integrations/webhook_events/>
|
||||
- GitLab group webhooks: <https://docs.gitlab.com/user/group/webhooks/>
|
||||
- GitLab releases: <https://gitlab.com/gitlab-org/gitlab/-/releases>
|
||||
|
||||
## Command Center Signals
|
||||
|
||||
Command Center → Signals reads aggregated incidents through `GET /api/command-center/signals`. Once a connector secret is configured and signed events arrive, the area displays total, open, resolved, MTTR, by-source, by-severity, and by-status metrics from local incident rows.
|
||||
|
||||
The empty state is intentionally explicit:
|
||||
|
||||
- no configured connector secret: prompt operators to connect Sentry, Datadog, PagerDuty, or the generic webhook;
|
||||
- no configured connector secret: prompt operators to connect GitLab, Sentry, Datadog, PagerDuty, or the generic webhook;
|
||||
- at least one configured connector secret but no rows in the selected range: report that Fusion is configured and awaiting signals.
|
||||
|
||||
## Separate monitor ingest path
|
||||
|
||||
@@ -70,6 +70,19 @@ describe("signals-analytics", () => {
|
||||
source: "pagerduty",
|
||||
severity: "warning",
|
||||
});
|
||||
insertIncident(db, {
|
||||
status: "open",
|
||||
openedAt: "2026-03-04T10:00:00.000Z",
|
||||
source: "gitlab",
|
||||
severity: "error",
|
||||
});
|
||||
insertIncident(db, {
|
||||
status: "resolved",
|
||||
openedAt: "2026-03-05T10:00:00.000Z",
|
||||
resolvedAt: "2026-03-05T10:15:00.000Z",
|
||||
source: "gitlab",
|
||||
severity: "info",
|
||||
});
|
||||
insertIncident(db, {
|
||||
status: "resolved",
|
||||
openedAt: "2026-02-01T10:00:00.000Z",
|
||||
@@ -80,21 +93,24 @@ describe("signals-analytics", () => {
|
||||
|
||||
const result = aggregateSignalsAnalytics(db, RANGE);
|
||||
|
||||
expect(result.totalSignals).toBe(2);
|
||||
expect(result.open).toBe(1);
|
||||
expect(result.resolved).toBe(1);
|
||||
expect(result.mttr).toEqual({ value: 45, unavailable: false, sampleCount: 1 });
|
||||
expect(result.totalSignals).toBe(4);
|
||||
expect(result.open).toBe(2);
|
||||
expect(result.resolved).toBe(2);
|
||||
expect(result.mttr).toEqual({ value: 30, unavailable: false, sampleCount: 2 });
|
||||
expect(result.bySource).toEqual([
|
||||
{ source: "gitlab", count: 2 },
|
||||
{ source: "pagerduty", count: 1 },
|
||||
{ source: "sentry", count: 1 },
|
||||
]);
|
||||
expect(result.bySeverity).toEqual([
|
||||
{ severity: "critical", count: 1 },
|
||||
{ severity: "error", count: 1 },
|
||||
{ severity: "info", count: 1 },
|
||||
{ severity: "warning", count: 1 },
|
||||
]);
|
||||
expect(result.byStatus).toEqual([
|
||||
{ status: "open", count: 1 },
|
||||
{ status: "resolved", count: 1 },
|
||||
{ status: "open", count: 2 },
|
||||
{ status: "resolved", count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -185,7 +185,10 @@ function populatedSignalsFixture() {
|
||||
open: 2,
|
||||
resolved: 1,
|
||||
mttr: { value: 30, unavailable: false },
|
||||
bySource: [{ source: "sentry", count: 2 }],
|
||||
bySource: [
|
||||
{ source: "gitlab", count: 1 },
|
||||
{ source: "sentry", count: 2 },
|
||||
],
|
||||
bySeverity: [{ severity: "high", count: 1 }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,7 +184,10 @@ function populatedSignalsFixture() {
|
||||
open: 2,
|
||||
resolved: 1,
|
||||
mttr: { value: 30, unavailable: false },
|
||||
bySource: [{ source: "sentry", count: 2 }],
|
||||
bySource: [
|
||||
{ source: "gitlab", count: 1 },
|
||||
{ source: "sentry", count: 2 },
|
||||
],
|
||||
bySeverity: [{ severity: "high", count: 1 }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ Signals area of the Command Center (PR #1683). Surfaces external-signal volume/s
|
||||
FNXC:CommandCenter 2026-06-19-00:00:
|
||||
Signals now reads a real `/api/command-center/signals` route backed by incidents instead of swallowing a missing endpoint. Empty still means no incident source has recorded data, and MTTR remains `—` until at least one incident is resolved. FN-6706 owns building external Sentry/Datadog/PagerDuty/webhook connectors into that incidents table.
|
||||
|
||||
FNXC:CommandCenterSignals 2026-07-02-00:00:
|
||||
GitLab is a first-class Signals connector beside Sentry, Datadog, PagerDuty, and generic webhooks. Empty/setup copy must include GitLab while preserving the existing no-new-affordance Signals layout across desktop and mobile.
|
||||
|
||||
FNXC:CommandCenterSignals 2026-06-25-22:40:
|
||||
Empty Signals copy is driven by the connectors-status endpoint, not fabricated metrics. Operators must see "no connector configured" when no HMAC secret exists and "configured, awaiting signals" when ingestion is ready but quiet; loading the status should keep the normal loading-before-empty behavior.
|
||||
*/
|
||||
@@ -78,7 +81,7 @@ export function SignalsArea({ range }: { range: DateRange }) {
|
||||
)
|
||||
: t(
|
||||
"commandCenter.signals.emptyNoConnectorConfigured",
|
||||
"No signal connector configured. Connect Sentry, Datadog, PagerDuty, or a generic webhook to see incident metrics here.",
|
||||
"No signal connector configured. Connect GitLab, Sentry, Datadog, PagerDuty, or a generic webhook to see incident metrics here.",
|
||||
);
|
||||
const hasStatusPie = !isEmpty && statusPieData.some((datum) => datum.value > 0);
|
||||
|
||||
|
||||
@@ -373,7 +373,10 @@ describe("SignalsArea", () => {
|
||||
open: 3,
|
||||
resolved: 5,
|
||||
mttr: { value: 42, unavailable: false },
|
||||
bySource: [{ source: "sentry", count: 8 }],
|
||||
bySource: [
|
||||
{ source: "gitlab", count: 3 },
|
||||
{ source: "sentry", count: 5 },
|
||||
],
|
||||
bySeverity: [{ severity: "error", count: 8 }],
|
||||
},
|
||||
{ connectors: [] },
|
||||
@@ -384,6 +387,7 @@ describe("SignalsArea", () => {
|
||||
expect(screen.getByTestId("cc-signals-mttr").textContent).toContain("42");
|
||||
expect(screen.getByTestId("cc-signals-pie")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Signal status share" })).toBeTruthy();
|
||||
expect(screen.getByTestId("cc-area-signals").textContent).toContain("gitlab");
|
||||
});
|
||||
|
||||
it("keeps signals pie safe for single-item and non-finite source/severity data", async () => {
|
||||
@@ -421,6 +425,7 @@ describe("SignalsArea", () => {
|
||||
render(<SignalsArea range={range7d} />);
|
||||
const empty = await screen.findByTestId("cc-area-signals-empty");
|
||||
expect(empty.textContent).toContain("No signal connector configured");
|
||||
expect(empty.textContent).toContain("GitLab");
|
||||
expect(screen.queryByTestId("cc-signals-pie")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -454,10 +459,11 @@ describe("SignalsArea", () => {
|
||||
mttr: { value: null, unavailable: true },
|
||||
bySource: [],
|
||||
bySeverity: [],
|
||||
connectors: { configured: ["sentry", "pagerduty"], anyConfigured: true },
|
||||
connectors: { configured: ["gitlab", "sentry", "pagerduty"], anyConfigured: true },
|
||||
},
|
||||
{
|
||||
connectors: [
|
||||
{ provider: "gitlab", configured: true },
|
||||
{ provider: "sentry", configured: true },
|
||||
{ provider: "pagerduty", configured: true },
|
||||
],
|
||||
@@ -466,7 +472,7 @@ describe("SignalsArea", () => {
|
||||
const rendered = render(<SignalsArea range={range7d} />);
|
||||
const empty = await screen.findByTestId("cc-area-signals-empty");
|
||||
expect(empty.textContent).toContain("Connector configured, awaiting signals in this range");
|
||||
expect(empty.textContent).toContain("sentry, pagerduty");
|
||||
expect(empty.textContent).toContain("gitlab, sentry, pagerduty");
|
||||
expect(screen.queryByTestId("cc-signals-pie")).toBeNull();
|
||||
rendered.unmount();
|
||||
}
|
||||
|
||||
@@ -267,6 +267,7 @@ const SIGNAL_SECRET_ENV_KEYS = [
|
||||
"FUSION_SIGNAL_SENTRY_SECRET",
|
||||
"FUSION_SIGNAL_DATADOG_SECRET",
|
||||
"FUSION_SIGNAL_PAGERDUTY_SECRET",
|
||||
"FUSION_SIGNAL_GITLAB_SECRET",
|
||||
] as const;
|
||||
|
||||
describe("register-command-center-routes", () => {
|
||||
@@ -611,6 +612,7 @@ describe("register-command-center-routes", () => {
|
||||
|
||||
process.env.FUSION_SIGNAL_SENTRY_SECRET = "configured-sentry";
|
||||
process.env.FUSION_SIGNAL_WEBHOOK_SECRET = "configured-webhook";
|
||||
process.env.FUSION_SIGNAL_GITLAB_SECRET = "configured-gitlab";
|
||||
seedSignalMetrics(dbA, { prefix: "SIG-A", source: "sentry", open: 1, resolved: 1 });
|
||||
const signals = await request(app, "GET", `/api/command-center/signals?${range}&projectId=proj-a`);
|
||||
expect(signals.status).toBe(200);
|
||||
@@ -618,7 +620,7 @@ describe("register-command-center-routes", () => {
|
||||
totalSignals: 2,
|
||||
open: 1,
|
||||
resolved: 1,
|
||||
connectors: { configured: ["webhook", "sentry"], anyConfigured: true },
|
||||
connectors: { configured: ["webhook", "sentry", "gitlab"], anyConfigured: true },
|
||||
});
|
||||
expect(signals.body).toHaveProperty("mttr");
|
||||
expect(signals.body).toHaveProperty("bySource");
|
||||
@@ -876,6 +878,7 @@ describe("register-command-center-routes", () => {
|
||||
it("signals connectors endpoint reports env-backed configuration without leaking secrets", async () => {
|
||||
process.env.FUSION_SIGNAL_WEBHOOK_SECRET = "webhook-secret-value";
|
||||
process.env.FUSION_SIGNAL_DATADOG_SECRET = "datadog-secret-value";
|
||||
process.env.FUSION_SIGNAL_GITLAB_SECRET = "gitlab-secret-value";
|
||||
|
||||
const a = await request(app, "GET", "/api/command-center/signals/connectors?projectId=proj-a");
|
||||
const b = await request(app, "GET", "/api/command-center/signals/connectors?projectId=proj-b");
|
||||
@@ -889,11 +892,13 @@ describe("register-command-center-routes", () => {
|
||||
{ provider: "sentry", configured: false },
|
||||
{ provider: "datadog", configured: true },
|
||||
{ provider: "pagerduty", configured: false },
|
||||
{ provider: "gitlab", configured: true },
|
||||
],
|
||||
});
|
||||
const serialized = JSON.stringify(a.body);
|
||||
expect(serialized).not.toContain("webhook-secret-value");
|
||||
expect(serialized).not.toContain("datadog-secret-value");
|
||||
expect(serialized).not.toContain("gitlab-secret-value");
|
||||
});
|
||||
|
||||
it("signals endpoint defaults invalid ranges and stays project scoped", async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { webhookSource } from "../signal-sources/webhook.js";
|
||||
import { sentrySource } from "../signal-sources/sentry.js";
|
||||
import { datadogSource } from "../signal-sources/datadog.js";
|
||||
import { pagerdutySource } from "../signal-sources/pagerduty.js";
|
||||
import { gitlabSource } from "../signal-sources/gitlab.js";
|
||||
|
||||
function sign(body: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(Buffer.from(body)).digest("hex");
|
||||
@@ -75,6 +76,7 @@ const SECRETS: Record<string, string> = {
|
||||
FUSION_SIGNAL_SENTRY_SECRET: "sentry-secret",
|
||||
FUSION_SIGNAL_DATADOG_SECRET: "datadog-secret",
|
||||
FUSION_SIGNAL_PAGERDUTY_SECRET: "pd-secret",
|
||||
FUSION_SIGNAL_GITLAB_SECRET: "gitlab-secret",
|
||||
};
|
||||
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
@@ -118,15 +120,18 @@ function signedSignalContext(source: SignalSource, payload: object) {
|
||||
return ctxFor(source, payload, { "x-datadog-signature": sign(raw, SECRETS.FUSION_SIGNAL_DATADOG_SECRET) });
|
||||
case "pagerduty":
|
||||
return ctxFor(source, payload, { "x-pagerduty-signature": `v1=${sign(raw, SECRETS.FUSION_SIGNAL_PAGERDUTY_SECRET)}` });
|
||||
case "gitlab":
|
||||
return ctxFor(source, payload, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET });
|
||||
}
|
||||
}
|
||||
|
||||
describe("getSignalSource registry", () => {
|
||||
it("resolves all four providers and rejects unknown", () => {
|
||||
it("resolves all five providers and rejects unknown", () => {
|
||||
expect(getSignalSource("webhook")).toBe(webhookSource);
|
||||
expect(getSignalSource("sentry")).toBe(sentrySource);
|
||||
expect(getSignalSource("datadog")).toBe(datadogSource);
|
||||
expect(getSignalSource("pagerduty")).toBe(pagerdutySource);
|
||||
expect(getSignalSource("gitlab")).toBe(gitlabSource);
|
||||
expect(getSignalSource("bogus")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -369,6 +374,208 @@ describe("ingestSignal — Datadog & PagerDuty adapters (groupingKey from native
|
||||
});
|
||||
});
|
||||
|
||||
function gitlabIssuePayload(overrides: Record<string, unknown> = {}) {
|
||||
const { object_attributes: objectAttributeOverrides, ...topLevelOverrides } = overrides;
|
||||
const object_attributes = {
|
||||
id: 301,
|
||||
iid: 23,
|
||||
title: "New API: create/update/delete file",
|
||||
description: "Create new API for repository file edits",
|
||||
state: "opened",
|
||||
action: "open",
|
||||
severity: "high",
|
||||
url: "https://gitlab.example.com/gitlabhq/gitlab-test/-/issues/23",
|
||||
updated_at: new Date().toISOString(),
|
||||
...(asRecord(objectAttributeOverrides)),
|
||||
};
|
||||
return {
|
||||
object_kind: "issue",
|
||||
event_type: "issue",
|
||||
user: { username: "root" },
|
||||
project: {
|
||||
id: 14,
|
||||
name: "Gitlab Test",
|
||||
path_with_namespace: "gitlabhq/gitlab-test",
|
||||
web_url: "https://gitlab.example.com/gitlabhq/gitlab-test",
|
||||
},
|
||||
object_attributes,
|
||||
...topLevelOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
function gitlabMergeRequestPayload(overrides: Record<string, unknown> = {}) {
|
||||
const { object_attributes: objectAttributeOverrides, ...topLevelOverrides } = overrides;
|
||||
const object_attributes = {
|
||||
id: 701,
|
||||
iid: 7,
|
||||
title: "Improve alerts",
|
||||
description: "MR description",
|
||||
state: "opened",
|
||||
action: "open",
|
||||
url: "https://gitlab.com/acme/ops/-/merge_requests/7",
|
||||
updated_at: new Date().toISOString(),
|
||||
...(asRecord(objectAttributeOverrides)),
|
||||
};
|
||||
return {
|
||||
object_kind: "merge_request",
|
||||
event_type: "merge_request",
|
||||
project: {
|
||||
id: 99,
|
||||
name: "ops",
|
||||
path_with_namespace: "acme/ops",
|
||||
web_url: "https://gitlab.com/acme/ops",
|
||||
},
|
||||
object_attributes,
|
||||
...topLevelOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
describe("ingestSignal — GitLab adapter", () => {
|
||||
it("creates a triage task for a valid project issue webhook from a self-managed URL", async () => {
|
||||
const store = makeStore();
|
||||
const payload = gitlabIssuePayload();
|
||||
const res = await ingestSignal({
|
||||
source: gitlabSource,
|
||||
store,
|
||||
...ctxFor(gitlabSource, payload, {
|
||||
"x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET,
|
||||
"x-gitlab-event": "Issue Hook",
|
||||
"x-gitlab-event-uuid": "gl-delivery-issue-open",
|
||||
}),
|
||||
nonceCache: new DeliveryNonceCache(),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store._tasks).toHaveLength(1);
|
||||
expect(store._tasks[0].title).toBe("GitLab issue #23: New API: create/update/delete file");
|
||||
expect(store._tasks[0].description).toContain("https://gitlab.example.com/gitlabhq/gitlab-test/-/issues/23");
|
||||
const meta = store._tasks[0].source?.sourceMetadata as Record<string, unknown>;
|
||||
expect(meta.signalSource).toBe("gitlab");
|
||||
expect(meta.signalDeliveryId).toBe("delivery:gl-delivery-issue-open");
|
||||
expect(meta.signalGroupingKey).toBe("gitlab:gitlabhq/gitlab-test:issue:23");
|
||||
expect(meta.signalSeverity).toBe("error");
|
||||
});
|
||||
|
||||
it("normalizes group issue hooks and GitLab.com merge request hooks", async () => {
|
||||
const issue = gitlabIssuePayload({
|
||||
project: undefined,
|
||||
group: { id: 5, name: "Platform", full_path: "platform" },
|
||||
object_attributes: { iid: 44, title: "Group issue", action: "reopen", state: "opened", url: "https://gitlab.example.net/groups/platform/-/work_items/44" },
|
||||
});
|
||||
const mr = gitlabMergeRequestPayload({ object_attributes: { action: "merge", state: "merged" } });
|
||||
|
||||
const issueSignal = gitlabSource.normalize(issue, ctxFor(gitlabSource, issue, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET }));
|
||||
const mrSignal = gitlabSource.normalize(mr, ctxFor(gitlabSource, mr, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET }));
|
||||
|
||||
expect(issueSignal).toMatchObject({
|
||||
source: "gitlab",
|
||||
groupingKey: "gitlab:platform:issue:44",
|
||||
resolution: "open",
|
||||
link: "https://gitlab.example.net/groups/platform/-/work_items/44",
|
||||
});
|
||||
expect(mrSignal).toMatchObject({
|
||||
source: "gitlab",
|
||||
groupingKey: "gitlab:acme/ops:merge_request:7",
|
||||
resolution: "resolved",
|
||||
link: "https://gitlab.com/acme/ops/-/merge_requests/7",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps issue and merge-request lifecycle actions without suppressing recovery events", async () => {
|
||||
const { db, store } = makeDbStore();
|
||||
const open = gitlabIssuePayload({ object_attributes: { action: "open", state: "opened", updated_at: "2026-03-04T00:00:00.000Z" } });
|
||||
const close = gitlabIssuePayload({ object_attributes: { action: "close", state: "closed", updated_at: "2026-03-04T00:05:00.000Z" } });
|
||||
|
||||
expect((await ingestSignal({
|
||||
source: gitlabSource,
|
||||
store,
|
||||
...ctxFor(gitlabSource, open, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET, "x-gitlab-event-uuid": "gl-issue-open" }),
|
||||
nonceCache: new DeliveryNonceCache(),
|
||||
})).status).toBe(201);
|
||||
expect((await ingestSignal({
|
||||
source: gitlabSource,
|
||||
store,
|
||||
...ctxFor(gitlabSource, close, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET, "x-gitlab-event-uuid": "gl-issue-close" }),
|
||||
nonceCache: new DeliveryNonceCache(),
|
||||
})).status).toBe(201);
|
||||
|
||||
expect(store._tasks).toHaveLength(2);
|
||||
expect(incidents(db)).toMatchObject([{ groupingKey: "gitlab:gitlabhq/gitlab-test:issue:23", source: "gitlab", status: "resolved" }]);
|
||||
const updateSignal = gitlabSource.normalize(gitlabIssuePayload({ object_attributes: { action: "update", state: "opened" } }), ctxFor(gitlabSource, open, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET }));
|
||||
const reopenSignal = gitlabSource.normalize(gitlabIssuePayload({ object_attributes: { action: "reopen", state: "opened" } }), ctxFor(gitlabSource, open, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET }));
|
||||
const mergedSignal = gitlabSource.normalize(gitlabMergeRequestPayload({ object_attributes: { action: "merge", state: "merged" } }), ctxFor(gitlabSource, open, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET }));
|
||||
expect(updateSignal?.resolution).toBe("open");
|
||||
expect(reopenSignal?.resolution).toBe("open");
|
||||
expect(mergedSignal?.resolution).toBe("resolved");
|
||||
});
|
||||
|
||||
it("rejects missing secret, missing token, and invalid token without creating tasks", async () => {
|
||||
const payload = gitlabIssuePayload();
|
||||
const rawBody = Buffer.from(JSON.stringify(payload));
|
||||
const body = payload;
|
||||
|
||||
delete process.env.FUSION_SIGNAL_GITLAB_SECRET;
|
||||
const missingSecretStore = makeStore();
|
||||
expect((await ingestSignal({ source: gitlabSource, store: missingSecretStore, rawBody, headers: { "x-gitlab-token": "x" }, body, nonceCache: new DeliveryNonceCache() })).status).toBe(401);
|
||||
process.env.FUSION_SIGNAL_GITLAB_SECRET = SECRETS.FUSION_SIGNAL_GITLAB_SECRET;
|
||||
|
||||
const missingTokenStore = makeStore();
|
||||
expect((await ingestSignal({ source: gitlabSource, store: missingTokenStore, rawBody, headers: {}, body, nonceCache: new DeliveryNonceCache() })).status).toBe(401);
|
||||
const invalidTokenStore = makeStore();
|
||||
expect((await ingestSignal({ source: gitlabSource, store: invalidTokenStore, rawBody, headers: { "x-gitlab-token": "wrong" }, body, nonceCache: new DeliveryNonceCache() })).status).toBe(401);
|
||||
expect(missingSecretStore._tasks).toHaveLength(0);
|
||||
expect(missingTokenStore._tasks).toHaveLength(0);
|
||||
expect(invalidTokenStore._tasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts unsupported GitLab ping events as non-actionable and rejects malformed actionable payloads", async () => {
|
||||
const pingStore = makeStore();
|
||||
const ping = { object_kind: "push", event_name: "push" };
|
||||
expect((await ingestSignal({
|
||||
source: gitlabSource,
|
||||
store: pingStore,
|
||||
...ctxFor(gitlabSource, ping, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET, "x-gitlab-event": "Push Hook" }),
|
||||
nonceCache: new DeliveryNonceCache(),
|
||||
})).status).toBe(200);
|
||||
expect(pingStore._tasks).toHaveLength(0);
|
||||
|
||||
const malformedStore = makeStore();
|
||||
const malformed = { object_kind: "issue", object_attributes: { title: "Missing IID" } };
|
||||
expect((await ingestSignal({
|
||||
source: gitlabSource,
|
||||
store: malformedStore,
|
||||
...ctxFor(gitlabSource, malformed, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET, "x-gitlab-event": "Issue Hook" }),
|
||||
nonceCache: new DeliveryNonceCache(),
|
||||
})).status).toBe(400);
|
||||
expect(malformedStore._tasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops unsafe links, applies field caps, and keeps fallback external ids action-distinct", () => {
|
||||
const longTitle = "x".repeat(400);
|
||||
const payload = gitlabIssuePayload({
|
||||
project: { id: 10, path_with_namespace: "internal/project", web_url: "https://gitlab.example.com/internal/project" },
|
||||
object_attributes: {
|
||||
iid: 88,
|
||||
title: longTitle,
|
||||
description: "y".repeat(9000),
|
||||
action: "close",
|
||||
state: "closed",
|
||||
url: "http://127.0.0.1/internal",
|
||||
updated_at: "2026-03-04T00:05:00.000Z",
|
||||
},
|
||||
});
|
||||
const signal = gitlabSource.normalize(payload, ctxFor(gitlabSource, payload, { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET }));
|
||||
expect(signal?.title.length).toBeLessThanOrEqual(300);
|
||||
expect(signal?.body?.length).toBeLessThanOrEqual(8000);
|
||||
expect(signal?.link).toBeUndefined();
|
||||
expect(signal?.externalId).toContain(":close:closed:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ingestSignal — incident capture", () => {
|
||||
it("writes source and normalized severity for all configured providers", async () => {
|
||||
const cases = [
|
||||
@@ -414,6 +621,14 @@ describe("ingestSignal — incident capture", () => {
|
||||
},
|
||||
expected: { source: "pagerduty", severity: "critical", groupingKey: "pd-1" },
|
||||
},
|
||||
{
|
||||
source: gitlabSource,
|
||||
payload: gitlabIssuePayload({ object_attributes: { iid: 77, title: "GitLab incident", severity: "critical" } }),
|
||||
headers() {
|
||||
return { "x-gitlab-token": SECRETS.FUSION_SIGNAL_GITLAB_SECRET, "x-gitlab-event-uuid": "gl-incident-capture" };
|
||||
},
|
||||
expected: { source: "gitlab", severity: "critical", groupingKey: "gitlab:gitlabhq/gitlab-test:issue:77" },
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const c of cases) {
|
||||
@@ -518,6 +733,13 @@ describe("ingestSignal — incident capture", () => {
|
||||
},
|
||||
expectedSource: "pagerduty",
|
||||
},
|
||||
{
|
||||
source: gitlabSource,
|
||||
groupingKey: "gitlab:gitlabhq/gitlab-test:issue:23",
|
||||
openPayload: gitlabIssuePayload({ object_attributes: { action: "open", state: "opened", updated_at: new Date(now).toISOString() } }),
|
||||
resolvePayload: gitlabIssuePayload({ object_attributes: { action: "close", state: "closed", updated_at: new Date(now + 1_000).toISOString() } }),
|
||||
expectedSource: "gitlab",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const c of cases) {
|
||||
@@ -687,7 +909,8 @@ describe("helpers", () => {
|
||||
expect(resolveConfiguredSignalProviders({
|
||||
FUSION_SIGNAL_WEBHOOK_SECRET: "wh",
|
||||
FUSION_SIGNAL_PAGERDUTY_SECRET: "pd",
|
||||
})).toEqual(["webhook", "pagerduty"]);
|
||||
FUSION_SIGNAL_GITLAB_SECRET: "gl",
|
||||
})).toEqual(["webhook", "pagerduty", "gitlab"]);
|
||||
});
|
||||
|
||||
it("signalToTaskInput maps to a triage task with provenance metadata", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { webhookSource } from "../signal-sources/webhook.js";
|
||||
import { sentrySource } from "../signal-sources/sentry.js";
|
||||
import { datadogSource } from "../signal-sources/datadog.js";
|
||||
import { pagerdutySource } from "../signal-sources/pagerduty.js";
|
||||
import { gitlabSource } from "../signal-sources/gitlab.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,7 @@ const SIGNAL_SOURCES: Record<SignalProvider, SignalSource> = {
|
||||
sentry: sentrySource,
|
||||
datadog: datadogSource,
|
||||
pagerduty: pagerdutySource,
|
||||
gitlab: gitlabSource,
|
||||
};
|
||||
|
||||
export function getSignalSource(provider: string): SignalSource | undefined {
|
||||
@@ -49,9 +51,9 @@ export function getSignalSource(provider: string): SignalSource | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider's HMAC secret. Env var is the canonical, never
|
||||
* source-controlled source. An optional resolver (e.g. encrypted settings) can
|
||||
* be supplied for deployments that store secrets there.
|
||||
* Resolve a provider's webhook verification secret. Env var is the canonical,
|
||||
* never source-controlled source. An optional resolver (e.g. encrypted settings)
|
||||
* can be supplied for deployments that store secrets there.
|
||||
*/
|
||||
export function resolveSignalSecret(
|
||||
source: SignalSource,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
export type SignalSeverity = "critical" | "error" | "warning" | "info";
|
||||
|
||||
/** Supported external signal providers. */
|
||||
export type SignalProvider = "sentry" | "datadog" | "pagerduty" | "webhook";
|
||||
export type SignalProvider = "sentry" | "datadog" | "pagerduty" | "webhook" | "gitlab";
|
||||
|
||||
/** Normalized lifecycle intent for an ingested signal. */
|
||||
export type SignalResolution = "open" | "resolved";
|
||||
@@ -119,7 +119,7 @@ export interface SignalSource {
|
||||
* source-controlled; they come from the environment (or encrypted settings).
|
||||
*/
|
||||
readonly secretEnvVar: string;
|
||||
/** Mandatory HMAC signature verification against a per-provider secret. */
|
||||
/** Mandatory provider verification against a per-provider secret. */
|
||||
verify(ctx: SignalVerifyContext): SignalVerifyResult;
|
||||
/**
|
||||
* Normalize a parsed payload into a {@link Signal}. Throws (or returns null)
|
||||
@@ -150,6 +150,25 @@ export function verifyHmacSignature(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time comparison for providers whose webhook auth is a shared token
|
||||
* header rather than a body HMAC.
|
||||
*
|
||||
* FNXC:CommandCenterSignals 2026-07-02-00:00:
|
||||
* GitLab secret-token webhooks authenticate with `X-Gitlab-Token` instead of a local CLI or downloaded binary. Keep HMAC verification unchanged for existing providers while using the same timing-safe comparison discipline for GitLab.com and self-managed GitLab inbound webhooks.
|
||||
*/
|
||||
export function verifySharedSecretToken(token: string | undefined, secret: string): boolean {
|
||||
if (!token) return false;
|
||||
const tokenBuffer = Buffer.from(token);
|
||||
const secretBuffer = Buffer.from(secret);
|
||||
if (tokenBuffer.byteLength !== secretBuffer.byteLength) return false;
|
||||
try {
|
||||
return timingSafeEqual(tokenBuffer, secretBuffer);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the provider event timestamp is inside the replay window. */
|
||||
export function isWithinReplayWindow(
|
||||
timestampMs: number | undefined,
|
||||
|
||||
219
packages/dashboard/src/signal-sources/gitlab.ts
Normal file
219
packages/dashboard/src/signal-sources/gitlab.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
applySignalCaps,
|
||||
verifySharedSecretToken,
|
||||
type Signal,
|
||||
type SignalSeverity,
|
||||
type SignalSource,
|
||||
type SignalVerifyContext,
|
||||
type SignalVerifyResult,
|
||||
} from "../signal-source.js";
|
||||
|
||||
/*
|
||||
FNXC:CommandCenterSignals 2026-07-02-00:00:
|
||||
GitLab Command Center signals must support GitLab.com and self-managed instances through inbound HTTP webhooks only. Verify GitLab's `X-Gitlab-Token` secret-token header, normalize issue/MR payloads from project or group hooks, and never add a local GitLab CLI/download dependency or hard-code gitlab.com.
|
||||
*/
|
||||
|
||||
type GitLabObject = Record<string, unknown>;
|
||||
|
||||
type GitLabSignalKind = "issue" | "merge_request";
|
||||
|
||||
function asObject(value: unknown): GitLabObject | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as GitLabObject) : undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function asId(value: unknown): string | undefined {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
const str = asString(value);
|
||||
if (str) return str;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseTimestamp(...values: unknown[]): number | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveKind(payload: GitLabObject, headers: Record<string, string | undefined>): GitLabSignalKind | null {
|
||||
const kind = asString(payload.object_kind) ?? asString(payload.event_type);
|
||||
if (kind === "issue" || kind === "work_item") return "issue";
|
||||
if (kind === "merge_request") return "merge_request";
|
||||
|
||||
const event = headers["x-gitlab-event"]?.toLowerCase();
|
||||
if (event?.includes("issue")) return "issue";
|
||||
if (event?.includes("merge request")) return "merge_request";
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapGitLabSeverity(kind: GitLabSignalKind, attrs: GitLabObject): SignalSeverity {
|
||||
const severity = asString(attrs.severity)?.toLowerCase();
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
case "blocker":
|
||||
return "critical";
|
||||
case "high":
|
||||
case "major":
|
||||
return "error";
|
||||
case "medium":
|
||||
case "warning":
|
||||
case "at_risk":
|
||||
return "warning";
|
||||
case "low":
|
||||
case "info":
|
||||
return "info";
|
||||
}
|
||||
|
||||
const health = asString(attrs.health_status)?.toLowerCase();
|
||||
if (health === "at_risk") return "warning";
|
||||
if (health === "needs_attention") return "error";
|
||||
return kind === "issue" ? "warning" : "info";
|
||||
}
|
||||
|
||||
function mapGitLabResolution(kind: GitLabSignalKind, attrs: GitLabObject): Signal["resolution"] {
|
||||
const action = asString(attrs.action)?.toLowerCase();
|
||||
const state = asString(attrs.state)?.toLowerCase();
|
||||
if (action === "close" || action === "closed") return "resolved";
|
||||
if (kind === "merge_request" && (action === "merge" || action === "merged")) return "resolved";
|
||||
if (state === "closed" || state === "merged") return "resolved";
|
||||
return "open";
|
||||
}
|
||||
|
||||
function projectIdentity(payload: GitLabObject): {
|
||||
id?: string;
|
||||
path?: string;
|
||||
name?: string;
|
||||
webUrl?: string;
|
||||
} {
|
||||
const project = asObject(payload.project) ?? asObject(payload.repository) ?? {};
|
||||
const group = asObject(payload.group) ?? {};
|
||||
return {
|
||||
id: asId(project.id) ?? asId(payload.project_id) ?? asId(group.id),
|
||||
path: asString(project.path_with_namespace) ?? asString(project.path) ?? asString(group.full_path),
|
||||
name: asString(project.name) ?? asString(group.name),
|
||||
webUrl: firstString(project.web_url, project.homepage, group.web_url),
|
||||
};
|
||||
}
|
||||
|
||||
function labelsFrom(payload: GitLabObject, attrs: GitLabObject): string[] | undefined {
|
||||
const raw = Array.isArray(attrs.labels) ? attrs.labels : Array.isArray(payload.labels) ? payload.labels : undefined;
|
||||
if (!raw) return undefined;
|
||||
const labels = raw
|
||||
.map((label) => asObject(label)?.title ?? asString(label))
|
||||
.filter((label): label is string => Boolean(label));
|
||||
return labels.length > 0 ? labels : undefined;
|
||||
}
|
||||
|
||||
function deliveryIdFrom(headers: Record<string, string | undefined>): string | undefined {
|
||||
return firstString(
|
||||
headers["x-gitlab-event-uuid"],
|
||||
headers["idempotency-key"],
|
||||
headers["webhook-id"],
|
||||
headers["x-request-id"],
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackLink(kind: GitLabSignalKind, projectWebUrl: string | undefined, iid: string): string | undefined {
|
||||
if (!projectWebUrl) return undefined;
|
||||
const base = projectWebUrl.replace(/\/+$/, "");
|
||||
return kind === "issue" ? `${base}/-/issues/${iid}` : `${base}/-/merge_requests/${iid}`;
|
||||
}
|
||||
|
||||
export const gitlabSource: SignalSource = {
|
||||
provider: "gitlab",
|
||||
secretEnvVar: "FUSION_SIGNAL_GITLAB_SECRET",
|
||||
|
||||
verify(ctx: SignalVerifyContext): SignalVerifyResult {
|
||||
if (!ctx.secret) {
|
||||
return { valid: false, status: 401, error: "GitLab webhook secret is not configured" };
|
||||
}
|
||||
const token = ctx.headers["x-gitlab-token"];
|
||||
if (!token) {
|
||||
return { valid: false, status: 401, error: "Missing X-Gitlab-Token header" };
|
||||
}
|
||||
if (!verifySharedSecretToken(token, ctx.secret)) {
|
||||
return { valid: false, status: 401, error: "Invalid GitLab token" };
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
normalize(payload: unknown, ctx: SignalVerifyContext): Signal | null {
|
||||
const p = asObject(payload);
|
||||
if (!p) throw new Error("Payload must be a JSON object");
|
||||
|
||||
const kind = resolveKind(p, ctx.headers);
|
||||
if (!kind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attrs = asObject(p.object_attributes);
|
||||
if (!attrs) throw new Error("Missing GitLab object_attributes");
|
||||
|
||||
const iid = asId(attrs.iid);
|
||||
const title = asString(attrs.title);
|
||||
if (!iid) throw new Error("Missing GitLab object_attributes.iid");
|
||||
if (!title) throw new Error("Missing GitLab object_attributes.title");
|
||||
|
||||
const project = projectIdentity(p);
|
||||
const projectKey = project.path ?? project.id ?? "unknown-project";
|
||||
const groupingKey = `gitlab:${projectKey}:${kind}:${iid}`;
|
||||
const action = asString(attrs.action)?.toLowerCase() ?? "event";
|
||||
const state = asString(attrs.state)?.toLowerCase();
|
||||
const timestamp = parseTimestamp(attrs.updated_at, attrs.created_at, p.updated_at, p.created_at);
|
||||
const deliveryId = deliveryIdFrom(ctx.headers);
|
||||
const externalId = deliveryId
|
||||
? `delivery:${deliveryId}`
|
||||
: `${groupingKey}:${action}:${state ?? "unknown"}:${timestamp ?? "latest"}`;
|
||||
const itemLabel = kind === "issue" ? "issue" : "merge request";
|
||||
const ref = kind === "issue" ? `#${iid}` : `!${iid}`;
|
||||
const description = asString(attrs.description);
|
||||
const link = firstString(attrs.url, fallbackLink(kind, project.webUrl, iid));
|
||||
|
||||
const signal: Signal = {
|
||||
source: "gitlab",
|
||||
externalId,
|
||||
groupingKey,
|
||||
title: `GitLab ${itemLabel} ${ref}: ${title}`,
|
||||
body: [
|
||||
description,
|
||||
`GitLab ${itemLabel} ${ref} ${action}${state ? ` (${state})` : ""}.`,
|
||||
project.name || project.path ? `Project: ${project.name ?? project.path}` : undefined,
|
||||
]
|
||||
.filter((line): line is string => Boolean(line))
|
||||
.join("\n\n"),
|
||||
severity: mapGitLabSeverity(kind, attrs),
|
||||
resolution: mapGitLabResolution(kind, attrs),
|
||||
link,
|
||||
timestamp,
|
||||
meta: {
|
||||
kind,
|
||||
action,
|
||||
state,
|
||||
iid,
|
||||
objectId: asId(attrs.id),
|
||||
projectId: project.id,
|
||||
projectPath: project.path,
|
||||
projectUrl: project.webUrl,
|
||||
user: asObject(p.user)?.username ?? asObject(p.user)?.name,
|
||||
labels: labelsFrom(p, attrs),
|
||||
},
|
||||
};
|
||||
|
||||
return applySignalCaps(signal);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user