From 1598a9bf1dfdc0bc1223eff58f2a1f2458d66df2 Mon Sep 17 00:00:00 2001 From: caballeto Date: Wed, 6 May 2026 12:23:04 +0200 Subject: [PATCH 1/2] feat: add MaintenanceWindows resource for scheduling planned downtime Wires up `client.maintenanceWindows` so users can schedule alert-suppression windows directly from the SDK. Mirrors the `/api/v1/maintenance-windows` REST surface: list (auto-paginating + manual page control), listPage, get, create, update, and cancel. List forwards optional `monitorId` and `filter` ('active' | 'upcoming') as query params. Schemas/types are bridged through `src/schemas.ts` and `src/types.ts` so the public type surface (`MaintenanceWindowDto`, `CreateMaintenanceWindowRequest`, `UpdateMaintenanceWindowRequest`, `MaintenanceWindowFilters`) stays strict-mode clean and re-exportable from `@devhelm/sdk`. Co-authored-by: Cursor --- src/client.ts | 3 + src/index.ts | 5 + src/resources/maintenance-windows.ts | 157 +++++++++++++++++++++++++++ src/schemas.ts | 5 + src/types.ts | 3 + test/spec-paths.test.ts | 7 ++ 6 files changed, 180 insertions(+) create mode 100644 src/resources/maintenance-windows.ts diff --git a/src/client.ts b/src/client.ts index 602f2df..6e82f7d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -15,6 +15,7 @@ import {Dependencies} from './resources/dependencies.js' import {DeployLock} from './resources/deploy-lock.js' import {Status} from './resources/status.js' import {StatusPages} from './resources/status-pages.js' +import {MaintenanceWindows} from './resources/maintenance-windows.js' /** * DevHelm API client. @@ -50,6 +51,7 @@ export class Devhelm { readonly deployLock: DeployLock readonly status: Status readonly statusPages: StatusPages + readonly maintenanceWindows: MaintenanceWindows constructor(config: DevhelmConfig) { const client = buildClient(config) @@ -68,5 +70,6 @@ export class Devhelm { this.deployLock = new DeployLock(client) this.status = new Status(client) this.statusPages = new StatusPages(client) + this.maintenanceWindows = new MaintenanceWindows(client) } } diff --git a/src/index.ts b/src/index.ts index 251e87e..66f92e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ export type { DeployLockDto, AssertionTestResultDto, MonitorTestResultDto, + MaintenanceWindowDto, IncidentTimelineDto, CheckTraceDto, PolicySnapshotDto, @@ -60,6 +61,8 @@ export type { UpdateWebhookEndpointRequest, CreateApiKeyRequest, AcquireDeployLockRequest, + CreateMaintenanceWindowRequest, + UpdateMaintenanceWindowRequest, StatusPageDto, StatusPageComponentDto, StatusPageComponentGroupDto, @@ -109,3 +112,5 @@ export {Dependencies} from './resources/dependencies.js' export {DeployLock} from './resources/deploy-lock.js' export {Status} from './resources/status.js' export {StatusPages} from './resources/status-pages.js' +export {MaintenanceWindows} from './resources/maintenance-windows.js' +export type {MaintenanceWindowFilters} from './resources/maintenance-windows.js' diff --git a/src/resources/maintenance-windows.ts b/src/resources/maintenance-windows.ts new file mode 100644 index 0000000..f37e872 --- /dev/null +++ b/src/resources/maintenance-windows.ts @@ -0,0 +1,157 @@ +import type {ApiClient} from '../http.js' +import type { + MaintenanceWindowDto, + CreateMaintenanceWindowRequest, + UpdateMaintenanceWindowRequest, + Page, +} from '../types.js' +import { + MaintenanceWindowDtoSchema, + CreateMaintenanceWindowRequestSchema, + UpdateMaintenanceWindowRequestSchema, +} from '../schemas.js' +import {apiGet, fetchSingle, fetchVoid} from '../http.js' +import {parsePage, validateRequest} from '../validation.js' + +/** + * Filters for listing maintenance windows. + * + * - `monitorId` scopes results to a single monitor. + * - `filter` is a server-side status bucket: `"active"` for windows currently + * in progress, `"upcoming"` for windows whose `startsAt` is in the future. + * Omit to return every window in the org regardless of status. + */ +export interface MaintenanceWindowFilters { + monitorId?: string + filter?: 'active' | 'upcoming' +} + +/** + * Schedule planned downtime so DevHelm suppresses alerts during the window. + * + * Maintenance windows are scoped to a single monitor (set `monitorId`) or + * org-wide (leave `monitorId` unset / `null`). Recurring windows are + * expressed via an iCal RRULE in `repeatRule`. + */ +export class MaintenanceWindows { + constructor(private readonly client: ApiClient) {} + + /** + * List maintenance windows for the calling org. Auto-paginates through + * every page so callers receive the full set in a single array. + * + * @example + * ```ts + * const windows = await client.maintenanceWindows.list({filter: 'upcoming'}) + * ``` + */ + async list(filters: MaintenanceWindowFilters = {}): Promise { + const path = '/api/v1/maintenance-windows' + const baseQuery: Record = {} + if (filters.monitorId !== undefined) baseQuery['monitorId'] = filters.monitorId + if (filters.filter !== undefined) baseQuery['filter'] = filters.filter + + const all: MaintenanceWindowDto[] = [] + let page = 0 + const size = 200 + while (true) { + const raw = await apiGet(this.client, path, {...baseQuery, page, size}) + const validated = parsePage(MaintenanceWindowDtoSchema, raw, path) + all.push(...validated.data) + if (!validated.hasNext) break + page++ + } + return all + } + + /** + * List maintenance windows with manual page control. Useful for callers + * that render large windows in a paginated UI. + * + * @example + * ```ts + * const page = await client.maintenanceWindows.listPage(0, 50) + * ``` + */ + async listPage( + page: number, + size: number, + filters: MaintenanceWindowFilters = {}, + ): Promise> { + const path = '/api/v1/maintenance-windows' + const query: Record = {page, size} + if (filters.monitorId !== undefined) query['monitorId'] = filters.monitorId + if (filters.filter !== undefined) query['filter'] = filters.filter + const raw = await apiGet(this.client, path, query) + const validated = parsePage(MaintenanceWindowDtoSchema, raw, path) + return { + data: validated.data, + hasNext: validated.hasNext, + hasPrev: validated.hasPrev, + totalElements: validated.totalElements ?? null, + totalPages: validated.totalPages ?? null, + } + } + + /** + * Fetch a single maintenance window by UUID. + * + * @example + * ```ts + * const window = await client.maintenanceWindows.get(id) + * ``` + */ + async get(id: string): Promise { + return fetchSingle(this.client, 'GET', `/api/v1/maintenance-windows/${id}`, MaintenanceWindowDtoSchema) + } + + /** + * Schedule a new maintenance window. Set `monitorId` to `null` (or omit it) + * to create an org-wide window that suppresses every monitor's alerts. + * + * @example + * ```ts + * const window = await client.maintenanceWindows.create({ + * monitorId: '6f1a...', + * startsAt: '2026-06-01T03:00:00Z', + * endsAt: '2026-06-01T04:00:00Z', + * reason: 'Quarterly db migration', + * }) + * ``` + */ + async create(body: CreateMaintenanceWindowRequest): Promise { + validateRequest(CreateMaintenanceWindowRequestSchema, body, 'maintenanceWindows.create') + return fetchSingle(this.client, 'POST', '/api/v1/maintenance-windows', MaintenanceWindowDtoSchema, body) + } + + /** + * Update an existing maintenance window — adjust the start/end, swap the + * monitor it covers, or change the recurrence rule. + * + * @example + * ```ts + * await client.maintenanceWindows.update(id, { + * startsAt: '2026-06-01T04:00:00Z', + * endsAt: '2026-06-01T05:00:00Z', + * }) + * ``` + */ + async update(id: string, body: UpdateMaintenanceWindowRequest): Promise { + validateRequest(UpdateMaintenanceWindowRequestSchema, body, 'maintenanceWindows.update') + return fetchSingle(this.client, 'PUT', `/api/v1/maintenance-windows/${id}`, MaintenanceWindowDtoSchema, body) + } + + /** + * Cancel (delete) a maintenance window. After this call, alerts will fire + * normally for the affected monitor — even if the window's `endsAt` is + * still in the future. + * + * @example + * ```ts + * await client.maintenanceWindows.cancel(id) + * ``` + */ + async cancel(id: string): Promise { + return fetchVoid(this.client, `/api/v1/maintenance-windows/${id}`) + } +} diff --git a/src/schemas.ts b/src/schemas.ts index d10b16c..360c6a1 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -31,6 +31,7 @@ export const DashboardOverviewDtoSchema = schemas.DashboardOverviewDto export const DeployLockDtoSchema = schemas.DeployLockDto export const AssertionTestResultDtoSchema = schemas.AssertionTestResultDto export const MonitorTestResultDtoSchema = schemas.MonitorTestResultDto +export const MaintenanceWindowDtoSchema = schemas.MaintenanceWindowDto // ── Forensic DTO schemas ──────────────────────────────────────────── @@ -75,6 +76,8 @@ export const CreateWebhookEndpointRequestSchema = schemas.CreateWebhookEndpointR export const UpdateWebhookEndpointRequestSchema = schemas.UpdateWebhookEndpointRequest export const CreateApiKeyRequestSchema = schemas.CreateApiKeyRequest export const AcquireDeployLockRequestSchema = schemas.AcquireDeployLockRequest +export const CreateMaintenanceWindowRequestSchema = schemas.CreateMaintenanceWindowRequest +export const UpdateMaintenanceWindowRequestSchema = schemas.UpdateMaintenanceWindowRequest // ── Status Page Request schemas ───────────────────────────────────── @@ -124,6 +127,7 @@ export const SingleValueResponseServiceSubscriptionDtoSchema = schemas.SingleVal export const SingleValueResponseDashboardOverviewDtoSchema = schemas.SingleValueResponseDashboardOverviewDto export const SingleValueResponseDeployLockDtoSchema = schemas.SingleValueResponseDeployLockDto export const SingleValueResponseMonitorTestResultDtoSchema = schemas.SingleValueResponseMonitorTestResultDto +export const SingleValueResponseMaintenanceWindowDtoSchema = schemas.SingleValueResponseMaintenanceWindowDto export const SingleValueResponseStatusPageDtoSchema = schemas.SingleValueResponseStatusPageDto export const SingleValueResponseStatusPageComponentDtoSchema = schemas.SingleValueResponseStatusPageComponentDto export const SingleValueResponseStatusPageComponentGroupDtoSchema = schemas.SingleValueResponseStatusPageComponentGroupDto @@ -147,6 +151,7 @@ export const TableValueResultWebhookEndpointDtoSchema = schemas.TableValueResult export const TableValueResultApiKeyDtoSchema = schemas.TableValueResultApiKeyDto export const TableValueResultServiceSubscriptionDtoSchema = schemas.TableValueResultServiceSubscriptionDto export const TableValueResultMonitorVersionDtoSchema = schemas.TableValueResultMonitorVersionDto +export const TableValueResultMaintenanceWindowDtoSchema = schemas.TableValueResultMaintenanceWindowDto export const TableValueResultStatusPageDtoSchema = schemas.TableValueResultStatusPageDto export const TableValueResultStatusPageComponentDtoSchema = schemas.TableValueResultStatusPageComponentDto export const TableValueResultStatusPageComponentGroupDtoSchema = schemas.TableValueResultStatusPageComponentGroupDto diff --git a/src/types.ts b/src/types.ts index cbec11a..115316c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,7 @@ export type DashboardOverviewDto = z.infer export type DeployLockDto = z.infer export type AssertionTestResultDto = z.infer export type MonitorTestResultDto = z.infer +export type MaintenanceWindowDto = z.infer // ── Forensic DTOs ───────────────────────────────────────────────────── @@ -86,6 +87,8 @@ export type CreateWebhookEndpointRequest = z.infer export type CreateApiKeyRequest = z.infer export type AcquireDeployLockRequest = z.infer +export type CreateMaintenanceWindowRequest = z.infer +export type UpdateMaintenanceWindowRequest = z.infer // ── Status Page Request types ───────────────────────────────────────── diff --git a/test/spec-paths.test.ts b/test/spec-paths.test.ts index c444b05..d6db375 100644 --- a/test/spec-paths.test.ts +++ b/test/spec-paths.test.ts @@ -131,6 +131,13 @@ const SDK_ENDPOINTS: ReadonlyArray = [ // dashboard ['get', '/api/v1/dashboard/overview'], + // maintenance-windows + ['get', '/api/v1/maintenance-windows'], + ['post', '/api/v1/maintenance-windows'], + ['get', '/api/v1/maintenance-windows/{id}'], + ['put', '/api/v1/maintenance-windows/{id}'], + ['delete', '/api/v1/maintenance-windows/{id}'], + // status-pages ['get', '/api/v1/status-pages'], ['post', '/api/v1/status-pages'], From 70c12fc890792058c6ffbd00b916c2e8cadfc8ac Mon Sep 17 00:00:00 2001 From: caballeto Date: Wed, 6 May 2026 12:23:15 +0200 Subject: [PATCH 2/2] test: cover MaintenanceWindows happy paths Stubs `globalThis.fetch` (same approach as the surface-telemetry tests) to capture each outbound request and assert on the exact path, HTTP method, query string, and JSON body the SDK puts on the wire. Covers create, list with filters, list auto-pagination, listPage, get, update, cancel, plus a client-side validation guard that fails before any network I/O. Co-authored-by: Cursor --- test/client.test.ts | 1 + test/maintenance-windows.test.ts | 268 +++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 test/maintenance-windows.test.ts diff --git a/test/client.test.ts b/test/client.test.ts index ecf6a90..a27946b 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -19,6 +19,7 @@ describe('Devhelm client', () => { expect(client.deployLock).toBeDefined() expect(client.status).toBeDefined() expect(client.statusPages).toBeDefined() + expect(client.maintenanceWindows).toBeDefined() }) it('resource modules have expected CRUD methods', () => { diff --git a/test/maintenance-windows.test.ts b/test/maintenance-windows.test.ts new file mode 100644 index 0000000..c090fc9 --- /dev/null +++ b/test/maintenance-windows.test.ts @@ -0,0 +1,268 @@ +/** + * MaintenanceWindows resource tests. + * + * The HTTP layer is exercised end-to-end by stubbing `globalThis.fetch` — + * this is the same approach used in the surface-telemetry tests in + * `test/http.test.ts`. Capturing the live `Request` lets us assert on the + * exact path, method, query string, and body the SDK puts on the wire, + * without re-implementing the openapi-fetch envelope shape. + */ +import {describe, it, expect, beforeEach, afterEach} from 'vitest' +import {Devhelm} from '../src/index.js' +import type {MaintenanceWindowDto} from '../src/index.js' + +interface CapturedRequest { + method: string + url: URL + body: string | null +} + +const VALID_WINDOW: MaintenanceWindowDto = { + id: '550e8400-e29b-41d4-a716-446655440000', + monitorId: '550e8400-e29b-41d4-a716-446655440001', + organizationId: 1, + startsAt: '2026-06-01T03:00:00Z', + endsAt: '2026-06-01T04:00:00Z', + repeatRule: null, + reason: 'Quarterly db migration', + suppressAlerts: true, + createdAt: '2026-05-30T00:00:00Z', +} + +describe('MaintenanceWindows resource', () => { + const originalFetch = globalThis.fetch + let captured: CapturedRequest[] = [] + let nextResponse: (req: CapturedRequest) => Response + + beforeEach(() => { + captured = [] + nextResponse = () => new Response('{"data":null}', { + status: 200, + headers: {'Content-Type': 'application/json'}, + }) + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init) + const url = new URL(req.url) + const body = req.body ? await req.text() : null + const c: CapturedRequest = {method: req.method, url, body} + captured.push(c) + return nextResponse(c) + } + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + function buildClient() { + return new Devhelm({token: 't', baseUrl: 'http://localhost:0'}) + } + + it('exposes maintenanceWindows with the expected CRUD methods', () => { + const c = buildClient() + expect(c.maintenanceWindows).toBeDefined() + expect(typeof c.maintenanceWindows.list).toBe('function') + expect(typeof c.maintenanceWindows.listPage).toBe('function') + expect(typeof c.maintenanceWindows.get).toBe('function') + expect(typeof c.maintenanceWindows.create).toBe('function') + expect(typeof c.maintenanceWindows.update).toBe('function') + expect(typeof c.maintenanceWindows.cancel).toBe('function') + }) + + it('create posts to /api/v1/maintenance-windows with the request body', async () => { + nextResponse = () => new Response(JSON.stringify({data: VALID_WINDOW}), { + status: 201, + headers: {'Content-Type': 'application/json'}, + }) + const c = buildClient() + const result = await c.maintenanceWindows.create({ + monitorId: '550e8400-e29b-41d4-a716-446655440001', + startsAt: '2026-06-01T03:00:00Z', + endsAt: '2026-06-01T04:00:00Z', + reason: 'Quarterly db migration', + suppressAlerts: true, + }) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('POST') + expect(req.url.pathname).toBe('/api/v1/maintenance-windows') + expect(req.url.search).toBe('') + const body = JSON.parse(req.body ?? '{}') + expect(body).toEqual({ + monitorId: '550e8400-e29b-41d4-a716-446655440001', + startsAt: '2026-06-01T03:00:00Z', + endsAt: '2026-06-01T04:00:00Z', + reason: 'Quarterly db migration', + suppressAlerts: true, + }) + expect(result.id).toBe(VALID_WINDOW.id) + expect(result.suppressAlerts).toBe(true) + }) + + it('create rejects payloads missing required fields before hitting the network', async () => { + const c = buildClient() + await expect( + c.maintenanceWindows.create({ + // intentionally missing startsAt/endsAt to trigger client-side validation + } as unknown as Parameters[0]), + ).rejects.toThrow(/validation/i) + expect(captured).toHaveLength(0) + }) + + it('list forwards monitorId and filter as query params and unwraps the page envelope', async () => { + nextResponse = () => new Response(JSON.stringify({ + data: [VALID_WINDOW], + hasNext: false, + hasPrev: false, + totalElements: 1, + totalPages: 1, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + + const c = buildClient() + const result = await c.maintenanceWindows.list({ + monitorId: '550e8400-e29b-41d4-a716-446655440001', + filter: 'active', + }) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('GET') + expect(req.url.pathname).toBe('/api/v1/maintenance-windows') + expect(req.url.searchParams.get('monitorId')).toBe('550e8400-e29b-41d4-a716-446655440001') + expect(req.url.searchParams.get('filter')).toBe('active') + expect(req.url.searchParams.get('page')).toBe('0') + expect(req.url.searchParams.get('size')).toBe('200') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(VALID_WINDOW.id) + }) + + it('list omits filter params when no filters are provided', async () => { + nextResponse = () => new Response(JSON.stringify({ + data: [], + hasNext: false, + hasPrev: false, + totalElements: 0, + totalPages: 0, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + + const c = buildClient() + const result = await c.maintenanceWindows.list() + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.url.searchParams.has('monitorId')).toBe(false) + expect(req.url.searchParams.has('filter')).toBe(false) + expect(result).toEqual([]) + }) + + it('list auto-paginates while hasNext is true', async () => { + let call = 0 + nextResponse = () => { + call++ + if (call === 1) { + return new Response(JSON.stringify({ + data: [VALID_WINDOW], + hasNext: true, + hasPrev: false, + totalElements: 2, + totalPages: 2, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + } + return new Response(JSON.stringify({ + data: [{...VALID_WINDOW, id: '550e8400-e29b-41d4-a716-446655440099'}], + hasNext: false, + hasPrev: true, + totalElements: 2, + totalPages: 2, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + } + + const c = buildClient() + const result = await c.maintenanceWindows.list() + + expect(captured).toHaveLength(2) + expect(captured[0].url.searchParams.get('page')).toBe('0') + expect(captured[1].url.searchParams.get('page')).toBe('1') + expect(result).toHaveLength(2) + }) + + it('listPage forwards explicit page/size + filters', async () => { + nextResponse = () => new Response(JSON.stringify({ + data: [VALID_WINDOW], + hasNext: true, + hasPrev: false, + totalElements: 42, + totalPages: 5, + }), {status: 200, headers: {'Content-Type': 'application/json'}}) + + const c = buildClient() + const page = await c.maintenanceWindows.listPage(2, 10, {filter: 'upcoming'}) + + const [req] = captured + expect(req.url.searchParams.get('page')).toBe('2') + expect(req.url.searchParams.get('size')).toBe('10') + expect(req.url.searchParams.get('filter')).toBe('upcoming') + expect(page.totalElements).toBe(42) + expect(page.totalPages).toBe(5) + expect(page.hasNext).toBe(true) + }) + + it('get fetches the window by id from the right URL', async () => { + nextResponse = () => new Response(JSON.stringify({data: VALID_WINDOW}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }) + + const c = buildClient() + const result = await c.maintenanceWindows.get(VALID_WINDOW.id) + + const [req] = captured + expect(req.method).toBe('GET') + expect(req.url.pathname).toBe(`/api/v1/maintenance-windows/${VALID_WINDOW.id}`) + expect(result.id).toBe(VALID_WINDOW.id) + }) + + it('update PUTs the request body to /maintenance-windows/{id}', async () => { + const updated: MaintenanceWindowDto = { + ...VALID_WINDOW, + reason: 'Rescheduled — vendor delay', + endsAt: '2026-06-01T05:00:00Z', + } + nextResponse = () => new Response(JSON.stringify({data: updated}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }) + + const c = buildClient() + const result = await c.maintenanceWindows.update(VALID_WINDOW.id, { + startsAt: '2026-06-01T03:00:00Z', + endsAt: '2026-06-01T05:00:00Z', + reason: 'Rescheduled — vendor delay', + }) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('PUT') + expect(req.url.pathname).toBe(`/api/v1/maintenance-windows/${VALID_WINDOW.id}`) + const body = JSON.parse(req.body ?? '{}') + expect(body).toEqual({ + startsAt: '2026-06-01T03:00:00Z', + endsAt: '2026-06-01T05:00:00Z', + reason: 'Rescheduled — vendor delay', + }) + expect(result.reason).toBe('Rescheduled — vendor delay') + }) + + it('cancel DELETEs /maintenance-windows/{id} and resolves to void', async () => { + nextResponse = () => new Response(null, {status: 204}) + const c = buildClient() + const result = await c.maintenanceWindows.cancel(VALID_WINDOW.id) + + expect(captured).toHaveLength(1) + const [req] = captured + expect(req.method).toBe('DELETE') + expect(req.url.pathname).toBe(`/api/v1/maintenance-windows/${VALID_WINDOW.id}`) + expect(result).toBeUndefined() + }) +})