From 46473a66df860ea65830b43bb811f5cd2dcae2f7 Mon Sep 17 00:00:00 2001 From: Anthony Baldwin <6219998+anthonybaldwin@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:40:09 -0700 Subject: [PATCH 1/6] docs: design spec for generic RSS/Atom feed provider Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-05-rss-atom-feed-provider-design.md | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-05-rss-atom-feed-provider-design.md diff --git a/docs/superpowers/specs/2026-06-05-rss-atom-feed-provider-design.md b/docs/superpowers/specs/2026-06-05-rss-atom-feed-provider-design.md new file mode 100644 index 0000000..61a1ef6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-rss-atom-feed-provider-design.md @@ -0,0 +1,213 @@ +# Generic RSS/Atom Feed Provider — Design + +**Date:** 2026-06-05 +**Status:** Approved (design), pending implementation plan + +## Problem + +Squawk supports three status-page platforms via dedicated providers (Statuspage, +incident.io, Instatus), each probing a known vendor API shape. Status pages built +on anything else — e.g. Slack's bespoke `slack-status.com` (a custom Laravel app) +— can't be monitored, even though almost all of them publish an Atom or RSS feed. + +Rather than add a one-off provider per bespoke page, add a single generic **feed** +provider that consumes any Atom or RSS feed. This turns "we don't support that +page" into "give us its feed link" for the long tail of status pages. + +## Research findings (why feeds, why this shape) + +Verified against live data from Slack (bespoke) and GitHub (Statuspage vendor), +including each one's JSON API, Atom feed, and RSS feed. + +| Surface | Stable id | Per-update history | Status enum | Impact | Discoverable on an unknown site? | +|---|---|---|---|---|---| +| **JSON API** (Slack `/api/v2.0.0`, Statuspage `/api/v2`) | yes | yes (`notes[]`, own timestamps) | yes | yes | **No — vendor-specific shape** | +| **Atom** `` | yes (``) | yes — ``-delimited blocks, always present | sometimes (Statuspage emits `Investigating/Resolved`; Slack is prose only) | no | yes (the link the user supplies) | +| **RSS** `` | yes (``) | vendor-dependent (Statuspage duplicates blocks; Slack gives only a summary) | same as Atom when present | no | yes | + +Conclusions that drive the design: + +1. **JSON APIs are richer but useless as a generic fallback** — they only help when + the vendor is already known, which is exactly what the existing providers are for. + For an unknown site there is nothing to discover. The supplied feed is the only + self-describing structured source. +2. **An incident is one event, updated in place.** A feed entry has a stable id and + accumulates timestamped updates inside its ``/`` while its + ``/`` advances. Example: a Slack entry `published` 2025-12-08, + `updated` 2025-12-18, with four `3:23pm PST …` update blocks in one + entry. This maps directly onto Squawk's existing model: the polling loop already + dedupes incidents by `id` and updates by update-`id`, so once we emit each update + block as an `IncidentUpdate` with a stable id, "watch the same event for new + updates" is automatic. +3. **Impact (minor/major/critical) is the one field feeds never carry.** Feed + monitors will show a generic severity; everything else (status, update stream, + resolved state) is recoverable from content. + +## Goals + +- Monitor any status page that publishes a valid Atom or RSS feed, by supplying the + feed URL directly to `/monitor add`. +- Reuse the existing canonical `Summary`/`Incident` model and polling/render path + unchanged — the feed provider is just another `Provider`. +- Treat each feed entry as one incident-event and surface its accumulating updates + so the existing dedupe loop posts new updates over time. + +## Non-goals + +- **No HTML scanning or path-guessing.** We do not crawl a page's `` for + ``, nor probe well-known paths. The user supplies the exact + feed link. (Explicit decision — avoids false positives and fragility.) +- No attempt to recover an impact severity feeds don't provide. +- No new `/monitor` command surface or schema field beyond the provider enum. + +## Approach + +Add a `feed` provider as the **last** entry in `PROBE_ORDER`. Its `probe()` succeeds +only when the supplied URL *is itself* a parseable Atom or RSS document. Known +status-page URLs continue to match their real provider earlier in the order; a plain +HTML page matches nothing and falls through to a friendly error. A direct feed link +matches `feed`. + +The feed URL becomes the monitor's `baseUrl` (`provider: "feed"`). No separate +feed-URL field is needed. The human-facing page URL and page name are read from +inside the feed itself (both Slack and GitHub Atom carry +`` and a feed ``). + +### Registration / UX flow + +``` +/monitor add <url> + → probe statuspage, incidentio, instatus (unchanged) + → probe feed: fetch <url>, is it Atom or RSS? yes → feed monitor + → none match → error +``` + +Updated error message (replacing `src/index.ts:1794–1797`): + +> Auto-detection only supports **Statuspage, incident.io, and Instatus**. For other +> status pages, pass a direct **Atom or RSS** feed URL — e.g. +> `https://slack-status.com/feed/atom`. + +Example success: `/monitor add https://slack-status.com/feed/atom`. + +## Components + +A new `src/providers/feed.ts` plus small wiring changes. The module is organized into +pure, independently testable functions (mirroring `instatus.ts`): + +### 1. Format detection — `detectFeedKind(xml): "atom" | "rss" | null` +Inspect the document: a root/early `<feed` with the Atom namespace → `atom`; an +`<rss`/`<channel>` → `rss`; neither → `null` (probe returns null). No reliance on +content-type headers or file extension; detection is by document content. + +### 2. Atom parsing — `parseAtomFeed(xml, baseUrl): { page, incidents }` +- `page.name` ← feed `<title>`; `page.url` ← feed-level + `<link rel="alternate" type="text/html">` (fallback: `baseUrl`); `page.id` ← host of + that URL. +- Each `<entry>` → one `Incident`: + - `id` ← `<id>` (trimmed; reuse the Instatus tail-extraction approach for a clean id). + - `name` ← `<title>` (entity-decoded). + - `shortlink` ← entry `<link rel="alternate" type="text/html">`. + - `incident_updates` ← parsed from `<content type="html">` (see §4). + - `created_at` ← first update or `<published>`; `updated_at` ← last update or `<updated>`. + - `status` ← last update's status; `resolved_at` ← last update time when resolved. + - `impact` ← `"minor"` default (feeds carry no impact). + +### 3. RSS parsing — `parseRssFeed(xml, baseUrl): { page, incidents }` +- `page.name` ← `<channel><title>`; `page.url` ← `<channel><link>`. +- Each `<item>` → one `Incident`: `id` ← `<guid>` (fallback `<link>`), `name` ← + `<title>`, `shortlink` ← `<link>`, `updated_at` ← `<pubDate>`. Updates parsed from + `<description>` via the same block parser (§4); when no update blocks are present + (e.g. Slack RSS), fall back to a single update whose body is the description text. + +### 4. Update-block parsing — `parseFeedUpdates(html, entryTimeIso): IncidentUpdate[]` +Shared by Atom and RSS. The HTML content holds zero or more update blocks of the form +`<p><small>TIMESTAMP</small><br>[<strong>STATUS</strong> -] BODY</p>`: +- Split into `<p>` blocks containing a `<small>` timestamp. +- **Timestamp** ← parse `<small>` text to ISO (reuse/extend `parseUpdateTimestamp`; + must also accept absolute forms like Slack's `3:23pm PST` and Statuspage's + `Jun 5, 17:25 UTC`). Fallback to the entry-level time when unparseable. +- **Status** ← `<strong>WORD</strong>` marker via `canonicalIncidentStatus` when + present (Statuspage family); otherwise keyword-sniff the body + (`resolved`/`monitoring`/`identified`/`investigating`), defaulting to + `investigating`. +- **Body** ← tag-stripped, entity-decoded text (reuse `plainText`/`decodeEntities`). +- **Update id** ← `${entryId}:${update.created_at}` (the Instatus convention) so the + polling loop dedupes updates stably and posts only newly-appeared blocks. +- Sort ascending by time. If no blocks parse, emit a single update from the whole + content/summary so every incident has at least one update. + +Shared helpers (`decodeEntities`, `plainText`, `parseUpdateTimestamp`, +`canonicalIncidentStatus`, `canonicalImpact`) are currently private to `instatus.ts`. +The plan should extract the genuinely shared ones into a small `feed-text.ts` (or +exported from a shared module) and have both providers import them, rather than +duplicating. Scope this extraction to only what `feed.ts` reuses. + +### 5. Page status synthesis — `feedPageStatus(activeIncidents): PageStatus` +Feeds have no "all systems operational" indicator and always list past (resolved) +incidents. An incident is **active** when its latest update status is not `resolved`. +- No active incidents → `{ indicator: "none", description: "All Systems Operational" }`. +- Otherwise → a generic non-operational status (e.g. `{ indicator: "minor", + description: "Active Incidents" }`), since impact is unknown. + +### 6. Provider object +```ts +export const feed: Provider = { + id: "feed", + displayName: "RSS/Atom feed", + probe(baseUrl), // fetch, detectFeedKind, parse; return {page, status} or null + fetchSummary(monitor), // parse feed, filter to active incidents, synthesize status + fetchIncidents(monitor)// parse feed → full incident list (incl. resolved) +} +``` +All three fetch the single feed URL (`monitor.baseUrl`) once and parse it. `probe` and +the fetchers swallow network/parse errors and return `null`/throw per the existing +provider contract (probe returns `null` on any failure so the chain falls through to +the error message). + +### 7. Wiring (small, mechanical) +- `src/providers/types.ts`: add `"feed"` to `ProviderId`. +- `src/providers/index.ts`: import `feed`; add to `PROVIDERS`; append to `PROBE_ORDER` + (last, so real providers win first). Comment why it is last. +- `src/index.ts`: add `"feed"` to the `monitorSchema` provider enum; update the + `/monitor add` failure message (above). + +## Data flow + +`/monitor add <feed-url>` → `detectProvider` → providers miss → `feed.probe` fetches +and parses the feed → returns page+status → monitor persisted with `provider: "feed"`, +`baseUrl: <feed-url>`. Polling loop calls `feed.fetchIncidents` each interval → parses +entries → existing dedupe posts any incident/update whose id is newly seen. `/status` +and `/testpost` call `feed.fetchSummary` (active-only). + +## Error handling + +- Unreachable/non-feed URL during probe → `probe` returns `null` → standard add error. +- Malformed feed at poll time → `fetchIncidents` throws; the polling loop's existing + per-monitor error handling applies (no special-casing). +- Unparseable individual timestamps/blocks degrade gracefully to entry-level time and + a single update, never throwing. + +## Testing + +Following the existing `*.test.ts` pattern with fixture XML (no network in unit tests): +- `detectFeedKind`: atom vs rss vs garbage. +- `parseAtomFeed`: Slack fixture (prose updates, no status markers, multiple `<small>` + blocks, retrospective summary) and GitHub/Statuspage fixture (`<strong>STATUS</strong>` + markers). Assert stable ids, ordered updates, resolved detection, impact default. +- `parseRssFeed`: GitHub RSS (blocks in `<description>`) and Slack RSS (summary only → + single update) fixtures. +- `parseFeedUpdates`: both `<small>` timestamp formats; missing-marker keyword sniff; + empty/garbage content → single fallback update. +- `feedPageStatus`: all-resolved → operational; one active → non-operational. +- Provider-level `probe` returning `null` for an HTML page (fixture) so the probe order + is correct. + +## Known limitations (documented, accepted) + +- **No impact severity** from feeds → generic indicator. +- **Resolved detection for marker-less feeds (Slack) is heuristic** (keyword sniff); a + resolved incident that never says "resolved" in prose would stay shown as active. +- **RSS without update blocks** (Slack RSS) yields a single coarse update per incident; + Atom is strictly better for those pages, which is why we accept either and let the + user pick the link. From ea5c428f4cf3ca269a9a1dba41a708460cb50fd8 Mon Sep 17 00:00:00 2001 From: Anthony Baldwin <6219998+anthonybaldwin@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:15:20 -0700 Subject: [PATCH 2/6] docs: implementation plan for feed provider Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../plans/2026-06-05-feed-provider.md | 1173 +++++++++++++++++ 1 file changed, 1173 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-feed-provider.md diff --git a/docs/superpowers/plans/2026-06-05-feed-provider.md b/docs/superpowers/plans/2026-06-05-feed-provider.md new file mode 100644 index 0000000..67069ab --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-feed-provider.md @@ -0,0 +1,1173 @@ +# Generic RSS/Atom Feed Provider — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a generic `feed` provider so any status page with a direct Atom or RSS feed URL can be monitored, used as the last-resort fallback after the vendor-specific providers. + +**Architecture:** A new `src/providers/feed.ts` implements the existing `Provider` interface (`probe`/`fetchSummary`/`fetchIncidents`) by fetching a single feed URL and parsing it into the canonical `Summary`/`Incident` model. Each feed entry becomes one incident keyed by its stable `<id>`/`<guid>`; the timestamped update blocks inside `<content>`/`<description>` become `IncidentUpdate`s with stable ids, so the existing polling loop posts new updates over time. Shared XML-text helpers move to `src/providers/feed-text.ts`. The provider registers last in `PROBE_ORDER`; the feed URL the user supplies *is* the monitor's `baseUrl`. + +**Tech Stack:** TypeScript, Bun (`bun test`, `bun:test`), zod (monitor schema). Regex-based XML parsing matching the existing `instatus.ts` style (no XML library dependency). + +**Design reference:** `docs/superpowers/specs/2026-06-05-rss-atom-feed-provider-design.md` + +--- + +## File Structure + +- **Create** `src/providers/feed-text.ts` — shared, pure XML-text utilities (`decodeEntities`, `plainText`, `parseFeedTimestamp`). One responsibility: turning raw feed text fragments into clean strings/timestamps. +- **Create** `src/providers/feed-text.test.ts` — unit tests for the above. +- **Modify** `src/providers/instatus.ts` — drop its private `decodeEntities`/`plainText`, import them from `./feed-text` (DRY; no behavior change). +- **Create** `src/providers/feed.ts` — the feed provider: format detection, update/atom/rss parsing, page-status synthesis, and the `Provider` object. +- **Create** `src/providers/feed.test.ts` — unit tests for the parser functions (no network). +- **Modify** `src/providers/types.ts` — add `"feed"` to `ProviderId`. +- **Modify** `src/providers/index.ts` — register `feed`, append to `PROBE_ORDER`. +- **Modify** `src/index.ts` — add `"feed"` to the monitor schema enum; reword the `/monitor add` failure message. + +### Key types & signatures (used consistently across tasks) + +```ts +// feed-text.ts +export function decodeEntities(text: string): string +export function plainText(html: string): string +export function parseFeedTimestamp(token: string, baseIso: string): { iso: string; hasDate: boolean } | null + +// feed.ts +export function detectFeedKind(xml: string): "atom" | "rss" | null +export function parseFeedUpdates(rawContent: string, entryId: string, entryTimeIso: string): IncidentUpdate[] +export function parseAtomFeed(xml: string, baseUrl: string): { page: Summary["page"]; incidents: Incident[] } +export function parseRssFeed(xml: string, baseUrl: string): { page: Summary["page"]; incidents: Incident[] } +export function parseFeed(xml: string, baseUrl: string): { page: Summary["page"]; incidents: Incident[] } | null +export function feedPageStatus(incidents: Incident[]): PageStatus +export const feed: Provider +``` + +--- + +## Task 1: Shared XML-text helpers (`feed-text.ts`) + +**Files:** +- Create: `src/providers/feed-text.ts` +- Create: `src/providers/feed-text.test.ts` +- Modify: `src/providers/instatus.ts` (imports only) + +- [ ] **Step 1: Write the failing test** + +Create `src/providers/feed-text.test.ts`: + +```ts +import { test, expect, describe } from "bun:test"; +import { decodeEntities, plainText, parseFeedTimestamp } from "./feed-text"; + +describe("decodeEntities", () => { + test("decodes the entities that appear in status feeds", () => { + expect(decodeEntities("a & b")).toBe("a & b"); + expect(decodeEntities("<p>hi</p>")).toBe("<p>hi</p>"); + expect(decodeEntities(""x" 'y'")).toBe('"x" \'y\''); + expect(decodeEntities("it's")).toBe("it's"); + }); +}); + +describe("plainText", () => { + test("strips tags, decodes entities, collapses whitespace", () => { + expect(plainText("<p>hello <strong>world</strong></p>")).toBe("hello world"); + expect(plainText("a &amp; b")).toBe("a & b"); + }); + test("trims a single duplicate trailing period", () => { + expect(plainText("Done..")).toBe("Done."); + }); +}); + +describe("parseFeedTimestamp", () => { + const base = "2026-06-05T00:00:00Z"; + + test("parses Statuspage 'Mon D, HH:MM TZ' form with date", () => { + const r = parseFeedTimestamp("Jun 5, 17:25 UTC", base); + expect(r).not.toBeNull(); + expect(r!.hasDate).toBe(true); + expect(r!.iso).toBe("2026-06-05T17:25:00.000Z"); + }); + + test("parses Statuspage form with seconds", () => { + const r = parseFeedTimestamp("Jun 5, 01:40:38", base); + expect(r!.iso).toBe("2026-06-05T01:40:38.000Z"); + }); + + test("parses Slack time-only 'H:MMpm TZ' anchored to base date, hasDate=false", () => { + const r = parseFeedTimestamp("3:23pm PST", base); + expect(r).not.toBeNull(); + expect(r!.hasDate).toBe(false); + expect(r!.iso).toBe("2026-06-05T15:23:00.000Z"); + }); + + test("handles 12am/12pm correctly", () => { + expect(parseFeedTimestamp("12:00am PST", base)!.iso).toBe("2026-06-05T00:00:00.000Z"); + expect(parseFeedTimestamp("12:30pm PST", base)!.iso).toBe("2026-06-05T12:30:00.000Z"); + }); + + test("returns null when nothing parses", () => { + expect(parseFeedTimestamp("no time here", base)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/providers/feed-text.test.ts` +Expected: FAIL — `Cannot find module './feed-text'`. + +- [ ] **Step 3: Create `src/providers/feed-text.ts`** + +```ts +/** + * Pure XML-text utilities shared by feed-consuming providers (instatus, feed). + * No network, no DOM — just string and timestamp normalization. + */ + +/** Decode the handful of XML/HTML entities that appear in status feeds. */ +export function decodeEntities(text: string): string { + return text + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/�?39;|'|'/g, "'") + .replace(/&/g, "&"); +} + +/** Strip all tags and collapse whitespace; trim a single duplicate trailing period. */ +export function plainText(html: string): string { + const text = decodeEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim(); + // Some feeds append a period to bodies that already end in one, yielding "..". + return text.replace(/\.\.$/, "."); +} + +const MONTHS: Record<string, number> = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; + +/** + * Parse a feed update `<small>` timestamp into an ISO string, using `baseIso` + * (the entry's published/updated time) to fill missing components. + * + * - Statuspage form: "Jun 5, 17:25 UTC" / "Jun 5, 01:40:38" — month+day, no year. + * - Slack form: "3:23pm PST" — time-of-day only, no date. + * + * Timezone abbreviations are ignored (clock time is treated as UTC); within a + * single feed this keeps ordering and display consistent. `hasDate` is false for + * time-only tokens so the caller can apply day-rollover across an ordered run. + */ +export function parseFeedTimestamp(token: string, baseIso: string): { iso: string; hasDate: boolean } | null { + const base = new Date(baseIso); + const baseValid = !Number.isNaN(base.getTime()); + const year = baseValid ? base.getUTCFullYear() : 1970; + + // Statuspage: "Mon D, HH:MM[:SS]" + const md = token.match(/([A-Za-z]{3})\s+(\d{1,2})\s*,\s*(\d{1,2}):(\d{2})(?::(\d{2}))?/); + if (md) { + const month = MONTHS[md[1].toLowerCase()]; + if (month !== undefined) { + const iso = new Date(Date.UTC( + year, month, Number(md[2]), Number(md[3]), Number(md[4]), Number(md[5] ?? 0), + )).toISOString(); + return { iso, hasDate: true }; + } + } + + // Slack: "H:MM[am|pm]" time-only, anchored to the base date. + const t = token.match(/(\d{1,2}):(\d{2})\s*([ap]m)?/i); + if (t) { + let hour = Number(t[1]); + const min = Number(t[2]); + const ampm = t[3]?.toLowerCase(); + if (ampm === "pm" && hour < 12) hour += 12; + if (ampm === "am" && hour === 12) hour = 0; + const d = baseValid ? base : new Date(Date.UTC(1970, 0, 1)); + const iso = new Date(Date.UTC( + d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), hour, min, 0, + )).toISOString(); + return { iso, hasDate: false }; + } + + return null; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/providers/feed-text.test.ts` +Expected: PASS (all `describe` blocks green). + +- [ ] **Step 5: Refactor `instatus.ts` to import the shared helpers** + +In `src/providers/instatus.ts`, **delete** the local `decodeEntities` function (lines beginning `/** Decode the handful of XML/HTML entities…`) and the local `plainText` function (`/** Strip all tags and collapse whitespace…`). Add an import near the top, after the existing `import type { … } from "./types";`: + +```ts +import { decodeEntities, plainText } from "./feed-text"; +``` + +(Leave `normKey`, `firstMatch`, `parseUpdateTimestamp`, and all canonical mappers in `instatus.ts` unchanged.) + +- [ ] **Step 6: Verify no regression in instatus + feed-text** + +Run: `bun test src/providers/instatus.test.ts src/providers/feed-text.test.ts` +Expected: PASS (instatus suite unchanged behavior; feed-text green). + +- [ ] **Step 7: Typecheck** + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 8: Commit** + +```bash +git add src/providers/feed-text.ts src/providers/feed-text.test.ts src/providers/instatus.ts +git commit -m "refactor: extract shared feed-text helpers; add parseFeedTimestamp + +Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>" +``` + +--- + +## Task 2: Feed format detection (`detectFeedKind`) + +**Files:** +- Create: `src/providers/feed.ts` +- Create: `src/providers/feed.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/providers/feed.test.ts`: + +```ts +import { test, expect, describe } from "bun:test"; +import { detectFeedKind } from "./feed"; + +describe("detectFeedKind", () => { + test("recognizes Atom", () => { + const xml = `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>x`; + expect(detectFeedKind(xml)).toBe("atom"); + }); + test("recognizes RSS", () => { + const xml = `x`; + expect(detectFeedKind(xml)).toBe("rss"); + }); + test("recognizes a bare channel as RSS", () => { + expect(detectFeedKind(`x`)).toBe("rss"); + }); + test("returns null for HTML", () => { + expect(detectFeedKind(`Status`)).toBeNull(); + }); + test("returns null for empty/garbage", () => { + expect(detectFeedKind("")).toBeNull(); + expect(detectFeedKind("not xml at all")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/providers/feed.test.ts` +Expected: FAIL — `Cannot find module './feed'`. + +- [ ] **Step 3: Create `src/providers/feed.ts` with the detector** + +```ts +import { decodeEntities, plainText, parseFeedTimestamp } from "./feed-text"; +import { canonicalIncidentStatus } from "./instatus"; +import type { + Incident, + IncidentUpdate, + PageStatus, + Provider, + ProviderMonitor, + Summary, +} from "./types"; + +/** Identify whether a document is an Atom feed, an RSS feed, or neither. */ +export function detectFeedKind(xml: string): "atom" | "rss" | null { + const head = xml.slice(0, 4000).toLowerCase(); + if (/]/.test(head)) return "atom"; + if (/]/.test(head) || /]/.test(head)) return "rss"; + return null; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/providers/feed.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/providers/feed.ts src/providers/feed.test.ts +git commit -m "feat(feed): detect atom vs rss feed documents + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: Update-block parser (`parseFeedUpdates`) + +Parses the timestamped update blocks inside an entry's ``/`` into ordered `IncidentUpdate`s with stable ids. + +**Files:** +- Modify: `src/providers/feed.ts` +- Modify: `src/providers/feed.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/providers/feed.test.ts`: + +```ts +import { parseFeedUpdates } from "./feed"; + +describe("parseFeedUpdates", () => { + const base = "2026-06-05T00:00:00Z"; + + test("parses Statuspage-style blocks with STATUS markers", () => { + // Note: real atom is entity-escaped; the parser decodes first. + const content = decode(` +

Jun 5, 17:25 UTC
Update - Customers may see errors.

+

Jun 5, 17:20 UTC
Investigating - We are investigating.

+ `); + const u = parseFeedUpdates(content, "inc1", base); + expect(u.length).toBe(2); + // sorted ascending by time + expect(u[0].status).toBe("investigating"); + expect(u[0].body).toContain("investigating"); + expect(u[1].status).toBe("investigating"); // "Update" is not a lifecycle word -> default + expect(u[1].body).toContain("errors"); + // stable, order-independent ids + expect(u[0].id).not.toBe(u[1].id); + expect(u[0].id.startsWith("inc1:")).toBe(true); + }); + + test("parses Slack-style marker-less blocks and rolls over past midnight", () => { + const content = decode(` +

3:23pm PST We are aware of an issue impacting threads.

+

10:55pm PST Our work on this issue is still ongoing.

+

8:55am PST We have identified the cause and implemented a fix.

+

9:34am PST We have resolved the issue with threads.

+ `); + const u = parseFeedUpdates(content, "inc2", base); + expect(u.length).toBe(4); + // document order preserved (time-only feed); times monotonic non-decreasing + expect(u[0].body).toContain("aware"); + expect(u[3].body).toContain("resolved"); + expect(u[3].status).toBe("resolved"); // keyword sniff + expect(new Date(u[3].created_at).getTime()).toBeGreaterThan(new Date(u[0].created_at).getTime()); + // 8:55am is the *next* day relative to 10:55pm + expect(new Date(u[2].created_at).getTime()).toBeGreaterThan(new Date(u[1].created_at).getTime()); + }); + + test("falls back to a single update when there are no blocks", () => { + const u = parseFeedUpdates(decode("

From 7:12 AM to 4:50 PM some users saw errors. Issue resolved.

"), "inc3", base); + expect(u.length).toBe(1); + expect(u[0].id).toBe("inc3:0"); + expect(u[0].status).toBe("resolved"); + expect(u[0].body).toContain("some users"); + }); + + test("returns empty array for empty content", () => { + expect(parseFeedUpdates("", "inc4", base)).toEqual([]); + }); +}); + +// Helper: real feeds escape their HTML content; mimic that so tests exercise the decode path. +function decode(realHtml: string): string { + return realHtml + .replace(/&/g, "&") + .replace(//g, ">"); +} +``` + +Add `decodeEntities` to the existing import at the top of the test file: + +```ts +import { detectFeedKind, parseFeedUpdates } from "./feed"; +import { decodeEntities } from "./feed-text"; +``` + +(The `decode()` helper double-encodes so the content arrives escaped like a real ``; `parseFeedUpdates` decodes it internally.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/providers/feed.test.ts` +Expected: FAIL — `parseFeedUpdates is not a function` / not exported. + +- [ ] **Step 3: Implement `parseFeedUpdates` (and helpers) in `feed.ts`** + +Add to `src/providers/feed.ts`: + +```ts +/** Feed bodies are double-encoded (`&nbsp;`); turn what remains into spaces. */ +function feedText(html: string): string { + return plainText(html).replace(/ /gi, " ").replace(/\s+/g, " ").trim(); +} + +/** Normalize a token into a compact, stable discriminator for update ids. */ +function tokenKey(token: string): string { + return token.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +/** Infer a lifecycle status from prose when no STATUS marker exists. */ +function sniffStatus(text: string): string { + const t = text.toLowerCase(); + if (/\bresolved\b/.test(t)) return "resolved"; + if (/\bmonitoring\b/.test(t)) return "monitoring"; + if (/\bidentified\b/.test(t)) return "identified"; + return "investigating"; +} + +type RawBlock = { token: string; statusWord?: string; body: string }; + +/** + * Parse an entry's (possibly entity-escaped) content into ordered updates. + * + * Each update block is delimited by a `TIMESTAMP` followed by an + * optional `STATUS -` marker and the body text. Update ids are + * `${entryId}:${tokenKey}` so they are stable across polls regardless of the + * feed's entry ordering (newest-first vs oldest-first). + */ +export function parseFeedUpdates(rawContent: string, entryId: string, entryTimeIso: string): IncidentUpdate[] { + const html = decodeEntities(rawContent); + const blocks: RawBlock[] = []; + + const smallRe = /([\s\S]*?)<\/small>([\s\S]*?)(?=|$)/gi; + let m: RegExpExecArray | null; + while ((m = smallRe.exec(html)) !== null) { + const token = plainText(m[1]); + let rest = m[2]; + let statusWord: string | undefined; + const marker = rest.match(/\s*([A-Za-z ]+?)\s*<\/strong>\s*-?/); + if (marker && !marker[1].trim().endsWith(":")) { + statusWord = marker[1].trim(); + rest = rest.replace(marker[0], " "); + } + blocks.push({ token, statusWord, body: feedText(rest) || "No message provided." }); + } + + if (blocks.length === 0) { + const body = feedText(html); + if (!body) return []; + return [{ + id: `${entryId}:0`, + status: sniffStatus(body), + body, + created_at: entryTimeIso, + updated_at: entryTimeIso, + }]; + } + + // Assign timestamps. If any block carries a full date (Statuspage), trust parsed + // times and sort. Otherwise (Slack time-only) keep document order and roll the day + // forward whenever the clock decreases, so an overnight run stays chronological. + const parsed = blocks.map((b) => parseFeedTimestamp(b.token, entryTimeIso)); + const anyDated = parsed.some((p) => p?.hasDate); + + let times: string[]; + if (anyDated) { + times = parsed.map((p) => p?.iso ?? entryTimeIso); + } else { + const base = new Date(entryTimeIso); + const baseValid = !Number.isNaN(base.getTime()); + let dayOffset = 0; + let prevMinutes = -1; + times = blocks.map((b) => { + const t = b.token.match(/(\d{1,2}):(\d{2})\s*([ap]m)?/i); + if (!t || !baseValid) return entryTimeIso; + let hour = Number(t[1]); + const min = Number(t[2]); + const ampm = t[3]?.toLowerCase(); + if (ampm === "pm" && hour < 12) hour += 12; + if (ampm === "am" && hour === 12) hour = 0; + const minutes = hour * 60 + min; + if (prevMinutes >= 0 && minutes < prevMinutes) dayOffset++; + prevMinutes = minutes; + return new Date(Date.UTC( + base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate() + dayOffset, hour, min, 0, + )).toISOString(); + }); + } + + const updates: IncidentUpdate[] = blocks.map((b, i) => ({ + id: `${entryId}:${tokenKey(b.token) || i}`, + status: b.statusWord ? canonicalIncidentStatus(b.statusWord) : sniffStatus(b.body), + body: b.body, + created_at: times[i], + updated_at: times[i], + })); + + if (anyDated) { + updates.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + } + return updates; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/providers/feed.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck + commit** + +Run: `bun run typecheck` (expect no errors), then: + +```bash +git add src/providers/feed.ts src/providers/feed.test.ts +git commit -m "feat(feed): parse timestamped update blocks into stable-id updates + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Atom feed parser (`parseAtomFeed`) + +**Files:** +- Modify: `src/providers/feed.ts` +- Modify: `src/providers/feed.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/providers/feed.test.ts`: + +```ts +import { parseAtomFeed } from "./feed"; + +const SLACK_ATOM = ` + + https://status.slack.com + + Slack System Status + 2025-12-18T08:44:51-08:00 + + https://slack-status.com/2025-12/a8c230d2dfa1ac93 + 2025-12-08T15:23:58-08:00 + 2025-12-18T08:44:51-08:00 + + Incident: Issues loading or viewing threads + <p><small>3:23pm PST</small> We are aware of an issue impacting threads.</p><p><small>9:34am PST</small> We have resolved the issue with threads.</p> + +`; + +const GH_ATOM = ` + + GitHub Status - Incident History + + + tag:www.githubstatus.com,2005:Incident/30463506 + 2026-06-05T17:20:00Z + 2026-06-05T17:25:44Z + + Disruption with some GitHub services + <p><small>Jun 5, 17:25 UTC</small><br><strong>Update</strong> - Customers may see unexpected events.</p><p><small>Jun 5, 17:20 UTC</small><br><strong>Investigating</strong> - We are investigating reports.</p> + +`; + +describe("parseAtomFeed", () => { + test("reads page name and home link from the feed head", () => { + const { page } = parseAtomFeed(SLACK_ATOM, "https://slack-status.com/feed/atom"); + expect(page.name).toBe("Slack System Status"); + expect(page.url).toBe("https://slack-status.com"); + }); + + test("Slack: marker-less entry resolves via keyword sniff", () => { + const { incidents } = parseAtomFeed(SLACK_ATOM, "https://slack-status.com/feed/atom"); + expect(incidents.length).toBe(1); + const inc = incidents[0]; + expect(inc.id).toBe("https://slack-status.com/2025-12/a8c230d2dfa1ac93"); + expect(inc.name).toBe("Incident: Issues loading or viewing threads"); + expect(inc.status).toBe("resolved"); + expect(inc.resolved_at).not.toBeNull(); + expect(inc.impact).toBe("minor"); + expect(inc.shortlink).toBe("https://slack-status.com/2025-12/a8c230d2dfa1ac93"); + expect(inc.incident_updates.length).toBe(2); + }); + + test("GitHub: newest-first entry sorts updates ascending and stays active", () => { + const { incidents } = parseAtomFeed(GH_ATOM, "https://www.githubstatus.com/history.atom"); + const inc = incidents[0]; + expect(inc.id).toBe("tag:www.githubstatus.com,2005:Incident/30463506"); + expect(inc.incident_updates[0].body).toContain("investigating reports"); + expect(inc.incident_updates[1].body).toContain("unexpected events"); + expect(inc.status).not.toBe("resolved"); + expect(inc.resolved_at).toBeNull(); + }); +}); +``` + +Update the top import to include `parseAtomFeed` (combine with the existing `./feed` import line). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/providers/feed.test.ts` +Expected: FAIL — `parseAtomFeed is not a function`. + +- [ ] **Step 3: Implement `parseAtomFeed` and shared assembly helpers in `feed.ts`** + +```ts +function firstMatch(source: string, re: RegExp): string | undefined { + const m = source.match(re); + return m ? m[1] : undefined; +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url.replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + } +} + +/** Build a canonical Incident from an entry's parsed parts. Resolved is terminal: + * any resolved update marks the incident resolved, independent of feed ordering. */ +function assembleIncident( + id: string, + name: string, + shortlink: string | undefined, + entryCreated: string, + entryUpdated: string, + updates: IncidentUpdate[], +): Incident { + const resolved = updates.find((u) => u.status === "resolved"); + const latest = [...updates].sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + )[0]; + const status = resolved ? "resolved" : (latest?.status ?? "investigating"); + return { + id, + name, + status, + impact: "minor", // feeds carry no impact severity + shortlink, + created_at: updates[0]?.created_at ?? entryCreated, + updated_at: latest?.created_at ?? entryUpdated, + resolved_at: resolved ? (resolved.created_at ?? entryUpdated) : null, + incident_updates: updates, + }; +} + +/** Parse a full Atom document into a page descriptor + canonical incidents. */ +export function parseAtomFeed(xml: string, baseUrl: string): { page: Summary["page"]; incidents: Incident[] } { + const head = xml.split(/]/)[0]; + const name = decodeEntities(firstMatch(head, /]*>([\s\S]*?)<\/title>/)?.trim() ?? "Status feed"); + const homeLink = + firstMatch(head, /]*rel=["']alternate["'][^>]*type=["']text\/html["'][^>]*href=["']([^"']+)["']/) ?? + firstMatch(head, /]*type=["']text\/html["'][^>]*href=["']([^"']+)["']/) ?? + baseUrl; + const page = { id: hostOf(homeLink), name, url: homeLink }; + + const entries = [...xml.matchAll(/]([\s\S]*?)<\/entry>/g)].map((m) => m[1]); + const incidents = entries.map((entry) => { + const id = (firstMatch(entry, /([\s\S]*?)<\/id>/)?.trim() ?? "").replace(/\s+/g, ""); + const name = decodeEntities(firstMatch(entry, /]*>([\s\S]*?)<\/title>/)?.trim() ?? "Untitled incident"); + const published = firstMatch(entry, /([\s\S]*?)<\/published>/)?.trim() ?? new Date(0).toISOString(); + const updated = firstMatch(entry, /([\s\S]*?)<\/updated>/)?.trim() ?? published; + const shortlink = firstMatch(entry, /]*rel=["']alternate["'][^>]*href=["']([^"']+)["']/); + const content = + firstMatch(entry, /]*>([\s\S]*?)<\/content>/) ?? + firstMatch(entry, /]*>([\s\S]*?)<\/summary>/) ?? + ""; + const updates = parseFeedUpdates(content, id || shortlink || name, published); + return assembleIncident(id || shortlink || name, name, shortlink, published, updated, updates); + }); + + return { page, incidents }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/providers/feed.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck + commit** + +Run: `bun run typecheck`, then: + +```bash +git add src/providers/feed.ts src/providers/feed.test.ts +git commit -m "feat(feed): parse Atom feeds into canonical incidents + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: RSS feed parser (`parseRssFeed`) + +**Files:** +- Modify: `src/providers/feed.ts` +- Modify: `src/providers/feed.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/providers/feed.test.ts`: + +```ts +import { parseRssFeed } from "./feed"; + +const GH_RSS = ` + + GitHub Status - Incident History + https://www.githubstatus.com + + Disruption with some GitHub services + <p><small>Jun 5, 17:25 UTC</small><br><strong>Update</strong> - Customers may see events.</p><p><small>Jun 5, 17:20 UTC</small><br><strong>Investigating</strong> - We are investigating.</p> + Fri, 05 Jun 2026 17:25:44 +0000 + https://www.githubstatus.com/incidents/2nmfnbknhlnv + https://www.githubstatus.com/incidents/2nmfnbknhlnv + +`; + +const SLACK_RSS = ` + + Slack System Status + https://slack-status.com + + Incident: Issues loading or viewing threads + From 7:12 AM to 4:50 PM PST some users encountered errors loading threads. + Thu, 18 Dec 2025 08:44:51 -0800 + https://slack-status.com/2025-12/a8c230d2dfa1ac93 + https://slack-status.com/2025-12/a8c230d2dfa1ac93 + +`; + +describe("parseRssFeed", () => { + test("reads channel title/link", () => { + const { page } = parseRssFeed(GH_RSS, "https://www.githubstatus.com/history.rss"); + expect(page.name).toBe("GitHub Status - Incident History"); + expect(page.url).toBe("https://www.githubstatus.com"); + }); + + test("GitHub RSS: parses update blocks from ", () => { + const { incidents } = parseRssFeed(GH_RSS, "https://www.githubstatus.com/history.rss"); + const inc = incidents[0]; + expect(inc.id).toBe("https://www.githubstatus.com/incidents/2nmfnbknhlnv"); + expect(inc.incident_updates.length).toBe(2); + expect(inc.shortlink).toBe("https://www.githubstatus.com/incidents/2nmfnbknhlnv"); + }); + + test("Slack RSS: summary-only description yields one update", () => { + const { incidents } = parseRssFeed(SLACK_RSS, "https://slack-status.com/feed/rss"); + const inc = incidents[0]; + expect(inc.incident_updates.length).toBe(1); + expect(inc.incident_updates[0].body).toContain("7:12 AM"); + }); +}); +``` + +Add `parseRssFeed` to the `./feed` import line. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/providers/feed.test.ts` +Expected: FAIL — `parseRssFeed is not a function`. + +- [ ] **Step 3: Implement `parseRssFeed` in `feed.ts`** + +```ts +/** Parse a full RSS document into a page descriptor + canonical incidents. */ +export function parseRssFeed(xml: string, baseUrl: string): { page: Summary["page"]; incidents: Incident[] } { + const channelHead = xml.split(/]/)[0]; + const name = decodeEntities(firstMatch(channelHead, /]*>([\s\S]*?)<\/title>/)?.trim() ?? "Status feed"); + const home = firstMatch(channelHead, /]*>([\s\S]*?)<\/link>/)?.trim() ?? baseUrl; + const page = { id: hostOf(home), name, url: home }; + + const items = [...xml.matchAll(/]([\s\S]*?)<\/item>/g)].map((m) => m[1]); + const incidents = items.map((item) => { + const id = ( + firstMatch(item, /]*>([\s\S]*?)<\/guid>/) ?? + firstMatch(item, /]*>([\s\S]*?)<\/link>/) ?? + "" + ).trim(); + const name = decodeEntities(firstMatch(item, /]*>([\s\S]*?)<\/title>/)?.trim() ?? "Untitled incident"); + const pubRaw = firstMatch(item, /([\s\S]*?)<\/pubDate>/)?.trim(); + const pubDate = pubRaw ? new Date(pubRaw) : null; + const pubIso = pubDate && !Number.isNaN(pubDate.getTime()) ? pubDate.toISOString() : new Date(0).toISOString(); + const shortlink = firstMatch(item, /]*>([\s\S]*?)<\/link>/)?.trim(); + const description = firstMatch(item, /]*>([\s\S]*?)<\/description>/) ?? ""; + const updates = parseFeedUpdates(description, id || shortlink || name, pubIso); + return assembleIncident(id || shortlink || name, name, shortlink, pubIso, pubIso, updates); + }); + + return { page, incidents }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/providers/feed.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck + commit** + +Run: `bun run typecheck`, then: + +```bash +git add src/providers/feed.ts src/providers/feed.test.ts +git commit -m "feat(feed): parse RSS feeds into canonical incidents + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: Page-status synthesis + feed dispatch (`feedPageStatus`, `parseFeed`) + +**Files:** +- Modify: `src/providers/feed.ts` +- Modify: `src/providers/feed.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/providers/feed.test.ts`: + +```ts +import { feedPageStatus, parseFeed } from "./feed"; +import type { Incident } from "./types"; + +function inc(status: string): Incident { + return { + id: "x", name: "x", status, impact: "minor", + created_at: "2026-06-05T00:00:00Z", updated_at: "2026-06-05T00:00:00Z", + resolved_at: status === "resolved" ? "2026-06-05T00:00:00Z" : null, + incident_updates: [], + }; +} + +describe("feedPageStatus", () => { + test("all resolved => operational", () => { + expect(feedPageStatus([inc("resolved"), inc("resolved")])).toEqual({ + indicator: "none", description: "All Systems Operational", + }); + }); + test("one active => active incident", () => { + expect(feedPageStatus([inc("resolved"), inc("investigating")])).toEqual({ + indicator: "minor", description: "Active Incident", + }); + }); + test("multiple active => plural", () => { + expect(feedPageStatus([inc("investigating"), inc("monitoring")]).description).toBe("Active Incidents"); + }); +}); + +describe("parseFeed", () => { + test("dispatches to atom", () => { + const r = parseFeed(SLACK_ATOM, "https://slack-status.com/feed/atom"); + expect(r).not.toBeNull(); + expect(r!.page.name).toBe("Slack System Status"); + }); + test("dispatches to rss", () => { + const r = parseFeed(GH_RSS, "https://www.githubstatus.com/history.rss"); + expect(r!.incidents[0].incident_updates.length).toBe(2); + }); + test("returns null for non-feed HTML", () => { + expect(parseFeed("", "https://example.com")).toBeNull(); + }); +}); +``` + +Add `feedPageStatus` and `parseFeed` to the `./feed` import line. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/providers/feed.test.ts` +Expected: FAIL — `feedPageStatus is not a function`. + +- [ ] **Step 3: Implement `feedPageStatus` and `parseFeed` in `feed.ts`** + +```ts +/** Synthesize a page-level status. Feeds have no operational indicator and always + * list past (resolved) incidents, so the page is operational unless something is + * still active. Impact is unknown, so active pages report a generic "minor". */ +export function feedPageStatus(incidents: Incident[]): PageStatus { + const active = incidents.filter((i) => i.status !== "resolved"); + if (active.length === 0) { + return { indicator: "none", description: "All Systems Operational" }; + } + return { indicator: "minor", description: active.length === 1 ? "Active Incident" : "Active Incidents" }; +} + +/** Detect the feed kind and parse accordingly; null when the document is not a feed. */ +export function parseFeed(xml: string, baseUrl: string): { page: Summary["page"]; incidents: Incident[] } | null { + const kind = detectFeedKind(xml); + if (kind === "atom") return parseAtomFeed(xml, baseUrl); + if (kind === "rss") return parseRssFeed(xml, baseUrl); + return null; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/providers/feed.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck + commit** + +Run: `bun run typecheck`, then: + +```bash +git add src/providers/feed.ts src/providers/feed.test.ts +git commit -m "feat(feed): synthesize page status and add parseFeed dispatch + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: The `feed` Provider object + +**Files:** +- Modify: `src/providers/feed.ts` +- Modify: `src/providers/feed.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/providers/feed.test.ts`: + +```ts +import { feed } from "./feed"; + +describe("feed provider object", () => { + test("has the expected identity and Provider shape", () => { + expect(feed.id).toBe("feed"); + expect(feed.displayName).toBe("RSS/Atom feed"); + expect(typeof feed.probe).toBe("function"); + expect(typeof feed.fetchSummary).toBe("function"); + expect(typeof feed.fetchIncidents).toBe("function"); + }); +}); +``` + +Add `feed` to the `./feed` import line. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/providers/feed.test.ts` +Expected: FAIL — `feed` is undefined / not exported. + +- [ ] **Step 3: Implement the provider in `feed.ts`** + +```ts +async function fetchFeedXml(url: string): Promise { + const response = await fetch(url, { + headers: { Accept: "application/atom+xml, application/rss+xml, application/xml, text/xml" }, + }); + if (!response.ok) { + throw new Error(`Feed request failed (${response.status}) for ${url}`); + } + return response.text(); +} + +export const feed: Provider = { + id: "feed", + displayName: "RSS/Atom feed", + + async probe(baseUrl) { + try { + const xml = await fetchFeedXml(baseUrl); + const parsed = parseFeed(xml, baseUrl); + if (!parsed) return null; + return { page: parsed.page, status: feedPageStatus(parsed.incidents) }; + } catch { + return null; + } + }, + + async fetchSummary(monitor: ProviderMonitor): Promise { + const xml = await fetchFeedXml(monitor.baseUrl); + const parsed = parseFeed(xml, monitor.baseUrl); + if (!parsed) throw new Error(`Could not parse a feed at ${monitor.baseUrl}`); + const active = parsed.incidents.filter((i) => i.status !== "resolved"); + return { page: parsed.page, status: feedPageStatus(parsed.incidents), incidents: active }; + }, + + async fetchIncidents(monitor: ProviderMonitor): Promise { + const xml = await fetchFeedXml(monitor.baseUrl); + const parsed = parseFeed(xml, monitor.baseUrl); + if (!parsed) throw new Error(`Could not parse a feed at ${monitor.baseUrl}`); + return parsed.incidents; + }, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/providers/feed.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck + commit** + +Run: `bun run typecheck`, then: + +```bash +git add src/providers/feed.ts src/providers/feed.test.ts +git commit -m "feat(feed): add the feed Provider (probe/fetchSummary/fetchIncidents) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 8: Wire `feed` into the registry, schema, and add-command + +**Files:** +- Modify: `src/providers/types.ts:8` +- Modify: `src/providers/index.ts` +- Modify: `src/index.ts:54` and `src/index.ts:1794-1797` +- Modify: `src/providers/index.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/providers/index.test.ts` (match its existing import/style; if it has no imports yet, add the first two lines): + +```ts +import { test, expect, describe } from "bun:test"; +import { SUPPORTED_PROVIDERS } from "./index"; + +describe("provider registry", () => { + test("feed provider is registered and probed last", () => { + const ids = SUPPORTED_PROVIDERS.map((p) => p.id); + expect(ids).toContain("feed"); + expect(ids[ids.length - 1]).toBe("feed"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/providers/index.test.ts` +Expected: FAIL — `feed` not in `SUPPORTED_PROVIDERS`. + +- [ ] **Step 3: Add `"feed"` to the `ProviderId` union** + +In `src/providers/types.ts` line 8: + +```ts +export type ProviderId = "statuspage" | "incidentio" | "instatus" | "feed"; +``` + +- [ ] **Step 4: Register the provider in `src/providers/index.ts`** + +Add the import alongside the others: + +```ts +import { feed } from "./feed"; +``` + +Add it to the `PROVIDERS` map: + +```ts +const PROVIDERS: Record = { + statuspage, + incidentio, + instatus, + feed, +}; +``` + +Append it to `PROBE_ORDER` (must be **last**) and extend the comment: + +```ts +const PROBE_ORDER: Provider[] = [incidentio, statuspage, instatus, feed]; +``` + +Above `PROBE_ORDER`, add to the existing doc comment: + +``` + * `feed` is probed last and only matches when the supplied URL is itself a + * parseable Atom/RSS document, so real provider pages always win first and a + * plain HTML page matches nothing (falling through to the add-command error). +``` + +- [ ] **Step 5: Add `"feed"` to the monitor schema enum** + +In `src/index.ts` line 54: + +```ts + provider: z.enum(["statuspage", "incidentio", "instatus", "feed"]).optional(), +``` + +- [ ] **Step 6: Reword the `/monitor add` failure message** + +Replace the `throw new Error(...)` block at `src/index.ts:1795-1797` with: + +```ts + throw new Error( + `Could not monitor \`${baseUrl}\`. Auto-detection supports ${supported}. For other status pages, pass a direct **Atom or RSS** feed URL — e.g. \`https://slack-status.com/feed/atom\`.`, + ); +``` + +(`supported` is the existing `SUPPORTED_PROVIDERS.map((p) => p.displayName).join(", ")`. Since `feed` is now in `SUPPORTED_PROVIDERS`, "RSS/Atom feed" appears in that list automatically — which reads correctly in the message.) + +- [ ] **Step 7: Run the registry test + full suite + typecheck** + +Run: `bun test` +Expected: PASS (all suites, including the new feed/feed-text/registry tests). + +Run: `bun run typecheck` +Expected: no errors. + +- [ ] **Step 8: Commit** + +```bash +git add src/providers/types.ts src/providers/index.ts src/providers/index.test.ts src/index.ts +git commit -m "feat: register feed provider and guide users to feed URLs + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 9: Documentation + +**Files:** +- Modify: `AGENTS.md` (provider list / conventions), `README.md` if it enumerates providers. + +- [ ] **Step 1: Update provider references** + +Search for where providers are listed: `grep -rin "instatus\|incident.io\|statuspage" AGENTS.md README.md`. In each place that enumerates the supported providers, add the generic feed fallback, e.g.: + +> **RSS/Atom feed (`feed`)** — fallback for any status page not covered by a vendor provider. Add it by passing a direct Atom or RSS feed URL to `/monitor add` (Atom is richer; RSS works too). Impact severity is not available from feeds. + +Keep wording consistent with the surrounding doc style. Do not invent sections that don't exist; only extend existing provider lists/tables. + +- [ ] **Step 2: Verify build still clean** + +Run: `bun test && bun run typecheck` +Expected: PASS, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add AGENTS.md README.md +git commit -m "docs: document the generic RSS/Atom feed provider + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review (completed during planning) + +**Spec coverage:** +- Feed provider as last probe → Tasks 7, 8. ✅ +- No scanning; feed URL = baseUrl → Tasks 7 (provider fetches `monitor.baseUrl`), 8 (no schema field added beyond enum). ✅ +- Atom vs RSS auto-detect from content → Tasks 2, 6. ✅ +- Entry = event with stable id; updates accumulate, dedupe by update id → Task 3 (`${entryId}:${tokenKey}`), Task 4/5 (entry id from ``/``). ✅ +- Status from `` marker or keyword sniff; resolved terminal → Task 3 (`sniffStatus`, marker parse), Task 4 (`assembleIncident`). ✅ +- Impact defaults; page status synthesized → Task 4 (`impact: "minor"`), Task 6 (`feedPageStatus`). ✅ +- Shared helper extraction scoped to reuse → Task 1 (`decodeEntities`/`plainText` only). ✅ +- Error message guides to a feed URL → Task 8 Step 6. ✅ +- Tests with Slack + Statuspage fixtures, both feed kinds → Tasks 3–7. ✅ +- Known limitations documented → Task 9. ✅ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code. ✅ + +**Type consistency:** `parseFeed`/`parseAtomFeed`/`parseRssFeed` all return `{ page: Summary["page"]; incidents: Incident[] }`; `parseFeedUpdates` returns `IncidentUpdate[]`; `feedPageStatus` takes `Incident[]` → `PageStatus`; provider methods match the `Provider` interface in `types.ts`. Update ids use the single convention `${entryId}:${tokenKey|index}`. ✅ From 0b8c80825f8d0ee3444cf115477c09c90c1eb340 Mon Sep 17 00:00:00 2001 From: Anthony Baldwin <6219998+anthonybaldwin@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:16:17 -0700 Subject: [PATCH 3/6] refactor: extract shared feed-text helpers; add parseFeedTimestamp Co-Authored-By: Claude Opus 4.8 (1M context) --- src/providers/feed-text.test.ts | 53 ++++++++++++++++++++++++ src/providers/feed-text.ts | 72 +++++++++++++++++++++++++++++++++ src/providers/instatus.ts | 18 +-------- 3 files changed, 126 insertions(+), 17 deletions(-) create mode 100644 src/providers/feed-text.test.ts create mode 100644 src/providers/feed-text.ts diff --git a/src/providers/feed-text.test.ts b/src/providers/feed-text.test.ts new file mode 100644 index 0000000..bb56f5c --- /dev/null +++ b/src/providers/feed-text.test.ts @@ -0,0 +1,53 @@ +import { test, expect, describe } from "bun:test"; +import { decodeEntities, plainText, parseFeedTimestamp } from "./feed-text"; + +describe("decodeEntities", () => { + test("decodes the entities that appear in status feeds", () => { + expect(decodeEntities("a & b")).toBe("a & b"); + expect(decodeEntities("<p>hi</p>")).toBe("

hi

"); + expect(decodeEntities(""x" 'y'")).toBe('"x" \'y\''); + expect(decodeEntities("it's")).toBe("it's"); + }); +}); + +describe("plainText", () => { + test("strips tags, decodes entities, collapses whitespace", () => { + expect(plainText("

hello world

")).toBe("hello world"); + expect(plainText("a &amp; b")).toBe("a & b"); + }); + test("trims a single duplicate trailing period", () => { + expect(plainText("Done..")).toBe("Done."); + }); +}); + +describe("parseFeedTimestamp", () => { + const base = "2026-06-05T00:00:00Z"; + + test("parses Statuspage 'Mon D, HH:MM TZ' form with date", () => { + const r = parseFeedTimestamp("Jun 5, 17:25 UTC", base); + expect(r).not.toBeNull(); + expect(r!.hasDate).toBe(true); + expect(r!.iso).toBe("2026-06-05T17:25:00.000Z"); + }); + + test("parses Statuspage form with seconds", () => { + const r = parseFeedTimestamp("Jun 5, 01:40:38", base); + expect(r!.iso).toBe("2026-06-05T01:40:38.000Z"); + }); + + test("parses Slack time-only 'H:MMpm TZ' anchored to base date, hasDate=false", () => { + const r = parseFeedTimestamp("3:23pm PST", base); + expect(r).not.toBeNull(); + expect(r!.hasDate).toBe(false); + expect(r!.iso).toBe("2026-06-05T15:23:00.000Z"); + }); + + test("handles 12am/12pm correctly", () => { + expect(parseFeedTimestamp("12:00am PST", base)!.iso).toBe("2026-06-05T00:00:00.000Z"); + expect(parseFeedTimestamp("12:30pm PST", base)!.iso).toBe("2026-06-05T12:30:00.000Z"); + }); + + test("returns null when nothing parses", () => { + expect(parseFeedTimestamp("no time here", base)).toBeNull(); + }); +}); diff --git a/src/providers/feed-text.ts b/src/providers/feed-text.ts new file mode 100644 index 0000000..0aff587 --- /dev/null +++ b/src/providers/feed-text.ts @@ -0,0 +1,72 @@ +/** + * Pure XML-text utilities shared by feed-consuming providers (instatus, feed). + * No network, no DOM — just string and timestamp normalization. + */ + +/** Decode the handful of XML/HTML entities that appear in status feeds. */ +export function decodeEntities(text: string): string { + return text + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/�?39;|'|'/g, "'") + .replace(/&/g, "&"); +} + +/** Strip all tags and collapse whitespace; trim a single duplicate trailing period. */ +export function plainText(html: string): string { + const text = decodeEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim(); + // Some feeds append a period to bodies that already end in one, yielding "..". + return text.replace(/\.\.$/, "."); +} + +const MONTHS: Record = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; + +/** + * Parse a feed update `` timestamp into an ISO string, using `baseIso` + * (the entry's published/updated time) to fill missing components. + * + * - Statuspage form: "Jun 5, 17:25 UTC" / "Jun 5, 01:40:38" — month+day, no year. + * - Slack form: "3:23pm PST" — time-of-day only, no date. + * + * Timezone abbreviations are ignored (clock time is treated as UTC); within a + * single feed this keeps ordering and display consistent. `hasDate` is false for + * time-only tokens so the caller can apply day-rollover across an ordered run. + */ +export function parseFeedTimestamp(token: string, baseIso: string): { iso: string; hasDate: boolean } | null { + const base = new Date(baseIso); + const baseValid = !Number.isNaN(base.getTime()); + const year = baseValid ? base.getUTCFullYear() : 1970; + + // Statuspage: "Mon D, HH:MM[:SS]" + const md = token.match(/([A-Za-z]{3})\s+(\d{1,2})\s*,\s*(\d{1,2}):(\d{2})(?::(\d{2}))?/); + if (md) { + const month = MONTHS[md[1].toLowerCase()]; + if (month !== undefined) { + const iso = new Date(Date.UTC( + year, month, Number(md[2]), Number(md[3]), Number(md[4]), Number(md[5] ?? 0), + )).toISOString(); + return { iso, hasDate: true }; + } + } + + // Slack: "H:MM[am|pm]" time-only, anchored to the base date. + const t = token.match(/(\d{1,2}):(\d{2})\s*([ap]m)?/i); + if (t) { + let hour = Number(t[1]); + const min = Number(t[2]); + const ampm = t[3]?.toLowerCase(); + if (ampm === "pm" && hour < 12) hour += 12; + if (ampm === "am" && hour === 12) hour = 0; + const d = baseValid ? base : new Date(Date.UTC(1970, 0, 1)); + const iso = new Date(Date.UTC( + d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), hour, min, 0, + )).toISOString(); + return { iso, hasDate: false }; + } + + return null; +} diff --git a/src/providers/instatus.ts b/src/providers/instatus.ts index 826e292..2b38f1a 100644 --- a/src/providers/instatus.ts +++ b/src/providers/instatus.ts @@ -1,3 +1,4 @@ +import { decodeEntities, plainText } from "./feed-text"; import type { Incident, IncidentUpdate, @@ -100,23 +101,6 @@ export function parseUpdateTimestamp(small: string, publishedIso: string): strin return date.toISOString(); } -/** Decode the handful of XML/HTML entities that appear in Instatus feeds. */ -function decodeEntities(text: string): string { - return text - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/�?39;|'|'/g, "'") - .replace(/&/g, "&"); -} - -/** Strip all tags and collapse whitespace; trim a single duplicate trailing period. */ -function plainText(html: string): string { - const text = decodeEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim(); - // Instatus appends a period to update bodies that already end in one, yielding "..". - return text.replace(/\.\.$/, "."); -} - function firstMatch(source: string, re: RegExp): string | undefined { const m = source.match(re); return m ? m[1] : undefined; From 0597595649f916e73834a34dc4b17d2b551a903b Mon Sep 17 00:00:00 2001 From: Anthony Baldwin <6219998+anthonybaldwin@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:18:33 -0700 Subject: [PATCH 4/6] feat(feed): generic RSS/Atom feed provider Parse Atom and RSS feeds into the canonical Summary/Incident model: detect feed kind, parse timestamped update blocks into stable-id updates (marker- and prose-based status), synthesize page status, and expose a Provider whose probe matches only real feed documents. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/providers/feed.test.ts | 252 ++++++++++++++++++++++++++++++++ src/providers/feed.ts | 284 +++++++++++++++++++++++++++++++++++++ 2 files changed, 536 insertions(+) create mode 100644 src/providers/feed.test.ts create mode 100644 src/providers/feed.ts diff --git a/src/providers/feed.test.ts b/src/providers/feed.test.ts new file mode 100644 index 0000000..677efb0 --- /dev/null +++ b/src/providers/feed.test.ts @@ -0,0 +1,252 @@ +import { test, expect, describe } from "bun:test"; +import { + detectFeedKind, + parseFeedUpdates, + parseAtomFeed, + parseRssFeed, + parseFeed, + feedPageStatus, + feed, +} from "./feed"; +import { decodeEntities } from "./feed-text"; +import type { Incident } from "./types"; + +describe("detectFeedKind", () => { + test("recognizes Atom", () => { + const xml = `x`; + expect(detectFeedKind(xml)).toBe("atom"); + }); + test("recognizes RSS", () => { + const xml = `x`; + expect(detectFeedKind(xml)).toBe("rss"); + }); + test("recognizes a bare channel as RSS", () => { + expect(detectFeedKind(`x`)).toBe("rss"); + }); + test("returns null for HTML", () => { + expect(detectFeedKind(`Status`)).toBeNull(); + }); + test("returns null for empty/garbage", () => { + expect(detectFeedKind("")).toBeNull(); + expect(detectFeedKind("not xml at all")).toBeNull(); + }); +}); + +// Helper: real feeds escape their HTML content; mimic that so tests exercise the decode path. +function decode(realHtml: string): string { + return realHtml + .replace(/&/g, "&") + .replace(//g, ">"); +} + +describe("parseFeedUpdates", () => { + const base = "2026-06-05T00:00:00Z"; + + test("parses Statuspage-style blocks with STATUS markers", () => { + const content = decode(` +

Jun 5, 17:25 UTC
Update - Customers may see errors.

+

Jun 5, 17:20 UTC
Investigating - We are investigating.

+ `); + const u = parseFeedUpdates(content, "inc1", base); + expect(u.length).toBe(2); + // sorted ascending by time + expect(u[0].status).toBe("investigating"); + expect(u[0].body).toContain("investigating"); + expect(u[1].status).toBe("investigating"); // "Update" is not a lifecycle word -> default + expect(u[1].body).toContain("errors"); + // stable, order-independent ids + expect(u[0].id).not.toBe(u[1].id); + expect(u[0].id.startsWith("inc1:")).toBe(true); + }); + + test("parses Slack-style marker-less blocks and rolls over past midnight", () => { + const content = decode(` +

3:23pm PST We are aware of an issue impacting threads.

+

10:55pm PST Our work on this issue is still ongoing.

+

8:55am PST We have identified the cause and implemented a fix.

+

9:34am PST We have resolved the issue with threads.

+ `); + const u = parseFeedUpdates(content, "inc2", base); + expect(u.length).toBe(4); + // document order preserved (time-only feed); times monotonic non-decreasing + expect(u[0].body).toContain("aware"); + expect(u[3].body).toContain("resolved"); + expect(u[3].status).toBe("resolved"); // keyword sniff + expect(new Date(u[3].created_at).getTime()).toBeGreaterThan(new Date(u[0].created_at).getTime()); + // 8:55am is the *next* day relative to 10:55pm + expect(new Date(u[2].created_at).getTime()).toBeGreaterThan(new Date(u[1].created_at).getTime()); + }); + + test("falls back to a single update when there are no blocks", () => { + const u = parseFeedUpdates(decode("

From 7:12 AM to 4:50 PM some users saw errors. Issue resolved.

"), "inc3", base); + expect(u.length).toBe(1); + expect(u[0].id).toBe("inc3:0"); + expect(u[0].status).toBe("resolved"); + expect(u[0].body).toContain("some users"); + }); + + test("returns empty array for empty content", () => { + expect(parseFeedUpdates("", "inc4", base)).toEqual([]); + }); +}); + +const SLACK_ATOM = ` + + https://status.slack.com + + Slack System Status + 2025-12-18T08:44:51-08:00 + + https://slack-status.com/2025-12/a8c230d2dfa1ac93 + 2025-12-08T15:23:58-08:00 + 2025-12-18T08:44:51-08:00 + + Incident: Issues loading or viewing threads + <p><small>3:23pm PST</small> We are aware of an issue impacting threads.</p><p><small>9:34am PST</small> We have resolved the issue with threads.</p> + +`; + +const GH_ATOM = ` + + GitHub Status - Incident History + + + tag:www.githubstatus.com,2005:Incident/30463506 + 2026-06-05T17:20:00Z + 2026-06-05T17:25:44Z + + Disruption with some GitHub services + <p><small>Jun 5, 17:25 UTC</small><br><strong>Update</strong> - Customers may see unexpected events.</p><p><small>Jun 5, 17:20 UTC</small><br><strong>Investigating</strong> - We are investigating reports.</p> + +`; + +describe("parseAtomFeed", () => { + test("reads page name and home link from the feed head", () => { + const { page } = parseAtomFeed(SLACK_ATOM, "https://slack-status.com/feed/atom"); + expect(page.name).toBe("Slack System Status"); + expect(page.url).toBe("https://slack-status.com"); + }); + + test("Slack: marker-less entry resolves via keyword sniff", () => { + const { incidents } = parseAtomFeed(SLACK_ATOM, "https://slack-status.com/feed/atom"); + expect(incidents.length).toBe(1); + const inc = incidents[0]; + expect(inc.id).toBe("https://slack-status.com/2025-12/a8c230d2dfa1ac93"); + expect(inc.name).toBe("Incident: Issues loading or viewing threads"); + expect(inc.status).toBe("resolved"); + expect(inc.resolved_at).not.toBeNull(); + expect(inc.impact).toBe("minor"); + expect(inc.shortlink).toBe("https://slack-status.com/2025-12/a8c230d2dfa1ac93"); + expect(inc.incident_updates.length).toBe(2); + }); + + test("GitHub: newest-first entry sorts updates ascending and stays active", () => { + const { incidents } = parseAtomFeed(GH_ATOM, "https://www.githubstatus.com/history.atom"); + const inc = incidents[0]; + expect(inc.id).toBe("tag:www.githubstatus.com,2005:Incident/30463506"); + expect(inc.incident_updates[0].body).toContain("investigating reports"); + expect(inc.incident_updates[1].body).toContain("unexpected events"); + expect(inc.status).not.toBe("resolved"); + expect(inc.resolved_at).toBeNull(); + }); +}); + +const GH_RSS = ` + + GitHub Status - Incident History + https://www.githubstatus.com + + Disruption with some GitHub services + <p><small>Jun 5, 17:25 UTC</small><br><strong>Update</strong> - Customers may see events.</p><p><small>Jun 5, 17:20 UTC</small><br><strong>Investigating</strong> - We are investigating.</p> + Fri, 05 Jun 2026 17:25:44 +0000 + https://www.githubstatus.com/incidents/2nmfnbknhlnv + https://www.githubstatus.com/incidents/2nmfnbknhlnv + +`; + +const SLACK_RSS = ` + + Slack System Status + https://slack-status.com + + Incident: Issues loading or viewing threads + From 7:12 AM to 4:50 PM PST some users encountered errors loading threads. + Thu, 18 Dec 2025 08:44:51 -0800 + https://slack-status.com/2025-12/a8c230d2dfa1ac93 + https://slack-status.com/2025-12/a8c230d2dfa1ac93 + +`; + +describe("parseRssFeed", () => { + test("reads channel title/link", () => { + const { page } = parseRssFeed(GH_RSS, "https://www.githubstatus.com/history.rss"); + expect(page.name).toBe("GitHub Status - Incident History"); + expect(page.url).toBe("https://www.githubstatus.com"); + }); + + test("GitHub RSS: parses update blocks from ", () => { + const { incidents } = parseRssFeed(GH_RSS, "https://www.githubstatus.com/history.rss"); + const inc = incidents[0]; + expect(inc.id).toBe("https://www.githubstatus.com/incidents/2nmfnbknhlnv"); + expect(inc.incident_updates.length).toBe(2); + expect(inc.shortlink).toBe("https://www.githubstatus.com/incidents/2nmfnbknhlnv"); + }); + + test("Slack RSS: summary-only description yields one update", () => { + const { incidents } = parseRssFeed(SLACK_RSS, "https://slack-status.com/feed/rss"); + const inc = incidents[0]; + expect(inc.incident_updates.length).toBe(1); + expect(inc.incident_updates[0].body).toContain("7:12 AM"); + }); +}); + +function inc(status: string): Incident { + return { + id: "x", name: "x", status, impact: "minor", + created_at: "2026-06-05T00:00:00Z", updated_at: "2026-06-05T00:00:00Z", + resolved_at: status === "resolved" ? "2026-06-05T00:00:00Z" : null, + incident_updates: [], + }; +} + +describe("feedPageStatus", () => { + test("all resolved => operational", () => { + expect(feedPageStatus([inc("resolved"), inc("resolved")])).toEqual({ + indicator: "none", description: "All Systems Operational", + }); + }); + test("one active => active incident", () => { + expect(feedPageStatus([inc("resolved"), inc("investigating")])).toEqual({ + indicator: "minor", description: "Active Incident", + }); + }); + test("multiple active => plural", () => { + expect(feedPageStatus([inc("investigating"), inc("monitoring")]).description).toBe("Active Incidents"); + }); +}); + +describe("parseFeed", () => { + test("dispatches to atom", () => { + const r = parseFeed(SLACK_ATOM, "https://slack-status.com/feed/atom"); + expect(r).not.toBeNull(); + expect(r!.page.name).toBe("Slack System Status"); + }); + test("dispatches to rss", () => { + const r = parseFeed(GH_RSS, "https://www.githubstatus.com/history.rss"); + expect(r!.incidents[0].incident_updates.length).toBe(2); + }); + test("returns null for non-feed HTML", () => { + expect(parseFeed("", "https://example.com")).toBeNull(); + }); +}); + +describe("feed provider object", () => { + test("has the expected identity and Provider shape", () => { + expect(feed.id).toBe("feed"); + expect(feed.displayName).toBe("RSS/Atom feed"); + expect(typeof feed.probe).toBe("function"); + expect(typeof feed.fetchSummary).toBe("function"); + expect(typeof feed.fetchIncidents).toBe("function"); + }); +}); diff --git a/src/providers/feed.ts b/src/providers/feed.ts new file mode 100644 index 0000000..04b54b9 --- /dev/null +++ b/src/providers/feed.ts @@ -0,0 +1,284 @@ +import { decodeEntities, plainText, parseFeedTimestamp } from "./feed-text"; +import { canonicalIncidentStatus } from "./instatus"; +import type { + Incident, + IncidentUpdate, + PageStatus, + Provider, + ProviderMonitor, + Summary, +} from "./types"; + +/** Identify whether a document is an Atom feed, an RSS feed, or neither. */ +export function detectFeedKind(xml: string): "atom" | "rss" | null { + const head = xml.slice(0, 4000).toLowerCase(); + if (/]/.test(head)) return "atom"; + if (/]/.test(head) || /]/.test(head)) return "rss"; + return null; +} + +/** Feed bodies are double-encoded (`&nbsp;`); turn what remains into spaces. */ +function feedText(html: string): string { + return plainText(html).replace(/ /gi, " ").replace(/\s+/g, " ").trim(); +} + +/** Normalize a token into a compact, stable discriminator for update ids. */ +function tokenKey(token: string): string { + return token.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +/** Infer a lifecycle status from prose when no STATUS marker exists. */ +function sniffStatus(text: string): string { + const t = text.toLowerCase(); + if (/\bresolved\b/.test(t)) return "resolved"; + if (/\bmonitoring\b/.test(t)) return "monitoring"; + if (/\bidentified\b/.test(t)) return "identified"; + return "investigating"; +} + +type RawBlock = { token: string; statusWord?: string; body: string }; + +/** + * Parse an entry's (possibly entity-escaped) content into ordered updates. + * + * Each update block is delimited by a `TIMESTAMP` followed by an + * optional `STATUS -` marker and the body text. Update ids are + * `${entryId}:${tokenKey}` so they are stable across polls regardless of the + * feed's entry ordering (newest-first vs oldest-first). + */ +export function parseFeedUpdates(rawContent: string, entryId: string, entryTimeIso: string): IncidentUpdate[] { + const html = decodeEntities(rawContent); + const blocks: RawBlock[] = []; + + const smallRe = /([\s\S]*?)<\/small>([\s\S]*?)(?=|$)/gi; + let m: RegExpExecArray | null; + while ((m = smallRe.exec(html)) !== null) { + const token = plainText(m[1]); + let rest = m[2]; + let statusWord: string | undefined; + const marker = rest.match(/\s*([A-Za-z ]+?)\s*<\/strong>\s*-?/); + if (marker && !marker[1].trim().endsWith(":")) { + statusWord = marker[1].trim(); + rest = rest.replace(marker[0], " "); + } + blocks.push({ token, statusWord, body: feedText(rest) || "No message provided." }); + } + + if (blocks.length === 0) { + const body = feedText(html); + if (!body) return []; + return [{ + id: `${entryId}:0`, + status: sniffStatus(body), + body, + created_at: entryTimeIso, + updated_at: entryTimeIso, + }]; + } + + // Assign timestamps. If any block carries a full date (Statuspage), trust parsed + // times and sort. Otherwise (Slack time-only) keep document order and roll the day + // forward whenever the clock decreases, so an overnight run stays chronological. + const parsed = blocks.map((b) => parseFeedTimestamp(b.token, entryTimeIso)); + const anyDated = parsed.some((p) => p?.hasDate); + + let times: string[]; + if (anyDated) { + times = parsed.map((p) => p?.iso ?? entryTimeIso); + } else { + const base = new Date(entryTimeIso); + const baseValid = !Number.isNaN(base.getTime()); + let dayOffset = 0; + let prevMinutes = -1; + times = blocks.map((b) => { + const t = b.token.match(/(\d{1,2}):(\d{2})\s*([ap]m)?/i); + if (!t || !baseValid) return entryTimeIso; + let hour = Number(t[1]); + const min = Number(t[2]); + const ampm = t[3]?.toLowerCase(); + if (ampm === "pm" && hour < 12) hour += 12; + if (ampm === "am" && hour === 12) hour = 0; + const minutes = hour * 60 + min; + if (prevMinutes >= 0 && minutes < prevMinutes) dayOffset++; + prevMinutes = minutes; + return new Date(Date.UTC( + base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate() + dayOffset, hour, min, 0, + )).toISOString(); + }); + } + + const updates: IncidentUpdate[] = blocks.map((b, i) => ({ + id: `${entryId}:${tokenKey(b.token) || i}`, + status: b.statusWord ? canonicalIncidentStatus(b.statusWord) : sniffStatus(b.body), + body: b.body, + created_at: times[i], + updated_at: times[i], + })); + + if (anyDated) { + updates.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + } + return updates; +} + +function firstMatch(source: string, re: RegExp): string | undefined { + const m = source.match(re); + return m ? m[1] : undefined; +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url.replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + } +} + +/** + * Build a canonical Incident from an entry's parsed parts. Resolved is terminal: + * any resolved update marks the incident resolved, independent of feed ordering. + */ +function assembleIncident( + id: string, + name: string, + shortlink: string | undefined, + entryCreated: string, + entryUpdated: string, + updates: IncidentUpdate[], +): Incident { + const resolved = updates.find((u) => u.status === "resolved"); + const latest = [...updates].sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + )[0]; + const status = resolved ? "resolved" : (latest?.status ?? "investigating"); + return { + id, + name, + status, + impact: "minor", // feeds carry no impact severity + shortlink, + created_at: updates[0]?.created_at ?? entryCreated, + updated_at: latest?.created_at ?? entryUpdated, + resolved_at: resolved ? (resolved.created_at ?? entryUpdated) : null, + incident_updates: updates, + }; +} + +/** Parse a full Atom document into a page descriptor + canonical incidents. */ +export function parseAtomFeed(xml: string, baseUrl: string): { page: Summary["page"]; incidents: Incident[] } { + const head = xml.split(/]/)[0]; + const name = decodeEntities(firstMatch(head, /]*>([\s\S]*?)<\/title>/)?.trim() ?? "Status feed"); + const homeLink = + firstMatch(head, /]*rel=["']alternate["'][^>]*type=["']text\/html["'][^>]*href=["']([^"']+)["']/) ?? + firstMatch(head, /]*type=["']text\/html["'][^>]*href=["']([^"']+)["']/) ?? + baseUrl; + const page = { id: hostOf(homeLink), name, url: homeLink }; + + const entries = [...xml.matchAll(/]([\s\S]*?)<\/entry>/g)].map((m) => m[1]); + const incidents = entries.map((entry) => { + const id = (firstMatch(entry, /([\s\S]*?)<\/id>/)?.trim() ?? "").replace(/\s+/g, ""); + const name = decodeEntities(firstMatch(entry, /]*>([\s\S]*?)<\/title>/)?.trim() ?? "Untitled incident"); + const published = firstMatch(entry, /([\s\S]*?)<\/published>/)?.trim() ?? new Date(0).toISOString(); + const updated = firstMatch(entry, /([\s\S]*?)<\/updated>/)?.trim() ?? published; + const shortlink = firstMatch(entry, /]*rel=["']alternate["'][^>]*href=["']([^"']+)["']/); + const content = + firstMatch(entry, /]*>([\s\S]*?)<\/content>/) ?? + firstMatch(entry, /]*>([\s\S]*?)<\/summary>/) ?? + ""; + const entryId = id || shortlink || name; + const updates = parseFeedUpdates(content, entryId, published); + return assembleIncident(entryId, name, shortlink, published, updated, updates); + }); + + return { page, incidents }; +} + +/** Parse a full RSS document into a page descriptor + canonical incidents. */ +export function parseRssFeed(xml: string, baseUrl: string): { page: Summary["page"]; incidents: Incident[] } { + const channelHead = xml.split(/]/)[0]; + const name = decodeEntities(firstMatch(channelHead, /]*>([\s\S]*?)<\/title>/)?.trim() ?? "Status feed"); + const home = firstMatch(channelHead, /]*>([\s\S]*?)<\/link>/)?.trim() ?? baseUrl; + const page = { id: hostOf(home), name, url: home }; + + const items = [...xml.matchAll(/]([\s\S]*?)<\/item>/g)].map((m) => m[1]); + const incidents = items.map((item) => { + const id = ( + firstMatch(item, /]*>([\s\S]*?)<\/guid>/) ?? + firstMatch(item, /]*>([\s\S]*?)<\/link>/) ?? + "" + ).trim(); + const name = decodeEntities(firstMatch(item, /]*>([\s\S]*?)<\/title>/)?.trim() ?? "Untitled incident"); + const pubRaw = firstMatch(item, /([\s\S]*?)<\/pubDate>/)?.trim(); + const pubDate = pubRaw ? new Date(pubRaw) : null; + const pubIso = pubDate && !Number.isNaN(pubDate.getTime()) ? pubDate.toISOString() : new Date(0).toISOString(); + const shortlink = firstMatch(item, /]*>([\s\S]*?)<\/link>/)?.trim(); + const description = firstMatch(item, /]*>([\s\S]*?)<\/description>/) ?? ""; + const entryId = id || shortlink || name; + const updates = parseFeedUpdates(description, entryId, pubIso); + return assembleIncident(entryId, name, shortlink, pubIso, pubIso, updates); + }); + + return { page, incidents }; +} + +/** + * Synthesize a page-level status. Feeds have no operational indicator and always + * list past (resolved) incidents, so the page is operational unless something is + * still active. Impact is unknown, so active pages report a generic "minor". + */ +export function feedPageStatus(incidents: Incident[]): PageStatus { + const active = incidents.filter((i) => i.status !== "resolved"); + if (active.length === 0) { + return { indicator: "none", description: "All Systems Operational" }; + } + return { indicator: "minor", description: active.length === 1 ? "Active Incident" : "Active Incidents" }; +} + +/** Detect the feed kind and parse accordingly; null when the document is not a feed. */ +export function parseFeed(xml: string, baseUrl: string): { page: Summary["page"]; incidents: Incident[] } | null { + const kind = detectFeedKind(xml); + if (kind === "atom") return parseAtomFeed(xml, baseUrl); + if (kind === "rss") return parseRssFeed(xml, baseUrl); + return null; +} + +async function fetchFeedXml(url: string): Promise { + const response = await fetch(url, { + headers: { Accept: "application/atom+xml, application/rss+xml, application/xml, text/xml" }, + }); + if (!response.ok) { + throw new Error(`Feed request failed (${response.status}) for ${url}`); + } + return response.text(); +} + +export const feed: Provider = { + id: "feed", + displayName: "RSS/Atom feed", + + async probe(baseUrl) { + try { + const xml = await fetchFeedXml(baseUrl); + const parsed = parseFeed(xml, baseUrl); + if (!parsed) return null; + return { page: parsed.page, status: feedPageStatus(parsed.incidents) }; + } catch { + return null; + } + }, + + async fetchSummary(monitor: ProviderMonitor): Promise { + const xml = await fetchFeedXml(monitor.baseUrl); + const parsed = parseFeed(xml, monitor.baseUrl); + if (!parsed) throw new Error(`Could not parse a feed at ${monitor.baseUrl}`); + const active = parsed.incidents.filter((i) => i.status !== "resolved"); + return { page: parsed.page, status: feedPageStatus(parsed.incidents), incidents: active }; + }, + + async fetchIncidents(monitor: ProviderMonitor): Promise { + const xml = await fetchFeedXml(monitor.baseUrl); + const parsed = parseFeed(xml, monitor.baseUrl); + if (!parsed) throw new Error(`Could not parse a feed at ${monitor.baseUrl}`); + return parsed.incidents; + }, +}; From df4a4e4a3b3d2f2fd5826a15a0a8968217e07545 Mon Sep 17 00:00:00 2001 From: Anthony Baldwin <6219998+anthonybaldwin@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:20:22 -0700 Subject: [PATCH 5/6] feat: register feed provider as last-resort probe Add "feed" to ProviderId and the monitor schema, register it last in PROBE_ORDER (matches only real feed documents), and reword the /monitor add failure to point users at a direct Atom/RSS feed URL. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/index.ts | 4 ++-- src/providers/index.test.ts | 37 ++++++++++++++++++++++++++++++++++++- src/providers/index.ts | 8 +++++++- src/providers/types.ts | 2 +- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 020b639..a6acff3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,7 +51,7 @@ const monitorSchema = z.object({ baseUrl: z.string().url(), label: z.string().min(1).optional(), iconUrl: z.string().url().optional(), - provider: z.enum(["statuspage", "incidentio", "instatus"]).optional(), + provider: z.enum(["statuspage", "incidentio", "instatus", "feed"]).optional(), }); const envSchema = z.object({ @@ -1793,7 +1793,7 @@ async function handleMonitorAdd(interaction: ChatInputCommandInteraction, client if (!detected) { const supported = SUPPORTED_PROVIDERS.map((p) => p.displayName).join(", "); throw new Error( - `Could not reach a supported status page at \`${baseUrl}\`. Supported providers: ${supported}. Example URLs: \`https://status.atlassian.com\` (Statuspage), \`https://status.openai.com\` (incident.io).`, + `Could not monitor \`${baseUrl}\`. Auto-detection supports ${supported}. For other status pages, pass a direct **Atom or RSS** feed URL — e.g. \`https://slack-status.com/feed/atom\`.`, ); } const { provider, summary } = detected; diff --git a/src/providers/index.test.ts b/src/providers/index.test.ts index 277958b..144fa20 100644 --- a/src/providers/index.test.ts +++ b/src/providers/index.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach } from "bun:test"; -import { detectProvider } from "./index"; +import { detectProvider, SUPPORTED_PROVIDERS } from "./index"; const realFetch = globalThis.fetch; afterEach(() => { @@ -24,3 +24,38 @@ describe("detectProvider with Instatus", () => { expect(detected?.summary.page.name).toBe("Kagi"); }); }); + +describe("provider registry", () => { + test("feed provider is registered and probed last", () => { + const ids = SUPPORTED_PROVIDERS.map((p) => p.id); + expect(ids).toContain("feed"); + expect(ids[ids.length - 1]).toBe("feed"); + }); +}); + +describe("detectProvider feed fallback", () => { + test("falls through to the feed provider for a direct Atom URL", async () => { + const atom = ` + + Example System Status + + + https://status.example.com/incidents/1 + 2026-06-05T00:00:00Z + 2026-06-05T00:10:00Z + Something broke + <p><small>Jun 5, 00:00 UTC</small><br><strong>Investigating</strong> - Looking into it.</p> + + `; + + // Every endpoint returns the Atom doc: the vendor providers fail to parse it + // (not their JSON/widget shape) and the feed provider matches the URL itself. + globalThis.fetch = (async (_input: string | URL | Request) => + new Response(atom, { status: 200, headers: { "content-type": "application/atom+xml" } })) as typeof fetch; + + const detected = await detectProvider("https://status.example.com/feed/atom"); + expect(detected?.provider.id).toBe("feed"); + expect(detected?.summary.page.name).toBe("Example System Status"); + expect(detected?.summary.page.url).toBe("https://status.example.com"); + }); +}); diff --git a/src/providers/index.ts b/src/providers/index.ts index ec9be2c..cdeeece 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,3 +1,4 @@ +import { feed } from "./feed"; import { incidentio } from "./incidentio"; import { instatus } from "./instatus"; import { statuspage } from "./statuspage"; @@ -7,6 +8,7 @@ const PROVIDERS: Record = { statuspage, incidentio, instatus, + feed, }; /** @@ -18,8 +20,12 @@ const PROVIDERS: Record = { * incident.io first ensures we use the richer native widget API when * available. Statuspage URLs that aren't on incident.io will fail the * `/proxy/` probe cleanly (404) and fall through to statuspage. + * + * `feed` is probed last and only matches when the supplied URL is itself a + * parseable Atom/RSS document, so real provider pages always win first and a + * plain HTML page matches nothing (falling through to the add-command error). */ -const PROBE_ORDER: Provider[] = [incidentio, statuspage, instatus]; +const PROBE_ORDER: Provider[] = [incidentio, statuspage, instatus, feed]; export function getProvider(monitor: ProviderMonitor): Provider { const id = monitor.provider ?? "statuspage"; diff --git a/src/providers/types.ts b/src/providers/types.ts index c6af5bf..b61ffa9 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -5,7 +5,7 @@ * rendering, state, and commands stay provider-agnostic. */ -export type ProviderId = "statuspage" | "incidentio" | "instatus"; +export type ProviderId = "statuspage" | "incidentio" | "instatus" | "feed"; export type PageStatus = { indicator: string; From b90ce20de27189cc45a9440e2be240a3dd5529db Mon Sep 17 00:00:00 2001 From: Anthony Baldwin <6219998+anthonybaldwin@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:22:08 -0700 Subject: [PATCH 6/6] docs: document the generic RSS/Atom feed provider Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +++- README.md | 6 +++--- docs/wiki/API-Integration.md | 25 ++++++++++++++++++++++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a32836..5f37a84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Instructions for AI coding agents working on this project. **All agents MUST rea ## Project Overview -Squawk is a Bun-based Discord bot that polls public status pages (Statuspage.io, incident.io, and Instatus are supported) and posts incident updates as threaded conversations in Discord. It supports multiple monitors, runtime monitor management, and persistent state. +Squawk is a Bun-based Discord bot that polls public status pages (Statuspage.io, incident.io, and Instatus are auto-detected, plus a generic RSS/Atom feed fallback for any other status page that publishes a feed) and posts incident updates as threaded conversations in Discord. It supports multiple monitors, runtime monitor management, and persistent state. The repo was previously named `statuspage-discord`. The legacy `STATUSPAGE_MONITORS_JSON` env var is still honored as a deprecated alias for `MONITORS_JSON`. @@ -25,6 +25,8 @@ src/providers/ # Per-provider API adapters (one file per provider) statuspage.ts # Statuspage.io adapter incidentio.ts # incident.io adapter (uses /proxy/ widget API) instatus.ts # Instatus adapter (v3 JSON API + Atom history feed) + feed.ts # Generic RSS/Atom feed fallback (probed last; user supplies a direct feed URL) + feed-text.ts # Shared XML-text helpers (decodeEntities, plainText, parseFeedTimestamp) data/state.json # Runtime state (git-ignored, auto-created) data/monitors.json # Runtime monitors (git-ignored, auto-created) AGENTS.md # Agent instructions (cross-tool) diff --git a/README.md b/README.md index 5f06606..c441e27 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ A Bun-based Discord bot that: -- polls one or more public status pages (Statuspage.io, incident.io, and Instatus are supported) and groups each incident into its own Discord thread +- polls one or more public status pages (Statuspage.io, incident.io, and Instatus are auto-detected, plus a generic RSS/Atom feed fallback for anything else) and groups each incident into its own Discord thread - answers slash-command status questions with the current page health - supports replay and preview flows so you can test notifications without waiting for a live incident -Supported providers are auto-detected at `/monitor add` time — drop in any public Statuspage.io URL (e.g. `https://status.atlassian.com`), incident.io URL (e.g. `https://status.openai.com`), or Instatus URL (e.g. `https://status.perplexity.com`) and the bot picks the right adapter. +Supported providers are auto-detected at `/monitor add` time — drop in any public Statuspage.io URL (e.g. `https://status.atlassian.com`), incident.io URL (e.g. `https://status.openai.com`), or Instatus URL (e.g. `https://status.perplexity.com`) and the bot picks the right adapter. For a status page on none of these (e.g. Slack's bespoke `slack-status.com`), pass a direct **Atom or RSS** feed URL — e.g. `https://slack-status.com/feed/atom` — and the generic feed provider handles it. Atom is preferred where available (it carries the full update history); impact severity isn't available from feeds.

Squawk @@ -46,7 +46,7 @@ Full docs live in the [wiki](https://github.com/anthonybaldwin/squawk/wiki): ## Notes -- The bot uses public APIs only — Statuspage.io's v2 API (`/api/v2/...`), incident.io's widget proxy (`/proxy/`), or Instatus's v3 JSON API + Atom history feed (`/v3/summary.json`, `/history.atom`) — so a public page URL is all you need. +- The bot uses public APIs only — Statuspage.io's v2 API (`/api/v2/...`), incident.io's widget proxy (`/proxy/`), or Instatus's v3 JSON API + Atom history feed (`/v3/summary.json`, `/history.atom`) — so a public page URL is all you need. For unsupported pages, the generic feed provider reads a direct Atom or RSS feed URL you supply. - For development, setting `DISCORD_GUILD_ID` makes slash-command registration update faster than global commands. - On first startup, the bot seeds current incident-update IDs without posting them unless `POST_EXISTING_UPDATES_ON_START=true`. - The bot needs Send Messages, Embed Links, Create Public Threads, and Manage Messages permissions. diff --git a/docs/wiki/API-Integration.md b/docs/wiki/API-Integration.md index beee0b9..5188ee7 100644 --- a/docs/wiki/API-Integration.md +++ b/docs/wiki/API-Integration.md @@ -9,6 +9,7 @@ The bot supports multiple status page providers. Each provider lives in its own | Statuspage.io (Atlassian) | `statuspage` | `https://status.atlassian.com` | Public v2 API, no key required | | incident.io | `incidentio` | `https://status.openai.com` | Public widget proxy, no key required | | Instatus | `instatus` | `https://status.perplexity.com` | Public v3 JSON API + Atom history feed, no key required | +| RSS/Atom feed (fallback) | `feed` | `https://slack-status.com/feed/atom` | Any Atom or RSS feed; user supplies a direct feed URL | No API key is required for any supported provider — all endpoints are public. @@ -112,7 +113,29 @@ Instatus exposes a documented keyless JSON API plus a standard Atom history feed ### Probe order -Instatus is probed last (`PROBE_ORDER` is `[incidentio, statuspage, instatus]`). Its `/v3/summary.json` path does not collide with incident.io's `/proxy/` or Statuspage's `/api/v2/summary.json`, and the probe additionally rejects Statuspage-shaped summaries (which carry `page.id` and a top-level `status` object). +Instatus is probed before the generic feed fallback (`PROBE_ORDER` is `[incidentio, statuspage, instatus, feed]`). Its `/v3/summary.json` path does not collide with incident.io's `/proxy/` or Statuspage's `/api/v2/summary.json`, and the probe additionally rejects Statuspage-shaped summaries (which carry `page.id` and a top-level `status` object). + +## RSS/Atom Feed Adapter (fallback) + +The `feed` provider is the last-resort adapter for status pages on none of the vendors above (e.g. Slack's bespoke `slack-status.com`). It is **not** auto-discovered from a page's HTML — the user passes a direct Atom or RSS feed URL to `/monitor add`, and that URL becomes the monitor's `baseUrl`. The provider fetches that single URL on every poll. + +| Endpoint | Used By | Purpose | +|----------|---------|---------| +| `` (the feed URL itself) | `probe()`, `fetchSummary()`, `fetchIncidents()` | The Atom/RSS document — page metadata + incident entries with their update history | + +### Normalization details + +- **Format detection** is by document content: a `` root → Atom, an ``/`` → RSS. `probe()` returns `null` for anything that isn't a feed (so a plain HTML page falls through to the add-command error). +- **Page name/url** come from the feed itself: Atom `` + `<link rel="alternate" type="text/html">`, or RSS `<channel><title>`/`<link>`. `page.id` is the host of that URL. +- **One entry = one incident**, keyed by the Atom `<id>` / RSS `<guid>` (falls back to the entry link). Updates accumulate inside the entry over time, so this maps onto the existing dedup-by-id polling model. +- **Update bodies** are parsed from the entry `<content>` (Atom) / `<description>` (RSS). Each `<small>timestamp</small> … body` block becomes an `IncidentUpdate` with id `<entryId>:<tokenKey>` (a normalized form of the timestamp text, stable regardless of newest-first vs oldest-first ordering). Feeds with no update blocks (e.g. Slack RSS, which carries only a summary) yield a single update. +- **Status** comes from a `<strong>Status</strong>` marker when present (Statuspage-family feeds) via the shared incident-status mapper; otherwise it is sniffed from the prose (`resolved`/`monitoring`/`identified`, default `investigating`). **Resolved is terminal:** any resolved update marks the incident resolved, independent of feed ordering. +- **Update timestamps** handle two `<small>` forms — Statuspage's `Mon D, HH:MM TZ` (date known) and Slack's time-only `H:MMpm TZ` (anchored to the entry's published date, with forward day-rollover across an overnight run). Timezone abbreviations are treated as UTC, so times are approximate. Unparseable tokens fall back to the entry time. +- **Impact** is always `minor` — feeds carry no impact severity. Page status is synthesized: operational when no incident is active, otherwise a generic non-operational indicator. + +### Probe order + +`feed` is probed last (`PROBE_ORDER` is `[incidentio, statuspage, instatus, feed]`). It only matches when the supplied URL is itself a parseable Atom/RSS document, so the vendor providers always win first for a normal status-page URL. ## Favicon Fetching