diff --git a/.console/log.md b/.console/log.md index 0bc8ff42c..83bdb1ab7 100644 --- a/.console/log.md +++ b/.console/log.md @@ -1,3 +1,14 @@ +## 2026-06-18 — feat: complete coverage trend enrichment + alert routing + +Wired the last 5 unbaselined coverage methods into `_record_coverage_trend`: +`calculate_trend_slope` + `calculate_volatility_score` + `get_historical_data` +enrich each trend record with direction/stability/history-depth; `categorize_alert` ++ `AlertChannelConfig.get_routes_for_alert` categorize and route every generated +alert to its delivery channels (default → operator). Pruned all 5 from +`audit.d12_baseline`; D12/DC10 gate confirms 0 — they're genuinely reachable from +production now, not baseline-hidden. New test drives the below-threshold → +categorize+route path. Closes the observer-plane completion backlog. + ## 2026-06-18 — chore: bump custodian pin to d6ba8ab (collision fix) Local `.venv` custodian was pinned at a29648a (pre-#48), and the reviewer fleet diff --git a/.custodian/config.yaml b/.custodian/config.yaml index 4b76ba5a4..3b01f051f 100644 --- a/.custodian/config.yaml +++ b/.custodian/config.yaml @@ -32,10 +32,7 @@ audit: - build_skipped - by_audit_type - by_repo - - calculate_trend_slope - - calculate_volatility_score - calibration_for - - categorize_alert - check_failure_rate_degradation - checkout_branch - cleanup_old_aggregations @@ -65,14 +62,12 @@ audit: - get_comment_reactions - get_error_rate_per_minute - get_extracted_data_summary - - get_historical_data - get_index_entry - get_latest_snapshot - get_latest_test_signal - get_pr_reactions - get_repo_changes - get_repository_health - - get_routes_for_alert - get_signal_by_run_id - get_signal_changes - get_snapshot diff --git a/src/operations_center/observer/service.py b/src/operations_center/observer/service.py index 66d3ecfdc..1ee15caac 100644 --- a/src/operations_center/observer/service.py +++ b/src/operations_center/observer/service.py @@ -496,7 +496,12 @@ def _record_coverage_trend( if pct is None: return try: - from operations_center.observer.coverage_alerting import CoverageAlertManager + from operations_center.observer.coverage_alerting import ( + AlertSeverity, + AlertType, + CoverageAlertManager, + ) + from operations_center.observer.coverage_config import AlertChannelConfig from operations_center.observer.coverage_models import CoverageSnapshot snapshot = CoverageSnapshot( @@ -514,17 +519,51 @@ def _record_coverage_trend( metric_type="line", granularity="repository", window_days=7 ) manager.save_trend_analysis(trend) + # Enrich the point-in-time trend with direction (slope), stability + # (volatility), and how much history backs it — so downstream + # autonomy can tell a noisy blip from a sustained decline. + slope = manager.calculate_trend_slope( + metric_type="line", granularity="repository", window_days=7 + ) + volatility = manager.calculate_volatility_score( + metric_type="line", granularity="repository", window_days=7 + ) + history_points = len( + manager.get_historical_data( + metric_type="line", granularity="repository" + ) + ) regressed = manager.detect_regression(snapshot, metric_type="line") - alerts = CoverageAlertManager().generate_alerts(snapshot, trend_analysis=trend) + alert_mgr = CoverageAlertManager() + alerts = alert_mgr.generate_alerts(snapshot, trend_analysis=trend) + # Categorize each alert and resolve its delivery channels, so an + # alert is actionable (typed + routed) rather than just recorded. + channel_config = AlertChannelConfig() + routed_summary: list[str] = [] for alert in alerts: manager.save_alert(alert) + category = alert_mgr.categorize_alert(alert) + module = alert.scope_id if alert.granularity == "module" else None + channels = channel_config.get_routes_for_alert( + AlertType(alert.alert_type), + AlertSeverity(alert.severity), + module, + ) + routed_summary.append( + f"{category['category']}→{','.join(channels)}" + ) if regressed or alerts: logger.warning( - "Coverage trend: run=%s coverage=%.1f%% regressed=%s alerts=%d", + "Coverage trend: run=%s coverage=%.1f%% slope=%+.2f%%/d " + "volatility=%.2f history=%d regressed=%s alerts=%d routed=[%s]", context.run_id, float(pct), + slope, + volatility, + history_points, regressed, len(alerts), + "; ".join(routed_summary), ) except Exception as exc: # noqa: BLE001 — trend/alerting is best-effort logger.debug("coverage trend recording skipped: %s", exc) diff --git a/tests/unit/observer/test_service_cov.py b/tests/unit/observer/test_service_cov.py index 16025d269..9e35dc22a 100644 --- a/tests/unit/observer/test_service_cov.py +++ b/tests/unit/observer/test_service_cov.py @@ -161,6 +161,40 @@ def test_observe_drives_coverage_trend_recording(tmp_path: Path) -> None: assert len(manager.list_snapshots(limit=5)) == 1 +def test_observe_low_coverage_categorizes_and_routes_alerts( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # A below-threshold coverage observation drives the full trend+alert wire: + # slope/volatility/history enrichment plus per-alert categorization and + # channel routing. The previously-unwired methods (calculate_trend_slope, + # calculate_volatility_score, get_historical_data, categorize_alert, + # get_routes_for_alert) all run here. + builder = MagicMock() + builder.build.return_value = "BUILT" + writer = MagicMock() + writer.root = tmp_path / "obs" + writer.write.return_value = ["x.json"] + svc = _make_service( + snapshot_builder=builder, + artifact_writer=writer, + coverage_signal_collector=_collector( + CoverageSignal(status="ok", total_coverage_pct=10.0) + ), + ) + + with caplog.at_level("WARNING", logger=service_mod.logger.name): + svc.observe(_make_context(tmp_path)) + + # A below-threshold alert was generated, categorized, and routed. + trend_log = [r.getMessage() for r in caplog.records if "Coverage trend:" in r.getMessage()] + assert trend_log, "expected a coverage-trend warning for the below-threshold run" + msg = trend_log[0] + assert "slope=" in msg and "volatility=" in msg + assert "routed=[" in msg + # Default routing falls back to the operator channel. + assert "operator" in msg + + def test_observe_no_coverage_does_not_record_trend(tmp_path: Path) -> None: builder = MagicMock() builder.build.return_value = "BUILT"