From e35e28670f9d7569ce1b9355d8cc30d6bf526d0a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:51:32 +0000 Subject: [PATCH] Refactor Slack notifications: severity-aware, human-centered format - Replace flat title+bullets layout with structured 4-section messages: 1) Change Summary (impacted endpoints and schema changes) 2) Impact (what it means for clients) 3) Docs Status (what was regenerated) 4) Action (what the reader needs to do) - Severity inferred from drift data: green/yellow/red circle emoji - Header format: '{emoji} Docs Drift Guard | {Severity Label}' - No more bell emoji, no alarmist tone for non-breaking changes - Dedicated build_drift_message() function for deterministic output - Separate send_drift_notification() and send_audit_notification() - Existing webhook integration preserved Co-Authored-By: unknown <> --- drift_guard/guard.py | 29 ++-- drift_guard/slack_notify.py | 259 ++++++++++++++++++++++++++++++++---- 2 files changed, 249 insertions(+), 39 deletions(-) diff --git a/drift_guard/guard.py b/drift_guard/guard.py index cd593bc..2526f4c 100644 --- a/drift_guard/guard.py +++ b/drift_guard/guard.py @@ -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() @@ -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" @@ -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." ), ) diff --git a/drift_guard/slack_notify.py b/drift_guard/slack_notify.py index 6e553ef..cf4f3b2 100644 --- a/drift_guard/slack_notify.py +++ b/drift_guard/slack_notify.py @@ -1,14 +1,21 @@ """ -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 @@ -16,19 +23,198 @@ 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": [ { @@ -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)