diff --git a/apps/api/tests/test_awooop_source_correlation_apply_smoke.py b/apps/api/tests/test_awooop_source_correlation_apply_smoke.py new file mode 100644 index 00000000..322c0ee2 --- /dev/null +++ b/apps/api/tests/test_awooop_source_correlation_apply_smoke.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import importlib.util +import sys +from types import SimpleNamespace +from pathlib import Path + + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[3] + / "scripts" + / "awooop_source_correlation_apply_smoke.py" +) +SPEC = importlib.util.spec_from_file_location( + "awooop_source_correlation_apply_smoke", + SCRIPT_PATH, +) +awooop_source_correlation_apply_smoke = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +sys.dont_write_bytecode = True +sys.modules[SPEC.name] = awooop_source_correlation_apply_smoke +SPEC.loader.exec_module(awooop_source_correlation_apply_smoke) + + +def test_failed_check_summary_lists_preflight_failures() -> None: + payload = { + "checks": [ + { + "name": "source_review_work_item", + "passed": True, + "detail": "source_correlation_review", + }, + { + "name": "accepted_review_recorded", + "passed": False, + "detail": "missing", + }, + { + "name": "target_incident_present", + "passed": False, + "detail": "missing target_incident_id", + }, + ], + } + + assert awooop_source_correlation_apply_smoke._failed_check_summary(payload) == ( + "accepted_review_recorded=missing, " + "target_incident_present=missing target_incident_id" + ) + + +def test_source_review_readback_state_detects_accepted_review() -> None: + recurrence = { + "items": [ + { + "work_item": { + "kind": "source_correlation_review", + "work_item_id": ( + "source-evidence:sentry:upstream_canary:" + "awoooi-source-link-canary-gitea-cd-4294-1" + ), + }, + "source_correlation_review": { + "work_item_id": ( + "source-evidence:sentry:upstream_canary:" + "awoooi-source-link-canary-gitea-cd-4294-1" + ), + "decision": "accepted", + "review_id": "review-1", + }, + } + ], + } + + state = awooop_source_correlation_apply_smoke._source_review_readback_state( + recurrence, + work_item_id=( + "source-evidence:sentry:upstream_canary:" + "awoooi-source-link-canary-gitea-cd-4294-1" + ), + ) + + assert state == { + "found": True, + "decision": "accepted", + "review_id": "review-1", + } + + +def test_source_review_readback_state_reports_missing_review() -> None: + recurrence = { + "items": [ + { + "work_item": { + "kind": "source_correlation_review", + "work_item_id": "source-evidence:sentry:upstream_canary:item-1", + }, + "source_correlation_review": None, + } + ], + } + + state = awooop_source_correlation_apply_smoke._source_review_readback_state( + recurrence, + work_item_id="source-evidence:sentry:upstream_canary:item-1", + ) + + assert state == { + "found": True, + "decision": "missing", + "review_id": None, + } + + +def test_wait_for_review_readback_retries_until_accepted(monkeypatch) -> None: + work_item_id = "source-evidence:sentry:upstream_canary:item-1" + calls: list[str] = [] + recurrences = [ + { + "items": [ + { + "work_item": { + "kind": "source_correlation_review", + "work_item_id": work_item_id, + }, + "source_correlation_review": None, + } + ], + }, + { + "items": [ + { + "work_item": { + "kind": "source_correlation_review", + "work_item_id": work_item_id, + }, + "source_correlation_review": { + "work_item_id": work_item_id, + "decision": "accepted", + "review_id": "review-2", + }, + } + ], + }, + ] + + def fake_http_json(url: str, **_: object) -> dict[str, object]: + calls.append(url) + return recurrences[min(len(calls) - 1, len(recurrences) - 1)] + + monkeypatch.setattr(awooop_source_correlation_apply_smoke, "_http_json", fake_http_json) + monkeypatch.setattr(awooop_source_correlation_apply_smoke.time, "sleep", lambda _: None) + + state = awooop_source_correlation_apply_smoke._wait_for_review_readback( + args=SimpleNamespace( + api_url="https://awoooi.wooo.work", + project_id="awoooi", + provider="sentry", + limit=300, + review_readback_attempts=2, + review_readback_interval_seconds=0, + ), + work_item_id=work_item_id, + ) + + assert state == { + "found": True, + "decision": "accepted", + "review_id": "review-2", + } + assert len(calls) == 2 diff --git a/scripts/awooop_source_correlation_apply_smoke.py b/scripts/awooop_source_correlation_apply_smoke.py index 228e8a85..cd01681d 100644 --- a/scripts/awooop_source_correlation_apply_smoke.py +++ b/scripts/awooop_source_correlation_apply_smoke.py @@ -301,6 +301,88 @@ def _refresh_candidate_result(item: dict[str, Any]) -> dict[str, Any]: } +def _failed_check_summary(payload: dict[str, Any]) -> str: + checks = payload.get("checks") + if not isinstance(checks, list): + return "-" + failed: list[str] = [] + for check in checks: + if not isinstance(check, dict) or check.get("passed") is True: + continue + name = str(check.get("name") or "unknown").strip() or "unknown" + detail = str(check.get("detail") or "failed").strip() or "failed" + failed.append(f"{name}={detail}") + return ", ".join(failed) if failed else "-" + + +def _source_review_readback_state( + recurrence: dict[str, Any], + *, + work_item_id: str, +) -> dict[str, Any]: + items = recurrence.get("items") + if not isinstance(items, list): + return {"found": False, "decision": "missing_items", "review_id": None} + for item in items: + if not isinstance(item, dict): + continue + work_item = item.get("work_item") + if not isinstance(work_item, dict): + continue + source_review = ( + item.get("source_correlation_review") + if isinstance(item.get("source_correlation_review"), dict) + else {} + ) + candidate_ids = { + _source_review_work_item_id(item), + str(work_item.get("work_item_id") or "").strip(), + str(source_review.get("work_item_id") or "").strip(), + } + if work_item_id not in candidate_ids: + continue + return { + "found": True, + "decision": str(source_review.get("decision") or "missing").strip() + or "missing", + "review_id": source_review.get("review_id"), + } + return {"found": False, "decision": "not_found", "review_id": None} + + +def _wait_for_review_readback( + *, + args: argparse.Namespace, + work_item_id: str, +) -> dict[str, Any]: + recurrence_url = _url( + args.api_url, + "/api/v1/platform/events/dossier/recurrence", + { + "project_id": args.project_id, + "provider": args.provider, + "limit": args.limit, + }, + ) + last_state: dict[str, Any] = {} + attempts = max(int(args.review_readback_attempts or 1), 1) + for attempt in range(attempts): + recurrence = _http_json(recurrence_url) + last_state = _source_review_readback_state( + recurrence, + work_item_id=work_item_id, + ) + if last_state.get("found") and last_state.get("decision") == "accepted": + return last_state + if attempt + 1 < attempts: + time.sleep(max(float(args.review_readback_interval_seconds or 0), 0.0)) + raise SmokeError( + "accepted review did not appear in recurrence read model: " + f"work_item_id={work_item_id} found={last_state.get('found')} " + f"decision={last_state.get('decision')}" + ) + + def _apply_source_correlation_item( *, args: argparse.Namespace, @@ -332,6 +414,7 @@ def _apply_source_correlation_item( "accepted review was not recorded: " f"status={review.get('review_record_status')} allowed={review.get('allowed')}" ) + _wait_for_review_readback(args=args, work_item_id=work_item_id) apply_url = _url( args.api_url, @@ -352,7 +435,9 @@ def _apply_source_correlation_item( if apply_result.get("apply_status") != "applied": raise SmokeError( "source correlation apply did not complete: " - f"status={apply_result.get('apply_status')} allowed={apply_result.get('allowed')}" + f"status={apply_result.get('apply_status')} " + f"allowed={apply_result.get('allowed')} " + f"failed_checks={_failed_check_summary(apply_result)}" ) if apply_result.get("writes_incident_state") is not False: raise SmokeError("apply unexpectedly wrote Incident state") @@ -536,6 +621,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--min-applied", type=int, default=1) parser.add_argument("--status-attempts", type=int, default=8) parser.add_argument("--status-interval-seconds", type=float, default=2.0) + parser.add_argument("--review-readback-attempts", type=int, default=8) + parser.add_argument("--review-readback-interval-seconds", type=float, default=2.0) parser.add_argument("--allow-non-canary", action="store_true") parser.add_argument("--allow-existing-apply", action="store_true") parser.add_argument(