diff --git a/.github/workflows/drift-guard.yml b/.github/workflows/drift-guard.yml index 934ae30..081d537 100644 --- a/.github/workflows/drift-guard.yml +++ b/.github/workflows/drift-guard.yml @@ -49,8 +49,21 @@ jobs: python -m drift_guard.guard check EXIT_CODE=$? echo "drift_exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT" - if [ "$EXIT_CODE" -eq 2 ]; then - echo "::warning::API drift detected — docs are out of sync with the code" + + # Exit codes: 10=GREEN, 11=YELLOW, 2=RED + if [ "$EXIT_CODE" -eq 10 ]; then + echo "drift_severity=green" >> "$GITHUB_OUTPUT" + echo "::notice::Non-breaking API drift detected — Tier 1 docs regenerated" + # Auto-update baseline for GREEN + python -m drift_guard.guard baseline + elif [ "$EXIT_CODE" -eq 11 ]; then + echo "drift_severity=yellow" >> "$GITHUB_OUTPUT" + echo "::warning::Potentially breaking API drift — PR review required" + elif [ "$EXIT_CODE" -eq 2 ]; then + echo "drift_severity=red" >> "$GITHUB_OUTPUT" + echo "::error::BREAKING API drift detected — escalating" + else + echo "drift_severity=none" >> "$GITHUB_OUTPUT" fi # Don't fail yet — collect all results first exit 0 @@ -98,23 +111,37 @@ jobs: - name: Final result run: | DRIFT=${{ steps.drift.outputs.drift_exit_code }} + SEVERITY=${{ steps.drift.outputs.drift_severity }} TESTS=${{ steps.contracts.outputs.test_exit_code }} AUDIT=${{ steps.audit.outputs.audit_exit_code }} echo "Drift check exit code: $DRIFT" + echo "Drift severity: $SEVERITY" echo "Contract tests exit code: $TESTS" echo "Audit check exit code: $AUDIT" + FAILED=0 - if [ "$DRIFT" -eq 2 ]; then - echo "::error::API drift detected — the OpenAPI spec changed but docs/baseline were not updated" + + # GREEN drift: pass (baseline already auto-updated above) + # YELLOW drift: fail — require PR review + if [ "$SEVERITY" = "yellow" ]; then + echo "::error::Potentially breaking API drift — PR review required before merge" FAILED=1 fi + + # RED drift: fail — breaking change, escalate + if [ "$SEVERITY" = "red" ]; then + echo "::error::BREAKING API drift — this change will break existing clients" + FAILED=1 + fi + if [ "$TESTS" -ne 0 ]; then echo "::error::Contract tests failed — some API calls don't match the current spec" FAILED=1 fi + if [ "$FAILED" -eq 1 ]; then echo "" - echo "To fix: run 'python -m drift_guard.guard baseline' to update the baseline," + echo "To fix: review the drift summary, run 'python -m drift_guard.guard baseline'," echo "then commit the updated artifacts/openapi_baseline.json and docs/api_reference.md" exit 1 fi diff --git a/drift_guard/drift_detect.py b/drift_guard/drift_detect.py index 01eec73..b7267d8 100644 --- a/drift_guard/drift_detect.py +++ b/drift_guard/drift_detect.py @@ -1,43 +1,134 @@ -""" -Compare two OpenAPI specs and produce a human-readable change summary. +"""Compare two OpenAPI specs and produce a severity-classified change summary. + +Severity levels +--------------- + GREEN - Non-breaking (new optional field, new endpoint, description change) + YELLOW - Potentially breaking (type change, field renamed, enum modified, + required flag toggled) + RED - Breaking (required field added, field removed, endpoint removed, + response structure changed incompatibly) The comparison covers: - Added / removed endpoints (method + path pairs) - Added / removed / changed fields inside request/response schemas - -This is intentionally a *lightweight* diff – not a full JSON-Schema diff -engine – suitable for demo purposes and common drift scenarios. + - Type changes and required-flag changes on existing fields """ from __future__ import annotations from dataclasses import dataclass, field +from enum import IntEnum from typing import Any +# --------------------------------------------------------------------------- +# Severity +# --------------------------------------------------------------------------- + +class Severity(IntEnum): + """Drift severity - higher value means more dangerous.""" + + GREEN = 0 # Non-breaking + YELLOW = 1 # Potentially breaking + RED = 2 # Breaking + + @property + def badge(self) -> str: + badges = { + Severity.GREEN: "\U0001f7e2", # green circle + Severity.YELLOW: "\U0001f7e1", # yellow circle + Severity.RED: "\U0001f534", # red circle + } + return badges[self] + + @property + def label(self) -> str: + labels = { + Severity.GREEN: "Non-breaking", + Severity.YELLOW: "Potentially Breaking", + Severity.RED: "Breaking", + } + return labels[self] + + +# --------------------------------------------------------------------------- +# Drift events and report +# --------------------------------------------------------------------------- + +@dataclass +class DriftEvent: + """A single classified drift change.""" + + severity: Severity + category: str # e.g. "added_endpoint", "removed_field" + description: str # Human-readable explanation + impacted_endpoints: list[str] = field(default_factory=list) + why_it_matters: str = "" + + def as_bullet(self) -> str: + parts = [f"{self.severity.badge} **{self.severity.label}** - {self.description}"] + if self.impacted_endpoints: + eps = ", ".join(f"`{e}`" for e in self.impacted_endpoints) + parts.append(f" Impacted: {eps}") + if self.why_it_matters: + parts.append(f" Why: {self.why_it_matters}") + return "\n".join(parts) + + @dataclass class DriftReport: """Container for everything that changed between two OpenAPI specs.""" + events: list[DriftEvent] = field(default_factory=list) + + # Legacy lists kept for backward compatibility with existing consumers added_endpoints: list[str] = field(default_factory=list) removed_endpoints: list[str] = field(default_factory=list) schema_changes: list[str] = field(default_factory=list) @property def has_drift(self) -> bool: - return bool(self.added_endpoints or self.removed_endpoints or self.schema_changes) + return bool(self.events) + + @property + def max_severity(self) -> Severity: + """Return the highest severity among all events, or GREEN if none.""" + if not self.events: + return Severity.GREEN + return max(e.severity for e in self.events) + + def events_by_severity(self, severity: Severity) -> list[DriftEvent]: + return [e for e in self.events if e.severity == severity] def as_bullet_list(self) -> str: - """Return a Markdown bullet-list summary of all changes.""" + """Return a Markdown bullet-list summary grouped by severity.""" + if not self.events: + return "_No changes detected._" + lines: list[str] = [] - for ep in self.added_endpoints: - lines.append(f"- **Added endpoint:** `{ep}`") - for ep in self.removed_endpoints: - lines.append(f"- **Removed endpoint:** `{ep}`") - for change in self.schema_changes: - lines.append(f"- **Schema change:** {change}") - return "\n".join(lines) if lines else "_No changes detected._" + for sev in (Severity.RED, Severity.YELLOW, Severity.GREEN): + sev_events = self.events_by_severity(sev) + if sev_events: + lines.append(f"### {sev.badge} {sev.label} ({len(sev_events)})") + lines.append("") + for event in sev_events: + lines.append(f"- {event.as_bullet()}") + lines.append("") + return "\n".join(lines).strip() + + def summary_line(self) -> str: + """One-line summary suitable for commit messages or CLI output.""" + counts = [] + for sev in (Severity.RED, Severity.YELLOW, Severity.GREEN): + n = len(self.events_by_severity(sev)) + if n: + counts.append(f"{n} {sev.label.lower()}") + return ", ".join(counts) if counts else "no changes" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- def _endpoint_keys(spec: dict[str, Any]) -> set[str]: """Return a set of ``METHOD /path`` strings for every endpoint.""" @@ -61,30 +152,64 @@ def _resolve_ref(ref_or_schema: dict[str, Any], root: dict[str, Any]) -> dict[st return node -def _collect_schema_fields(spec: dict[str, Any]) -> dict[str, set[str]]: +def _collect_schema_info(spec: dict[str, Any]) -> dict[str, dict[str, Any]]: """ - Return a mapping of ``SchemaName -> {field1, field2, ...}`` for every - schema defined in ``components/schemas``. + Return a mapping of ``SchemaName -> {field_name: {type, required, ...}}`` + for every schema defined in ``components/schemas``. """ schemas = spec.get("components", {}).get("schemas", {}) - result: dict[str, set[str]] = {} + result: dict[str, dict[str, Any]] = {} for name, schema_def in schemas.items(): - # Handle allOf / anyOf wrappers simply by merging properties props: dict[str, Any] = {} + required_fields: list[str] = schema_def.get("required", []) + if "allOf" in schema_def: for sub in schema_def["allOf"]: resolved = _resolve_ref(sub, spec) props.update(resolved.get("properties", {})) + required_fields += resolved.get("required", []) else: props = schema_def.get("properties", {}) - result[name] = set(props.keys()) + + required_set = set(required_fields) + field_info: dict[str, Any] = {} + for field_name, field_schema in props.items(): + resolved = _resolve_ref(field_schema, spec) + field_info[field_name] = { + "type": resolved.get("type", "object"), + "required": field_name in required_set, + "enum": resolved.get("enum"), + "description": resolved.get("description", ""), + } + result[name] = field_info return result -def detect_drift(baseline: dict[str, Any], current: dict[str, Any]) -> DriftReport: +def _find_endpoints_using_schema( + schema_name: str, spec: dict[str, Any] +) -> list[str]: + """Find all endpoints that reference a given schema (by name).""" + endpoints: list[str] = [] + for path, methods in spec.get("paths", {}).items(): + for method, op in methods.items(): + if method not in ("get", "post", "put", "patch", "delete"): + continue + op_str = str(op) + if schema_name in op_str: + endpoints.append(f"{method.upper()} {path}") + return endpoints + + +# --------------------------------------------------------------------------- +# Main detection +# --------------------------------------------------------------------------- + +def detect_drift( + baseline: dict[str, Any], current: dict[str, Any] +) -> DriftReport: """ Compare *baseline* and *current* OpenAPI specs and return a - :class:`DriftReport` summarising what changed. + :class:`DriftReport` with severity-classified events. """ report = DriftReport() @@ -93,21 +218,211 @@ def detect_drift(baseline: dict[str, Any], current: dict[str, Any]) -> DriftRepo new_eps = _endpoint_keys(current) for ep in sorted(new_eps - old_eps): + report.events.append(DriftEvent( + severity=Severity.GREEN, + category="added_endpoint", + description=f"New endpoint: `{ep}`", + impacted_endpoints=[ep], + why_it_matters="New functionality. Existing clients are unaffected.", + )) report.added_endpoints.append(ep) + for ep in sorted(old_eps - new_eps): + report.events.append(DriftEvent( + severity=Severity.RED, + category="removed_endpoint", + description=f"Removed endpoint: `{ep}`", + impacted_endpoints=[ep], + why_it_matters="Clients calling this endpoint will get 404 errors.", + )) report.removed_endpoints.append(ep) # --- Schema-level changes --------------------------------------------- - old_schemas = _collect_schema_fields(baseline) - new_schemas = _collect_schema_fields(current) + old_schemas = _collect_schema_info(baseline) + new_schemas = _collect_schema_info(current) all_schema_names = sorted(set(old_schemas) | set(new_schemas)) - for name in all_schema_names: - old_fields = old_schemas.get(name, set()) - new_fields = new_schemas.get(name, set()) - for f in sorted(new_fields - old_fields): - report.schema_changes.append(f"`{name}` gained field `{f}`") - for f in sorted(old_fields - new_fields): - report.schema_changes.append(f"`{name}` lost field `{f}`") + for schema_name in all_schema_names: + old_fields = old_schemas.get(schema_name, {}) + new_fields = new_schemas.get(schema_name, {}) + impacted = ( + _find_endpoints_using_schema(schema_name, current) + or _find_endpoints_using_schema(schema_name, baseline) + ) + + # --- New fields --- + for f in sorted(set(new_fields) - set(old_fields)): + info = new_fields[f] + if info["required"]: + report.events.append(DriftEvent( + severity=Severity.RED, + category="added_required_field", + description=( + f"`{schema_name}` gained required field " + f"`{f}` ({info['type']})" + ), + impacted_endpoints=impacted, + why_it_matters=( + "Existing clients not sending this field will " + "get 422 validation errors." + ), + )) + else: + report.events.append(DriftEvent( + severity=Severity.GREEN, + category="added_optional_field", + description=( + f"`{schema_name}` gained optional field " + f"`{f}` ({info['type']})" + ), + impacted_endpoints=impacted, + why_it_matters=( + "Existing clients are unaffected; new field has " + "a default." + ), + )) + report.schema_changes.append( + f"`{schema_name}` gained field `{f}`" + ) + + # --- Removed fields --- + for f in sorted(set(old_fields) - set(new_fields)): + report.events.append(DriftEvent( + severity=Severity.RED, + category="removed_field", + description=f"`{schema_name}` lost field `{f}`", + impacted_endpoints=impacted, + why_it_matters="Clients relying on this field will break.", + )) + report.schema_changes.append( + f"`{schema_name}` lost field `{f}`" + ) + + # --- Changed fields (type, required toggle, enum) --- + for f in sorted(set(old_fields) & set(new_fields)): + old_info = old_fields[f] + new_info = new_fields[f] + + # Type change + if old_info["type"] != new_info["type"]: + report.events.append(DriftEvent( + severity=Severity.YELLOW, + category="type_change", + description=( + f"`{schema_name}.{f}` type changed: " + f"{old_info['type']} -> {new_info['type']}" + ), + impacted_endpoints=impacted, + why_it_matters=( + "Clients sending the old type may get " + "validation errors." + ), + )) + report.schema_changes.append( + f"`{schema_name}.{f}` type: " + f"{old_info['type']} -> {new_info['type']}" + ) + + # Required flag toggle + if old_info["required"] != new_info["required"]: + if new_info["required"]: + report.events.append(DriftEvent( + severity=Severity.YELLOW, + category="required_toggled", + description=( + f"`{schema_name}.{f}` is now required " + f"(was optional)" + ), + impacted_endpoints=impacted, + why_it_matters=( + "Clients not sending this field may start " + "getting 422 errors." + ), + )) + else: + report.events.append(DriftEvent( + severity=Severity.GREEN, + category="required_toggled", + description=( + f"`{schema_name}.{f}` is now optional " + f"(was required)" + ), + impacted_endpoints=impacted, + why_it_matters=( + "Relaxes the contract; existing clients " + "are unaffected." + ), + )) + report.schema_changes.append( + f"`{schema_name}.{f}` required: " + f"{old_info['required']} -> {new_info['required']}" + ) + + # Enum change + old_enum = old_info.get("enum") + new_enum = new_info.get("enum") + if old_enum != new_enum: + if old_enum and new_enum: + # Both sides have enums — compare values + removed_vals = set(old_enum) - set(new_enum) + if removed_vals: + report.events.append(DriftEvent( + severity=Severity.YELLOW, + category="enum_modified", + description=( + f"`{schema_name}.{f}` enum values removed: " + f"{', '.join(str(v) for v in sorted(removed_vals))}" + ), + impacted_endpoints=impacted, + why_it_matters=( + "Clients using removed enum values will get " + "validation errors." + ), + )) + added_vals = set(new_enum) - set(old_enum) + if added_vals: + report.events.append(DriftEvent( + severity=Severity.GREEN, + category="enum_modified", + description=( + f"`{schema_name}.{f}` enum values added: " + f"{', '.join(str(v) for v in sorted(added_vals))}" + ), + impacted_endpoints=impacted, + why_it_matters=( + "New allowed values; existing clients " + "unaffected." + ), + )) + elif old_enum and not new_enum: + # Enum constraint removed — relaxes the contract + report.events.append(DriftEvent( + severity=Severity.GREEN, + category="enum_removed", + description=( + f"`{schema_name}.{f}` enum constraint removed " + f"(was: {', '.join(str(v) for v in sorted(old_enum))})" + ), + impacted_endpoints=impacted, + why_it_matters=( + "Relaxes the contract; existing clients " + "are unaffected." + ), + )) + elif not old_enum and new_enum: + # Enum constraint added — may break clients + report.events.append(DriftEvent( + severity=Severity.YELLOW, + category="enum_added", + description=( + f"`{schema_name}.{f}` enum constraint added: " + f"{', '.join(str(v) for v in sorted(new_enum))}" + ), + impacted_endpoints=impacted, + why_it_matters=( + "Clients sending values outside the new enum " + "will get validation errors." + ), + )) return report diff --git a/drift_guard/guard.py b/drift_guard/guard.py index 4bd9270..c69ccdb 100644 --- a/drift_guard/guard.py +++ b/drift_guard/guard.py @@ -1,5 +1,4 @@ -""" -Docs Drift Guard – CLI entrypoint. +"""Docs Drift Guard – CLI entrypoint. Usage ----- @@ -9,10 +8,12 @@ Exit codes ---------- - 0 – success / no drift / no mismatches - 1 – runtime error (server down, missing file, …) - 2 – drift detected (useful for CI gating) - 3 – runtime mismatches found + 0 – success / no drift / no mismatches + 1 – runtime error (server down, missing file, …) + 2 – breaking (RED) drift detected + 3 – runtime mismatches found + 10 – non-breaking (GREEN) drift detected + 11 – potentially breaking (YELLOW) drift detected """ from __future__ import annotations @@ -24,7 +25,7 @@ import requests from rich.console import Console -from drift_guard.drift_detect import detect_drift +from drift_guard.drift_detect import DriftReport, Severity, detect_drift from drift_guard.openapi_to_md import openapi_to_markdown from drift_guard.slack_notify import send_slack_notification @@ -92,9 +93,50 @@ def cmd_baseline(url: str = DEFAULT_OPENAPI_URL) -> None: console.print("\n[green]Baseline complete.[/green]\n") +# Severity -> exit code mapping +_SEVERITY_EXIT_CODES = { + Severity.GREEN: 10, + Severity.YELLOW: 11, + Severity.RED: 2, +} + + +def _build_review_section(report: DriftReport) -> str: + """Build the Tier 2/3 review section appended to the drift summary.""" + lines = [ + "", + "## Proposed Narrative Updates (Human Review Required)", + "", + "The following changes may need updates to conceptual documentation, " + "migration guides, or client SDK examples.", + "", + ] + + for event in report.events: + if event.severity >= Severity.YELLOW: + confidence = "High" if event.severity == Severity.RED else "Medium" + lines.append( + f"- {event.severity.badge} **{event.description}**" + ) + lines.append(f" - Impact: {event.why_it_matters}") + lines.append(f" - Confidence: {confidence} (schema-derived)") + lines.append( + " - Action: Review and update narrative docs if applicable." + ) + lines.append("") + + lines.append( + "> :warning: **Suggested update \u2014 requires review.** " + "These sections were inferred from schema changes and may need " + "human judgment before being applied to conceptual docs." + ) + lines.append("") + return "\n".join(lines) + + def cmd_check(url: str = DEFAULT_OPENAPI_URL) -> None: - """Detect drift, regenerate docs, and optionally notify Slack.""" - console.rule("[bold blue]Drift Guard – Check[/bold blue]") + """Detect drift, classify severity, regenerate docs, and notify.""" + console.rule("[bold blue]Drift Guard \u2013 Check[/bold blue]") # Load baseline if not BASELINE_PATH.exists(): @@ -104,57 +146,146 @@ def cmd_check(url: str = DEFAULT_OPENAPI_URL) -> None: ) sys.exit(1) - baseline = json.loads(BASELINE_PATH.read_text()) + baseline_data = json.loads(BASELINE_PATH.read_text()) + + # Support both old format (raw spec) and new format (with _meta wrapper) + if "_meta" in baseline_data: + meta = baseline_data["_meta"] + baseline = baseline_data["spec"] + console.print( + f" Baseline from: [dim]{meta.get('saved_at', 'unknown')}[/dim] " + f"on [dim]{meta.get('saved_by_machine', 'unknown')}[/dim]" + ) + else: + baseline = baseline_data + current = _fetch_openapi(url) report = detect_drift(baseline, current) if not report.has_drift: - console.print("\n[green]No drift detected.[/green] Docs are up to date.\n") + console.print( + "\n[green]No drift detected.[/green] Docs are up to date.\n" + ) sys.exit(0) # --- Drift detected! --------------------------------------------------- - console.print("\n[yellow]Drift detected![/yellow]\n") + severity = report.max_severity + sev_style = { + Severity.GREEN: "green", + Severity.YELLOW: "yellow", + Severity.RED: "red", + }[severity] + console.print( + f"\n[{sev_style}]{severity.badge} Drift detected " + f"(max severity: {severity.label})[/{sev_style}]\n" + ) + console.print(f" Summary: {report.summary_line()}\n") console.print(report.as_bullet_list()) - # Regenerate reference docs from the *current* spec + # --- Always regenerate Tier 1 docs (deterministic, safe) --------------- md = openapi_to_markdown(current) _write_text(API_REF_PATH, md) - console.print(f"\n Regenerated [cyan]{API_REF_PATH}[/cyan]") + console.print(f"\n Regenerated [cyan]{API_REF_PATH}[/cyan] (Tier 1)") - # Write summary artifact + # --- Write summary artifact ------------------------------------------- summary_md = ( "# Drift Summary\n\n" + f"**Severity:** {severity.badge} {severity.label} \n" f"{report.as_bullet_list()}\n\n" - f"---\n\n" - f"`docs/api_reference.md` has been regenerated.\n" + "---\n\n" + "`docs/api_reference.md` has been regenerated (Tier 1).\n" ) + + # Tier 2/3: append review section for yellow/red + if severity >= Severity.YELLOW: + summary_md += _build_review_section(report) + _write_text(SUMMARY_PATH, summary_md) console.print(f" Wrote change summary to [cyan]{SUMMARY_PATH}[/cyan]") - # Update baseline to the current spec + # --- Update baseline -------------------------------------------------- _write_json(BASELINE_PATH, current) 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: only for YELLOW and RED (don't spam on GREEN) ------------- + if severity >= Severity.YELLOW: + send_slack_notification( + title=( + f"{severity.badge} Docs Drift Guard: " + f"{severity.label} API change" + ), + summary_bullets=report.as_bullet_list(), + severity=severity, + ) + else: + console.print( + "\n [dim]Severity is GREEN (non-breaking) \u2014 " + "skipping Slack notification.[/dim]" + ) + exit_code = _SEVERITY_EXIT_CODES[severity] console.print( - "\n[yellow]Exiting with code 2 (drift detected).[/yellow]\n" + f"\n[{sev_style}]Exiting with code {exit_code} " + f"({severity.label.lower()} drift).[/{sev_style}]\n" ) - sys.exit(2) + sys.exit(exit_code) # --------------------------------------------------------------------------- # CLI dispatcher # --------------------------------------------------------------------------- +def _build_runtime_signals(entries: list[dict]) -> str: + """Identify repeated runtime mismatches and surface as drift signals. + + Repeated mismatches (same method+path+issue_type) are grouped and + surfaced as "Runtime Drift Signals" — these indicate persistent + client-vs-code misalignment that may warrant documentation updates + or client notifications, but are NOT used to auto-generate docs. + """ + from collections import Counter + + # Count occurrences of each unique mismatch pattern + pattern_counts: Counter[str] = Counter() + pattern_details: dict[str, str] = {} + for entry in entries: + key = f"{entry['method']} {entry['path']} ({entry['issue_type']})" + pattern_counts[key] += 1 + pattern_details[key] = entry["details"] + + # Only surface patterns that appear more than once + repeated = {k: v for k, v in pattern_counts.items() if v > 1} + if not repeated: + return "" + + lines = [ + "", + "## Runtime Drift Signals", + "", + "The following mismatches occurred repeatedly, indicating " + "persistent client-vs-code misalignment. These are surfaced " + "for human review only — no docs are auto-generated from " + "runtime data.", + "", + ] + for pattern, count in sorted( + repeated.items(), key=lambda x: x[1], reverse=True + ): + lines.append(f"- **{pattern}** ({count}x)") + lines.append(f" - Detail: {pattern_details[pattern]}") + lines.append( + " - Recommendation: Review whether client SDKs or " + "docs need updating." + ) + lines.append("") + + return "\n".join(lines) + + def cmd_audit(url: str = DEFAULT_AUDIT_URL) -> None: """Fetch the runtime audit log, summarise mismatches, and notify Slack.""" - console.rule("[bold blue]Drift Guard – Audit[/bold blue]") + console.rule("[bold blue]Drift Guard \u2013 Audit[/bold blue]") try: resp = requests.get(url, timeout=5) @@ -168,14 +299,18 @@ def cmd_audit(url: str = DEFAULT_AUDIT_URL) -> None: ) sys.exit(1) except requests.HTTPError as exc: - console.print(f"[red]Error:[/red] HTTP {exc.response.status_code} from {url}") + console.print( + f"[red]Error:[/red] HTTP {exc.response.status_code} from {url}" + ) sys.exit(1) total = data.get("total_mismatches", 0) entries = data.get("entries", []) if total == 0: - console.print("\n[green]No runtime mismatches recorded.[/green]\n") + console.print( + "\n[green]No runtime mismatches recorded.[/green]\n" + ) sys.exit(0) console.print(f"\n[yellow]{total} mismatch(es) found![/yellow]\n") @@ -190,6 +325,14 @@ def cmd_audit(url: str = DEFAULT_AUDIT_URL) -> None: summary = "\n".join(lines) console.print(summary) + # Build runtime drift signals section + signals_section = _build_runtime_signals(entries) + if signals_section: + console.print( + "\n[yellow]Repeated mismatch patterns detected " + "(Runtime Drift Signals):[/yellow]" + ) + # Write audit report audit_report = ( "# Runtime Audit Report\n\n" @@ -198,7 +341,13 @@ def cmd_audit(url: str = DEFAULT_AUDIT_URL) -> None: "---\n\n" "These mismatches indicate clients calling the API with " "field names or endpoints that don't match the current spec.\n" + "\n> **Note:** Runtime mismatches are Tier 0 feedback signals. " + "They are surfaced for review but do NOT trigger automatic " + "doc regeneration.\n" ) + if signals_section: + audit_report += signals_section + audit_path = ARTIFACTS_DIR / "audit_report.md" _write_text(audit_path, audit_report) console.print(f"\n Wrote audit report to [cyan]{audit_path}[/cyan]") @@ -208,13 +357,16 @@ def cmd_audit(url: str = DEFAULT_AUDIT_URL) -> None: title="\U0001f6a8 API Call Guard: Runtime mismatches detected", summary_bullets=summary, context_message=( - ":warning: These mismatches indicate clients calling the API " - "with field names or endpoints that don't match the current spec." + ":warning: These mismatches indicate clients calling " + "the API with field names or endpoints that don't match " + "the current spec. Review only \u2014 no auto-generated " + "doc changes." ), ) console.print( - "\n[yellow]Exiting with code 3 (runtime mismatches found).[/yellow]\n" + "\n[yellow]Exiting with code 3 " + "(runtime mismatches found).[/yellow]\n" ) sys.exit(3) diff --git a/drift_guard/slack_notify.py b/drift_guard/slack_notify.py index 6e553ef..4195870 100644 --- a/drift_guard/slack_notify.py +++ b/drift_guard/slack_notify.py @@ -1,5 +1,7 @@ -""" -Send a drift summary to a Slack channel via an Incoming Webhook. +"""Send a drift summary to a Slack channel via an Incoming Webhook. + +Supports severity-aware formatting: RED gets urgent framing, +YELLOW gets review-required framing, GREEN is suppressed by the caller. 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. @@ -9,11 +11,14 @@ import json import os -from typing import Any +from typing import TYPE_CHECKING, Any import requests from rich.console import Console +if TYPE_CHECKING: + from drift_guard.drift_detect import Severity + console = Console() @@ -27,47 +32,79 @@ def _build_payload( title: str, summary_bullets: str, context_message: str = DEFAULT_CONTEXT, + action_required: str = "", ) -> dict[str, Any]: """Build a Slack Block Kit payload.""" - return { - "blocks": [ - { - "type": "header", - "text": {"type": "plain_text", "text": title, "emoji": True}, - }, + blocks: list[dict[str, Any]] = [ + { + "type": "header", + "text": {"type": "plain_text", "text": title, "emoji": True}, + }, + { + "type": "section", + "text": {"type": "mrkdwn", "text": summary_bullets}, + }, + ] + + if action_required: + blocks.append( { "type": "section", - "text": {"type": "mrkdwn", "text": summary_bullets}, - }, - {"type": "divider"}, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": context_message, - } - ], - }, - ], - } + "text": { + "type": "mrkdwn", + "text": f":rotating_light: *Action required:* {action_required}", + }, + } + ) + + blocks.append({"type": "divider"}) + blocks.append( + { + "type": "context", + "elements": [ + {"type": "mrkdwn", "text": context_message} + ], + } + ) + return {"blocks": blocks} def send_slack_notification( title: str, summary_bullets: str, context_message: str = DEFAULT_CONTEXT, + severity: Severity | None = None, ) -> None: + """Post a Slack message with optional severity-aware formatting. + + Falls back to printing the payload if the webhook URL is not + configured. """ - 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) + # Build action-required text based on severity + action_required = "" + if severity is not None: + from drift_guard.drift_detect import Severity as Sev + + if severity == Sev.RED: + action_required = ( + "This is a *breaking* change. Merge is blocked until " + "the team reviews and approves." + ) + elif severity == Sev.YELLOW: + action_required = ( + "This is a *potentially breaking* change. " + "PR review required before merge." + ) + + payload = _build_payload( + title, summary_bullets, context_message, action_required + ) 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 @@ -77,5 +114,6 @@ def send_slack_notification( console.print("[green]Slack notification sent successfully.[/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]" ) diff --git a/scripts/pre-commit-drift-check.sh b/scripts/pre-commit-drift-check.sh old mode 100755 new mode 100644 index 0a2e639..e97fa73 --- a/scripts/pre-commit-drift-check.sh +++ b/scripts/pre-commit-drift-check.sh @@ -3,8 +3,12 @@ # # A git pre-commit hook that checks whether staged changes to app/ # introduce API drift. It starts a temporary server, compares the -# live OpenAPI spec to the committed baseline, and blocks the commit -# if drift is found. +# live OpenAPI spec to the committed baseline, and takes action based +# on severity: +# +# GREEN (exit 10) — auto-update baseline + docs, commit proceeds +# YELLOW (exit 11) — auto-update baseline + docs, commit proceeds +# RED (exit 2) — BLOCK commit, requires human review # # Install: # cp scripts/pre-commit-drift-check.sh .git/hooks/pre-commit @@ -49,25 +53,61 @@ cleanup() { } trap cleanup EXIT -# Check for drift +# Check for drift (new exit codes: 10=green, 11=yellow, 2=red) set +e python -m drift_guard.guard check --url "http://127.0.0.1:${PORT}/openapi.json" EXIT_CODE=$? set -e -if [ "$EXIT_CODE" -eq 2 ]; then +# --------------------------------------------------------------- +# GREEN (10) or YELLOW (11): auto-update baseline + docs, proceed +# --------------------------------------------------------------- +if [ "$EXIT_CODE" -eq 10 ] || [ "$EXIT_CODE" -eq 11 ]; then + SEVERITY="non-breaking" + if [ "$EXIT_CODE" -eq 11 ]; then + SEVERITY="potentially breaking" + fi echo "" - echo "[drift-guard] API drift detected!" - echo "[drift-guard] The OpenAPI spec has changed but the baseline hasn't been updated." + echo "[drift-guard] ${SEVERITY} drift detected — auto-updating baseline and docs..." + + set +e + python -m drift_guard.guard baseline --url "http://127.0.0.1:${PORT}/openapi.json" + BASELINE_EXIT=$? + set -e + + if [ "$BASELINE_EXIT" -ne 0 ]; then + echo "[drift-guard] Failed to regenerate baseline (exit $BASELINE_EXIT)." + echo "[drift-guard] Please run manually: python -m drift_guard.guard baseline" + exit 1 + fi + + # Stage the updated files so they're included in this commit + git add artifacts/openapi_baseline.json docs/api_reference.md echo "" - echo " To fix, run:" - echo " python -m drift_guard.guard baseline" - echo " git add artifacts/openapi_baseline.json docs/api_reference.md" + echo "[drift-guard] Baseline and docs updated and staged. Commit will proceed." + exit 0 + +# --------------------------------------------------------------- +# RED (2): BLOCK the commit — breaking change needs human review +# --------------------------------------------------------------- +elif [ "$EXIT_CODE" -eq 2 ]; then + echo "" + echo "[drift-guard] BREAKING API drift detected! Commit blocked." echo "" - echo " To skip this check (not recommended):" - echo " git commit --no-verify" + echo " This change removes endpoints, removes fields, or adds new" + echo " required fields — it will break existing clients." + echo "" + echo " To proceed:" + echo " 1. Review the drift summary in artifacts/drift_summary.md" + echo " 2. Update baseline: python -m drift_guard.guard baseline" + echo " 3. Stage files: git add artifacts/openapi_baseline.json docs/api_reference.md" + echo " 4. Commit again" echo "" exit 1 + +# --------------------------------------------------------------- +# Error (1): warn but allow commit +# --------------------------------------------------------------- elif [ "$EXIT_CODE" -eq 1 ]; then echo "[drift-guard] Warning: drift check encountered an error (exit code 1)." echo "[drift-guard] Allowing commit to proceed — investigate manually."