From 39e10ea62a6d552e7133ac75e3d60a5c22a7a9d7 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Thu, 15 Oct 2026 17:29:34 +0200 Subject: [PATCH] fix pr --- apps/server/src/routes/rpc/adapter.test.ts | 10 +++ apps/server/src/routes/rpc/adapter.ts | 10 ++- .../incident/__tests__/incident.test.ts | 2 +- .../src/routes/rpc/handlers/incident/index.ts | 55 ++++++------ .../routes/rpc/handlers/maintenance/index.ts | 26 ++---- .../rpc/handlers/status-report/index.ts | 27 ++---- .../interceptors/__tests__/tracking.test.ts | 6 +- apps/server/src/routes/rpc/parse.test.ts | 49 +++++++++++ apps/server/src/routes/rpc/parse.ts | 57 ++++++++++++ .../server/src/routes/slack/incident-slack.ts | 32 +++++-- apps/server/static/openapi.yaml | 8 +- .../content/pages/changelog/incident-api.mdx | 2 +- .../docs/sdk/nodejs/incident-service.mdx | 6 +- .../pages/docs/sdk/nodejs/overview.mdx | 2 +- .../api/openstatus/incident/v1/service.proto | 18 +++- packages/proto/gen/openapi.yaml | 8 +- .../ts/openstatus/incident/v1/service_pb.ts | 4 +- packages/services/src/errors.ts | 6 ++ .../src/incident/__tests__/effects.test.ts | 34 ++++++-- packages/services/src/incident/close.ts | 6 +- packages/services/src/incident/delete.ts | 6 +- packages/services/src/incident/effects.ts | 86 +++++++++++++------ packages/services/src/incident/index.ts | 2 +- packages/services/src/incident/list-events.ts | 25 +++++- packages/services/src/incident/schemas.ts | 3 + packages/services/src/incident/set-status.ts | 6 +- packages/services/src/index.ts | 1 + packages/services/src/status-report/list.ts | 14 ++- 28 files changed, 376 insertions(+), 135 deletions(-) create mode 100644 apps/server/src/routes/rpc/parse.test.ts create mode 100644 apps/server/src/routes/rpc/parse.ts diff --git a/apps/server/src/routes/rpc/adapter.test.ts b/apps/server/src/routes/rpc/adapter.test.ts index c46fed3f1..09e988efa 100644 --- a/apps/server/src/routes/rpc/adapter.test.ts +++ b/apps/server/src/routes/rpc/adapter.test.ts @@ -1,6 +1,7 @@ import { Code, ConnectError } from "@connectrpc/connect"; import type { Workspace } from "@openstatus/db/src/schema"; import { + ConcurrentUpdateError, ConflictError, ForbiddenError, NotFoundError, @@ -100,6 +101,15 @@ describe("toConnectError", () => { expect((err as ConnectError).code).toBe(Code.FailedPrecondition); }); + test("ConcurrentUpdateError → ConnectError(Aborted)", () => { + const err = captureThrow(() => + toConnectError( + new ConcurrentUpdateError("Incident #1 changed concurrently"), + ), + ); + expect((err as ConnectError).code).toBe(Code.Aborted); + }); + test("re-throws an existing ConnectError unchanged", () => { const original = new ConnectError("custom", Code.Aborted); const err = captureThrow(() => toConnectError(original)); diff --git a/apps/server/src/routes/rpc/adapter.ts b/apps/server/src/routes/rpc/adapter.ts index 930639a17..8e5692712 100644 --- a/apps/server/src/routes/rpc/adapter.ts +++ b/apps/server/src/routes/rpc/adapter.ts @@ -1,5 +1,9 @@ import { Code, ConnectError } from "@connectrpc/connect"; -import { type ServiceContext, ServiceError } from "@openstatus/services"; +import { + ConcurrentUpdateError, + type ServiceContext, + ServiceError, +} from "@openstatus/services"; import { ZodError } from "zod"; import { tb } from "@/libs/clients"; @@ -40,6 +44,10 @@ export function toConnectError(err: unknown): never { Code.InvalidArgument, ); } + // A lost race is retryable; other conflicts need the caller to change state. + if (err instanceof ConcurrentUpdateError) { + throw new ConnectError(err.message, Code.Aborted); + } if (err instanceof ServiceError) { switch (err.code) { case "NOT_FOUND": diff --git a/apps/server/src/routes/rpc/handlers/incident/__tests__/incident.test.ts b/apps/server/src/routes/rpc/handlers/incident/__tests__/incident.test.ts index 9358fe1a9..99b084104 100644 --- a/apps/server/src/routes/rpc/handlers/incident/__tests__/incident.test.ts +++ b/apps/server/src/routes/rpc/handlers/incident/__tests__/incident.test.ts @@ -763,7 +763,7 @@ describe("IncidentService: list", () => { owner, ); const ids = all.incidents.map((i) => i.id); - expect(ids.indexOf(open)).toBeLessThan(ids.indexOf(resolved)); + expect(ids.indexOf(open)).toBeGreaterThan(ids.indexOf(resolved)); expect(all.totalSize).toBe(all.incidents.length); const page1 = await rpc( diff --git a/apps/server/src/routes/rpc/handlers/incident/index.ts b/apps/server/src/routes/rpc/handlers/incident/index.ts index 7433f6210..211836fbf 100644 --- a/apps/server/src/routes/rpc/handlers/incident/index.ts +++ b/apps/server/src/routes/rpc/handlers/incident/index.ts @@ -1,7 +1,7 @@ import type { ServiceImpl } from "@connectrpc/connect"; import { sendIncidentCommander } from "@openstatus/emails"; import type { IncidentService } from "@openstatus/proto/incident/v1"; -import type { ServiceContext } from "@openstatus/services"; +import { NotFoundError, type ServiceContext } from "@openstatus/services"; import { actorDisplayName, addIncidentNote, @@ -16,6 +16,7 @@ import { declareIncident, deleteIncident, draftPostmortem, + getIncidentEvent, getIncidentOrThrow, getPostmortem, type IncidentEffects, @@ -36,6 +37,7 @@ import { runInBackground } from "@/libs/background"; import { toConnectError, toServiceCtx } from "../../adapter"; import { getRpcContext } from "../../interceptors"; +import { parseDate as parseRpcDate, parseId as parseRpcId } from "../../parse"; import { incidentEventToProto, incidentSummaryToProto, @@ -46,6 +48,7 @@ import { } from "./converters"; import { conflictingFieldsError, + incidentNotFoundError, invalidCommanderError, invalidDateFormatError, invalidIdError, @@ -56,22 +59,12 @@ const DASHBOARD_URL = resolveDashboardUrl({ override: env.DASHBOARD_URL, }); -const NUMERIC_ID = /^\d+$/; - function parseId(field: string, value: string): number { - const trimmed = value.trim(); - // past 2^53 the digits round to a neighbouring id - const id = Number(trimmed); - if (!NUMERIC_ID.test(trimmed) || !Number.isSafeInteger(id)) { - throw invalidIdError(field, value); - } - return id; + return parseRpcId(value, () => invalidIdError(field, value)); } function parseDate(value: string): Date { - const date = new Date(value); - if (Number.isNaN(date.getTime())) throw invalidDateFormatError(value); - return date; + return parseRpcDate(value, () => invalidDateFormatError(value)); } async function resolveCommander( @@ -107,6 +100,14 @@ function afterResponse( ); } +/** Missing incidents carry the structured `INCIDENT_NOT_FOUND` reason. */ +function toIncidentError(err: unknown, incidentId: string): never { + if (err instanceof NotFoundError && err.entity === "incident") { + throw incidentNotFoundError(incidentId); + } + toConnectError(err); +} + async function readIncident(ctx: ServiceContext, id: number) { return incidentToProto(await getIncidentOrThrow({ ctx, input: { id } })); } @@ -155,7 +156,7 @@ export const incidentServiceImpl: ServiceImpl = { const events = await listIncidentEvents({ ctx: sCtx, input: { id } }); return { incident: incidentToProto(view, events) }; } catch (err) { - toConnectError(err); + toIncidentError(err, req.id); } }, @@ -212,7 +213,7 @@ export const incidentServiceImpl: ServiceImpl = { ); return { incident: await readIncident(sCtx, id) }; } catch (err) { - toConnectError(err); + toIncidentError(err, req.id); } }, @@ -236,7 +237,7 @@ export const incidentServiceImpl: ServiceImpl = { ); return { incident: await readIncident(sCtx, id) }; } catch (err) { - toConnectError(err); + toIncidentError(err, req.id); } }, @@ -248,13 +249,15 @@ export const incidentServiceImpl: ServiceImpl = { ctx: sCtx, input: { id, message: req.message }, }); - const events = await listIncidentEvents({ ctx: sCtx, input: { id } }); - const event = events.find((e) => e.id === added.id); + const event = await getIncidentEvent({ + ctx: sCtx, + input: { id, eventId: added.id }, + }); return { event: incidentEventToProto(event ?? { ...added, createdByUser: null }), }; } catch (err) { - toConnectError(err); + toIncidentError(err, req.id); } }, @@ -271,7 +274,7 @@ export const incidentServiceImpl: ServiceImpl = { }); return { incident: await readIncident(sCtx, id) }; } catch (err) { - toConnectError(err); + toIncidentError(err, req.id); } }, @@ -282,7 +285,7 @@ export const incidentServiceImpl: ServiceImpl = { await unlinkIncidentStatusReport({ ctx: sCtx, input: { id } }); return { incident: await readIncident(sCtx, id) }; } catch (err) { - toConnectError(err); + toIncidentError(err, req.id); } }, @@ -299,7 +302,7 @@ export const incidentServiceImpl: ServiceImpl = { ); return { incident: await readIncident(sCtx, id) }; } catch (err) { - toConnectError(err); + toIncidentError(err, req.id); } }, @@ -314,7 +317,7 @@ export const incidentServiceImpl: ServiceImpl = { ); return { success: true }; } catch (err) { - toConnectError(err); + toIncidentError(err, req.id); } }, @@ -325,7 +328,7 @@ export const incidentServiceImpl: ServiceImpl = { const row = await getPostmortem({ ctx: sCtx, input: { id } }); return { postmortem: row ? postmortemToProto(row) : undefined }; } catch (err) { - toConnectError(err); + toIncidentError(err, req.incidentId); } }, @@ -340,7 +343,7 @@ export const incidentServiceImpl: ServiceImpl = { const row = await getPostmortem({ ctx: sCtx, input: { id } }); return { postmortem: row ? postmortemToProto(row) : undefined }; } catch (err) { - toConnectError(err); + toIncidentError(err, req.incidentId); } }, @@ -361,7 +364,7 @@ export const incidentServiceImpl: ServiceImpl = { incident: await readIncident(sCtx, id), }; } catch (err) { - toConnectError(err); + toIncidentError(err, req.incidentId); } }, }; diff --git a/apps/server/src/routes/rpc/handlers/maintenance/index.ts b/apps/server/src/routes/rpc/handlers/maintenance/index.ts index 16da7ea2a..4d4d5cca6 100644 --- a/apps/server/src/routes/rpc/handlers/maintenance/index.ts +++ b/apps/server/src/routes/rpc/handlers/maintenance/index.ts @@ -14,6 +14,7 @@ import { import { toConnectError, toServiceCtx } from "../../adapter"; import { getRpcContext } from "../../interceptors"; +import { parseDate as parseRpcDate, parseId as parseRpcId } from "../../parse"; import { dbMaintenanceToProto, dbMaintenanceToProtoSummary, @@ -26,28 +27,15 @@ import { } from "./errors"; function parseDate(dateString: string): Date { - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) { - throw invalidDateFormatError(dateString); - } - return date; + return parseRpcDate(dateString, () => invalidDateFormatError(dateString)); } -// `Number("1e3")` is 1000 and `Number("0x10")` is 16 — both would silently -// target another row, so only plain decimal digits are accepted. -const DECIMAL_ID = /^\d+$/; - function parseId(value: string, label: string): number { - const trimmed = value.trim(); - // past 2^53 the digits round to a neighbouring id - const id = Number(trimmed); - if (!DECIMAL_ID.test(trimmed) || !Number.isSafeInteger(id)) { - throw new ConnectError( - `Invalid ${label}: "${value}"`, - Code.InvalidArgument, - ); - } - return id; + return parseRpcId( + value, + () => + new ConnectError(`Invalid ${label}: "${value}"`, Code.InvalidArgument), + ); } function parsePageComponentIds(ids: ReadonlyArray): number[] { diff --git a/apps/server/src/routes/rpc/handlers/status-report/index.ts b/apps/server/src/routes/rpc/handlers/status-report/index.ts index 08c0d1d20..768ffde14 100644 --- a/apps/server/src/routes/rpc/handlers/status-report/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-report/index.ts @@ -18,6 +18,7 @@ import { import { toConnectError, toServiceCtx } from "../../adapter"; import { getRpcContext } from "../../interceptors"; +import { parseDate as parseRpcDate, parseId as parseRpcId } from "../../parse"; import { dbReportToProto, dbReportToProtoSummary, @@ -32,29 +33,15 @@ import { } from "./errors"; function parseDate(dateString: string): Date { - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) { - throw invalidDateFormatError(dateString); - } - return date; + return parseRpcDate(dateString, () => invalidDateFormatError(dateString)); } -// Match the digits explicitly: `Number("")` is 0 (finite!), so a blank id used -// to slip through and target component 0, and `Number.parseInt("1.5")` is 1, so -// swapping in parseInt alone would still truncate a malformed id silently. -const DECIMAL_ID = /^\d+$/; - function parseId(value: string, label: string): number { - const trimmed = value.trim(); - // past 2^53 the digits round to a neighbouring id - const id = Number(trimmed); - if (!DECIMAL_ID.test(trimmed) || !Number.isSafeInteger(id)) { - throw new ConnectError( - `Invalid ${label}: "${value}"`, - Code.InvalidArgument, - ); - } - return id; + return parseRpcId( + value, + () => + new ConnectError(`Invalid ${label}: "${value}"`, Code.InvalidArgument), + ); } function parsePageComponentIds(ids: ReadonlyArray): number[] { diff --git a/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts b/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts index 5d9387d2c..4f61679b5 100644 --- a/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts +++ b/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts @@ -492,7 +492,8 @@ describe("IncidentService tracking", () => { await Promise.resolve(); expect(mockTrack).toHaveBeenCalledWith({ ...Events.DeclareManagedIncident, - additionalProps: { source: "api", severity: "critical" }, + source: "api", + severity: "critical", }); }); @@ -510,7 +511,8 @@ describe("IncidentService tracking", () => { await Promise.resolve(); expect(mockTrack).toHaveBeenCalledWith({ ...Events.ChangeManagedIncidentStatus, - additionalProps: { source: "api", status: "resolved" }, + source: "api", + status: "resolved", }); }); }); diff --git a/apps/server/src/routes/rpc/parse.test.ts b/apps/server/src/routes/rpc/parse.test.ts new file mode 100644 index 000000000..487aa0d0b --- /dev/null +++ b/apps/server/src/routes/rpc/parse.test.ts @@ -0,0 +1,49 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { parseDate, parseId } from "./parse"; + +const invalid = () => new ConnectError("invalid", Code.InvalidArgument); + +describe("parseDate", () => { + test("accepts RFC 3339 with Z, offsets and fractions", () => { + expect(parseDate("2024-02-29T10:30:00Z", invalid).toISOString()).toBe( + "2024-02-29T10:30:00.000Z", + ); + expect( + parseDate("2024-03-15T10:30:00.123+02:00", invalid).toISOString(), + ).toBe("2024-03-15T08:30:00.123Z"); + }); + + test("rejects impossible calendar values instead of rolling over", () => { + for (const value of [ + "2024-02-31T10:30:00Z", + "2023-02-29T10:30:00Z", + "2024-13-01T10:30:00Z", + "2024-03-15T24:00:00Z", + "2024-03-15T10:60:00Z", + "2024-03-15T10:30:00+24:00", + ]) { + expect(() => parseDate(value, invalid)).toThrow(ConnectError); + } + }); + + test("rejects non RFC 3339 strings", () => { + for (const value of ["2024-03-15", "March 15, 2024", ""]) { + expect(() => parseDate(value, invalid)).toThrow(ConnectError); + } + }); +}); + +describe("parseId", () => { + test("accepts decimal digits", () => { + expect(parseId(" 42 ", invalid)).toBe(42); + }); + + test("rejects anything else", () => { + for (const value of ["", "1e3", "0x10", "1.5", "9007199254740993"]) { + expect(() => parseId(value, invalid)).toThrow(ConnectError); + } + }); +}); diff --git a/apps/server/src/routes/rpc/parse.ts b/apps/server/src/routes/rpc/parse.ts new file mode 100644 index 000000000..41553bede --- /dev/null +++ b/apps/server/src/routes/rpc/parse.ts @@ -0,0 +1,57 @@ +import type { ConnectError } from "@connectrpc/connect"; + +// Match the digits explicitly: `Number("")` is 0, `Number("1e3")` is 1000 and +// `Number("0x10")` is 16 — each would silently target another row. +const DECIMAL_ID = /^\d+$/; + +/** A decimal id string, or `onInvalid`'s error. */ +export function parseId(value: string, onInvalid: () => ConnectError): number { + const trimmed = value.trim(); + // past 2^53 the digits round to a neighbouring id + const id = Number(trimmed); + if (!DECIMAL_ID.test(trimmed) || !Number.isSafeInteger(id)) { + throw onInvalid(); + } + return id; +} + +const RFC_3339 = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/i; + +/** + * An RFC 3339 timestamp, or `onInvalid`'s error. `new Date` rolls impossible + * calendar values over (Feb 31 becomes Mar 2), so each component is checked. + */ +export function parseDate(value: string, onInvalid: () => ConnectError): Date { + const match = RFC_3339.exec(value); + const date = new Date(value); + if (!match || Number.isNaN(date.getTime())) throw onInvalid(); + // a missing (Z) offset reads as 0 + const [year, month, day, hour, minute, second, offsetH, offsetM] = match + .slice(1) + .map((part) => Number(part ?? 0)) as [ + number, + number, + number, + number, + number, + number, + number, + number, + ]; + const calendar = new Date(0); + calendar.setUTCFullYear(year, month - 1, day); + if ( + calendar.getUTCFullYear() !== year || + calendar.getUTCMonth() !== month - 1 || + calendar.getUTCDate() !== day || + hour > 23 || + minute > 59 || + second > 59 || + offsetH > 23 || + offsetM > 59 + ) { + throw onInvalid(); + } + return date; +} diff --git a/apps/server/src/routes/slack/incident-slack.ts b/apps/server/src/routes/slack/incident-slack.ts index b11c3abeb..d0fe3ff7b 100644 --- a/apps/server/src/routes/slack/incident-slack.ts +++ b/apps/server/src/routes/slack/incident-slack.ts @@ -13,6 +13,7 @@ import { type IncidentEffects, incidentEffects, type OpenChannelResult, + openIncidentSlackChannel, type SlackClientFactory, } from "@openstatus/services/incident"; import { WebClient } from "@slack/web-api"; @@ -161,14 +162,29 @@ export async function onIncidentDeclared( config: SlackConfig, ): Promise { trackSlackIncident(ctx, "declare"); - const incident = await getIncident({ ctx, input: { id: incidentId } }); - const result = (incident && - (await afterIncidentDeclared({ - ctx, - effects: await slackEffects(ctx, config), - incident, - openSlackChannel: true, - }))) ?? { status: "skipped" as const }; + const effects = await slackEffects(ctx, config); + // The incident has committed: a failed read skips the commander email + // (best effort) but must not keep the channel from opening. + const incident = await getIncident({ ctx, input: { id: incidentId } }).catch( + (error: unknown) => { + logger.warn("slack incident read failed", { error, incidentId }); + return undefined; + }, + ); + const result = (incident === undefined + ? await openIncidentSlackChannel({ + ctx, + incidentId, + clientFor: effects.clientFor, + dashboardUrl: effects.dashboardUrl, + }) + : incident && + (await afterIncidentDeclared({ + ctx, + effects, + incident, + openSlackChannel: true, + }))) ?? { status: "skipped" as const }; logger.info("slack incident channel", { incidentId, ...result }); return result; } diff --git a/apps/server/static/openapi.yaml b/apps/server/static/openapi.yaml index ee1a7cb21..9726fe555 100644 --- a/apps/server/static/openapi.yaml +++ b/apps/server/static/openapi.yaml @@ -227,7 +227,8 @@ components: - "null" title: summary maxLength: 4000 - description: Short human summary (optional, up to 4000 characters). + minLength: 1 + description: Short human summary (optional, 1-4000 characters). commanderEmail: type: - string @@ -869,7 +870,10 @@ components: description: New start of impact (RFC 3339 format, optional). title: UpdateIncidentRequest additionalProperties: false - description: UpdateIncidentRequest is the request to edit an incident. + description: | + UpdateIncidentRequest is the request to edit an incident. + commander_email_or_clear_commander // set either commander_email or clear_commander, not both + summary_or_clear_summary // set either summary or clear_summary, not both openstatus.incident.v1.UpdateIncidentResponse: type: object properties: diff --git a/apps/web/src/content/pages/changelog/incident-api.mdx b/apps/web/src/content/pages/changelog/incident-api.mdx index 88d6c4098..1299c7810 100644 --- a/apps/web/src/content/pages/changelog/incident-api.mdx +++ b/apps/web/src/content/pages/changelog/incident-api.mdx @@ -15,7 +15,7 @@ Everything you do from the dashboard works from your scripts and integrations to - Add timeline notes, link the public status report, and write and approve the postmortem. - Close the incident, or delete one declared by mistake. -Changes made through the API send the same commander emails and Slack channel messages as the dashboard. `createStatusReport` now takes an `incidentId`, and status reports return the incident they are linked to. +Changes made through the API send the same commander emails and Slack channel messages as the dashboard. `createStatusReport` now takes an `incidentId`, and status reports return their linked `incidentId`. Read the [Incident Service guide](/docs/sdk/nodejs/incident-service) to get started. diff --git a/apps/web/src/content/pages/docs/sdk/nodejs/incident-service.mdx b/apps/web/src/content/pages/docs/sdk/nodejs/incident-service.mdx index fc133984e..14b4b6a98 100644 --- a/apps/web/src/content/pages/docs/sdk/nodejs/incident-service.mdx +++ b/apps/web/src/content/pages/docs/sdk/nodejs/incident-service.mdx @@ -96,7 +96,7 @@ const { incidents, totalSize } = await client.incident.v1.IncidentService }); ``` -Incidents are listed open first, then by most recently declared. Combine `statuses: [RESOLVED]` with `closed: false` to find the incidents that still need a postmortem. +Incidents are listed by most recently declared. Combine `statuses: [RESOLVED]` with `closed: false` to find resolved incidents that are not closed yet, then call `getPostmortem` to check whether a postmortem is still needed. How long an incident lasted is `startedAt` to `resolvedAt` (or `closedAt` when canceled), or to now while it is ongoing. @@ -162,7 +162,7 @@ Closing needs a resolved incident and an approved postmortem, or `skipPostmortem await client.incident.v1.IncidentService.deleteIncident({ id: "42" }); ``` -Delete is for incidents declared by mistake: it only works while the incident is open and was never mitigated, resolved or closed (`deletable` is true). Cancel the incident otherwise; that keeps the record. +Delete is for incidents declared by mistake: it only works while the incident is open and was never mitigated, resolved or closed (`deletable` is true). Otherwise, cancel an `OPEN` or `MITIGATED` incident instead; that keeps the record. A resolved incident has to be reopened before it can be canceled, and a closed one cannot be canceled. ## Permissions @@ -178,4 +178,4 @@ Keys that were created before keys had an owner cannot close, delete or approve; ## Slack -When the [Slack agent](/docs/guides/how-to-setup-slack-agent) is connected, changes made through the API are announced in the incident's channel, and closing, canceling or deleting the incident archives it, just like changes made in the dashboard. Incidents return `slackChannelUrl` when a channel is bound. +When the [Slack agent](/docs/guides/how-to-setup-slack-agent) is connected, edits, status changes, closing, deleting and postmortem approvals made through the API are announced in the incident's channel (notes and status report links are not), and closing, canceling or deleting the incident archives it, just like changes made in the dashboard. Incidents return `slackChannelUrl` when a channel is bound. diff --git a/apps/web/src/content/pages/docs/sdk/nodejs/overview.mdx b/apps/web/src/content/pages/docs/sdk/nodejs/overview.mdx index c539c1117..dd544d3a4 100644 --- a/apps/web/src/content/pages/docs/sdk/nodejs/overview.mdx +++ b/apps/web/src/content/pages/docs/sdk/nodejs/overview.mdx @@ -38,7 +38,7 @@ The client exposes one service per domain. Each has its own reference page: | Status Report | [Status Report Service](/docs/sdk/nodejs/status-report-service) | Manage incident reports and their update timelines. | | Status Page | [Status Page Service](/docs/sdk/nodejs/status-page-service) | Manage status pages, components, component groups, and subscribers. | | Maintenance | [Maintenance Service](/docs/sdk/nodejs/maintenance-service) | Schedule and manage planned maintenance windows. | -| Incident | [Incident Service](/docs/sdk/nodejs/incident-service) | Declare and run incidents: lifecycle, timeline, status report link and postmortem. | +| Incident (coming soon) | [Incident Service](/docs/sdk/nodejs/incident-service) | Declare and run incidents: lifecycle, timeline, status report link and postmortem. API is live; SDK support ships in an upcoming release. | | Notification | [Notification Service](/docs/sdk/nodejs/notification-service) | Configure notification channels and check usage limits. | | Private Location | [Private Location Service](/docs/sdk/nodejs/private-location-service) | Manage self-hosted probes and their agent tokens. | diff --git a/packages/proto/api/openstatus/incident/v1/service.proto b/packages/proto/api/openstatus/incident/v1/service.proto index 693e677dd..7371b08d0 100644 --- a/packages/proto/api/openstatus/incident/v1/service.proto +++ b/packages/proto/api/openstatus/incident/v1/service.proto @@ -94,8 +94,11 @@ message DeclareIncidentRequest { not_in: [0] }]; - // Short human summary (optional, up to 4000 characters). - optional string summary = 3 [(buf.validate.field).string.max_len = 4000]; + // Short human summary (optional, 1-4000 characters). + optional string summary = 3 [(buf.validate.field).string = { + min_len: 1 + max_len: 4000 + }]; // Email of the member who leads the response (optional, defaults to unassigned). optional string commander_email = 4 [ @@ -166,6 +169,17 @@ message ListIncidentsResponse { // UpdateIncidentRequest is the request to edit an incident. message UpdateIncidentRequest { + option (buf.validate.message).cel = { + id: "summary_or_clear_summary" + message: "set either summary or clear_summary, not both" + expression: "!(has(this.summary) && has(this.clear_summary) && this.clear_summary)" + }; + option (buf.validate.message).cel = { + id: "commander_email_or_clear_commander" + message: "set either commander_email or clear_commander, not both" + expression: "!(has(this.commander_email) && has(this.clear_commander) && this.clear_commander)" + }; + // ID of the incident to update (required). string id = 1 [(buf.validate.field).string.min_len = 1]; diff --git a/packages/proto/gen/openapi.yaml b/packages/proto/gen/openapi.yaml index ee1a7cb21..9726fe555 100644 --- a/packages/proto/gen/openapi.yaml +++ b/packages/proto/gen/openapi.yaml @@ -227,7 +227,8 @@ components: - "null" title: summary maxLength: 4000 - description: Short human summary (optional, up to 4000 characters). + minLength: 1 + description: Short human summary (optional, 1-4000 characters). commanderEmail: type: - string @@ -869,7 +870,10 @@ components: description: New start of impact (RFC 3339 format, optional). title: UpdateIncidentRequest additionalProperties: false - description: UpdateIncidentRequest is the request to edit an incident. + description: | + UpdateIncidentRequest is the request to edit an incident. + commander_email_or_clear_commander // set either commander_email or clear_commander, not both + summary_or_clear_summary // set either summary or clear_summary, not both openstatus.incident.v1.UpdateIncidentResponse: type: object properties: diff --git a/packages/proto/gen/ts/openstatus/incident/v1/service_pb.ts b/packages/proto/gen/ts/openstatus/incident/v1/service_pb.ts index 6eefccffa..67830433f 100644 --- a/packages/proto/gen/ts/openstatus/incident/v1/service_pb.ts +++ b/packages/proto/gen/ts/openstatus/incident/v1/service_pb.ts @@ -14,7 +14,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file openstatus/incident/v1/service.proto. */ export const file_openstatus_incident_v1_service: GenFile = /*@__PURE__*/ - fileDesc("CiRvcGVuc3RhdHVzL2luY2lkZW50L3YxL3NlcnZpY2UucHJvdG8SFm9wZW5zdGF0dXMuaW5jaWRlbnQudjEioAQKFkRlY2xhcmVJbmNpZGVudFJlcXVlc3QSOAoFdGl0bGUYASABKAlCKbpHHDoaEhhDaGVja291dCBBUEkgcmV0dXJucyA1MDK6SAdyBRABGIACEkYKCHNldmVyaXR5GAIgASgOMigub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudFNldmVyaXR5Qgq6SAeCAQQQASAAEh4KB3N1bW1hcnkYAyABKAlCCLpIBXIDGKAfSACIAQESPAoPY29tbWFuZGVyX2VtYWlsGAQgASgJQh66RxQ6EhIQamFuZUBleGFtcGxlLmNvbbpIBHICYAFIAYgBARKBAQoKc3RhcnRlZF9hdBgFIAEoCUJoukcaOhgSFiIyMDI0LTAzLTE1VDEwOjMwOjAwWiK6SEhyRjJEXlxkezR9LVxkezJ9LVxkezJ9VFxkezJ9OlxkezJ9OlxkezJ9KFwuXGR7MSw5fSk/KFp8WystXVxkezJ9OlxkezJ9KSRIAogBARImChBzdGF0dXNfcmVwb3J0X2lkGAYgASgJQge6SARyAhABSAOIAQESHwoSb3Blbl9zbGFja19jaGFubmVsGAcgASgISASIAQFCCgoIX3N1bW1hcnlCEgoQX2NvbW1hbmRlcl9lbWFpbEINCgtfc3RhcnRlZF9hdEITChFfc3RhdHVzX3JlcG9ydF9pZEIVChNfb3Blbl9zbGFja19jaGFubmVsIk0KF0RlY2xhcmVJbmNpZGVudFJlc3BvbnNlEjIKCGluY2lkZW50GAEgASgLMiAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudCIpChJHZXRJbmNpZGVudFJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEiSQoTR2V0SW5jaWRlbnRSZXNwb25zZRIyCghpbmNpZGVudBgBIAEoCzIgLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuSW5jaWRlbnQi0wEKFExpc3RJbmNpZGVudHNSZXF1ZXN0Eh0KBWxpbWl0GAEgASgFQgm6SAYaBBhkKAFIAIgBARIcCgZvZmZzZXQYAiABKAVCB7pIBBoCKABIAYgBARJJCghzdGF0dXNlcxgDIAMoDjImLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuSW5jaWRlbnRTdGF0dXNCD7pIDJIBCSIHggEEEAEgABITCgZjbG9zZWQYBCABKAhIAogBAUIICgZfbGltaXRCCQoHX29mZnNldEIJCgdfY2xvc2VkImcKFUxpc3RJbmNpZGVudHNSZXNwb25zZRI6CglpbmNpZGVudHMYASADKAsyJy5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkluY2lkZW50U3VtbWFyeRISCgp0b3RhbF9zaXplGAIgASgFIu4DChVVcGRhdGVJbmNpZGVudFJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESHgoFdGl0bGUYAiABKAlCCrpIB3IFEAEYgAJIAIgBARJLCghzZXZlcml0eRgDIAEoDjIoLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuSW5jaWRlbnRTZXZlcml0eUIKukgHggEEEAEgAEgBiAEBEiAKB3N1bW1hcnkYBCABKAlCCrpIB3IFEAEYoB9IAogBARIaCg1jbGVhcl9zdW1tYXJ5GAUgASgISAOIAQESJQoPY29tbWFuZGVyX2VtYWlsGAYgASgJQge6SARyAmABSASIAQESHAoPY2xlYXJfY29tbWFuZGVyGAcgASgISAWIAQESZAoKc3RhcnRlZF9hdBgIIAEoCUJLukhIckYyRF5cZHs0fS1cZHsyfS1cZHsyfVRcZHsyfTpcZHsyfTpcZHsyfShcLlxkezEsOX0pPyhafFsrLV1cZHsyfTpcZHsyfSkkSAaIAQFCCAoGX3RpdGxlQgsKCV9zZXZlcml0eUIKCghfc3VtbWFyeUIQCg5fY2xlYXJfc3VtbWFyeUISChBfY29tbWFuZGVyX2VtYWlsQhIKEF9jbGVhcl9jb21tYW5kZXJCDQoLX3N0YXJ0ZWRfYXQiTAoWVXBkYXRlSW5jaWRlbnRSZXNwb25zZRIyCghpbmNpZGVudBgBIAEoCzIgLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuSW5jaWRlbnQimQEKGFNldEluY2lkZW50U3RhdHVzUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARJCCgZzdGF0dXMYAiABKA4yJi5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkluY2lkZW50U3RhdHVzQgq6SAeCAQQQASAAEhsKBG5vdGUYAyABKAlCCLpIBXIDGJBOSACIAQFCBwoFX25vdGUiTwoZU2V0SW5jaWRlbnRTdGF0dXNSZXNwb25zZRIyCghpbmNpZGVudBgBIAEoCzIgLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuSW5jaWRlbnQiSgoWQWRkSW5jaWRlbnROb3RlUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIbCgdtZXNzYWdlGAIgASgJQgq6SAdyBRABGJBOIk8KF0FkZEluY2lkZW50Tm90ZVJlc3BvbnNlEjQKBWV2ZW50GAEgASgLMiUub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudEV2ZW50IlEKF0xpbmtTdGF0dXNSZXBvcnRSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEiEKEHN0YXR1c19yZXBvcnRfaWQYAiABKAlCB7pIBHICEAEiTgoYTGlua1N0YXR1c1JlcG9ydFJlc3BvbnNlEjIKCGluY2lkZW50GAEgASgLMiAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudCIwChlVbmxpbmtTdGF0dXNSZXBvcnRSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIlAKGlVubGlua1N0YXR1c1JlcG9ydFJlc3BvbnNlEjIKCGluY2lkZW50GAEgASgLMiAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudCJdChRDbG9zZUluY2lkZW50UmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIcCg9za2lwX3Bvc3Rtb3J0ZW0YAiABKAhIAIgBAUISChBfc2tpcF9wb3N0bW9ydGVtIksKFUNsb3NlSW5jaWRlbnRSZXNwb25zZRIyCghpbmNpZGVudBgBIAEoCzIgLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuSW5jaWRlbnQiLAoVRGVsZXRlSW5jaWRlbnRSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIikKFkRlbGV0ZUluY2lkZW50UmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCI0ChRHZXRQb3N0bW9ydGVtUmVxdWVzdBIcCgtpbmNpZGVudF9pZBgBIAEoCUIHukgEcgIQASJjChVHZXRQb3N0bW9ydGVtUmVzcG9uc2USOwoKcG9zdG1vcnRlbRgBIAEoCzIiLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuUG9zdG1vcnRlbUgAiAEBQg0KC19wb3N0bW9ydGVtIlUKF1VwZGF0ZVBvc3Rtb3J0ZW1SZXF1ZXN0EhwKC2luY2lkZW50X2lkGAEgASgJQge6SARyAhABEhwKB2NvbnRlbnQYAiABKAlCC7pICHIGEAEYoI0GIlIKGFVwZGF0ZVBvc3Rtb3J0ZW1SZXNwb25zZRI2Cgpwb3N0bW9ydGVtGAEgASgLMiIub3BlbnN0YXR1cy5pbmNpZGVudC52MS5Qb3N0bW9ydGVtIlYKGEFwcHJvdmVQb3N0bW9ydGVtUmVxdWVzdBIcCgtpbmNpZGVudF9pZBgBIAEoCUIHukgEcgIQARISCgVjbG9zZRgCIAEoCEgAiAEBQggKBl9jbG9zZSKHAQoZQXBwcm92ZVBvc3Rtb3J0ZW1SZXNwb25zZRI2Cgpwb3N0bW9ydGVtGAEgASgLMiIub3BlbnN0YXR1cy5pbmNpZGVudC52MS5Qb3N0bW9ydGVtEjIKCGluY2lkZW50GAIgASgLMiAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudDLqHAoPSW5jaWRlbnRTZXJ2aWNlEqoFCg9EZWNsYXJlSW5jaWRlbnQSLi5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkRlY2xhcmVJbmNpZGVudFJlcXVlc3QaLy5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkRlY2xhcmVJbmNpZGVudFJlc3BvbnNlIrUEukexBBquBERlY2xhcmVzIGEgbmV3IGluY2lkZW50IGluIHRoZSBvcGVuIHN0YXR1cy4gVGhlIGNvbW1hbmRlciBpcyBzZXQgYnkgZW1haWwgYW5kIG11c3QgYmUgYSBtZW1iZXIgb2YgdGhlIHdvcmtzcGFjZTsgd2l0aG91dCBvbmUgdGhlIGluY2lkZW50IGlzIHVuYXNzaWduZWQuIEEgbmV3bHkgYXNzaWduZWQgY29tbWFuZGVyIGlzIGVtYWlsZWQgdW5sZXNzIHRoZXkgb3duIHRoZSBBUEkga2V5LiBXaGVuIG9wZW5fc2xhY2tfY2hhbm5lbCBpcyB0cnVlIGFuZCBTbGFjayBpcyBjb25uZWN0ZWQsIGEgZGVkaWNhdGVkIGNoYW5uZWwgaXMgY3JlYXRlZCBhbmQgdGhlIHRlYW0gaW52aXRlZC4gc3RhcnRlZF9hdCBkZWZhdWx0cyB0byBub3cgYW5kIGNhbiBiZSBzZXQgaW4gdGhlIHBhc3QgZm9yIGEgcmV0cm9hY3RpdmUgZGVjbGFyZS4gQSBzdGF0dXNfcmVwb3J0X2lkIHRoYXQgZG9lcyBub3QgZXhpc3QgaW4gdGhlIHdvcmtzcGFjZSBmYWlscyB3aXRoIG5vdF9mb3VuZDsgb25lIGFscmVhZHkgbGlua2VkIHRvIGFub3RoZXIgaW5jaWRlbnQgZmFpbHMgd2l0aCBmYWlsZWRfcHJlY29uZGl0aW9uLhJrCgtHZXRJbmNpZGVudBIqLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuR2V0SW5jaWRlbnRSZXF1ZXN0Gisub3BlbnN0YXR1cy5pbmNpZGVudC52MS5HZXRJbmNpZGVudFJlc3BvbnNlIgOQAgEScQoNTGlzdEluY2lkZW50cxIsLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuTGlzdEluY2lkZW50c1JlcXVlc3QaLS5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkxpc3RJbmNpZGVudHNSZXNwb25zZSIDkAIBEm8KDlVwZGF0ZUluY2lkZW50Ei0ub3BlbnN0YXR1cy5pbmNpZGVudC52MS5VcGRhdGVJbmNpZGVudFJlcXVlc3QaLi5vcGVuc3RhdHVzLmluY2lkZW50LnYxLlVwZGF0ZUluY2lkZW50UmVzcG9uc2USyQQKEVNldEluY2lkZW50U3RhdHVzEjAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5TZXRJbmNpZGVudFN0YXR1c1JlcXVlc3QaMS5vcGVuc3RhdHVzLmluY2lkZW50LnYxLlNldEluY2lkZW50U3RhdHVzUmVzcG9uc2UizgO6R8oDGscDTW92ZXMgYW4gaW5jaWRlbnQgdG8gYSBuZXcgc3RhdHVzLiBBbGxvd2VkIHRyYW5zaXRpb25zOiBvcGVuIC0+IG1pdGlnYXRlZCwgcmVzb2x2ZWQgb3IgY2FuY2VsZWQ7IG1pdGlnYXRlZCAtPiByZXNvbHZlZCwgb3BlbiBvciBjYW5jZWxlZDsgcmVzb2x2ZWQgLT4gb3Blbi4gQ2FuY2VsZWQgaXMgdGVybWluYWwgYW5kIGNsb3NlcyB0aGUgaW5jaWRlbnQuIFRoZSBzYW1lIHN0YXR1cywgYSBmb3JiaWRkZW4gdHJhbnNpdGlvbiBvciBhIGNsb3NlZCBpbmNpZGVudCBmYWlsIHdpdGggZmFpbGVkX3ByZWNvbmRpdGlvbjsgSW5jaWRlbnQuYWxsb3dlZF90cmFuc2l0aW9ucyBsaXN0cyB3aGF0IGlzIHZhbGlkLiBBIGxpbmtlZCBzdGF0dXMgcmVwb3J0IGlzIG5ldmVyIHVwZGF0ZWQ6IHBvc3QgdGhlIHB1YmxpYyB1cGRhdGUgd2l0aCBTdGF0dXNSZXBvcnRTZXJ2aWNlLkFkZFN0YXR1c1JlcG9ydFVwZGF0ZS4ScgoPQWRkSW5jaWRlbnROb3RlEi4ub3BlbnN0YXR1cy5pbmNpZGVudC52MS5BZGRJbmNpZGVudE5vdGVSZXF1ZXN0Gi8ub3BlbnN0YXR1cy5pbmNpZGVudC52MS5BZGRJbmNpZGVudE5vdGVSZXNwb25zZRJ1ChBMaW5rU3RhdHVzUmVwb3J0Ei8ub3BlbnN0YXR1cy5pbmNpZGVudC52MS5MaW5rU3RhdHVzUmVwb3J0UmVxdWVzdBowLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuTGlua1N0YXR1c1JlcG9ydFJlc3BvbnNlEnsKElVubGlua1N0YXR1c1JlcG9ydBIxLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuVW5saW5rU3RhdHVzUmVwb3J0UmVxdWVzdBoyLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuVW5saW5rU3RhdHVzUmVwb3J0UmVzcG9uc2USsQMKDUNsb3NlSW5jaWRlbnQSLC5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkNsb3NlSW5jaWRlbnRSZXF1ZXN0Gi0ub3BlbnN0YXR1cy5pbmNpZGVudC52MS5DbG9zZUluY2lkZW50UmVzcG9uc2UiwgK6R74CGrsCQ2xvc2VzIGEgcmVzb2x2ZWQgaW5jaWRlbnQsIGFmdGVyIHdoaWNoIG9ubHkgaXRzIHBvc3Rtb3J0ZW0gY2FuIGNoYW5nZS4gUmVxdWlyZXMgYW4gYXBwcm92ZWQgcG9zdG1vcnRlbSwgb3Igc2tpcF9wb3N0bW9ydGVtIHNldCB0byB0cnVlLiBBbGxvd2VkIGZvciB3b3Jrc3BhY2Ugb3duZXJzIGFuZCBhZG1pbnMgYW5kIGZvciB0aGUgaW5jaWRlbnQgY29tbWFuZGVyLiBUaGUgQVBJIGtleSBhY3RzIGFzIGl0cyBjcmVhdG9yLCB3aXRoIHRoZSBjcmVhdG9yJ3MgY3VycmVudCByb2xlOyBrZXlzIHdpdGhvdXQgYSBjcmVhdG9yIGNhbm5vdCBjYWxsIHRoaXMuErkDCg5EZWxldGVJbmNpZGVudBItLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuRGVsZXRlSW5jaWRlbnRSZXF1ZXN0Gi4ub3BlbnN0YXR1cy5pbmNpZGVudC52MS5EZWxldGVJbmNpZGVudFJlc3BvbnNlIscCukfDAhrAAkRlbGV0ZXMgYW4gaW5jaWRlbnQgZGVjbGFyZWQgYnkgbWlzdGFrZS4gT25seSBhbGxvd2VkIHdoaWxlIHRoZSBpbmNpZGVudCBpcyBvcGVuIGFuZCB3YXMgbmV2ZXIgbWl0aWdhdGVkLCByZXNvbHZlZCBvciBjbG9zZWQgKEluY2lkZW50LmRlbGV0YWJsZSk7IGNhbmNlbCBpdCBvdGhlcndpc2UuIEFsbG93ZWQgZm9yIHdvcmtzcGFjZSBvd25lcnMgYW5kIGFkbWlucyBvbmx5LiBUaGUgQVBJIGtleSBhY3RzIGFzIGl0cyBjcmVhdG9yLCB3aXRoIHRoZSBjcmVhdG9yJ3MgY3VycmVudCByb2xlOyBrZXlzIHdpdGhvdXQgYSBjcmVhdG9yIGNhbm5vdCBjYWxsIHRoaXMuEnEKDUdldFBvc3Rtb3J0ZW0SLC5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkdldFBvc3Rtb3J0ZW1SZXF1ZXN0Gi0ub3BlbnN0YXR1cy5pbmNpZGVudC52MS5HZXRQb3N0bW9ydGVtUmVzcG9uc2UiA5ACARJ1ChBVcGRhdGVQb3N0bW9ydGVtEi8ub3BlbnN0YXR1cy5pbmNpZGVudC52MS5VcGRhdGVQb3N0bW9ydGVtUmVxdWVzdBowLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuVXBkYXRlUG9zdG1vcnRlbVJlc3BvbnNlEsoEChFBcHByb3ZlUG9zdG1vcnRlbRIwLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuQXBwcm92ZVBvc3Rtb3J0ZW1SZXF1ZXN0GjEub3BlbnN0YXR1cy5pbmNpZGVudC52MS5BcHByb3ZlUG9zdG1vcnRlbVJlc3BvbnNlIs8DukfLAxrIA0FwcHJvdmVzIHRoZSBkcmFmdCBwb3N0bW9ydGVtIG9mIGEgcmVzb2x2ZWQgaW5jaWRlbnQuIFdpdGggY2xvc2Ugc2V0IHRvIHRydWUgdGhlIGluY2lkZW50IGlzIGNsb3NlZCBpbiB0aGUgc2FtZSBzdGVwLiBGYWlscyB3aXRoIG5vdF9mb3VuZCB3aGVuIG5vIHBvc3Rtb3J0ZW0gaGFzIGJlZW4gd3JpdHRlbiwgYW5kIHdpdGggZmFpbGVkX3ByZWNvbmRpdGlvbiB3aGVuIHRoZSBpbmNpZGVudCBpcyBub3QgcmVzb2x2ZWQgb3IgdGhlIHBvc3Rtb3J0ZW0gaXMgYWxyZWFkeSBhcHByb3ZlZC4gQWxsb3dlZCBmb3Igd29ya3NwYWNlIG93bmVycyBhbmQgYWRtaW5zIGFuZCBmb3IgdGhlIGluY2lkZW50IGNvbW1hbmRlci4gVGhlIEFQSSBrZXkgYWN0cyBhcyBpdHMgY3JlYXRvciwgd2l0aCB0aGUgY3JlYXRvcidzIGN1cnJlbnQgcm9sZTsga2V5cyB3aXRob3V0IGEgY3JlYXRvciBjYW5ub3QgY2FsbCB0aGlzLkJVWlNnaXRodWIuY29tL29wZW5zdGF0dXNocS9vcGVuc3RhdHVzL3BhY2thZ2VzL3Byb3RvL29wZW5zdGF0dXMvaW5jaWRlbnQvdjE7aW5jaWRlbnR2MWIGcHJvdG8z", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_incident_v1_incident]); + fileDesc("CiRvcGVuc3RhdHVzL2luY2lkZW50L3YxL3NlcnZpY2UucHJvdG8SFm9wZW5zdGF0dXMuaW5jaWRlbnQudjEiogQKFkRlY2xhcmVJbmNpZGVudFJlcXVlc3QSOAoFdGl0bGUYASABKAlCKbpHHDoaEhhDaGVja291dCBBUEkgcmV0dXJucyA1MDK6SAdyBRABGIACEkYKCHNldmVyaXR5GAIgASgOMigub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudFNldmVyaXR5Qgq6SAeCAQQQASAAEiAKB3N1bW1hcnkYAyABKAlCCrpIB3IFEAEYoB9IAIgBARI8Cg9jb21tYW5kZXJfZW1haWwYBCABKAlCHrpHFDoSEhBqYW5lQGV4YW1wbGUuY29tukgEcgJgAUgBiAEBEoEBCgpzdGFydGVkX2F0GAUgASgJQmi6Rxo6GBIWIjIwMjQtMDMtMTVUMTA6MzA6MDBaIrpISHJGMkReXGR7NH0tXGR7Mn0tXGR7Mn1UXGR7Mn06XGR7Mn06XGR7Mn0oXC5cZHsxLDl9KT8oWnxbKy1dXGR7Mn06XGR7Mn0pJEgCiAEBEiYKEHN0YXR1c19yZXBvcnRfaWQYBiABKAlCB7pIBHICEAFIA4gBARIfChJvcGVuX3NsYWNrX2NoYW5uZWwYByABKAhIBIgBAUIKCghfc3VtbWFyeUISChBfY29tbWFuZGVyX2VtYWlsQg0KC19zdGFydGVkX2F0QhMKEV9zdGF0dXNfcmVwb3J0X2lkQhUKE19vcGVuX3NsYWNrX2NoYW5uZWwiTQoXRGVjbGFyZUluY2lkZW50UmVzcG9uc2USMgoIaW5jaWRlbnQYASABKAsyIC5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkluY2lkZW50IikKEkdldEluY2lkZW50UmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASJJChNHZXRJbmNpZGVudFJlc3BvbnNlEjIKCGluY2lkZW50GAEgASgLMiAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudCLTAQoUTGlzdEluY2lkZW50c1JlcXVlc3QSHQoFbGltaXQYASABKAVCCbpIBhoEGGQoAUgAiAEBEhwKBm9mZnNldBgCIAEoBUIHukgEGgIoAEgBiAEBEkkKCHN0YXR1c2VzGAMgAygOMiYub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudFN0YXR1c0IPukgMkgEJIgeCAQQQASAAEhMKBmNsb3NlZBgEIAEoCEgCiAEBQggKBl9saW1pdEIJCgdfb2Zmc2V0QgkKB19jbG9zZWQiZwoVTGlzdEluY2lkZW50c1Jlc3BvbnNlEjoKCWluY2lkZW50cxgBIAMoCzInLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuSW5jaWRlbnRTdW1tYXJ5EhIKCnRvdGFsX3NpemUYAiABKAUiuwYKFVVwZGF0ZUluY2lkZW50UmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIeCgV0aXRsZRgCIAEoCUIKukgHcgUQARiAAkgAiAEBEksKCHNldmVyaXR5GAMgASgOMigub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudFNldmVyaXR5Qgq6SAeCAQQQASAASAGIAQESIAoHc3VtbWFyeRgEIAEoCUIKukgHcgUQARigH0gCiAEBEhoKDWNsZWFyX3N1bW1hcnkYBSABKAhIA4gBARIlCg9jb21tYW5kZXJfZW1haWwYBiABKAlCB7pIBHICYAFIBIgBARIcCg9jbGVhcl9jb21tYW5kZXIYByABKAhIBYgBARJkCgpzdGFydGVkX2F0GAggASgJQku6SEhyRjJEXlxkezR9LVxkezJ9LVxkezJ9VFxkezJ9OlxkezJ9OlxkezJ9KFwuXGR7MSw5fSk/KFp8WystXVxkezJ9OlxkezJ9KSRIBogBATrKArpIxgIakAEKGHN1bW1hcnlfb3JfY2xlYXJfc3VtbWFyeRItc2V0IGVpdGhlciBzdW1tYXJ5IG9yIGNsZWFyX3N1bW1hcnksIG5vdCBib3RoGkUhKGhhcyh0aGlzLnN1bW1hcnkpICYmIGhhcyh0aGlzLmNsZWFyX3N1bW1hcnkpICYmIHRoaXMuY2xlYXJfc3VtbWFyeSkasAEKImNvbW1hbmRlcl9lbWFpbF9vcl9jbGVhcl9jb21tYW5kZXISN3NldCBlaXRoZXIgY29tbWFuZGVyX2VtYWlsIG9yIGNsZWFyX2NvbW1hbmRlciwgbm90IGJvdGgaUSEoaGFzKHRoaXMuY29tbWFuZGVyX2VtYWlsKSAmJiBoYXModGhpcy5jbGVhcl9jb21tYW5kZXIpICYmIHRoaXMuY2xlYXJfY29tbWFuZGVyKUIICgZfdGl0bGVCCwoJX3NldmVyaXR5QgoKCF9zdW1tYXJ5QhAKDl9jbGVhcl9zdW1tYXJ5QhIKEF9jb21tYW5kZXJfZW1haWxCEgoQX2NsZWFyX2NvbW1hbmRlckINCgtfc3RhcnRlZF9hdCJMChZVcGRhdGVJbmNpZGVudFJlc3BvbnNlEjIKCGluY2lkZW50GAEgASgLMiAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudCKZAQoYU2V0SW5jaWRlbnRTdGF0dXNSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEkIKBnN0YXR1cxgCIAEoDjImLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuSW5jaWRlbnRTdGF0dXNCCrpIB4IBBBABIAASGwoEbm90ZRgDIAEoCUIIukgFcgMYkE5IAIgBAUIHCgVfbm90ZSJPChlTZXRJbmNpZGVudFN0YXR1c1Jlc3BvbnNlEjIKCGluY2lkZW50GAEgASgLMiAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudCJKChZBZGRJbmNpZGVudE5vdGVSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEhsKB21lc3NhZ2UYAiABKAlCCrpIB3IFEAEYkE4iTwoXQWRkSW5jaWRlbnROb3RlUmVzcG9uc2USNAoFZXZlbnQYASABKAsyJS5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkluY2lkZW50RXZlbnQiUQoXTGlua1N0YXR1c1JlcG9ydFJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESIQoQc3RhdHVzX3JlcG9ydF9pZBgCIAEoCUIHukgEcgIQASJOChhMaW5rU3RhdHVzUmVwb3J0UmVzcG9uc2USMgoIaW5jaWRlbnQYASABKAsyIC5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkluY2lkZW50IjAKGVVubGlua1N0YXR1c1JlcG9ydFJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEiUAoaVW5saW5rU3RhdHVzUmVwb3J0UmVzcG9uc2USMgoIaW5jaWRlbnQYASABKAsyIC5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkluY2lkZW50Il0KFENsb3NlSW5jaWRlbnRSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEhwKD3NraXBfcG9zdG1vcnRlbRgCIAEoCEgAiAEBQhIKEF9za2lwX3Bvc3Rtb3J0ZW0iSwoVQ2xvc2VJbmNpZGVudFJlc3BvbnNlEjIKCGluY2lkZW50GAEgASgLMiAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5JbmNpZGVudCIsChVEZWxldGVJbmNpZGVudFJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEiKQoWRGVsZXRlSW5jaWRlbnRSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIIjQKFEdldFBvc3Rtb3J0ZW1SZXF1ZXN0EhwKC2luY2lkZW50X2lkGAEgASgJQge6SARyAhABImMKFUdldFBvc3Rtb3J0ZW1SZXNwb25zZRI7Cgpwb3N0bW9ydGVtGAEgASgLMiIub3BlbnN0YXR1cy5pbmNpZGVudC52MS5Qb3N0bW9ydGVtSACIAQFCDQoLX3Bvc3Rtb3J0ZW0iVQoXVXBkYXRlUG9zdG1vcnRlbVJlcXVlc3QSHAoLaW5jaWRlbnRfaWQYASABKAlCB7pIBHICEAESHAoHY29udGVudBgCIAEoCUILukgIcgYQARigjQYiUgoYVXBkYXRlUG9zdG1vcnRlbVJlc3BvbnNlEjYKCnBvc3Rtb3J0ZW0YASABKAsyIi5vcGVuc3RhdHVzLmluY2lkZW50LnYxLlBvc3Rtb3J0ZW0iVgoYQXBwcm92ZVBvc3Rtb3J0ZW1SZXF1ZXN0EhwKC2luY2lkZW50X2lkGAEgASgJQge6SARyAhABEhIKBWNsb3NlGAIgASgISACIAQFCCAoGX2Nsb3NlIocBChlBcHByb3ZlUG9zdG1vcnRlbVJlc3BvbnNlEjYKCnBvc3Rtb3J0ZW0YASABKAsyIi5vcGVuc3RhdHVzLmluY2lkZW50LnYxLlBvc3Rtb3J0ZW0SMgoIaW5jaWRlbnQYAiABKAsyIC5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkluY2lkZW50MuocCg9JbmNpZGVudFNlcnZpY2USqgUKD0RlY2xhcmVJbmNpZGVudBIuLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuRGVjbGFyZUluY2lkZW50UmVxdWVzdBovLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuRGVjbGFyZUluY2lkZW50UmVzcG9uc2UitQS6R7EEGq4ERGVjbGFyZXMgYSBuZXcgaW5jaWRlbnQgaW4gdGhlIG9wZW4gc3RhdHVzLiBUaGUgY29tbWFuZGVyIGlzIHNldCBieSBlbWFpbCBhbmQgbXVzdCBiZSBhIG1lbWJlciBvZiB0aGUgd29ya3NwYWNlOyB3aXRob3V0IG9uZSB0aGUgaW5jaWRlbnQgaXMgdW5hc3NpZ25lZC4gQSBuZXdseSBhc3NpZ25lZCBjb21tYW5kZXIgaXMgZW1haWxlZCB1bmxlc3MgdGhleSBvd24gdGhlIEFQSSBrZXkuIFdoZW4gb3Blbl9zbGFja19jaGFubmVsIGlzIHRydWUgYW5kIFNsYWNrIGlzIGNvbm5lY3RlZCwgYSBkZWRpY2F0ZWQgY2hhbm5lbCBpcyBjcmVhdGVkIGFuZCB0aGUgdGVhbSBpbnZpdGVkLiBzdGFydGVkX2F0IGRlZmF1bHRzIHRvIG5vdyBhbmQgY2FuIGJlIHNldCBpbiB0aGUgcGFzdCBmb3IgYSByZXRyb2FjdGl2ZSBkZWNsYXJlLiBBIHN0YXR1c19yZXBvcnRfaWQgdGhhdCBkb2VzIG5vdCBleGlzdCBpbiB0aGUgd29ya3NwYWNlIGZhaWxzIHdpdGggbm90X2ZvdW5kOyBvbmUgYWxyZWFkeSBsaW5rZWQgdG8gYW5vdGhlciBpbmNpZGVudCBmYWlscyB3aXRoIGZhaWxlZF9wcmVjb25kaXRpb24uEmsKC0dldEluY2lkZW50Eioub3BlbnN0YXR1cy5pbmNpZGVudC52MS5HZXRJbmNpZGVudFJlcXVlc3QaKy5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkdldEluY2lkZW50UmVzcG9uc2UiA5ACARJxCg1MaXN0SW5jaWRlbnRzEiwub3BlbnN0YXR1cy5pbmNpZGVudC52MS5MaXN0SW5jaWRlbnRzUmVxdWVzdBotLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuTGlzdEluY2lkZW50c1Jlc3BvbnNlIgOQAgESbwoOVXBkYXRlSW5jaWRlbnQSLS5vcGVuc3RhdHVzLmluY2lkZW50LnYxLlVwZGF0ZUluY2lkZW50UmVxdWVzdBouLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuVXBkYXRlSW5jaWRlbnRSZXNwb25zZRLJBAoRU2V0SW5jaWRlbnRTdGF0dXMSMC5vcGVuc3RhdHVzLmluY2lkZW50LnYxLlNldEluY2lkZW50U3RhdHVzUmVxdWVzdBoxLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuU2V0SW5jaWRlbnRTdGF0dXNSZXNwb25zZSLOA7pHygMaxwNNb3ZlcyBhbiBpbmNpZGVudCB0byBhIG5ldyBzdGF0dXMuIEFsbG93ZWQgdHJhbnNpdGlvbnM6IG9wZW4gLT4gbWl0aWdhdGVkLCByZXNvbHZlZCBvciBjYW5jZWxlZDsgbWl0aWdhdGVkIC0+IHJlc29sdmVkLCBvcGVuIG9yIGNhbmNlbGVkOyByZXNvbHZlZCAtPiBvcGVuLiBDYW5jZWxlZCBpcyB0ZXJtaW5hbCBhbmQgY2xvc2VzIHRoZSBpbmNpZGVudC4gVGhlIHNhbWUgc3RhdHVzLCBhIGZvcmJpZGRlbiB0cmFuc2l0aW9uIG9yIGEgY2xvc2VkIGluY2lkZW50IGZhaWwgd2l0aCBmYWlsZWRfcHJlY29uZGl0aW9uOyBJbmNpZGVudC5hbGxvd2VkX3RyYW5zaXRpb25zIGxpc3RzIHdoYXQgaXMgdmFsaWQuIEEgbGlua2VkIHN0YXR1cyByZXBvcnQgaXMgbmV2ZXIgdXBkYXRlZDogcG9zdCB0aGUgcHVibGljIHVwZGF0ZSB3aXRoIFN0YXR1c1JlcG9ydFNlcnZpY2UuQWRkU3RhdHVzUmVwb3J0VXBkYXRlLhJyCg9BZGRJbmNpZGVudE5vdGUSLi5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkFkZEluY2lkZW50Tm90ZVJlcXVlc3QaLy5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkFkZEluY2lkZW50Tm90ZVJlc3BvbnNlEnUKEExpbmtTdGF0dXNSZXBvcnQSLy5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkxpbmtTdGF0dXNSZXBvcnRSZXF1ZXN0GjAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5MaW5rU3RhdHVzUmVwb3J0UmVzcG9uc2USewoSVW5saW5rU3RhdHVzUmVwb3J0EjEub3BlbnN0YXR1cy5pbmNpZGVudC52MS5VbmxpbmtTdGF0dXNSZXBvcnRSZXF1ZXN0GjIub3BlbnN0YXR1cy5pbmNpZGVudC52MS5VbmxpbmtTdGF0dXNSZXBvcnRSZXNwb25zZRKxAwoNQ2xvc2VJbmNpZGVudBIsLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuQ2xvc2VJbmNpZGVudFJlcXVlc3QaLS5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkNsb3NlSW5jaWRlbnRSZXNwb25zZSLCArpHvgIauwJDbG9zZXMgYSByZXNvbHZlZCBpbmNpZGVudCwgYWZ0ZXIgd2hpY2ggb25seSBpdHMgcG9zdG1vcnRlbSBjYW4gY2hhbmdlLiBSZXF1aXJlcyBhbiBhcHByb3ZlZCBwb3N0bW9ydGVtLCBvciBza2lwX3Bvc3Rtb3J0ZW0gc2V0IHRvIHRydWUuIEFsbG93ZWQgZm9yIHdvcmtzcGFjZSBvd25lcnMgYW5kIGFkbWlucyBhbmQgZm9yIHRoZSBpbmNpZGVudCBjb21tYW5kZXIuIFRoZSBBUEkga2V5IGFjdHMgYXMgaXRzIGNyZWF0b3IsIHdpdGggdGhlIGNyZWF0b3IncyBjdXJyZW50IHJvbGU7IGtleXMgd2l0aG91dCBhIGNyZWF0b3IgY2Fubm90IGNhbGwgdGhpcy4SuQMKDkRlbGV0ZUluY2lkZW50Ei0ub3BlbnN0YXR1cy5pbmNpZGVudC52MS5EZWxldGVJbmNpZGVudFJlcXVlc3QaLi5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkRlbGV0ZUluY2lkZW50UmVzcG9uc2UixwK6R8MCGsACRGVsZXRlcyBhbiBpbmNpZGVudCBkZWNsYXJlZCBieSBtaXN0YWtlLiBPbmx5IGFsbG93ZWQgd2hpbGUgdGhlIGluY2lkZW50IGlzIG9wZW4gYW5kIHdhcyBuZXZlciBtaXRpZ2F0ZWQsIHJlc29sdmVkIG9yIGNsb3NlZCAoSW5jaWRlbnQuZGVsZXRhYmxlKTsgY2FuY2VsIGl0IG90aGVyd2lzZS4gQWxsb3dlZCBmb3Igd29ya3NwYWNlIG93bmVycyBhbmQgYWRtaW5zIG9ubHkuIFRoZSBBUEkga2V5IGFjdHMgYXMgaXRzIGNyZWF0b3IsIHdpdGggdGhlIGNyZWF0b3IncyBjdXJyZW50IHJvbGU7IGtleXMgd2l0aG91dCBhIGNyZWF0b3IgY2Fubm90IGNhbGwgdGhpcy4ScQoNR2V0UG9zdG1vcnRlbRIsLm9wZW5zdGF0dXMuaW5jaWRlbnQudjEuR2V0UG9zdG1vcnRlbVJlcXVlc3QaLS5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkdldFBvc3Rtb3J0ZW1SZXNwb25zZSIDkAIBEnUKEFVwZGF0ZVBvc3Rtb3J0ZW0SLy5vcGVuc3RhdHVzLmluY2lkZW50LnYxLlVwZGF0ZVBvc3Rtb3J0ZW1SZXF1ZXN0GjAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5VcGRhdGVQb3N0bW9ydGVtUmVzcG9uc2USygQKEUFwcHJvdmVQb3N0bW9ydGVtEjAub3BlbnN0YXR1cy5pbmNpZGVudC52MS5BcHByb3ZlUG9zdG1vcnRlbVJlcXVlc3QaMS5vcGVuc3RhdHVzLmluY2lkZW50LnYxLkFwcHJvdmVQb3N0bW9ydGVtUmVzcG9uc2UizwO6R8sDGsgDQXBwcm92ZXMgdGhlIGRyYWZ0IHBvc3Rtb3J0ZW0gb2YgYSByZXNvbHZlZCBpbmNpZGVudC4gV2l0aCBjbG9zZSBzZXQgdG8gdHJ1ZSB0aGUgaW5jaWRlbnQgaXMgY2xvc2VkIGluIHRoZSBzYW1lIHN0ZXAuIEZhaWxzIHdpdGggbm90X2ZvdW5kIHdoZW4gbm8gcG9zdG1vcnRlbSBoYXMgYmVlbiB3cml0dGVuLCBhbmQgd2l0aCBmYWlsZWRfcHJlY29uZGl0aW9uIHdoZW4gdGhlIGluY2lkZW50IGlzIG5vdCByZXNvbHZlZCBvciB0aGUgcG9zdG1vcnRlbSBpcyBhbHJlYWR5IGFwcHJvdmVkLiBBbGxvd2VkIGZvciB3b3Jrc3BhY2Ugb3duZXJzIGFuZCBhZG1pbnMgYW5kIGZvciB0aGUgaW5jaWRlbnQgY29tbWFuZGVyLiBUaGUgQVBJIGtleSBhY3RzIGFzIGl0cyBjcmVhdG9yLCB3aXRoIHRoZSBjcmVhdG9yJ3MgY3VycmVudCByb2xlOyBrZXlzIHdpdGhvdXQgYSBjcmVhdG9yIGNhbm5vdCBjYWxsIHRoaXMuQlVaU2dpdGh1Yi5jb20vb3BlbnN0YXR1c2hxL29wZW5zdGF0dXMvcGFja2FnZXMvcHJvdG8vb3BlbnN0YXR1cy9pbmNpZGVudC92MTtpbmNpZGVudHYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_incident_v1_incident]); /** * DeclareIncidentRequest is the request to declare a new incident. @@ -37,7 +37,7 @@ export type DeclareIncidentRequest = Message<"openstatus.incident.v1.DeclareInci severity: IncidentSeverity; /** - * Short human summary (optional, up to 4000 characters). + * Short human summary (optional, 1-4000 characters). * * @generated from field: optional string summary = 3; */ diff --git a/packages/services/src/errors.ts b/packages/services/src/errors.ts index d6e574fc5..1e859ddbb 100644 --- a/packages/services/src/errors.ts +++ b/packages/services/src/errors.ts @@ -49,6 +49,12 @@ export class ConflictError extends ServiceError { } } +/** + * A concurrent write won the race: the same request may succeed on a retry. + * Still `CONFLICT` for adapters that don't tell the two apart. + */ +export class ConcurrentUpdateError extends ConflictError {} + export class ValidationError extends ServiceError { constructor(message: string, cause?: unknown) { super("VALIDATION", message, cause); diff --git a/packages/services/src/incident/__tests__/effects.test.ts b/packages/services/src/incident/__tests__/effects.test.ts index ff31b6d4f..7447190ed 100644 --- a/packages/services/src/incident/__tests__/effects.test.ts +++ b/packages/services/src/incident/__tests__/effects.test.ts @@ -165,7 +165,7 @@ describe("notifyIncidentCommander", () => { await notifyIncidentCommander({ ctx: ctxFor(tx), effects, - incidentId: row.id, + incident: row, }); expect(emails).toHaveLength(1); expect(emails[0]).toMatchObject({ @@ -175,12 +175,29 @@ describe("notifyIncidentCommander", () => { assignedBy: "Jane Doe", url: `https://dash.test/incidents/${row.id}`, }); - expect(emails[0]?.idempotencyKey).toBe( - `incident-commander:${row.id}:${memberId}:${row.updatedAt.getTime()}`, + expect(emails[0]?.idempotencyKey).toMatch( + new RegExp(`^incident-commander:${row.id}:${memberId}:[1-9]\\d*$`), ); }); }); + test("each reassignment gets its own idempotency key", async () => { + await withTestTransaction(async (tx) => { + const row = await declare(tx, memberId); + const ctx = ctxFor(tx); + const { effects, emails } = makeEffects(); + await notifyIncidentCommander({ ctx, effects, incident: row }); + await updateIncident({ ctx, input: { id: row.id, commanderId: null } }); + const again = await updateIncident({ + ctx, + input: { id: row.id, commanderId: memberId }, + }); + await notifyIncidentCommander({ ctx, effects, incident: again }); + expect(emails).toHaveLength(2); + expect(emails[0]?.idempotencyKey).not.toBe(emails[1]?.idempotencyKey); + }); + }); + test("skips the actor themselves", async () => { await withTestTransaction(async (tx) => { const row = await declare(tx, ownerId); @@ -188,7 +205,7 @@ describe("notifyIncidentCommander", () => { await notifyIncidentCommander({ ctx: ctxFor(tx), effects, - incidentId: row.id, + incident: row, }); expect(emails).toHaveLength(0); }); @@ -201,7 +218,7 @@ describe("notifyIncidentCommander", () => { await notifyIncidentCommander({ ctx: ctxFor(tx), effects, - incidentId: row.id, + incident: row, }); expect(emails).toHaveLength(0); }); @@ -214,7 +231,7 @@ describe("notifyIncidentCommander", () => { await notifyIncidentCommander({ ctx: ctxFor(tx), effects, - incidentId: row.id, + incident: row, }); }); }); @@ -334,6 +351,10 @@ describe("channel announcements", () => { await connectSlack(tx); const row = await declare(tx); await bind(tx, row.id); + await tx + .update(incident) + .set({ status: "mitigated" }) + .where(eq(incident.id, row.id)); const { effects, calls } = makeEffects(); await afterIncidentStatusChanged({ ctx: ctxFor(tx), @@ -346,6 +367,7 @@ describe("channel announcements", () => { expect(calls[0]?.args.text).toBe( "Jane marked the incident *mitigated*.\n>rolled back", ); + expect(calls[1]?.args.topic).toContain("· mitigated ·"); }); }); diff --git a/packages/services/src/incident/close.ts b/packages/services/src/incident/close.ts index 9842492e2..39650fa83 100644 --- a/packages/services/src/incident/close.ts +++ b/packages/services/src/incident/close.ts @@ -9,7 +9,7 @@ import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { requireRole } from "../auth/require-role"; import { type DB, type ServiceContext, withTransaction } from "../context"; -import { ConflictError } from "../errors"; +import { ConcurrentUpdateError, ConflictError } from "../errors"; import { appendIncidentEvent, assertNotClosed, @@ -42,7 +42,9 @@ export async function closeIncidentInTx( .returning() .get(); if (!updated) { - throw new ConflictError(`Incident #${existing.id} changed concurrently`); + throw new ConcurrentUpdateError( + `Incident #${existing.id} changed concurrently`, + ); } await emitAudit(tx, ctx, { action: "incident.update", diff --git a/packages/services/src/incident/delete.ts b/packages/services/src/incident/delete.ts index bf6af9682..cbaa74c27 100644 --- a/packages/services/src/incident/delete.ts +++ b/packages/services/src/incident/delete.ts @@ -5,7 +5,7 @@ import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { requireRole } from "../auth/require-role"; import { type ServiceContext, withTransaction } from "../context"; -import { ConflictError } from "../errors"; +import { ConcurrentUpdateError, ConflictError } from "../errors"; import { getIncidentInWorkspace } from "./internal"; import { IncidentIdInput } from "./schemas"; @@ -52,7 +52,9 @@ export async function deleteIncident(args: { .returning() .get(); if (!deleted) { - throw new ConflictError(`Incident #${existing.id} changed concurrently`); + throw new ConcurrentUpdateError( + `Incident #${existing.id} changed concurrently`, + ); } await emitAudit(tx, ctx, { action: "incident.delete", diff --git a/packages/services/src/incident/effects.ts b/packages/services/src/incident/effects.ts index b9b4effed..1664a9a91 100644 --- a/packages/services/src/incident/effects.ts +++ b/packages/services/src/incident/effects.ts @@ -1,13 +1,14 @@ -import type { - Incident, - IncidentSeverity, - IncidentStatus, +import { and, desc, eq, inArray } from "@openstatus/db"; +import { + type Incident, + type IncidentSeverity, + type IncidentStatus, + incidentEvent, + user, } from "@openstatus/db/src/schema"; -import { displayName } from "../attribution"; import { type ServiceContext, getReadDb, tryGetActorUserId } from "../context"; import { userDisplayName } from "./internal"; -import { getIncident } from "./list"; import { type OpenChannelResult, type SlackClientFactory, @@ -64,7 +65,12 @@ export function resolveDashboardUrl(env: { nodeEnv?: string; override?: string; }): string { - if (env.override) return env.override.replace(/\/+$/, ""); + if (env.override) { + // a loop, not /\/+$/: that regex backtracks quadratically on many slashes + let end = env.override.length; + while (end > 0 && env.override[end - 1] === "/") end--; + return env.override.slice(0, end); + } return env.nodeEnv === "production" ? "https://app.openstatus.dev" : "http://localhost:3001"; @@ -87,40 +93,69 @@ export function quoteNote(note: string | undefined): string { return note ? `\n>${escapeMrkdwn(note).replaceAll("\n", "\n>")}` : ""; } -/** Best effort: a failed email never fails the mutation that assigned them. */ +type CommanderSnapshot = Pick< + Incident, + "id" | "title" | "severity" | "commanderId" +>; + +/** + * Best effort: a failed email never fails the mutation that assigned them. + * Reads the recipient from the mutation's own snapshot, never a reload, so an + * overlapping reassignment can't redirect this email to the later commander. + */ export async function notifyIncidentCommander( - args: EffectArgs & { incidentId: number }, + args: EffectArgs & { incident: CommanderSnapshot }, ): Promise { - const { ctx, effects, incidentId } = args; + const { ctx, effects, incident } = args; + const commanderId = incident.commanderId; + if (commanderId === null) return; + if (commanderId === tryGetActorUserId(ctx.actor)) return; try { - const row = await getIncident({ ctx, input: { id: incidentId } }); - const commander = row?.commander; - if (!row || !commander?.email) return; - if (commander.id === tryGetActorUserId(ctx.actor)) return; + const db = getReadDb(ctx); + const commander = await db + .select({ email: user.email, deletedAt: user.deletedAt }) + .from(user) + .where(eq(user.id, commanderId)) + .get(); + if (!commander?.email || commander.deletedAt) return; + // Each assignment writes its own event, so its id is unique per + // assignment yet stable if this effect is retried. + const assignment = await db + .select({ id: incidentEvent.id }) + .from(incidentEvent) + .where( + and( + eq(incidentEvent.incidentId, incident.id), + inArray(incidentEvent.type, ["declared", "commander_changed"]), + ), + ) + .orderBy(desc(incidentEvent.id)) + .get(); await effects.sendCommanderEmail({ to: commander.email, - incidentTitle: row.title, - severity: row.severity, + incidentTitle: incident.title, + severity: incident.severity, workspaceName: ctx.workspace.name ?? ctx.workspace.slug, assignedBy: effects.assignedBy, - url: incidentDashboardUrl(effects.dashboardUrl, row.id), - idempotencyKey: `incident-commander:${row.id}:${commander.id}:${row.updatedAt.getTime()}`, + url: incidentDashboardUrl(effects.dashboardUrl, incident.id), + idempotencyKey: `incident-commander:${incident.id}:${commanderId}:${assignment?.id ?? 0}`, }); } catch (err) { - console.warn("incident commander email failed", { incidentId, err }); + console.warn("incident commander email failed", { + incidentId: incident.id, + err, + }); } } export async function afterIncidentDeclared( args: EffectArgs & { - incident: Pick; + incident: CommanderSnapshot; openSlackChannel: boolean; }, ): Promise { const { ctx, effects, incident } = args; - if (incident.commanderId !== null) { - await notifyIncidentCommander({ ctx, effects, incidentId: incident.id }); - } + await notifyIncidentCommander({ ctx, effects, incident }); if (!args.openSlackChannel) return undefined; return openIncidentSlackChannel({ ctx, @@ -173,9 +208,8 @@ export async function afterIncidentUpdated( const commanderChanged = after.commanderId !== before.commanderId; let commanderName: string | null = null; if (commanderChanged && after.commanderId !== null) { - await notifyIncidentCommander({ ctx, effects, incidentId: after.id }); - const fresh = await getIncident({ ctx, input: { id: after.id } }); - commanderName = fresh?.commander ? displayName(fresh.commander) : null; + await notifyIncidentCommander({ ctx, effects, incident: after }); + commanderName = await userDisplayName(getReadDb(ctx), after.commanderId); } const changes = describeIncidentChanges(before, after, commanderName); if (!changes.length) return; diff --git a/packages/services/src/incident/index.ts b/packages/services/src/incident/index.ts index 18abf0016..f43288c19 100644 --- a/packages/services/src/incident/index.ts +++ b/packages/services/src/incident/index.ts @@ -26,7 +26,7 @@ export { unlinkIncidentFromStatusReport, unlinkIncidentStatusReport, } from "./link-status-report"; -export { listIncidentEvents } from "./list-events"; +export { getIncidentEvent, listIncidentEvents } from "./list-events"; export { clearIncidentCommander } from "./members"; export { type ClaimOnce, diff --git a/packages/services/src/incident/list-events.ts b/packages/services/src/incident/list-events.ts index 4e233e3ef..3abb7b673 100644 --- a/packages/services/src/incident/list-events.ts +++ b/packages/services/src/incident/list-events.ts @@ -1,9 +1,9 @@ -import { desc, eq } from "@openstatus/db"; +import { and, desc, eq } from "@openstatus/db"; import { incidentEvent } from "@openstatus/db/src/schema"; import { type ServiceContext, getReadDb } from "../context"; import { getIncidentInWorkspace, incidentUserColumns } from "./internal"; -import { ListIncidentEventsInput } from "./schemas"; +import { GetIncidentEventInput, ListIncidentEventsInput } from "./schemas"; /** The incident's timeline, newest first. */ export async function listIncidentEvents(args: { @@ -23,3 +23,24 @@ export async function listIncidentEvents(args: { }, }); } + +/** One timeline event of the incident, or null when it isn't on it. */ +export async function getIncidentEvent(args: { + ctx: ServiceContext; + input: GetIncidentEventInput; +}) { + const { ctx } = args; + const input = GetIncidentEventInput.parse(args.input); + const db = getReadDb(ctx); + const existing = await getIncidentInWorkspace(db, ctx.workspace.id, input.id); + const event = await db.query.incidentEvent.findFirst({ + where: and( + eq(incidentEvent.incidentId, existing.id), + eq(incidentEvent.id, input.eventId), + ), + with: { + createdByUser: { columns: incidentUserColumns }, + }, + }); + return event ?? null; +} diff --git a/packages/services/src/incident/schemas.ts b/packages/services/src/incident/schemas.ts index f2a3bb9cc..c1192a9aa 100644 --- a/packages/services/src/incident/schemas.ts +++ b/packages/services/src/incident/schemas.ts @@ -90,6 +90,9 @@ export const ListIncidentEventsInput = IncidentIdInput.extend({ }); export type ListIncidentEventsInput = z.infer; +export const GetIncidentEventInput = IncidentIdInput.extend({ eventId: id }); +export type GetIncidentEventInput = z.infer; + export const BindIncidentSlackChannelInput = z.object({ id, teamId: z.string().min(1), diff --git a/packages/services/src/incident/set-status.ts b/packages/services/src/incident/set-status.ts index 7c838e359..d1af30c75 100644 --- a/packages/services/src/incident/set-status.ts +++ b/packages/services/src/incident/set-status.ts @@ -13,7 +13,7 @@ import { tryGetActorUserId, withTransaction, } from "../context"; -import { ConflictError } from "../errors"; +import { ConcurrentUpdateError } from "../errors"; import { appendIncidentEvent, assertTransition, @@ -87,7 +87,9 @@ export async function setIncidentStatus(args: { .returning() .get(); if (!updated) { - throw new ConflictError(`Incident #${existing.id} changed concurrently`); + throw new ConcurrentUpdateError( + `Incident #${existing.id} changed concurrently`, + ); } await emitAudit(tx, ctx, { diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts index afe6989b0..8688634a2 100644 --- a/packages/services/src/index.ts +++ b/packages/services/src/index.ts @@ -19,6 +19,7 @@ export { } from "./retry"; export { + ConcurrentUpdateError, ConflictError, ForbiddenError, InternalServiceError, diff --git a/packages/services/src/status-report/list.ts b/packages/services/src/status-report/list.ts index fa9e6ff94..e3cb71318 100644 --- a/packages/services/src/status-report/list.ts +++ b/packages/services/src/status-report/list.ts @@ -90,6 +90,7 @@ export type ListStatusReportsResult = { */ async function enrichReportsBatch( db: DB, + workspaceId: number, rows: StatusReport[], ): Promise { if (rows.length === 0) return []; @@ -98,7 +99,7 @@ async function enrichReportsBatch( const pageIdsSet = new Set(); for (const r of rows) if (r.pageId != null) pageIdsSet.add(r.pageId); const pageIds = Array.from(pageIdsSet); - // Keeps the batch a fixed four statements when no report carries a pageId; + // Keeps the batch a fixed five statements when no report carries a pageId; // `inArray` with an empty list is not a valid predicate. const anyPage = pageIds.length > 0 ? inArray(pageTable.id, pageIds) : sql`0`; const anyPageComponent = @@ -136,7 +137,12 @@ async function enrichReportsBatch( db .select({ id: incident.id, statusReportId: incident.statusReportId }) .from(incident) - .where(inArray(incident.statusReportId, reportIds)), + .where( + and( + eq(incident.workspaceId, workspaceId), + inArray(incident.statusReportId, reportIds), + ), + ), ]); const incidentByReport = new Map(); for (const row of incidentRows) { @@ -293,7 +299,7 @@ export async function listStatusReports(args: { totalSize = countRow?.count ?? totalSize; } - const items = await enrichReportsBatch(db, rows); + const items = await enrichReportsBatch(db, ctx.workspace.id, rows); return { items, totalSize }; } @@ -310,7 +316,7 @@ export async function getStatusReport(args: { id: input.id, workspaceId: ctx.workspace.id, }); - const [enriched] = await enrichReportsBatch(db, [report]); + const [enriched] = await enrichReportsBatch(db, ctx.workspace.id, [report]); // `enrichReportsBatch` guarantees a 1:1 mapping for a non-empty input. // oxlint-disable-next-line typescript/no-non-null-assertion -- always non-null for len === 1 return enriched!; -- 2.54.0