From 2dbd6c13235a8a95b442ff8f4bfeedde270903c6 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Fri, 22 May 2026 20:36:26 +0200 Subject: [PATCH] fix: incident.io page fetcher (#2189) --- .github/workflows/deploy-workflows.yml | 1 + .../__tests__/fetchers/incidentio.test.ts | 243 ++++++------------ .../status-fetcher/src/fetchers/incidentio.ts | 144 ++--------- 3 files changed, 99 insertions(+), 289 deletions(-) diff --git a/.github/workflows/deploy-workflows.yml b/.github/workflows/deploy-workflows.yml index 2138fd32..c7ac191b 100644 --- a/.github/workflows/deploy-workflows.yml +++ b/.github/workflows/deploy-workflows.yml @@ -10,6 +10,7 @@ on: - "packages/utils/**" - "packages/tsconfig/**" - "packages/notifications/**" + - "packages/status-fetcher/**" - "pnpm-workspace.yaml" jobs: deploy-workflows: diff --git a/packages/status-fetcher/__tests__/fetchers/incidentio.test.ts b/packages/status-fetcher/__tests__/fetchers/incidentio.test.ts index 03298488..dd01544a 100644 --- a/packages/status-fetcher/__tests__/fetchers/incidentio.test.ts +++ b/packages/status-fetcher/__tests__/fetchers/incidentio.test.ts @@ -71,7 +71,7 @@ describe("IncidentioFetcher", () => { }); describe("fetch", () => { - it("should fetch and parse operational status (no incidents)", async () => { + it("should fetch and parse operational status", async () => { const entry: StatusPageEntry = { id: "test", name: "Test Service", @@ -82,9 +82,16 @@ describe("IncidentioFetcher", () => { }; const mockResponse = { - ongoing_incidents: [], - in_progress_maintenances: [], - scheduled_maintenances: [], + page: { + id: "01GX91T1T0RXR54F1EKBQMAYCJ", + name: "Test Service", + url: "https://status.test.com", + updated_at: "2024-02-16T12:00:00Z", + }, + status: { + indicator: "none", + description: "All Systems Operational", + }, }; const fetchMock = installMockFetch(() => @@ -97,10 +104,11 @@ describe("IncidentioFetcher", () => { const result = await runFetcher(fetcher, entry); expect(result.severity).toBe("none"); + expect(result.status).toBe("operational"); expect(result.description).toBe("All Systems Operational"); - expect(result.timezone).toBe("UTC"); + expect(typeof result.updated_at).toBe("number"); expect(fetchMock).toHaveBeenCalledWith( - "https://status.test.com/api/widget", + "https://status.test.com/api/v2/summary.json", expect.objectContaining({ headers: expect.objectContaining({ "User-Agent": "OpenStatus-Directory/1.0", @@ -110,46 +118,7 @@ describe("IncidentioFetcher", () => { ); }); - it("should handle ongoing incidents with investigating status", async () => { - const entry: StatusPageEntry = { - id: "test", - name: "Test", - url: "https://test.com", - status_page_url: "https://status.test.com", - provider: "incidentio", - industry: ["saas"], - }; - - const mockResponse = { - ongoing_incidents: [ - { - id: "123", - name: "API Errors", - status: "investigating", - last_update: { - message: "We are investigating", - updated_at: "2024-02-16T12:00:00.000Z", - }, - }, - ], - in_progress_maintenances: [], - scheduled_maintenances: [], - }; - - installMockFetch(() => - Promise.resolve({ - ok: true, - json: async () => mockResponse, - } as Response), - ); - - const result = await runFetcher(fetcher, entry); - - expect(result.severity).toBe("major"); - expect(result.description).toBe("Incident: API Errors"); - }); - - it("should handle ongoing incidents with monitoring status", async () => { + it("should handle a response without timezone", async () => { const entry: StatusPageEntry = { id: "test", name: "Test", @@ -160,19 +129,16 @@ describe("IncidentioFetcher", () => { }; const mockResponse = { - ongoing_incidents: [ - { - id: "123", - name: "Database Slowness", - status: "monitoring", - last_update: { - message: "Monitoring the fix", - updated_at: "2024-02-16T12:00:00.000Z", - }, - }, - ], - in_progress_maintenances: [], - scheduled_maintenances: [], + page: { + id: "01GX91T1T0RXR54F1EKBQMAYCJ", + name: "Test", + url: "https://status.test.com", + updated_at: "2024-02-16T12:00:00Z", + }, + status: { + indicator: "minor", + description: "Elevated Error Rates", + }, }; installMockFetch(() => @@ -185,10 +151,11 @@ describe("IncidentioFetcher", () => { const result = await runFetcher(fetcher, entry); expect(result.severity).toBe("minor"); - expect(result.description).toBe("Monitoring: Database Slowness"); + expect(result.description).toBe("Elevated Error Rates"); + expect(result.timezone).toBeUndefined(); }); - it("should handle in-progress maintenance", async () => { + it("should handle major incidents", async () => { const entry: StatusPageEntry = { id: "test", name: "Test", @@ -199,19 +166,17 @@ describe("IncidentioFetcher", () => { }; const mockResponse = { - ongoing_incidents: [], - in_progress_maintenances: [ - { - id: "456", - name: "Database Upgrade", - status: "in_progress", - last_update: { - message: "Maintenance in progress", - updated_at: "2024-02-16T12:00:00.000Z", - }, - }, - ], - scheduled_maintenances: [], + page: { + id: "01GX91T1T0RXR54F1EKBQMAYCJ", + name: "Test", + url: "https://status.test.com", + timezone: "America/New_York", + updated_at: "2024-02-16T12:00:00Z", + }, + status: { + indicator: "major", + description: "Investigating API Errors", + }, }; installMockFetch(() => @@ -223,11 +188,13 @@ describe("IncidentioFetcher", () => { const result = await runFetcher(fetcher, entry); - expect(result.severity).toBe("none"); - expect(result.description).toBe("Maintenance: Database Upgrade"); + expect(result.severity).toBe("major"); + expect(result.status).toBe("investigating"); + expect(result.description).toBe("Investigating API Errors"); + expect(result.timezone).toBe("America/New_York"); }); - it("should handle scheduled maintenance", async () => { + it("should use custom endpoint if provided", async () => { const entry: StatusPageEntry = { id: "test", name: "Test", @@ -235,40 +202,41 @@ describe("IncidentioFetcher", () => { status_page_url: "https://status.test.com", provider: "incidentio", industry: ["saas"], + api_config: { + type: "incidentio", + endpoint: "https://custom.endpoint.com/summary.json", + }, }; const mockResponse = { - ongoing_incidents: [], - in_progress_maintenances: [], - scheduled_maintenances: [ - { - id: "789", - name: "Server Maintenance", - status: "scheduled", - last_update: { - message: "Scheduled for tomorrow", - updated_at: "2024-02-16T12:00:00.000Z", - }, - }, - ], + page: { + id: "01GX91T1T0RXR54F1EKBQMAYCJ", + name: "Test", + url: "https://status.test.com", + updated_at: "2024-02-16T12:00:00Z", + }, + status: { + indicator: "none", + description: "All Systems Operational", + }, }; - installMockFetch(() => + const fetchMock = installMockFetch(() => Promise.resolve({ ok: true, json: async () => mockResponse, } as Response), ); - const result = await runFetcher(fetcher, entry); + await runFetcher(fetcher, entry); - expect(result.severity).toBe("none"); - expect(result.description).toBe( - "All Systems Operational (Scheduled: Server Maintenance)", + expect(fetchMock).toHaveBeenCalledWith( + "https://custom.endpoint.com/summary.json", + expect.any(Object), ); }); - it("should prioritize ongoing incidents over maintenance", async () => { + it("should fail with FetchError on 5xx response", async () => { const entry: StatusPageEntry = { id: "test", name: "Test", @@ -278,81 +246,21 @@ describe("IncidentioFetcher", () => { industry: ["saas"], }; - const mockResponse = { - ongoing_incidents: [ - { - id: "123", - name: "Critical Issue", - status: "investigating", - last_update: { - message: "Investigating", - updated_at: "2024-02-16T12:00:00.000Z", - }, - }, - ], - in_progress_maintenances: [ - { - id: "456", - name: "Maintenance", - status: "in_progress", - last_update: { - message: "In progress", - updated_at: "2024-02-16T12:00:00.000Z", - }, - }, - ], - scheduled_maintenances: [], - }; - installMockFetch(() => Promise.resolve({ - ok: true, - json: async () => mockResponse, - } as Response), - ); - - const result = await runFetcher(fetcher, entry); - - expect(result.severity).toBe("major"); - expect(result.description).toBe("Incident: Critical Issue"); - }); - - it("should use custom endpoint if provided", async () => { - const entry: StatusPageEntry = { - id: "test", - name: "Test", - url: "https://test.com", - status_page_url: "https://status.test.com", - provider: "incidentio", - industry: ["saas"], - api_config: { - type: "incidentio", - endpoint: "https://custom.endpoint.com/widget", - }, - }; - - const mockResponse = { - ongoing_incidents: [], - in_progress_maintenances: [], - scheduled_maintenances: [], - }; - - const fetchMock = installMockFetch(() => - Promise.resolve({ - ok: true, - json: async () => mockResponse, + ok: false, + status: 503, + statusText: "Service Unavailable", } as Response), ); - await runFetcher(fetcher, entry); - - expect(fetchMock).toHaveBeenCalledWith( - "https://custom.endpoint.com/widget", - expect.any(Object), - ); + const exit = await runFetcherExit(fetcher, entry); + const err = expectFetchError(exit); + expect(err.httpStatus).toBe(503); + expect(err.fetcherName).toBe("incidentio"); }); - it("should fail with FetchError on 5xx response", async () => { + it("should fail with FetchError on invalid JSON schema", async () => { const entry: StatusPageEntry = { id: "test", name: "Test", @@ -364,15 +272,14 @@ describe("IncidentioFetcher", () => { installMockFetch(() => Promise.resolve({ - ok: false, - status: 503, - statusText: "Service Unavailable", + ok: true, + json: async () => ({ invalid: "data" }), } as Response), ); const exit = await runFetcherExit(fetcher, entry); const err = expectFetchError(exit); - expect(err.httpStatus).toBe(503); + expect(err.cause).toBeInstanceOf(Error); }); }); }); diff --git a/packages/status-fetcher/src/fetchers/incidentio.ts b/packages/status-fetcher/src/fetchers/incidentio.ts index 40336ef9..acee7025 100644 --- a/packages/status-fetcher/src/fetchers/incidentio.ts +++ b/packages/status-fetcher/src/fetchers/incidentio.ts @@ -2,29 +2,25 @@ import { Effect } from "effect"; import { z } from "zod"; import { type FetchError, fetchJson } from "../fetch"; import type { StatusFetcher, StatusPageEntry, StatusResult } from "../types"; -import { urlHostnameEndsWith } from "../utils"; - -const incidentSchema = z.object({ - id: z.string(), - name: z.string(), - status: z.string(), - last_update: z - .object({ - message: z.string(), - updated_at: z.string(), - }) - .optional(), - affected_components: z.array(z.string()).optional(), -}); +import { SEVERITY_LEVELS } from "../types"; +import { inferStatus, urlHostnameEndsWith } from "../utils"; +// incident.io status pages expose an Atlassian Statuspage-compatible API, so the +// summary endpoint returns the same shape AtlassianFetcher consumes. const incidentioResponseSchema = z.object({ - ongoing_incidents: z.array(incidentSchema), - in_progress_maintenances: z.array(incidentSchema), - scheduled_maintenances: z.array(incidentSchema), + page: z.object({ + id: z.string(), + name: z.string(), + url: z.string().url(), + timezone: z.string().optional(), + updated_at: z.string().datetime({ offset: true }), + }), + status: z.object({ + indicator: z.enum(SEVERITY_LEVELS), + description: z.string(), + }), }); -type IncidentioData = z.infer; - export class IncidentioFetcher implements StatusFetcher { name = "incidentio"; @@ -38,7 +34,9 @@ export class IncidentioFetcher implements StatusFetcher { } fetch(entry: StatusPageEntry): Effect.Effect { - const apiUrl = entry.api_config?.endpoint || this.constructApiUrl(entry); + const apiUrl = + entry.api_config?.endpoint || + `${entry.status_page_url}/api/v2/summary.json`; return fetchJson({ url: apiUrl, @@ -47,112 +45,16 @@ export class IncidentioFetcher implements StatusFetcher { entryId: entry.id, }).pipe( Effect.map((data) => { - const { severity, status, description } = this.analyzeIncidents(data); + const severity = data.status.indicator; + const description = data.status.description; return { severity, - status, + status: inferStatus(description, severity), description, - updated_at: this.getLatestUpdateTime(data), - timezone: "UTC", + updated_at: new Date(data.page.updated_at).getTime(), + timezone: data.page.timezone, }; }), ); } - - private constructApiUrl(entry: StatusPageEntry): string { - const url = new URL(entry.status_page_url); - return `${url.origin}/api/widget`; - } - - private analyzeIncidents(data: IncidentioData): { - severity: "none" | "minor" | "major"; - status: - | "operational" - | "investigating" - | "identified" - | "monitoring" - | "under_maintenance"; - description: string; - } { - const { - ongoing_incidents, - in_progress_maintenances, - scheduled_maintenances, - } = data; - - if (ongoing_incidents.length > 0) { - const incident = ongoing_incidents[0]; - const incidentStatus = incident.status.toLowerCase(); - - if (incidentStatus.includes("investigating")) { - return { - severity: "major", - status: "investigating", - description: `Incident: ${incident.name}`, - }; - } - if (incidentStatus.includes("identified")) { - return { - severity: "major", - status: "identified", - description: `Incident: ${incident.name}`, - }; - } - if (incidentStatus.includes("monitoring")) { - return { - severity: "minor", - status: "monitoring", - description: `Monitoring: ${incident.name}`, - }; - } - - return { - severity: "major", - status: "investigating", - description: incident.name, - }; - } - - if (in_progress_maintenances.length > 0) { - const maintenance = in_progress_maintenances[0]; - return { - severity: "none", - status: "under_maintenance", - description: `Maintenance: ${maintenance.name}`, - }; - } - - if (scheduled_maintenances.length > 0) { - const maintenance = scheduled_maintenances[0]; - return { - severity: "none", - status: "operational", - description: `All Systems Operational (Scheduled: ${maintenance.name})`, - }; - } - - return { - severity: "none", - status: "operational", - description: "All Systems Operational", - }; - } - - private getLatestUpdateTime(data: IncidentioData): number { - const allItems = [ - ...data.ongoing_incidents, - ...data.in_progress_maintenances, - ...data.scheduled_maintenances, - ]; - - const timestamps: number[] = []; - for (const item of allItems) { - const updatedAt = item.last_update?.updated_at; - if (updatedAt) { - timestamps.push(new Date(updatedAt).getTime()); - } - } - - return timestamps.length > 0 ? Math.max(...timestamps) : Date.now(); - } } -- 2.51.2