diff --git a/packages/importers/src/providers/instatus/api-types.ts b/packages/importers/src/providers/instatus/api-types.ts index 2fe1f611..2094354d 100644 --- a/packages/importers/src/providers/instatus/api-types.ts +++ b/packages/importers/src/providers/instatus/api-types.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { lenientEnum } from "../../schemas"; + export const InstatusPageSchema = z.object({ id: z.string(), name: z.string(), @@ -20,7 +22,7 @@ export const InstatusComponentSchema = z.object({ id: z.string(), name: z.string(), description: z.string().nullable(), - status: z.enum([ + status: lenientEnum([ "OPERATIONAL", "UNDERMAINTENANCE", "DEGRADEDPERFORMANCE", @@ -39,7 +41,12 @@ export const InstatusIncidentUpdateSchema = z.object({ id: z.string(), message: z.string().nullable(), messageHtml: z.string().nullable(), - status: z.enum(["INVESTIGATING", "IDENTIFIED", "MONITORING", "RESOLVED"]), + status: lenientEnum([ + "INVESTIGATING", + "IDENTIFIED", + "MONITORING", + "RESOLVED", + ]), notify: z.boolean(), started: z.string(), createdAt: z.string(), @@ -57,7 +64,12 @@ const InstatusIncidentComponentRefSchema = z.object({ export const InstatusIncidentSchema = z.object({ id: z.string(), name: z.string(), - status: z.enum(["INVESTIGATING", "IDENTIFIED", "MONITORING", "RESOLVED"]), + status: lenientEnum([ + "INVESTIGATING", + "IDENTIFIED", + "MONITORING", + "RESOLVED", + ]), started: z.string(), resolved: z.string().nullable(), updates: z.array(InstatusIncidentUpdateSchema).optional(), @@ -70,7 +82,7 @@ export const InstatusMaintenanceUpdateSchema = z.object({ id: z.string(), message: z.string().nullable(), messageHtml: z.string().nullable(), - status: z.enum(["NOTSTARTEDYET", "INPROGRESS", "COMPLETED"]), + status: lenientEnum(["NOTSTARTEDYET", "INPROGRESS", "COMPLETED"]), notify: z.boolean(), started: z.string(), }); @@ -82,7 +94,7 @@ export type InstatusMaintenanceUpdate = z.infer< export const InstatusMaintenanceSchema = z.object({ id: z.string(), name: z.string(), - status: z.enum(["NOTSTARTEDYET", "INPROGRESS", "COMPLETED"]), + status: lenientEnum(["NOTSTARTEDYET", "INPROGRESS", "COMPLETED"]), start: z.string(), duration: z.number().nullable(), updates: z.array(InstatusMaintenanceUpdateSchema).optional(), diff --git a/packages/importers/src/providers/instatus/client.test.ts b/packages/importers/src/providers/instatus/client.test.ts index 79edfe00..df139d12 100644 --- a/packages/importers/src/providers/instatus/client.test.ts +++ b/packages/importers/src/providers/instatus/client.test.ts @@ -75,6 +75,21 @@ describe("InstatusClient", () => { expect(incidents[1].status).toBe("IDENTIFIED"); }); + test("getIncidents parses unrecognised statuses as unknown", async () => { + const [incident] = MOCK_INCIDENTS; + const updates = incident.updates ?? []; + mockFetchPaginated([ + { + ...incident, + status: "POSTMORTEM", + updates: [{ ...updates[0], status: "POSTMORTEM" }], + }, + ]); + const incidents = await client.getIncidents("in_page_001"); + expect(incidents[0].status).toBe("unknown"); + expect(incidents[0].updates?.[0].status).toBe("unknown"); + }); + test("getMaintenances returns parsed maintenances", async () => { mockFetchPaginated(MOCK_MAINTENANCES); const maintenances = await client.getMaintenances("in_page_001"); diff --git a/packages/importers/src/providers/instatus/mapper.test.ts b/packages/importers/src/providers/instatus/mapper.test.ts index 17a95994..a451afcf 100644 --- a/packages/importers/src/providers/instatus/mapper.test.ts +++ b/packages/importers/src/providers/instatus/mapper.test.ts @@ -118,6 +118,7 @@ describe("mapIncidentStatus", () => { ["IDENTIFIED", "identified"], ["MONITORING", "monitoring"], ["RESOLVED", "resolved"], + ["unknown", "investigating"], ] as const) { it(`maps ${input} to ${expected}`, () => { expect(mapIncidentStatus(input)).toBe(expected); diff --git a/packages/importers/src/providers/statuspage/api-types.ts b/packages/importers/src/providers/statuspage/api-types.ts index a9585b80..64de54bc 100644 --- a/packages/importers/src/providers/statuspage/api-types.ts +++ b/packages/importers/src/providers/statuspage/api-types.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { lenientEnum } from "../../schemas"; + export const StatuspageComponentSchema = z.object({ id: z.string(), page_id: z.string(), @@ -7,12 +9,14 @@ export const StatuspageComponentSchema = z.object({ name: z.string(), description: z.string().nullable(), position: z.number(), - status: z.enum([ + status: lenientEnum([ "operational", "degraded_performance", "partial_outage", "major_outage", "under_maintenance", + // the spec allows an empty status + "", ]), showcase: z.boolean(), only_show_if_degraded: z.boolean(), @@ -39,19 +43,23 @@ export type StatuspageGroupComponent = z.infer< typeof StatuspageGroupComponentSchema >; +// `postmortem` is set on the incident and on the update that publishes it. +const StatuspageIncidentStatusSchema = lenientEnum([ + "investigating", + "identified", + "monitoring", + "resolved", + "postmortem", + "scheduled", + "in_progress", + "verifying", + "completed", +]); + export const StatuspageIncidentUpdateSchema = z.object({ id: z.string(), incident_id: z.string(), - status: z.enum([ - "investigating", - "identified", - "monitoring", - "resolved", - "scheduled", - "in_progress", - "verifying", - "completed", - ]), + status: StatuspageIncidentStatusSchema, body: z.string().nullable(), display_at: z.string().nullable(), deliver_notifications: z.boolean(), @@ -77,17 +85,14 @@ export const StatuspageIncidentSchema = z.object({ id: z.string(), page_id: z.string(), name: z.string(), - status: z.enum([ - "investigating", - "identified", - "monitoring", - "resolved", - "scheduled", - "in_progress", - "verifying", - "completed", - ]), - impact: z.enum(["none", "minor", "major", "critical"]).nullable(), + status: StatuspageIncidentStatusSchema, + impact: lenientEnum([ + "none", + "maintenance", + "minor", + "major", + "critical", + ]).nullable(), shortlink: z.string().nullable(), scheduled_for: z.string().nullable(), scheduled_until: z.string().nullable(), @@ -106,7 +111,14 @@ export type StatuspageIncident = z.infer; export const StatuspageSubscriberSchema = z.object({ id: z.string(), page_id: z.string(), - mode: z.enum(["email", "sms", "slack", "webhook", "integration_partner"]), + mode: lenientEnum([ + "email", + "sms", + "slack", + "webhook", + "teams", + "integration_partner", + ]), email: z.string().nullable(), endpoint: z.string().nullable(), phone_number: z.string().nullable(), diff --git a/packages/importers/src/providers/statuspage/client.test.ts b/packages/importers/src/providers/statuspage/client.test.ts index 79b7c7fc..26f98e4a 100644 --- a/packages/importers/src/providers/statuspage/client.test.ts +++ b/packages/importers/src/providers/statuspage/client.test.ts @@ -90,6 +90,61 @@ describe("StatuspageClient", () => { expect(incidents[2].scheduled_for).toBe("2024-06-20T02:00:00.000Z"); }); + test("getIncidents accepts postmortem status on incidents and updates", async () => { + const [incident] = MOCK_INCIDENTS; + const updates = incident.incident_updates ?? []; + mockFetchPaginated([ + { + ...incident, + status: "postmortem", + incident_updates: [ + { ...updates[0], id: "sp_upd_postmortem", status: "postmortem" }, + ...updates, + ], + }, + ]); + const incidents = await client.getIncidents("sp_page_001"); + expect(incidents[0].status).toBe("postmortem"); + expect(incidents[0].incident_updates?.[0].status).toBe("postmortem"); + }); + + test("getIncidents accepts maintenance impact and empty component status", async () => { + const scheduled = MOCK_INCIDENTS[2]; + mockFetchPaginated([ + { + ...scheduled, + impact: "maintenance", + components: [{ ...MOCK_COMPONENTS[0], status: "" }], + }, + ]); + const incidents = await client.getIncidents("sp_page_001"); + expect(incidents[0].impact).toBe("maintenance"); + expect(incidents[0].components?.[0].status).toBe(""); + }); + + test("getIncidents parses unrecognised values as unknown", async () => { + const [incident] = MOCK_INCIDENTS; + const updates = incident.incident_updates ?? []; + mockFetchPaginated([ + { + ...incident, + status: "brand_new_status", + impact: "brand_new_impact", + incident_updates: [{ ...updates[0], status: "brand_new_status" }], + }, + ]); + const incidents = await client.getIncidents("sp_page_001"); + expect(incidents[0].status).toBe("unknown"); + expect(incidents[0].impact).toBe("unknown"); + expect(incidents[0].incident_updates?.[0].status).toBe("unknown"); + }); + + test("getSubscribers accepts teams subscribers", async () => { + mockFetchPaginated([{ ...MOCK_SUBSCRIBERS[2], mode: "teams" }]); + const subscribers = await client.getSubscribers("sp_page_001"); + expect(subscribers[0].mode).toBe("teams"); + }); + test("getSubscribers returns parsed subscribers", async () => { mockFetchPaginated(MOCK_SUBSCRIBERS); const subscribers = await client.getSubscribers("sp_page_001"); diff --git a/packages/importers/src/providers/statuspage/mapper.test.ts b/packages/importers/src/providers/statuspage/mapper.test.ts index 07b241b1..dabfc431 100644 --- a/packages/importers/src/providers/statuspage/mapper.test.ts +++ b/packages/importers/src/providers/statuspage/mapper.test.ts @@ -111,6 +111,32 @@ describe("mapIncidentToStatusReport", () => { expect(lastUpdate.message).toContain("## Summary"); }); + it("does not duplicate the postmortem when a postmortem update exists", () => { + const incident = MOCK_INCIDENTS[0]; + const updates = incident.incident_updates ?? []; + const postmortemUpdate = { + ...updates[updates.length - 1], + id: "sp_upd_postmortem", + status: "postmortem" as const, + body: incident.postmortem_body, + affected_components: null, + created_at: "2099-01-01T00:00:00.000Z", + }; + const result = mapIncidentToStatusReport( + { + ...incident, + status: "postmortem", + incident_updates: [...updates, postmortemUpdate], + }, + 1, + 10, + ); + expect(result.report.status).toBe("resolved"); + const lastUpdate = result.updates[result.updates.length - 1]; + expect(lastUpdate.status).toBe("resolved"); + expect(lastUpdate.message).toBe(incident.postmortem_body); + }); + it("maps per-update component impacts from affected_components", () => { const result = mapIncidentToStatusReport(MOCK_INCIDENTS[0], 1, 10); expect(result.updates[0].componentImpacts).toEqual([ @@ -131,10 +157,12 @@ describe("mapIncidentUpdateStatus", () => { ["identified", "identified"], ["monitoring", "monitoring"], ["resolved", "resolved"], + ["postmortem", "resolved"], ["scheduled", "investigating"], ["in_progress", "investigating"], ["verifying", "monitoring"], ["completed", "resolved"], + ["unknown", "investigating"], ] as const) { it(`maps ${input} to ${expected}`, () => { expect(mapIncidentUpdateStatus(input)).toBe(expected); @@ -186,4 +214,9 @@ describe("mapSubscriber", () => { it("returns null for slack subscriber", () => { expect(mapSubscriber(MOCK_SUBSCRIBERS[4], 10)).toBeNull(); }); + + it("returns null for subscriber with unknown mode", () => { + const subscriber = { ...MOCK_SUBSCRIBERS[0], mode: "unknown" as const }; + expect(mapSubscriber(subscriber, 10)).toBeNull(); + }); }); diff --git a/packages/importers/src/providers/statuspage/mapper.ts b/packages/importers/src/providers/statuspage/mapper.ts index 01fa35a2..e24d6e4b 100644 --- a/packages/importers/src/providers/statuspage/mapper.ts +++ b/packages/importers/src/providers/statuspage/mapper.ts @@ -68,6 +68,7 @@ const INCIDENT_UPDATE_STATUS_MAP: Record = { identified: "identified", monitoring: "monitoring", resolved: "resolved", + postmortem: "resolved", scheduled: "investigating", in_progress: "investigating", verifying: "monitoring", @@ -105,7 +106,13 @@ export function mapIncidentToStatusReport( ), })); - if (incident.postmortem_body && mappedUpdates.length > 0) { + // A `postmortem` update already carries the postmortem as its body. + const hasPostmortemUpdate = updates.some((u) => u.status === "postmortem"); + if ( + incident.postmortem_body && + !hasPostmortemUpdate && + mappedUpdates.length > 0 + ) { const last = mappedUpdates[mappedUpdates.length - 1]; last.message = `${last.message}\n\n---\n\n**Postmortem**\n\n${incident.postmortem_body}`; } diff --git a/packages/importers/src/schemas.test.ts b/packages/importers/src/schemas.test.ts new file mode 100644 index 00000000..7559f30f --- /dev/null +++ b/packages/importers/src/schemas.test.ts @@ -0,0 +1,20 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { lenientEnum } from "./schemas"; + +describe("lenientEnum", () => { + const schema = lenientEnum(["a", "b"]); + + it("keeps known values", () => { + expect(schema.parse("a")).toBe("a"); + }); + + it("parses unknown strings to unknown", () => { + expect(schema.parse("brand_new_status")).toBe("unknown"); + }); + + it("still rejects non-strings", () => { + expect(() => schema.parse(42)).toThrow(); + }); +}); diff --git a/packages/importers/src/schemas.ts b/packages/importers/src/schemas.ts new file mode 100644 index 00000000..87a5da12 --- /dev/null +++ b/packages/importers/src/schemas.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const UNKNOWN_VALUE = "unknown"; + +/** + * Like `z.enum`, but any other string parses to `"unknown"` instead of + * failing. Providers add values without notice, and one unrecognised status + * must not abort a whole import — mappers fall back on `"unknown"`. + */ +export function lenientEnum( + values: T, +) { + return z.union([ + z.enum(values), + z.string().transform((): typeof UNKNOWN_VALUE => UNKNOWN_VALUE), + ]); +}