statusdoc 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrea Acanfora
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: statusdoc
3
+ Version: 0.1.0
4
+ Summary: Open Status Document: a spec and tools to discover, read and validate service status pages
5
+ Author: Andrea Acanfora
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/AndreaAcanfora/statusdoc
8
+ Project-URL: Specification, https://github.com/AndreaAcanfora/statusdoc/blob/master/SPEC.md
9
+ Keywords: status page,statuspage,incidents,monitoring,sre,uptime
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: System :: Monitoring
12
+ Classifier: Environment :: Console
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # statusdoc: Open Status Document
19
+
20
+ [![CI](https://github.com/AndreaAcanfora/statusdoc/actions/workflows/ci.yml/badge.svg)](https://github.com/AndreaAcanfora/statusdoc/actions/workflows/ci.yml)
21
+
22
+ A specification and a Python tool to **find, read and validate the status
23
+ pages of the services you depend on**, starting from nothing but their
24
+ domain.
25
+
26
+ ![statusdoc check: GitHub, Anthropic, Stripe, Slack and Vercel operational; Cloudflare with a partial outage and its incidents](docs/demo.png)
27
+
28
+ - **Specification:** [SPEC.md](SPEC.md), Open Status Document (OSD) 0.1, draft.
29
+ - **Compatible with what is deployed:** OSD specifies the `summary.json`
30
+ shape that Atlassian Statuspage made common and other providers mirror, so
31
+ thousands of status pages already serve conforming documents.
32
+ - **Adds discovery:** `https://{domain}/.well-known/status` tells programs
33
+ where a service's status document lives.
34
+ - **Zero dependencies**, Python 3.10+.
35
+
36
+ ## Why
37
+
38
+ Status pages are for people. Programs need the same information: a CI
39
+ pipeline that should hold a deploy while the cloud provider has an outage, a
40
+ dashboard of upstream dependencies, a support bot that explains an error.
41
+ Today that means knowing that GitHub's status lives on `githubstatus.com`
42
+ and Stripe's on `stripestatus.com`, and writing a parser for every format.
43
+ Commercial aggregators maintain thousands of such scrapers.
44
+
45
+ OSD turns the de facto format into a written spec, and lets each service
46
+ announce its document on its own domain.
47
+
48
+ ## Install
49
+
50
+ ```sh
51
+ pip install git+https://github.com/AndreaAcanfora/statusdoc
52
+ ```
53
+
54
+ ## Command line
55
+
56
+ ```sh
57
+ statusdoc check github.com npmjs.com # exit 1 if any is degraded
58
+ statusdoc check --fail-on major vercel.com # only major and critical fail
59
+ statusdoc check --json -v github.com # machine-readable, with components
60
+ statusdoc discover fly.io # where is the status document?
61
+ statusdoc validate status.example.com # is it conforming?
62
+ statusdoc convert status.betterstack.com # any supported format → OSD JSON
63
+ ```
64
+
65
+ Targets are domains (`github.com`), status page URLs or document URLs.
66
+ Exit codes of `check`: `0` all fine, `1` at least one service at or above
67
+ `--fail-on` (default `minor`), `3` a service could not be reached.
68
+
69
+ ### GitHub Action: hold a deploy during an upstream outage
70
+
71
+ ```yaml
72
+ - uses: AndreaAcanfora/statusdoc@v0
73
+ with:
74
+ services: github.com npmjs.com vercel.com
75
+ fail-on: major # minor, major, critical or never
76
+ ```
77
+
78
+ The step fails when a service is at or above `fail-on`, adds a table to the
79
+ job summary and annotations to the run, and sets the outputs `worst-level`,
80
+ `degraded`, `unreachable` and `report` (JSON) for later steps. A status page
81
+ that cannot be read only produces a warning, unless
82
+ `fail-on-unreachable: true`. It runs on Linux, macOS and Windows runners with
83
+ any Python 3.10+, and installs nothing.
84
+
85
+ Or from any CI:
86
+
87
+ ```sh
88
+ pip install git+https://github.com/AndreaAcanfora/statusdoc
89
+ statusdoc check --fail-on major github.com npmjs.com vercel.com
90
+ ```
91
+
92
+ ## How documents are found
93
+
94
+ 1. `https://{domain}/.well-known/status`, the discovery URI defined by OSD.
95
+ 2. A list of known services bundled with the tool (60 services, each verified
96
+ by hand), checked weekly by CI.
97
+ 3. Conventions: `status.{domain}`, `{name}status.com`, `www.{name}status.com`,
98
+ at `/api/v2/summary.json` (Statuspage shape) or `/index.json` (Better Stack).
99
+
100
+ Better Stack pages and the Slack Status API are converted to OSD on the fly.
101
+ Other formats can be added as adapters.
102
+
103
+ ## Library
104
+
105
+ ```python
106
+ from statusdoc import discover, validate_document
107
+
108
+ found = discover("github.com")
109
+ doc = found.document
110
+ print(doc.name, doc.level) # GitHub operational
111
+ for incident in doc.open_incidents:
112
+ print(incident.name, incident.impact, incident.shortlink)
113
+ for c in doc.components:
114
+ if c.status != "operational":
115
+ print(c.name, c.status)
116
+
117
+ issues = validate_document(found.response.json())
118
+ ```
119
+
120
+ `doc.level` is one of `operational`, `maintenance`, `minor`, `major`,
121
+ `critical`: the publisher's indicator, raised by open incidents and active
122
+ maintenance.
123
+
124
+ ## Publishing a conforming status page
125
+
126
+ If your status page runs on Statuspage or a compatible provider, it most
127
+ likely conforms already. Two small additions complete it:
128
+
129
+ 1. **Announce it on your own domain.** Serve this at
130
+ `https://example.com/.well-known/status`, with
131
+ `Content-Type: application/json` and `Access-Control-Allow-Origin: *`:
132
+
133
+ ```json
134
+ {
135
+ "osd_version": "0.1",
136
+ "document": "https://status.example.com/api/v2/summary.json",
137
+ "page": "https://status.example.com"
138
+ }
139
+ ```
140
+
141
+ With nginx:
142
+
143
+ ```nginx
144
+ location = /.well-known/status {
145
+ default_type application/json;
146
+ add_header Access-Control-Allow-Origin *;
147
+ return 200 '{"osd_version":"0.1","document":"https://status.example.com/api/v2/summary.json","page":"https://status.example.com"}';
148
+ }
149
+ ```
150
+
151
+ 2. **Run the validator:** `statusdoc validate example.com`.
152
+
153
+ If you build your own status page, `Document.to_dict()` produces a
154
+ conforming document, and SPEC.md lists every field.
155
+
156
+ ## Status
157
+
158
+ The spec is a 0.1 draft. The most useful contributions right now are
159
+ feedback on the spec, new verified entries for the service list and adapters
160
+ for other status page providers.
161
+
162
+ ## Related work
163
+
164
+ - Atlassian Statuspage's public API, whose `summary.json` shape OSD specifies.
165
+ - The expired IETF draft *Service Status Resource Format for Web Services*,
166
+ which proposed a new format instead.
167
+ - Aggregators such as StatusGator (commercial) and Statusphere (open source),
168
+ which scrape status pages with one parser per provider.
169
+
170
+ ## License
171
+
172
+ MIT
@@ -0,0 +1,155 @@
1
+ # statusdoc: Open Status Document
2
+
3
+ [![CI](https://github.com/AndreaAcanfora/statusdoc/actions/workflows/ci.yml/badge.svg)](https://github.com/AndreaAcanfora/statusdoc/actions/workflows/ci.yml)
4
+
5
+ A specification and a Python tool to **find, read and validate the status
6
+ pages of the services you depend on**, starting from nothing but their
7
+ domain.
8
+
9
+ ![statusdoc check: GitHub, Anthropic, Stripe, Slack and Vercel operational; Cloudflare with a partial outage and its incidents](docs/demo.png)
10
+
11
+ - **Specification:** [SPEC.md](SPEC.md), Open Status Document (OSD) 0.1, draft.
12
+ - **Compatible with what is deployed:** OSD specifies the `summary.json`
13
+ shape that Atlassian Statuspage made common and other providers mirror, so
14
+ thousands of status pages already serve conforming documents.
15
+ - **Adds discovery:** `https://{domain}/.well-known/status` tells programs
16
+ where a service's status document lives.
17
+ - **Zero dependencies**, Python 3.10+.
18
+
19
+ ## Why
20
+
21
+ Status pages are for people. Programs need the same information: a CI
22
+ pipeline that should hold a deploy while the cloud provider has an outage, a
23
+ dashboard of upstream dependencies, a support bot that explains an error.
24
+ Today that means knowing that GitHub's status lives on `githubstatus.com`
25
+ and Stripe's on `stripestatus.com`, and writing a parser for every format.
26
+ Commercial aggregators maintain thousands of such scrapers.
27
+
28
+ OSD turns the de facto format into a written spec, and lets each service
29
+ announce its document on its own domain.
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ pip install git+https://github.com/AndreaAcanfora/statusdoc
35
+ ```
36
+
37
+ ## Command line
38
+
39
+ ```sh
40
+ statusdoc check github.com npmjs.com # exit 1 if any is degraded
41
+ statusdoc check --fail-on major vercel.com # only major and critical fail
42
+ statusdoc check --json -v github.com # machine-readable, with components
43
+ statusdoc discover fly.io # where is the status document?
44
+ statusdoc validate status.example.com # is it conforming?
45
+ statusdoc convert status.betterstack.com # any supported format → OSD JSON
46
+ ```
47
+
48
+ Targets are domains (`github.com`), status page URLs or document URLs.
49
+ Exit codes of `check`: `0` all fine, `1` at least one service at or above
50
+ `--fail-on` (default `minor`), `3` a service could not be reached.
51
+
52
+ ### GitHub Action: hold a deploy during an upstream outage
53
+
54
+ ```yaml
55
+ - uses: AndreaAcanfora/statusdoc@v0
56
+ with:
57
+ services: github.com npmjs.com vercel.com
58
+ fail-on: major # minor, major, critical or never
59
+ ```
60
+
61
+ The step fails when a service is at or above `fail-on`, adds a table to the
62
+ job summary and annotations to the run, and sets the outputs `worst-level`,
63
+ `degraded`, `unreachable` and `report` (JSON) for later steps. A status page
64
+ that cannot be read only produces a warning, unless
65
+ `fail-on-unreachable: true`. It runs on Linux, macOS and Windows runners with
66
+ any Python 3.10+, and installs nothing.
67
+
68
+ Or from any CI:
69
+
70
+ ```sh
71
+ pip install git+https://github.com/AndreaAcanfora/statusdoc
72
+ statusdoc check --fail-on major github.com npmjs.com vercel.com
73
+ ```
74
+
75
+ ## How documents are found
76
+
77
+ 1. `https://{domain}/.well-known/status`, the discovery URI defined by OSD.
78
+ 2. A list of known services bundled with the tool (60 services, each verified
79
+ by hand), checked weekly by CI.
80
+ 3. Conventions: `status.{domain}`, `{name}status.com`, `www.{name}status.com`,
81
+ at `/api/v2/summary.json` (Statuspage shape) or `/index.json` (Better Stack).
82
+
83
+ Better Stack pages and the Slack Status API are converted to OSD on the fly.
84
+ Other formats can be added as adapters.
85
+
86
+ ## Library
87
+
88
+ ```python
89
+ from statusdoc import discover, validate_document
90
+
91
+ found = discover("github.com")
92
+ doc = found.document
93
+ print(doc.name, doc.level) # GitHub operational
94
+ for incident in doc.open_incidents:
95
+ print(incident.name, incident.impact, incident.shortlink)
96
+ for c in doc.components:
97
+ if c.status != "operational":
98
+ print(c.name, c.status)
99
+
100
+ issues = validate_document(found.response.json())
101
+ ```
102
+
103
+ `doc.level` is one of `operational`, `maintenance`, `minor`, `major`,
104
+ `critical`: the publisher's indicator, raised by open incidents and active
105
+ maintenance.
106
+
107
+ ## Publishing a conforming status page
108
+
109
+ If your status page runs on Statuspage or a compatible provider, it most
110
+ likely conforms already. Two small additions complete it:
111
+
112
+ 1. **Announce it on your own domain.** Serve this at
113
+ `https://example.com/.well-known/status`, with
114
+ `Content-Type: application/json` and `Access-Control-Allow-Origin: *`:
115
+
116
+ ```json
117
+ {
118
+ "osd_version": "0.1",
119
+ "document": "https://status.example.com/api/v2/summary.json",
120
+ "page": "https://status.example.com"
121
+ }
122
+ ```
123
+
124
+ With nginx:
125
+
126
+ ```nginx
127
+ location = /.well-known/status {
128
+ default_type application/json;
129
+ add_header Access-Control-Allow-Origin *;
130
+ return 200 '{"osd_version":"0.1","document":"https://status.example.com/api/v2/summary.json","page":"https://status.example.com"}';
131
+ }
132
+ ```
133
+
134
+ 2. **Run the validator:** `statusdoc validate example.com`.
135
+
136
+ If you build your own status page, `Document.to_dict()` produces a
137
+ conforming document, and SPEC.md lists every field.
138
+
139
+ ## Status
140
+
141
+ The spec is a 0.1 draft. The most useful contributions right now are
142
+ feedback on the spec, new verified entries for the service list and adapters
143
+ for other status page providers.
144
+
145
+ ## Related work
146
+
147
+ - Atlassian Statuspage's public API, whose `summary.json` shape OSD specifies.
148
+ - The expired IETF draft *Service Status Resource Format for Web Services*,
149
+ which proposed a new format instead.
150
+ - Aggregators such as StatusGator (commercial) and Statusphere (open source),
151
+ which scrape status pages with one parser per provider.
152
+
153
+ ## License
154
+
155
+ MIT
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "statusdoc"
7
+ version = "0.1.0"
8
+ description = "Open Status Document: a spec and tools to discover, read and validate service status pages"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{ name = "Andrea Acanfora" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = []
14
+ keywords = ["status page", "statuspage", "incidents", "monitoring", "sre", "uptime"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: System :: Monitoring",
18
+ "Environment :: Console",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/AndreaAcanfora/statusdoc"
23
+ Specification = "https://github.com/AndreaAcanfora/statusdoc/blob/master/SPEC.md"
24
+
25
+ [project.scripts]
26
+ statusdoc = "statusdoc.cli:main"
27
+
28
+ [tool.setuptools]
29
+ packages = ["statusdoc"]
30
+
31
+ [tool.setuptools.package-data]
32
+ statusdoc = ["registry.json", "py.typed"]
33
+
34
+ [tool.ruff]
35
+ line-length = 160
36
+ target-version = "py310"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ """Open Status Document: read, discover and validate service status pages."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .adapters import detect, from_betterstack, load
6
+ from .discover import Discovery, DiscoveryError, discover
7
+ from .model import Component, Document, FormatError, Incident, Update, parse
8
+ from .validate import Issue, validate_document, validate_http
9
+
10
+ __all__ = [
11
+ "Component",
12
+ "Discovery",
13
+ "DiscoveryError",
14
+ "Document",
15
+ "FormatError",
16
+ "Incident",
17
+ "Issue",
18
+ "Update",
19
+ "detect",
20
+ "discover",
21
+ "from_betterstack",
22
+ "load",
23
+ "parse",
24
+ "validate_document",
25
+ "validate_http",
26
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,237 @@
1
+ """Readers for status formats that are not OSD yet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .model import Component, Document, FormatError, Incident, Update, parse
8
+
9
+ # Better Stack resource states → OSD component statuses.
10
+ _BETTERSTACK_STATUS = {
11
+ "operational": "operational",
12
+ "maintenance": "under_maintenance",
13
+ "degraded": "degraded_performance",
14
+ "downtime": "major_outage",
15
+ }
16
+ _BETTERSTACK_SEVERITY = {"operational": 0, "under_maintenance": 1, "degraded_performance": 2, "partial_outage": 3, "major_outage": 4}
17
+ _IMPACT_OF = {"degraded_performance": "minor", "partial_outage": "major", "major_outage": "critical"}
18
+ _INDICATOR_OF = {0: "none", 1: "maintenance", 2: "minor", 3: "major", 4: "critical"}
19
+ _DESCRIPTION_OF = {
20
+ 0: "All Systems Operational",
21
+ 1: "Service Under Maintenance",
22
+ 2: "Partially Degraded Service",
23
+ 3: "Partial System Outage",
24
+ 4: "Major System Outage",
25
+ }
26
+
27
+
28
+ def detect(data: Any) -> str | None:
29
+ """Returns the format of a parsed JSON document, or None if unknown."""
30
+ if not isinstance(data, dict):
31
+ return None
32
+ if isinstance(data.get("page"), dict) and isinstance(data.get("status"), dict) and isinstance(data.get("components"), list):
33
+ return "osd"
34
+ d = data.get("data")
35
+ if isinstance(d, dict) and d.get("type") == "status_page" and isinstance(data.get("included"), list):
36
+ return "betterstack"
37
+ if isinstance(data.get("status"), str) and isinstance(data.get("active_incidents"), list):
38
+ return "slack"
39
+ return None
40
+
41
+
42
+ def load(data: Any) -> Document:
43
+ """Reads a status document in any supported format."""
44
+ fmt = detect(data)
45
+ if fmt == "osd":
46
+ return parse(data)
47
+ if fmt == "betterstack":
48
+ return from_betterstack(data)
49
+ if fmt == "slack":
50
+ return from_slack(data)
51
+ raise FormatError("not a status document in a known format")
52
+
53
+
54
+ def from_betterstack(data: dict[str, Any]) -> Document:
55
+ """Converts a Better Stack status page (`/index.json`, JSON:API) to OSD."""
56
+ page = data["data"].get("attributes", {})
57
+ included = [x for x in data["included"] if isinstance(x, dict)]
58
+
59
+ def of_type(t: str) -> list[dict[str, Any]]:
60
+ return [x for x in included if x.get("type") == t]
61
+
62
+ components: dict[str, Component] = {}
63
+ sections = {str(s.get("id")): s.get("attributes", {}) for s in of_type("status_page_section")}
64
+ for s_id, s in sections.items():
65
+ components["section-" + s_id] = Component(
66
+ id="section-" + s_id, name=str(s.get("name", "")).strip(), status="operational", group=True, position=s.get("position")
67
+ )
68
+ for r in of_type("status_page_resource"):
69
+ a = r.get("attributes", {})
70
+ if a.get("status") == "not_monitored":
71
+ # No data, so no claim either way: leave it out.
72
+ continue
73
+ section = a.get("status_page_section_id")
74
+ c = Component(
75
+ id=str(r.get("id")),
76
+ name=str(a.get("public_name", "")),
77
+ status=_BETTERSTACK_STATUS.get(str(a.get("status")), "degraded_performance"),
78
+ description=a.get("explanation") or None,
79
+ group_id=f"section-{section}" if section is not None and str(section) in sections else None,
80
+ position=a.get("position"),
81
+ )
82
+ components[c.id] = c
83
+ # A section is as bad as its worst resource.
84
+ for c in components.values():
85
+ if c.group_id:
86
+ group = components[c.group_id]
87
+ if _BETTERSTACK_SEVERITY[c.status] > _BETTERSTACK_SEVERITY[group.status]:
88
+ group.status = c.status
89
+
90
+ updates = {str(u.get("id")): u.get("attributes", {}) for u in of_type("status_update")}
91
+ incidents: list[Incident] = []
92
+ maintenances: list[Incident] = []
93
+ for report in of_type("status_report"):
94
+ a = report.get("attributes", {})
95
+ resolved = a.get("aggregate_state") == "resolved" or a.get("ends_at") is not None
96
+ affected = [
97
+ components[str(x.get("status_page_resource_id"))] for x in a.get("affected_resources", []) if str(x.get("status_page_resource_id")) in components
98
+ ]
99
+ worst = max((_BETTERSTACK_SEVERITY[c.status] for c in affected), default=0)
100
+ rel = report.get("relationships", {}).get("status_updates", {}).get("data", [])
101
+ report_updates = [updates[str(x.get("id"))] for x in rel if str(x.get("id")) in updates]
102
+ report_updates.sort(key=lambda u: str(u.get("published_at", "")), reverse=True)
103
+ is_maintenance = a.get("report_type") == "maintenance"
104
+ item = Incident(
105
+ id=str(report.get("id")),
106
+ name=str(a.get("title", "")),
107
+ status=("completed" if resolved else "in_progress") if is_maintenance else ("resolved" if resolved else "investigating"),
108
+ impact="none" if is_maintenance else _IMPACT_OF.get(_status_of(worst), "minor"),
109
+ created_at=str(a.get("starts_at", "")),
110
+ updates=[
111
+ Update(id=str(i), status="resolved" if resolved else "investigating", body=str(u.get("message", "")), created_at=str(u.get("published_at", "")))
112
+ for i, u in enumerate(report_updates)
113
+ ],
114
+ components=affected,
115
+ scheduled_for=a.get("starts_at") if is_maintenance else None,
116
+ scheduled_until=a.get("ends_at") if is_maintenance else None,
117
+ )
118
+ if is_maintenance:
119
+ if not resolved:
120
+ maintenances.append(item)
121
+ elif not resolved:
122
+ incidents.append(item)
123
+
124
+ # The page's own aggregate state is the publisher's judgement (spec section 4.3);
125
+ # fall back to the worst resource when it is missing.
126
+ aggregate = _BETTERSTACK_STATUS.get(str(page.get("aggregate_state")))
127
+ worst = _BETTERSTACK_SEVERITY[aggregate] if aggregate else max((_BETTERSTACK_SEVERITY[c.status] for c in components.values() if not c.group), default=0)
128
+ domain = page.get("custom_domain") or f"{page.get('subdomain', '')}.betteruptime.com"
129
+ return Document(
130
+ name=str(page.get("company_name", "")),
131
+ url=f"https://{domain}",
132
+ updated_at=str(page.get("updated_at", "")),
133
+ indicator=_INDICATOR_OF[worst],
134
+ description=_DESCRIPTION_OF[worst],
135
+ components=sorted(components.values(), key=lambda c: (c.group_id or c.id, not c.group, c.position or 0)),
136
+ incidents=incidents,
137
+ maintenances=maintenances,
138
+ source_format="betterstack",
139
+ )
140
+
141
+
142
+ def _status_of(severity: int) -> str:
143
+ for status, s in _BETTERSTACK_SEVERITY.items():
144
+ if s == severity:
145
+ return status
146
+ return "operational"
147
+
148
+
149
+ # Services listed by the Slack Status API documentation for /api/v2.0.0/current.
150
+ SLACK_SERVICES = (
151
+ "Login/SSO",
152
+ "Messaging",
153
+ "Notifications",
154
+ "Search",
155
+ "Workspace/Org Administration",
156
+ "Canvases",
157
+ "Connectivity",
158
+ "Files",
159
+ "Huddles",
160
+ "Apps/Integrations/APIs",
161
+ "Workflows",
162
+ )
163
+ # Slack issue types → (OSD incident impact, status of the affected services).
164
+ _SLACK_TYPES = {"outage": ("major", "major_outage"), "incident": ("minor", "degraded_performance"), "notice": ("none", "operational")}
165
+
166
+
167
+ def from_slack(data: dict[str, Any]) -> Document:
168
+ """Converts the Slack Status API (`https://slack-status.com/api/v2.0.0/current`) to OSD.
169
+
170
+ Slack reports issues, not component states: affected services take the
171
+ status implied by the issue type (an outage makes them major_outage, an
172
+ incident degraded_performance, a notice leaves them operational).
173
+ """
174
+ services: dict[str, Component] = {}
175
+
176
+ def service(name: str) -> Component:
177
+ if name not in services:
178
+ slug = "".join(ch if ch.isalnum() else "-" for ch in name.lower()).strip("-")
179
+ services[name] = Component(id=slug, name=name, status="operational", position=len(services))
180
+ return services[name]
181
+
182
+ for name in SLACK_SERVICES:
183
+ service(name)
184
+
185
+ incidents: list[Incident] = []
186
+ maintenances: list[Incident] = []
187
+ worst = 0
188
+ for item in data.get("active_incidents", []):
189
+ if not isinstance(item, dict):
190
+ continue
191
+ status = str(item.get("status", ""))
192
+ kind = str(item.get("type", ""))
193
+ impact, service_status = _SLACK_TYPES.get(kind, ("minor", "degraded_performance"))
194
+ notes = [n for n in item.get("notes", []) if isinstance(n, dict)]
195
+ notes.sort(key=lambda n: str(n.get("date_created", "")), reverse=True)
196
+ affected = [service(str(s)) for s in item.get("services", []) if isinstance(s, str)]
197
+ entry = Incident(
198
+ id=str(item.get("id", "")),
199
+ name=str(item.get("title", "")),
200
+ status="",
201
+ impact=impact,
202
+ created_at=str(item.get("date_created", "")),
203
+ updated_at=item.get("date_updated"),
204
+ shortlink=item.get("url"),
205
+ updates=[
206
+ Update(id=f"{item.get('id', '')}-{n}", status="", body=str(note.get("body", "")), created_at=str(note.get("date_created", "")))
207
+ for n, note in enumerate(notes)
208
+ ],
209
+ )
210
+ if status in ("scheduled", "completed", "cancelled"):
211
+ if status == "scheduled":
212
+ entry.status, entry.impact = "scheduled", "none"
213
+ entry.scheduled_for = entry.created_at
214
+ maintenances.append(entry)
215
+ continue
216
+ entry.status = "resolved" if status == "resolved" else "investigating"
217
+ for u in entry.updates:
218
+ u.status = entry.status
219
+ if not entry.resolved:
220
+ for c in affected:
221
+ if _BETTERSTACK_SEVERITY[service_status] > _BETTERSTACK_SEVERITY[c.status]:
222
+ c.status = service_status
223
+ worst = max(worst, {"none": 0, "minor": 2, "major": 3}[impact])
224
+ entry.components = [Component(id=c.id, name=c.name, status=c.status) for c in affected]
225
+ incidents.append(entry)
226
+
227
+ return Document(
228
+ name="Slack",
229
+ url="https://slack-status.com",
230
+ updated_at=str(data.get("date_updated", "")),
231
+ indicator=_INDICATOR_OF[worst],
232
+ description=_DESCRIPTION_OF[worst],
233
+ components=list(services.values()),
234
+ incidents=incidents,
235
+ maintenances=maintenances,
236
+ source_format="slack",
237
+ )