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
11 changes: 11 additions & 0 deletions .console/log.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 0 additions & 5 deletions .custodian/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 42 additions & 3 deletions src/operations_center/observer/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/observer/test_service_cov.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading