ginkgo-slack 0.4.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,58 @@
1
+ # Local environment and tool caches
2
+ .pixi/*
3
+ !.pixi/config.toml
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ ui/node_modules/
7
+ ui/.vite/
8
+ __pycache__/
9
+ *.py[cod]
10
+
11
+ *__pycache__*
12
+ # Ginkgo runtime state
13
+ .ginkgo/
14
+ examples/**/logs/
15
+ examples/**/results/
16
+ benchmarks/results/
17
+
18
+ # Agent / local-instructions files
19
+ AGENTS.md
20
+ # ...but not the one `ginkgo init` scaffolds into new projects.
21
+ !src/ginkgo/templates/init/agents/AGENTS.md
22
+ CLAUDE.md
23
+ .claude/
24
+ .agents/
25
+
26
+ # uncoded symbol index (regenerated locally via `uncoded sync`)
27
+ .uncoded/
28
+
29
+ # Local planning / design / worklog docs
30
+ plan.md
31
+ *plan.local.md
32
+ worklog.md
33
+ *worklog*.md
34
+ ginkgo-design-doc.md
35
+ example-workflow.py
36
+ docs/_build/
37
+ docs/site/node_modules/
38
+ docs/site/dist/
39
+ docs/site/.astro/
40
+
41
+ # OS/editor noise
42
+ .DS_Store
43
+
44
+ *.lock
45
+ # Env lockfiles that are part of a fixture or example are inputs, not build
46
+ # output: an example whose lock is missing re-solves on first use, which
47
+ # changes its env identity between runs and silently breaks cache reuse.
48
+ !tests/**/pixi.lock
49
+ !plugins/**/pixi.lock
50
+ !examples/**/pixi.lock
51
+
52
+ # Maintainer working documents (CLAUDE.md: plans are not committed)
53
+ docs/*-plan.md
54
+ docs/user-testing-*.md
55
+ docs/oke-validation.md
56
+ docs/*.pptx
57
+ scratchpad/
58
+ .vscode/
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.5
2
+ Name: ginkgo-slack
3
+ Version: 0.4.0
4
+ Summary: Slack run notifications for ginkgo
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: ginkgo-workflow==0.4.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # ginkgo-slack
11
+
12
+ Slack run notifications for ginkgo.
13
+
14
+ A plugin for [Ginkgo](https://sanjaynagi.github.io/ginkgo/). Install it with `pip install "ginkgo-workflow[slack]"`, and see the [plugins guide](https://sanjaynagi.github.io/ginkgo/getting-started/installation/#plugins).
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "ginkgo-slack"
3
+ version = "0.4.0"
4
+ description = "Slack run notifications for ginkgo"
5
+ requires-python = ">= 3.11"
6
+ readme = { text = "# ginkgo-slack\n\nSlack run notifications for ginkgo.\n\nA plugin for [Ginkgo](https://sanjaynagi.github.io/ginkgo/). Install it with `pip install \"ginkgo-workflow[slack]\"`, and see the [plugins guide](https://sanjaynagi.github.io/ginkgo/getting-started/installation/#plugins).", content-type = "text/markdown" }
7
+ license = "Apache-2.0"
8
+ dependencies = [
9
+ "ginkgo-workflow==0.4.0",
10
+ ]
11
+
12
+ [project.entry-points."ginkgo.plugins"]
13
+ slack = "ginkgo_slack:PLUGIN"
14
+
15
+ [build-system]
16
+ build-backend = "hatchling.build"
17
+ requires = ["hatchling"]
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/ginkgo_slack"]
@@ -0,0 +1,15 @@
1
+ """ginkgo-slack: posts run notifications to a Slack incoming webhook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ginkgo.plugins.api import EventSubscriberType, Plugin
6
+ from ginkgo_slack.subscriber import build_slack_subscriber, check_slack_config
7
+
8
+ __all__ = ["PLUGIN"]
9
+
10
+ PLUGIN = Plugin(
11
+ name="ginkgo-slack",
12
+ contributions=(
13
+ EventSubscriberType(name="slack", build=build_slack_subscriber, check=check_slack_config),
14
+ ),
15
+ )
@@ -0,0 +1,434 @@
1
+ """Slack run notifications: configuration and the delivery subscriber."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from concurrent.futures import Future, ThreadPoolExecutor
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from ginkgo.core.secret import SecretRef
12
+ from ginkgo.plugins.api import (
13
+ EventSubscriberCheckContext,
14
+ EventSubscriberConfigProblem,
15
+ EventSubscriberContext,
16
+ )
17
+ from ginkgo.plugins.registry import PluginNotFoundError
18
+ from ginkgo.runtime.events import GinkgoEvent, RunCompleted, RunStarted, TaskFailed
19
+ from ginkgo_slack.webhook import (
20
+ SlackDeliveryError,
21
+ SlackTaskFailure,
22
+ SlackUnreachableError,
23
+ build_retry_exhausted_payload,
24
+ build_run_failed_payload,
25
+ build_run_started_payload,
26
+ build_run_succeeded_payload,
27
+ post_slack_message,
28
+ )
29
+ from ginkgo.runtime.rundir import combined_log_tail
30
+ from ginkgo.runtime.environment.secrets import SecretResolutionError, SecretResolver
31
+ from ginkgo.runtime.run_summary import RunSummary, TaskSummary
32
+ from ginkgo.store.protocol import ProvenanceStore
33
+
34
+
35
+ VALID_NOTIFICATION_EVENTS = frozenset(
36
+ {"run_started", "run_succeeded", "run_failed", "retry_exhausted"}
37
+ )
38
+ EVENT_ALIASES = {"run_completed": "run_succeeded"}
39
+
40
+ #: The settings ``[notifications.slack]`` accepts, with each integer's bounds.
41
+ _INT_SETTINGS = {"log_tail_lines": (1, 25), "max_failed_tasks": (1, 10)}
42
+ _SETTINGS = frozenset({"enabled", "webhook", "events", *_INT_SETTINGS})
43
+ _TABLE = "[notifications.slack]"
44
+ _WEBHOOK_EXAMPLE = 'webhook = { env = "GINKGO_SLACK_WEBHOOK" }'
45
+
46
+
47
+ @dataclass(frozen=True, kw_only=True)
48
+ class SlackNotificationConfig:
49
+ """Resolved Slack notification settings.
50
+
51
+ ``ignored_events`` holds one message per ``events`` entry that names no
52
+ event. A misspelt entry beside valid ones is ignored rather than turning
53
+ every notification off, so the run still sends the events it can.
54
+ """
55
+
56
+ enabled: bool = False
57
+ webhook: SecretRef | None = None
58
+ events: frozenset[str] = frozenset({"run_failed"})
59
+ log_tail_lines: int = 10
60
+ max_failed_tasks: int = 3
61
+ ignored_events: tuple[str, ...] = ()
62
+
63
+
64
+ @dataclass(kw_only=True)
65
+ class SlackSubscriber:
66
+ """Subscribe to runtime events and deliver Slack notifications."""
67
+
68
+ config: SlackNotificationConfig
69
+ webhook_url: str
70
+ store: ProvenanceStore
71
+ run_id: str
72
+ run_dir: Path
73
+ workflow_path: Path
74
+ logger: Callable[[str], None]
75
+ _executor: ThreadPoolExecutor = field(
76
+ default_factory=lambda: ThreadPoolExecutor(
77
+ max_workers=1, thread_name_prefix="ginkgo-slack"
78
+ ),
79
+ init=False,
80
+ repr=False,
81
+ )
82
+ _futures: list[tuple[str, Future[None]]] = field(default_factory=list, init=False, repr=False)
83
+ _unreachable: SlackUnreachableError | None = field(default=None, init=False, repr=False)
84
+
85
+ def handle(self, event: GinkgoEvent) -> None:
86
+ """Handle one runtime event."""
87
+ if isinstance(event, RunStarted):
88
+ self._handle_run_started(event=event)
89
+ return
90
+ if isinstance(event, TaskFailed):
91
+ self._handle_task_failed(event=event)
92
+ return
93
+ if isinstance(event, RunCompleted):
94
+ self._handle_run_completed(event=event)
95
+
96
+ def close(self) -> None:
97
+ """Wait for outstanding deliveries and emit warnings for failures."""
98
+ self._executor.shutdown(wait=True)
99
+ for event_name, future in self._futures:
100
+ try:
101
+ future.result()
102
+ except Exception as exc:
103
+ self.logger(f"Slack notification for {event_name} failed: {exc}")
104
+
105
+ def _handle_run_started(self, *, event: RunStarted) -> None:
106
+ if "run_started" not in self.config.events:
107
+ return
108
+ payload = build_run_started_payload(
109
+ workflow_label=self._workflow_label,
110
+ run_id=event.run_id,
111
+ ts=event.ts,
112
+ )
113
+ self._submit(event_name="run_started", payload=payload)
114
+
115
+ def _handle_task_failed(self, *, event: TaskFailed) -> None:
116
+ if "retry_exhausted" not in self.config.events:
117
+ return
118
+
119
+ task_failure = self._retry_exhausted_failure(task_id=event.task_id)
120
+ if task_failure is None:
121
+ return
122
+
123
+ payload = build_retry_exhausted_payload(
124
+ workflow_label=self._workflow_label,
125
+ run_id=event.run_id,
126
+ ts=event.ts,
127
+ failed_task=task_failure,
128
+ )
129
+ self._submit(event_name=f"retry_exhausted ({task_failure.task_name})", payload=payload)
130
+
131
+ def _handle_run_completed(self, *, event: RunCompleted) -> None:
132
+ # A run someone interrupted needs no announcement: the person who
133
+ # stopped it is watching the terminal it stopped in, and posting it as
134
+ # a failure would report a deliberate act as an incident.
135
+ if event.status == "cancelled":
136
+ return
137
+
138
+ if event.status == "success":
139
+ if "run_succeeded" not in self.config.events:
140
+ return
141
+ payload = build_run_succeeded_payload(
142
+ workflow_label=self._workflow_label,
143
+ run_id=event.run_id,
144
+ ts=event.ts,
145
+ task_counts=event.task_counts,
146
+ )
147
+ self._submit(event_name="run_succeeded", payload=payload)
148
+ return
149
+
150
+ if "run_failed" not in self.config.events:
151
+ return
152
+
153
+ payload = build_run_failed_payload(
154
+ workflow_label=self._workflow_label,
155
+ run_id=event.run_id,
156
+ ts=event.ts,
157
+ task_counts=event.task_counts,
158
+ failed_tasks=self._failed_tasks(),
159
+ error=event.error,
160
+ )
161
+ self._submit(event_name="run_failed", payload=payload)
162
+
163
+ def _submit(self, *, event_name: str, payload: dict[str, Any]) -> None:
164
+ """Post *payload* in the background; *event_name* names it if delivery fails."""
165
+ self._futures.append((event_name, self._executor.submit(self._deliver, payload=payload)))
166
+
167
+ def _deliver(self, *, payload: dict[str, Any]) -> None:
168
+ """Post one payload, unless an earlier one found Slack unreachable.
169
+
170
+ Deliveries run one at a time on a single thread, so once the host has
171
+ failed to answer, each later notification would wait out the same
172
+ timeout and the run's end would wait for all of them. They are given up
173
+ instead, so an unreachable Slack costs a run one timeout.
174
+ """
175
+ if self._unreachable is not None:
176
+ raise SlackDeliveryError(f"not sent, as an earlier one failed: {self._unreachable}")
177
+ try:
178
+ post_slack_message(webhook_url=self.webhook_url, payload=payload)
179
+ except SlackUnreachableError as exc:
180
+ self._unreachable = exc
181
+ raise
182
+
183
+ def _summary(self) -> RunSummary:
184
+ """Reload the run from the ledger, which the recorder has committed."""
185
+ return RunSummary.load(self.store, self.run_id, runs_root=self.run_dir.parent)
186
+
187
+ def _failed_tasks(self) -> list[SlackTaskFailure]:
188
+ failed = self._summary().failed_tasks
189
+ return [self._failure(task=task) for task in failed[: self.config.max_failed_tasks]]
190
+
191
+ def _retry_exhausted_failure(self, *, task_id: str) -> SlackTaskFailure | None:
192
+ task = next(
193
+ (item for item in self._summary().tasks if item.task_key == task_id),
194
+ None,
195
+ )
196
+ if task is None or task.max_attempts is None or task.max_attempts < 2:
197
+ return None
198
+ if task.attempts < task.max_attempts:
199
+ return None
200
+ return self._failure(task=task)
201
+
202
+ def _failure(self, *, task: TaskSummary) -> SlackTaskFailure:
203
+ return SlackTaskFailure(
204
+ task_name=task.label,
205
+ exit_code=task.exit_code,
206
+ attempt=task.attempts,
207
+ max_attempts=task.max_attempts,
208
+ log_tail=tuple(
209
+ combined_log_tail(
210
+ run_dir=self.run_dir,
211
+ stdout_log=task.stdout_log,
212
+ stderr_log=task.stderr_log,
213
+ lines=self.config.log_tail_lines,
214
+ )
215
+ ),
216
+ )
217
+
218
+ @property
219
+ def _workflow_label(self) -> str:
220
+ try:
221
+ return str(self.workflow_path.relative_to(Path.cwd()))
222
+ except ValueError:
223
+ return str(self.workflow_path)
224
+
225
+
226
+ def build_slack_subscriber(context: EventSubscriberContext) -> SlackSubscriber | None:
227
+ """Return a ready-to-use notification service when Slack is configured.
228
+
229
+ Parameters
230
+ ----------
231
+ context : EventSubscriberContext
232
+ The run being observed.
233
+
234
+ Returns
235
+ -------
236
+ SlackSubscriber | None
237
+ ``None`` when ``[notifications.slack]`` is disabled or has problems;
238
+ each problem is reported through ``context.warn``.
239
+ """
240
+ config, problems = parse_notification_config(config=context.config)
241
+ if not config.enabled:
242
+ return None
243
+ webhook_url = None
244
+ if not problems:
245
+ webhook_url, problem = _resolve_webhook(
246
+ ref=config.webhook, resolver=context.secret_resolver
247
+ )
248
+ problems = [] if problem is None else [problem]
249
+ if webhook_url is None:
250
+ for problem in problems:
251
+ context.warn(f"Slack notifications are off: {problem}")
252
+ return None
253
+ for message in config.ignored_events:
254
+ context.warn(message)
255
+
256
+ return SlackSubscriber(
257
+ config=config,
258
+ webhook_url=webhook_url,
259
+ store=context.store,
260
+ run_id=context.run_id,
261
+ run_dir=context.run_dir,
262
+ workflow_path=context.workflow_path,
263
+ logger=context.warn,
264
+ )
265
+
266
+
267
+ def check_slack_config(context: EventSubscriberCheckContext) -> list[EventSubscriberConfigProblem]:
268
+ """Return every problem with ``[notifications.slack]``, for ``ginkgo doctor``.
269
+
270
+ Parameters
271
+ ----------
272
+ context : EventSubscriberCheckContext
273
+ The runtime config and the secret resolver a run would use.
274
+
275
+ Returns
276
+ -------
277
+ list[EventSubscriberConfigProblem]
278
+ One per problem; empty when a run would send notifications as
279
+ configured, or when they are disabled and otherwise valid. An unknown
280
+ event beside valid ones is not blocking: the run ignores it and still
281
+ sends the others.
282
+ """
283
+ config, problems = parse_notification_config(config=context.config)
284
+ if config.enabled and not problems:
285
+ _, problem = _resolve_webhook(ref=config.webhook, resolver=context.secret_resolver)
286
+ if problem is not None:
287
+ problems.append(problem)
288
+ return [
289
+ *(EventSubscriberConfigProblem(message=message) for message in problems),
290
+ *(
291
+ EventSubscriberConfigProblem(message=message, blocking=False)
292
+ for message in config.ignored_events
293
+ ),
294
+ ]
295
+
296
+
297
+ def parse_notification_config(
298
+ *, config: Mapping[str, Any] | None
299
+ ) -> tuple[SlackNotificationConfig, list[str]]:
300
+ """Parse ``[notifications.slack]`` from the loaded config mapping.
301
+
302
+ Parameters
303
+ ----------
304
+ config : Mapping[str, Any] | None
305
+ The merged runtime config.
306
+
307
+ Returns
308
+ -------
309
+ tuple[SlackNotificationConfig, list[str]]
310
+ The settings, and one message per invalid or unknown setting. A setting
311
+ with a problem keeps its default, so the settings are only to be used
312
+ when there are no problems.
313
+ """
314
+ notifications = config.get("notifications", {}) if isinstance(config, Mapping) else {}
315
+ raw = notifications.get("slack", {}) if isinstance(notifications, Mapping) else {}
316
+ if not isinstance(raw, Mapping):
317
+ return SlackNotificationConfig(), [f"{_TABLE} must be a table."]
318
+
319
+ problems = [
320
+ f"{_TABLE} has no setting {key!r}; the settings are {', '.join(sorted(_SETTINGS))}."
321
+ for key in sorted(set(raw) - _SETTINGS, key=str)
322
+ ]
323
+ settings: dict[str, Any] = {}
324
+
325
+ enabled = raw.get("enabled", False)
326
+ if isinstance(enabled, bool):
327
+ settings["enabled"] = enabled
328
+ else:
329
+ problems.append(f"{_TABLE} enabled must be true or false, not {enabled!r}.")
330
+
331
+ if "events" in raw:
332
+ events, ignored, event_problems = _parse_events(raw["events"])
333
+ settings["events"] = events
334
+ settings["ignored_events"] = ignored
335
+ problems.extend(event_problems)
336
+
337
+ for key, (minimum, maximum) in _INT_SETTINGS.items():
338
+ if key not in raw:
339
+ continue
340
+ value = raw[key]
341
+ if isinstance(value, int) and not isinstance(value, bool) and minimum <= value <= maximum:
342
+ settings[key] = value
343
+ else:
344
+ problems.append(
345
+ f"{_TABLE} {key} must be a whole number from {minimum} to {maximum}, "
346
+ f"not {value!r}."
347
+ )
348
+
349
+ # A disabled table may leave the webhook out; one it gives must still be valid.
350
+ if "webhook" in raw or enabled is True:
351
+ webhook, problem = _parse_webhook(raw.get("webhook"))
352
+ settings["webhook"] = webhook
353
+ if problem is not None:
354
+ problems.append(problem)
355
+
356
+ return SlackNotificationConfig(**settings), problems
357
+
358
+
359
+ def _parse_events(value: Any) -> tuple[frozenset[str], tuple[str, ...], list[str]]:
360
+ """Parse the ``events`` list.
361
+
362
+ Returns the events, one message per entry that names no event (ignored
363
+ while another entry is valid), and the problems that leave nothing to send.
364
+ """
365
+ if not isinstance(value, list):
366
+ return (
367
+ frozenset(),
368
+ (),
369
+ [f"{_TABLE} events must be a list of event names, not {value!r}."],
370
+ )
371
+ if not value:
372
+ return frozenset(), (), [f"{_TABLE} events is empty, so nothing would be sent."]
373
+ known = ", ".join(sorted({*VALID_NOTIFICATION_EVENTS, *EVENT_ALIASES}))
374
+ parsed: set[str] = set()
375
+ unknown: list[Any] = []
376
+ for item in value:
377
+ name = EVENT_ALIASES.get(item, item) if isinstance(item, str) else item
378
+ if name in VALID_NOTIFICATION_EVENTS:
379
+ parsed.add(name)
380
+ else:
381
+ unknown.append(item)
382
+ if not parsed:
383
+ return (
384
+ frozenset(),
385
+ (),
386
+ [
387
+ f"{_TABLE} events has no event {item!r}; the events are {known}."
388
+ for item in unknown
389
+ ],
390
+ )
391
+ ignored = tuple(
392
+ f"{_TABLE} events has no event {item!r}, so it is ignored and the other events "
393
+ f"are still sent; the events are {known}."
394
+ for item in unknown
395
+ )
396
+ return frozenset(parsed), ignored, []
397
+
398
+
399
+ def _parse_webhook(value: Any) -> tuple[SecretRef | None, str | None]:
400
+ """Parse the webhook's secret reference, explaining the shape when it is wrong."""
401
+ if value is None:
402
+ return None, f"{_TABLE} webhook is missing; add {_WEBHOOK_EXAMPLE}."
403
+ if isinstance(value, str):
404
+ return None, (
405
+ f"{_TABLE} webhook must be a secret reference such as {_WEBHOOK_EXAMPLE}, "
406
+ "naming where the URL is kept rather than the URL itself: a webhook URL "
407
+ "is a credential and does not belong in config."
408
+ )
409
+ if isinstance(value, Mapping) and len(value) == 1:
410
+ backend, name = next(iter(value.items()))
411
+ if isinstance(backend, str) and isinstance(name, str) and backend.strip() and name.strip():
412
+ return SecretRef(name=name.strip(), backend=backend.strip()), None
413
+ return None, f"{_TABLE} webhook must name one secret, such as {_WEBHOOK_EXAMPLE}."
414
+
415
+
416
+ def _resolve_webhook(
417
+ *, ref: SecretRef | None, resolver: SecretResolver | None
418
+ ) -> tuple[str | None, str | None]:
419
+ """Resolve the webhook URL, or say which secret is unset."""
420
+ assert ref is not None
421
+ if resolver is None:
422
+ return None, f"{_TABLE} webhook cannot be read: no secret resolver is available."
423
+ where = (
424
+ f"the environment variable {ref.name}"
425
+ if ref.backend == "env"
426
+ else f"the {ref.backend} secret {ref.name!r}"
427
+ )
428
+ try:
429
+ url = resolver.resolve(ref=ref)
430
+ except (SecretResolutionError, PluginNotFoundError):
431
+ url = ""
432
+ if not url.strip():
433
+ return None, f"{_TABLE} webhook reads {where}, which is unset or empty."
434
+ return url, None
@@ -0,0 +1,253 @@
1
+ """Slack notification transport and payload helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
10
+
11
+ from ginkgo.errors import GinkgoError
12
+
13
+
14
+ #: Seconds one delivery may wait on Slack. Delivery runs beside the workflow,
15
+ #: but the run waits for what is outstanding as it ends, so an unreachable
16
+ #: host costs at most this, once (see ``SlackSubscriber``).
17
+ DELIVERY_TIMEOUT_SECONDS = 3.0
18
+
19
+
20
+ class SlackDeliveryError(GinkgoError, RuntimeError):
21
+ """Raised when a Slack webhook request cannot be delivered."""
22
+
23
+
24
+ class SlackUnreachableError(SlackDeliveryError):
25
+ """Raised when the Slack webhook's host could not be reached at all."""
26
+
27
+
28
+ class _RefuseRedirects(HTTPRedirectHandler):
29
+ """Surface a redirect as an ``HTTPError``: a followed one was never delivered."""
30
+
31
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
32
+ return None
33
+
34
+
35
+ @dataclass(frozen=True, kw_only=True)
36
+ class SlackTaskFailure:
37
+ """Compact failed-task summary for Slack payloads."""
38
+
39
+ task_name: str
40
+ exit_code: int | None
41
+ attempt: int | None = None
42
+ max_attempts: int | None = None
43
+ log_tail: tuple[str, ...] = ()
44
+
45
+
46
+ def post_slack_message(
47
+ *,
48
+ webhook_url: str,
49
+ payload: dict[str, Any],
50
+ timeout_seconds: float = DELIVERY_TIMEOUT_SECONDS,
51
+ ) -> None:
52
+ """Send one JSON payload to a Slack incoming webhook."""
53
+ body = json.dumps(payload).encode("utf-8")
54
+ request = Request(
55
+ webhook_url,
56
+ data=body,
57
+ headers={"Content-Type": "application/json"},
58
+ method="POST",
59
+ )
60
+ try:
61
+ with build_opener(_RefuseRedirects).open(request, timeout=timeout_seconds) as response:
62
+ status_code = getattr(response, "status", response.getcode())
63
+ except HTTPError as exc:
64
+ raise SlackDeliveryError(
65
+ f"Slack webhook rejected the request with HTTP {exc.code}."
66
+ ) from exc
67
+ except URLError as exc:
68
+ reason = getattr(exc, "reason", None)
69
+ detail = str(reason) if reason else exc.__class__.__name__
70
+ raise SlackUnreachableError(f"Slack webhook request failed: {detail}.") from exc
71
+ except Exception as exc: # pragma: no cover - defensive wrapper
72
+ raise SlackDeliveryError("Slack webhook request failed.") from exc
73
+
74
+ if not 200 <= status_code < 300:
75
+ raise SlackDeliveryError(f"Slack webhook rejected the request with HTTP {status_code}.")
76
+
77
+
78
+ def build_run_started_payload(
79
+ *,
80
+ workflow_label: str,
81
+ run_id: str,
82
+ ts: str,
83
+ ) -> dict[str, Any]:
84
+ """Build a Slack payload for run start notifications."""
85
+ summary_lines = [
86
+ f"*Workflow*: `{workflow_label}`",
87
+ f"*Run*: `{run_id}`",
88
+ f"*Started*: `{ts}`",
89
+ ]
90
+
91
+ return _build_payload(
92
+ fallback=f"Ginkgo run started: {workflow_label} ({run_id})",
93
+ title="Ginkgo Run Started",
94
+ body_lines=summary_lines,
95
+ )
96
+
97
+
98
+ def build_run_succeeded_payload(
99
+ *,
100
+ workflow_label: str,
101
+ run_id: str,
102
+ ts: str,
103
+ task_counts: dict[str, int],
104
+ ) -> dict[str, Any]:
105
+ """Build a Slack payload for successful run completion."""
106
+ summary_lines = [
107
+ f"*Workflow*: `{workflow_label}`",
108
+ f"*Run*: `{run_id}`",
109
+ f"*Completed*: `{ts}`",
110
+ f"*Task counts*: {_format_task_counts(task_counts)}",
111
+ ]
112
+
113
+ return _build_payload(
114
+ fallback=f"Ginkgo run succeeded: {workflow_label} ({run_id})",
115
+ title="Ginkgo Run Succeeded",
116
+ body_lines=summary_lines,
117
+ )
118
+
119
+
120
+ def build_run_failed_payload(
121
+ *,
122
+ workflow_label: str,
123
+ run_id: str,
124
+ ts: str,
125
+ task_counts: dict[str, int],
126
+ failed_tasks: list[SlackTaskFailure],
127
+ error: str | None,
128
+ ) -> dict[str, Any]:
129
+ """Build a Slack payload for failed run completion.
130
+
131
+ The counts are as much use here as on a success, and more so under a
132
+ non-fatal failure policy: a card naming three failures without saying
133
+ that five thousand tasks went unrun describes a different run.
134
+ """
135
+ summary_lines = [
136
+ f"*Workflow*: `{workflow_label}`",
137
+ f"*Run*: `{run_id}`",
138
+ f"*Failed*: `{ts}`",
139
+ f"*Task counts*: {_format_task_counts(task_counts)}",
140
+ ]
141
+ if error:
142
+ summary_lines.append(f"*Error*: `{_truncate(error, limit=180)}`")
143
+
144
+ return _build_payload(
145
+ fallback=f"Ginkgo run failed: {workflow_label} ({run_id})",
146
+ title="Ginkgo Run Failed",
147
+ body_lines=summary_lines,
148
+ failure_lines=_format_failure_lines(failed_tasks),
149
+ )
150
+
151
+
152
+ def build_retry_exhausted_payload(
153
+ *,
154
+ workflow_label: str,
155
+ run_id: str,
156
+ ts: str,
157
+ failed_task: SlackTaskFailure,
158
+ ) -> dict[str, Any]:
159
+ """Build a Slack payload for retry exhaustion notifications."""
160
+ summary_lines = [
161
+ f"*Workflow*: `{workflow_label}`",
162
+ f"*Run*: `{run_id}`",
163
+ f"*Task*: `{failed_task.task_name}`",
164
+ f"*Failed*: `{ts}`",
165
+ ]
166
+ attempt_line = _format_attempts(failed_task)
167
+ if attempt_line is not None:
168
+ summary_lines.append(f"*Attempts*: `{attempt_line}`")
169
+ if failed_task.exit_code is not None:
170
+ summary_lines.append(f"*Exit code*: `{failed_task.exit_code}`")
171
+
172
+ return _build_payload(
173
+ fallback=f"Ginkgo task exhausted retries: {failed_task.task_name} ({run_id})",
174
+ title="Ginkgo Retries Exhausted",
175
+ body_lines=summary_lines,
176
+ failure_lines=_format_failure_lines([failed_task]),
177
+ )
178
+
179
+
180
+ def _build_payload(
181
+ *,
182
+ fallback: str,
183
+ title: str,
184
+ body_lines: list[str],
185
+ failure_lines: list[str] | None = None,
186
+ ) -> dict[str, Any]:
187
+ blocks: list[dict[str, Any]] = [
188
+ {
189
+ "type": "header",
190
+ "text": {"type": "plain_text", "text": title},
191
+ },
192
+ {
193
+ "type": "section",
194
+ "text": {"type": "mrkdwn", "text": "\n".join(body_lines)},
195
+ },
196
+ ]
197
+ if failure_lines:
198
+ blocks.append(
199
+ {
200
+ "type": "section",
201
+ "text": {"type": "mrkdwn", "text": "\n".join(failure_lines)},
202
+ }
203
+ )
204
+
205
+ return {"text": fallback, "blocks": blocks}
206
+
207
+
208
+ def _format_task_counts(task_counts: dict[str, int]) -> str:
209
+ ordered = [
210
+ (name, count)
211
+ for name, count in (
212
+ ("succeeded", task_counts.get("succeeded", 0)),
213
+ ("cached", task_counts.get("cached", 0)),
214
+ ("failed", task_counts.get("failed", 0)),
215
+ ("skipped", task_counts.get("skipped", 0)),
216
+ ("running", task_counts.get("running", 0)),
217
+ ("pending", task_counts.get("pending", 0)),
218
+ )
219
+ if count
220
+ ]
221
+ if not ordered:
222
+ return "none"
223
+ return ", ".join(f"{name}={count}" for name, count in ordered)
224
+
225
+
226
+ def _format_failure_lines(failed_tasks: list[SlackTaskFailure]) -> list[str]:
227
+ lines: list[str] = []
228
+ for item in failed_tasks:
229
+ detail_parts = [f"*Task*: `{item.task_name}`"]
230
+ if item.exit_code is not None:
231
+ detail_parts.append(f"*Exit*: `{item.exit_code}`")
232
+ attempt_line = _format_attempts(item)
233
+ if attempt_line is not None:
234
+ detail_parts.append(f"*Attempts*: `{attempt_line}`")
235
+ lines.append(" | ".join(detail_parts))
236
+ if item.log_tail:
237
+ excerpt = "\n".join(_truncate(line, limit=140) for line in item.log_tail)
238
+ lines.append(f"```{_truncate(excerpt, limit=900)}```")
239
+ return lines
240
+
241
+
242
+ def _format_attempts(item: SlackTaskFailure) -> str | None:
243
+ if item.attempt is None:
244
+ return None
245
+ if item.max_attempts is None:
246
+ return str(item.attempt)
247
+ return f"{item.attempt}/{item.max_attempts}"
248
+
249
+
250
+ def _truncate(value: str, *, limit: int) -> str:
251
+ if len(value) <= limit:
252
+ return value
253
+ return value[: limit - 3] + "..."
@@ -0,0 +1,310 @@
1
+ """Slack notification tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from ginkgo.core.secret import SecretRef
13
+ from ginkgo.plugins.api import EventSubscriberCheckContext, EventSubscriberConfigProblem
14
+ from ginkgo.runtime.environment.secrets import build_secret_resolver
15
+ from ginkgo_slack.subscriber import (
16
+ SlackNotificationConfig,
17
+ SlackSubscriber,
18
+ check_slack_config,
19
+ parse_notification_config,
20
+ )
21
+ from ginkgo_slack.webhook import (
22
+ SlackDeliveryError,
23
+ SlackUnreachableError,
24
+ build_run_failed_payload,
25
+ post_slack_message,
26
+ )
27
+ from tests.conftest import run_cli
28
+
29
+ _WORKFLOW = """
30
+ from ginkgo import flow, task
31
+
32
+ @task(retries=1)
33
+ def fail() -> str:
34
+ raise RuntimeError("boom from task")
35
+
36
+ @task()
37
+ def succeed() -> str:
38
+ return "ok"
39
+
40
+ @flow
41
+ def main():
42
+ return {body}
43
+ """
44
+
45
+
46
+ class _WebhookHandler(BaseHTTPRequestHandler):
47
+ def do_POST(self) -> None: # noqa: N802
48
+ length = int(self.headers.get("Content-Length", "0"))
49
+ body = self.rfile.read(length).decode("utf-8")
50
+ self.server.requests.append(json.loads(body)) # type: ignore[attr-defined]
51
+ self.send_response(200)
52
+ self.end_headers()
53
+ self.wfile.write(b"ok")
54
+
55
+ def log_message(self, format: str, *args) -> None:
56
+ return
57
+
58
+
59
+ class _RedirectingHandler(_WebhookHandler):
60
+ def do_POST(self) -> None: # noqa: N802
61
+ self.send_response(302)
62
+ self.send_header("Location", "/elsewhere")
63
+ self.end_headers()
64
+
65
+ def do_GET(self) -> None: # noqa: N802
66
+ self.send_response(200)
67
+ self.end_headers()
68
+
69
+
70
+ class _WebhookServer:
71
+ def __init__(self, handler: type[BaseHTTPRequestHandler] = _WebhookHandler) -> None:
72
+ self._server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
73
+ self._server.requests = [] # type: ignore[attr-defined]
74
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
75
+
76
+ @property
77
+ def url(self) -> str:
78
+ host, port = self._server.server_address
79
+ return f"http://{host}:{port}/hook"
80
+
81
+ @property
82
+ def requests(self) -> list[dict[str, object]]:
83
+ return list(self._server.requests) # type: ignore[attr-defined]
84
+
85
+ def __enter__(self) -> _WebhookServer:
86
+ self._thread.start()
87
+ return self
88
+
89
+ def __exit__(self, *_: object) -> None:
90
+ self._server.shutdown()
91
+ self._server.server_close()
92
+ self._thread.join(timeout=5)
93
+
94
+
95
+ def _payload_text(payload: dict[str, object]) -> str:
96
+ blocks = payload.get("blocks", [])
97
+ if not isinstance(blocks, list):
98
+ return str(payload.get("text", ""))
99
+ return "\n".join(
100
+ block["text"]["text"]
101
+ for block in blocks
102
+ if isinstance(block, dict) and isinstance(block.get("text"), dict)
103
+ )
104
+
105
+
106
+ def _slack(**settings: object) -> dict[str, object]:
107
+ return {"notifications": {"slack": {"enabled": True, **settings}}}
108
+
109
+
110
+ def _check(
111
+ config: dict[str, object], *, environ: dict[str, str]
112
+ ) -> list[EventSubscriberConfigProblem]:
113
+ resolver = build_secret_resolver(project_root=Path.cwd(), config=config, environ=environ)
114
+ return check_slack_config(EventSubscriberCheckContext(config=config, secret_resolver=resolver))
115
+
116
+
117
+ _WEBHOOK = {"env": "GINKGO_SLACK_WEBHOOK"}
118
+
119
+
120
+ class TestNotificationConfig:
121
+ def test_parse_notification_config_supports_aliases_and_secret_refs(self) -> None:
122
+ config, problems = parse_notification_config(
123
+ config=_slack(
124
+ webhook=_WEBHOOK,
125
+ events=["run_started", "run_completed", "retry_exhausted"],
126
+ log_tail_lines=25,
127
+ max_failed_tasks=10,
128
+ )
129
+ )
130
+
131
+ assert problems == []
132
+ assert config.webhook == SecretRef(name="GINKGO_SLACK_WEBHOOK", backend="env")
133
+ # "run_completed" is an alias for "run_succeeded"; the parser normalises it.
134
+ assert config.events == frozenset({"run_started", "run_succeeded", "retry_exhausted"})
135
+ assert (config.log_tail_lines, config.max_failed_tasks) == (25, 10)
136
+
137
+ def test_every_invalid_setting_is_named(self) -> None:
138
+ _, problems = parse_notification_config(
139
+ config=_slack(
140
+ webhook=_WEBHOOK, events=["run_finished"], channel="#alerts", log_tail_lines=-3
141
+ )
142
+ )
143
+
144
+ assert len(problems) == 3
145
+ assert "'channel'" in problems[0]
146
+ assert "'run_finished'" in problems[1]
147
+ assert "log_tail_lines" in problems[2]
148
+
149
+ def test_a_misspelt_event_beside_valid_ones_is_ignored_not_fatal(self) -> None:
150
+ """RP4-4: one typo must not turn off the alerts that were spelt right."""
151
+ settings = _slack(webhook=_WEBHOOK, events=["run_faild", "run_failed"])
152
+ config, problems = parse_notification_config(config=settings)
153
+
154
+ assert problems == []
155
+ assert config.events == frozenset({"run_failed"})
156
+ [ignored] = config.ignored_events
157
+ assert "'run_faild', so it is ignored" in ignored
158
+ # doctor still names it, as a warning: the run still sends.
159
+ environ = {"GINKGO_SLACK_WEBHOOK": "http://127.0.0.1:1/"}
160
+ assert _check(settings, environ=environ) == [
161
+ EventSubscriberConfigProblem(message=ignored, blocking=False)
162
+ ]
163
+
164
+ def test_a_literal_webhook_url_is_explained(self) -> None:
165
+ _, problems = parse_notification_config(
166
+ config=_slack(webhook="https://hooks.slack.com/services/x")
167
+ )
168
+
169
+ assert len(problems) == 1
170
+ assert 'webhook = { env = "GINKGO_SLACK_WEBHOOK" }' in problems[0]
171
+
172
+ def test_the_check_names_an_unset_environment_variable(self) -> None:
173
+ config = _slack(webhook=_WEBHOOK)
174
+
175
+ [problem] = _check(config, environ={"GINKGO_SLACK_WEBHOOK": ""})
176
+ assert "GINKGO_SLACK_WEBHOOK, which is unset or empty" in problem.message
177
+ assert _check(config, environ={"GINKGO_SLACK_WEBHOOK": "http://127.0.0.1:1/"}) == []
178
+
179
+
180
+ def test_once_slack_is_unreachable_later_notifications_are_not_attempted(
181
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
182
+ ) -> None:
183
+ attempts: list[dict[str, object]] = []
184
+
185
+ def unreachable(*, webhook_url: str, payload: dict[str, object]) -> None:
186
+ attempts.append(payload)
187
+ raise SlackUnreachableError("Slack webhook request failed: timed out.")
188
+
189
+ monkeypatch.setattr("ginkgo_slack.subscriber.post_slack_message", unreachable)
190
+ warnings: list[str] = []
191
+ subscriber = SlackSubscriber(
192
+ config=SlackNotificationConfig(enabled=True),
193
+ webhook_url="http://127.0.0.1:1/hook",
194
+ store=None, # type: ignore[arg-type]
195
+ run_id="run-1",
196
+ run_dir=tmp_path,
197
+ workflow_path=tmp_path / "workflow.py",
198
+ logger=warnings.append,
199
+ )
200
+
201
+ subscriber._submit(event_name="run_started", payload={"text": "a"})
202
+ subscriber._submit(event_name="run_failed", payload={"text": "b"})
203
+ subscriber.close()
204
+
205
+ assert attempts == [{"text": "a"}]
206
+ assert len(warnings) == 2
207
+ assert "run_failed" in warnings[1] and "not sent" in warnings[1]
208
+
209
+
210
+ def test_a_redirected_webhook_is_not_delivered() -> None:
211
+ with _WebhookServer(_RedirectingHandler) as server:
212
+ with pytest.raises(SlackDeliveryError, match="HTTP 302"):
213
+ post_slack_message(webhook_url=server.url, payload={"text": "hi"})
214
+
215
+
216
+ def test_the_failed_run_card_counts_the_tasks_a_failure_left_unrun() -> None:
217
+ """Three failures beside five thousand skips is a different run."""
218
+ payload = build_run_failed_payload(
219
+ workflow_label="flow.py",
220
+ run_id="run_1",
221
+ ts="2026-09-02T09:00:00+00:00",
222
+ task_counts={"succeeded": 4997, "failed": 3, "skipped": 4997},
223
+ failed_tasks=[],
224
+ error="3 tasks failed; the run continued under its failure policy.",
225
+ )
226
+
227
+ text = _payload_text(payload)
228
+ assert "failed=3" in text
229
+ assert "skipped=4997" in text
230
+
231
+
232
+ class TestSlackNotifications:
233
+ @pytest.fixture(autouse=True)
234
+ def _run_in_tmp_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
235
+ # Each test writes a workflow and config into the working directory.
236
+ monkeypatch.chdir(tmp_path)
237
+
238
+ def _project(self, *, events: list[str], body: str = "succeed()") -> None:
239
+ Path("ginkgo.toml").write_text(
240
+ "[notifications.slack]\nenabled = true\n"
241
+ 'webhook = { env = "GINKGO_SLACK_WEBHOOK" }\n'
242
+ f"events = {json.dumps(events)}\nlog_tail_lines = 5\n",
243
+ encoding="utf-8",
244
+ )
245
+ Path("workflow.py").write_text(_WORKFLOW.format(body=body), encoding="utf-8")
246
+
247
+ @pytest.mark.parametrize(
248
+ ("events", "body", "code", "expected"),
249
+ [
250
+ (
251
+ ["run_started", "run_completed"],
252
+ "succeed()",
253
+ 0,
254
+ [("Ginkgo Run Started", "workflow.py"), ("Ginkgo Run Succeeded", "succeeded=1")],
255
+ ),
256
+ (
257
+ ["retry_exhausted", "run_failed"],
258
+ "fail()",
259
+ 1,
260
+ [("Ginkgo Retries Exhausted", "boom from task"), ("Ginkgo Run Failed", "fail")],
261
+ ),
262
+ ],
263
+ )
264
+ def test_cli_run_sends_the_configured_notifications(
265
+ self, monkeypatch, events, body, code, expected
266
+ ) -> None:
267
+ with _WebhookServer() as server:
268
+ monkeypatch.setenv("GINKGO_SLACK_WEBHOOK", server.url)
269
+ self._project(events=events, body=body)
270
+
271
+ result = run_cli("run", "workflow.py")
272
+ assert result.returncode == code, result.stderr
273
+ texts = [_payload_text(request) for request in server.requests]
274
+
275
+ assert len(texts) == len(expected)
276
+ for text, fragments in zip(texts, expected):
277
+ assert all(fragment in text for fragment in fragments)
278
+
279
+ def test_doctor_blocks_an_invalid_slack_table(self, monkeypatch) -> None:
280
+ monkeypatch.delenv("GINKGO_SLACK_WEBHOOK", raising=False)
281
+ self._project(events=["run_finished"])
282
+
283
+ result = run_cli("doctor", "workflow.py")
284
+
285
+ assert result.returncode == 1
286
+ assert "notification_config" in result.stderr
287
+ assert "'run_finished'" in result.stderr
288
+
289
+ def test_doctor_warns_but_passes_on_an_unknown_event_beside_valid_ones(
290
+ self, monkeypatch
291
+ ) -> None:
292
+ monkeypatch.setenv("GINKGO_SLACK_WEBHOOK", "http://127.0.0.1:1/hook")
293
+ self._project(events=["run_faild", "run_failed"])
294
+
295
+ result = run_cli("doctor", "workflow.py")
296
+
297
+ assert result.returncode == 0, result.stdout + result.stderr
298
+ assert "'run_faild'" in result.stdout + result.stderr
299
+
300
+ def test_cli_run_warns_when_slack_webhook_is_unreachable(self, monkeypatch) -> None:
301
+ monkeypatch.setenv("GINKGO_SLACK_WEBHOOK", "http://127.0.0.1:1/hook")
302
+ self._project(events=["run_completed"])
303
+
304
+ result = run_cli("run", "workflow.py")
305
+ assert result.returncode == 0, result.stderr
306
+ assert "Slack notification for run_succeeded failed:" in result.stderr
307
+
308
+ # The failure is kept with the run, not only printed while it ran.
309
+ shown = run_cli("runs", "show")
310
+ assert "Notice: Slack notification for run_succeeded failed:" in shown.stdout