Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions drift_guard/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@

from drift_guard.drift_detect import detect_drift
from drift_guard.openapi_to_md import openapi_to_markdown
from drift_guard.slack_notify import send_slack_notification
from drift_guard.slack_notify import (
build_drift_message,
send_audit_notification,
send_drift_notification,
)

console = Console()

Expand Down Expand Up @@ -182,11 +186,14 @@ def cmd_check(url: str = DEFAULT_OPENAPI_URL) -> None:
_write_json(BASELINE_PATH, updated_baseline)
console.print(f" Updated baseline at [cyan]{BASELINE_PATH}[/cyan]")

# Slack notification
send_slack_notification(
title="πŸ”” Docs Drift Guard: API changed",
summary_bullets=report.as_bullet_list(),
# Slack notification (severity-aware, human-centered)
drift_msg = build_drift_message(
added_endpoints=report.added_endpoints,
removed_endpoints=report.removed_endpoints,
schema_changes=report.schema_changes,
tier1_regenerated=True,
)
send_drift_notification(drift_msg)

console.print(
"\n[yellow]Exiting with code 2 (drift detected).[/yellow]\n"
Expand Down Expand Up @@ -338,12 +345,12 @@ def cmd_audit(url: str = DEFAULT_AUDIT_URL) -> None:
f" Appended Runtime Mismatches section to [cyan]{API_REF_PATH}[/cyan]"
)

# Slack notification
send_slack_notification(
title="\U0001f6a8 API Call Guard: Runtime mismatches detected",
summary_bullets=summary,
context_message=(
":warning: These mismatches indicate clients calling the API "
# Slack notification (audit-specific format)
send_audit_notification(
title="Runtime Audit | API call mismatches detected",
summary_text=summary,
context_text=(
"These mismatches indicate clients calling the API "
"with field names or endpoints that don't match the current spec."
),
)
Expand Down
259 changes: 231 additions & 28 deletions drift_guard/slack_notify.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,220 @@
"""
Send a drift summary to a Slack channel via an Incoming Webhook.
Send drift and audit summaries to Slack via an Incoming Webhook.

If the ``SLACK_WEBHOOK_URL`` environment variable is not set the payload
is printed to stdout so the user can still see what *would* be sent.

Message format follows a human-centered, severity-aware structure:
1) Change Summary - impacted endpoints and schema changes
2) Impact - what this means for clients
3) Docs Status - what was regenerated or drafted
4) Action - what the reader needs to do (if anything)
"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass, field
from typing import Any

import requests
from rich.console import Console

console = Console()

# ---------------------------------------------------------------------------
# Severity helpers (inferred from drift data, NOT from detection logic)
# ---------------------------------------------------------------------------

DEFAULT_CONTEXT = (
":page_facing_up: `docs/api_reference.md` has been "
"regenerated from the live OpenAPI schema."
)
SEVERITY_NON_BREAKING = "non_breaking"
SEVERITY_POTENTIALLY_BREAKING = "potentially_breaking"
SEVERITY_BREAKING = "breaking"

_SEVERITY_META: dict[str, dict[str, str]] = {
SEVERITY_NON_BREAKING: {
"emoji": "\U0001f7e2", # green circle
"label": "Non-Breaking",
},
SEVERITY_POTENTIALLY_BREAKING: {
"emoji": "\U0001f7e1", # yellow circle
"label": "Potentially Breaking",
},
SEVERITY_BREAKING: {
"emoji": "\U0001f534", # red circle
"label": "Breaking",
},
}

def _build_payload(
title: str,
summary_bullets: str,
context_message: str = DEFAULT_CONTEXT,

def infer_severity(
added_endpoints: list[str],
removed_endpoints: list[str],
schema_changes: list[str],
) -> str:
"""Infer a severity level from the drift report contents.

This is purely a *formatting* concern -- it does NOT change detection
or classification logic.
"""
has_removed_endpoint = bool(removed_endpoints)
has_lost_field = any("lost field" in c for c in schema_changes)

if has_removed_endpoint or has_lost_field:
return SEVERITY_BREAKING

# If there are schema changes that aren't just "gained field" additions,
# treat them as potentially breaking.
non_additive_changes = [
c for c in schema_changes if "gained field" not in c
]
if non_additive_changes:
return SEVERITY_POTENTIALLY_BREAKING

return SEVERITY_NON_BREAKING


# ---------------------------------------------------------------------------
# Structured drift message
# ---------------------------------------------------------------------------

@dataclass
class DriftMessage:
"""All the data needed to build a human-centered Slack drift message."""

severity: str # one of the SEVERITY_* constants
change_details: list[str] = field(default_factory=list)
impact_lines: list[str] = field(default_factory=list)
docs_status_lines: list[str] = field(default_factory=list)
action_lines: list[str] = field(default_factory=list)


def build_drift_message(
added_endpoints: list[str],
removed_endpoints: list[str],
schema_changes: list[str],
tier1_regenerated: bool = True,
tier3_drafted: bool = False,
) -> DriftMessage:
"""Build a structured drift message from a DriftReport's data.

This is the single place that decides what text goes into each section
of the Slack notification. It does NOT touch detection or classification.
"""
severity = infer_severity(added_endpoints, removed_endpoints, schema_changes)

# --- 1. Change Summary ---------------------------------------------------
details: list[str] = []

for ep in added_endpoints:
details.append(f"New endpoint: {ep}")
for ep in removed_endpoints:
details.append(f"Removed endpoint: {ep}")
for change in schema_changes:
details.append(change)

# --- 2. Impact -----------------------------------------------------------
impact: list[str] = []
if severity == SEVERITY_BREAKING:
impact.append(
"Existing clients may fail. "
"Removed endpoints or fields will cause errors."
)
elif severity == SEVERITY_POTENTIALLY_BREAKING:
impact.append(
"Some clients may be affected depending on usage patterns."
)
else:
impact.append("Existing clients unaffected.")

# --- 3. Docs Status ------------------------------------------------------
docs: list[str] = []
if tier1_regenerated:
docs.append("Reference docs regenerated from live OpenAPI.")
if tier3_drafted:
docs.append("Migration guidance draft added.")

# --- 4. Action -----------------------------------------------------------
action: list[str] = []
if severity == SEVERITY_BREAKING:
action.append("Review required before merge.")
elif severity == SEVERITY_POTENTIALLY_BREAKING:
action.append("Review recommended before merge.")
else:
action.append("No action required.")

return DriftMessage(
severity=severity,
change_details=details,
impact_lines=impact,
docs_status_lines=docs,
action_lines=action,
)


# ---------------------------------------------------------------------------
# Slack payload builders
# ---------------------------------------------------------------------------

def _build_drift_payload(msg: DriftMessage) -> dict[str, Any]:
"""Build a Slack Block Kit payload from a DriftMessage."""
meta = _SEVERITY_META[msg.severity]
header_text = f"{meta['emoji']} Docs Drift Guard | {meta['label']}"

blocks: list[dict[str, Any]] = [
{
"type": "header",
"text": {"type": "plain_text", "text": header_text, "emoji": True},
},
]

# Change Summary section
if msg.change_details:
summary_text = "Change Summary:\n" + "\n".join(
f"\u2022 {d}" for d in msg.change_details
)
blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": summary_text},
})

# Impact section
if msg.impact_lines:
impact_text = "Impact:\n" + "\n".join(
f"\u2022 {line}" for line in msg.impact_lines
)
blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": impact_text},
})

# Docs Status section
if msg.docs_status_lines:
docs_text = "Docs Status:\n" + "\n".join(
f"\u2022 {line}" for line in msg.docs_status_lines
)
blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": docs_text},
})

# Action section
if msg.action_lines:
action_text = "Action:\n" + "\n".join(
f"\u2022 {line}" for line in msg.action_lines
)
blocks.append({
"type": "section",
"text": {"type": "mrkdwn", "text": action_text},
})

return {"blocks": blocks}


def _build_audit_payload(
title: str, summary_text: str, context_text: str
) -> dict[str, Any]:
"""Build a Slack Block Kit payload."""
"""Build a Slack Block Kit payload for runtime audit notifications."""
return {
"blocks": [
{
Expand All @@ -37,45 +223,62 @@ def _build_payload(
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": summary_bullets},
"text": {"type": "mrkdwn", "text": summary_text},
},
{"type": "divider"},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": context_message,
}
{"type": "mrkdwn", "text": context_text},
],
},
],
}


def send_slack_notification(
title: str,
summary_bullets: str,
context_message: str = DEFAULT_CONTEXT,
) -> None:
"""
Post a Slack message. Falls back to printing the payload if the
webhook URL is not configured.
"""
payload = _build_payload(title, summary_bullets, context_message)
# ---------------------------------------------------------------------------
# Public send functions
# ---------------------------------------------------------------------------

def _post_or_print(payload: dict[str, Any]) -> None:
"""Post a payload to the Slack webhook, or print it if not configured."""
webhook_url = os.environ.get("SLACK_WEBHOOK_URL")

if not webhook_url:
console.print(
"\n[yellow]SLACK_WEBHOOK_URL not set – printing payload instead:[/yellow]\n"
"\n[yellow]SLACK_WEBHOOK_URL not set "
"\u2013 printing payload instead:[/yellow]\n"
)
console.print_json(json.dumps(payload))
return

resp = requests.post(webhook_url, json=payload, timeout=10)
if resp.ok:
console.print("[green]Slack notification sent successfully.[/green]")
console.print("[green]Slack notification sent.[/green]")
else:
console.print(
f"[red]Slack webhook returned {resp.status_code}: {resp.text}[/red]"
f"[red]Slack webhook returned {resp.status_code}: "
f"{resp.text}[/red]"
)


def send_drift_notification(msg: DriftMessage) -> None:
"""Send a severity-aware drift notification to Slack.

Does nothing if *msg* represents no changes (safety guard so callers
don't need to check).
"""
if not msg.change_details:
return
payload = _build_drift_payload(msg)
_post_or_print(payload)


def send_audit_notification(
title: str,
summary_text: str,
context_text: str,
) -> None:
"""Send a runtime-audit notification to Slack."""
payload = _build_audit_payload(title, summary_text, context_text)
_post_or_print(payload)