Skip to content

Commit f6f1894

Browse files
committed
fix: canonicalize reviewer-bot timestamps
1 parent 1811158 commit f6f1894

14 files changed

Lines changed: 227 additions & 75 deletions

scripts/reviewer_bot_lib/deferred_gap_bookkeeping.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from dataclasses import dataclass
77
from datetime import datetime, timedelta, timezone
88

9+
from .timestamps import normalize_iso8601_utc_string, parse_iso8601_utc
10+
911

1012
@dataclass(frozen=True)
1113
class DeferredGap:
@@ -216,15 +218,7 @@ def _observer_now_iso(bot) -> str:
216218

217219

218220
def _parse_observer_timestamp(value: object) -> datetime | None:
219-
if not isinstance(value, str) or not value.strip():
220-
return None
221-
try:
222-
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
223-
except ValueError:
224-
return None
225-
if timestamp.tzinfo is None:
226-
return timestamp.replace(tzinfo=timezone.utc)
227-
return timestamp
221+
return parse_iso8601_utc(value)
228222

229223

230224
def _configured_seconds(bot, name: str, default: int) -> int:
@@ -247,6 +241,8 @@ def begin_observer_surface_scan(
247241
scan_started_at = now or bot.clock.now()
248242
if scan_started_at.tzinfo is None:
249243
scan_started_at = scan_started_at.replace(tzinfo=timezone.utc)
244+
else:
245+
scan_started_at = scan_started_at.astimezone(timezone.utc)
250246
lookback_seconds = _configured_seconds(bot, "DEFERRED_DISCOVERY_OVERLAP_SECONDS", 3600)
251247
bootstrap_window_seconds = _configured_seconds(bot, "DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS", 604800)
252248
watermark.update(
@@ -266,11 +262,12 @@ def begin_observer_surface_scan(
266262
def record_observer_watermark_event(bot, review_data: dict, surface: str, event_time: str, event_id: str) -> None:
267263
current = _ensure_observer_discovery_watermark(review_data, surface)
268264
now = _observer_now_iso(bot)
265+
safe_event_time = normalize_iso8601_utc_string(event_time) or event_time
269266
current.update(
270267
{
271268
"last_scan_started_at": current.get("last_scan_started_at") or now,
272269
"last_scan_completed_at": now,
273-
"last_safe_event_time": event_time,
270+
"last_safe_event_time": safe_event_time,
274271
"last_safe_event_id": event_id,
275272
"lookback_seconds": _configured_seconds(bot, "DEFERRED_DISCOVERY_OVERLAP_SECONDS", 3600),
276273
"bootstrap_window_seconds": _configured_seconds(bot, "DEFERRED_DISCOVERY_BOOTSTRAP_WINDOW_SECONDS", 604800),
@@ -304,7 +301,8 @@ def get_deferred_gap_reason(review_data: dict, source_event_key: str) -> str | N
304301

305302

306303
def _now_iso(bot) -> str:
307-
return bot.clock.now().isoformat()
304+
now = bot.clock.now()
305+
return normalize_iso8601_utc_string(now) or now.isoformat()
308306

309307

310308
def _reconciled_at_now() -> str:
@@ -360,7 +358,7 @@ def mark_reconciled_source_event(
360358
reconciled_at: str | None = None,
361359
) -> bool:
362360
reconciled = _reconciled_source_events(review_data)
363-
timestamp = reconciled_at or _reconciled_at_now()
361+
timestamp = normalize_iso8601_utc_string(reconciled_at) or reconciled_at or _reconciled_at_now()
364362
existing = reconciled.get(source_event_key)
365363
if isinstance(existing, dict):
366364
if not _is_valid_reconciled_source_event(existing, source_event_key):
@@ -424,14 +422,15 @@ def _copy_source_evidence(fields: dict, payload: dict, existing: dict) -> None:
424422

425423

426424
def _source_event_created_at(payload: dict, existing: dict):
427-
return (
425+
value = (
428426
payload.get("source_created_at")
429427
or payload.get("comment_created_at")
430428
or payload.get("source_submitted_at")
431429
or payload.get("source_dismissed_at")
432430
or payload.get("source_event_created_at")
433431
or existing.get("source_event_created_at")
434432
)
433+
return normalize_iso8601_utc_string(value) or value
435434

436435

437436
def record_deferred_gap_diagnostic(
@@ -469,7 +468,7 @@ def record_deferred_gap_diagnostic(
469468
}
470469
source_dismissed_at = _payload_or_existing(payload, existing, "source_dismissed_at")
471470
if source_dismissed_at is not None:
472-
fields["source_dismissed_at"] = source_dismissed_at
471+
fields["source_dismissed_at"] = normalize_iso8601_utc_string(source_dismissed_at) or source_dismissed_at
473472
_copy_source_evidence(fields, payload, existing)
474473
existing.update(fields)
475474
changed = previous != existing

scripts/reviewer_bot_lib/event_inputs.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import json
66
from dataclasses import dataclass
7-
from datetime import datetime, timezone
87
from pathlib import Path
98

109
from scripts.reviewer_bot_core.comment_routing_policy import PrCommentRouterOutcome
@@ -21,6 +20,7 @@
2120
PullRequestSyncRequest,
2221
)
2322
from .runtime_protocols import EventInputsContext
23+
from .timestamps import normalize_iso8601_utc_string
2424

2525

2626
class InvalidEventInput(RuntimeError):
@@ -137,16 +137,7 @@ def parse_issue_labels(bot: EventInputsContext) -> list[str]:
137137

138138

139139
def _normalize_iso8601_timestamp(value: str) -> str | None:
140-
value = value.strip()
141-
if not value:
142-
return None
143-
try:
144-
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
145-
except ValueError:
146-
return None
147-
if timestamp.tzinfo is None:
148-
return timestamp.replace(tzinfo=timezone.utc).isoformat()
149-
return value
140+
return normalize_iso8601_utc_string(value)
150141

151142

152143
def _is_parseable_iso8601(value: str) -> bool:

scripts/reviewer_bot_lib/overdue.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6-
from datetime import datetime, timezone
6+
from datetime import datetime
77

88
from . import assignment_flow
99
from .config import TRANSITION_NOTICE_MARKER_PREFIX, TRANSITION_WARNING_MARKER_PREFIX
1010
from .reminder_comments import ReminderCommentScan, scan_reviewer_reminder_comments
1111
from .repair_records import clear_repair_marker, store_repair_marker
12+
from .timestamps import parse_iso8601_utc
1213

1314
_TRANSITION_NOTICE_AUTHORS = {"github-actions[bot]", "guidelines-bot"}
1415

@@ -418,15 +419,7 @@ def build_reminder_delivery_persistence_result(
418419

419420

420421
def _parse_reminder_timestamp(value: object) -> datetime | None:
421-
if isinstance(value, datetime):
422-
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
423-
if not isinstance(value, str) or not value.strip():
424-
return None
425-
try:
426-
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
427-
except ValueError:
428-
return None
429-
return timestamp if timestamp.tzinfo is not None else timestamp.replace(tzinfo=timezone.utc)
422+
return parse_iso8601_utc(value)
430423

431424

432425
def derive_reminder_cadence_decision(

scripts/reviewer_bot_lib/reconcile_payloads.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6-
from datetime import datetime, timezone
76
from enum import StrEnum
87

8+
from .timestamps import normalize_iso8601_utc_string
9+
910

1011
class DeferredPayloadKind(StrEnum):
1112
DEFERRED_COMMENT = "deferred_comment"
@@ -520,13 +521,10 @@ def _first_string(payload: dict, field_names: tuple[str, ...], diagnostic_name:
520521

521522
def _recoverable_timestamp(payload: dict, field_names: tuple[str, ...]) -> str:
522523
timestamp = _first_string(payload, field_names, "source event timestamp")
523-
try:
524-
parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
525-
except ValueError as exc:
526-
raise RuntimeError("Deferred context payload source event timestamp is not parseable ISO-8601") from exc
527-
if parsed.tzinfo is None:
528-
return parsed.replace(tzinfo=timezone.utc).isoformat()
529-
return timestamp
524+
normalized = normalize_iso8601_utc_string(timestamp)
525+
if normalized is None:
526+
raise RuntimeError("Deferred context payload source event timestamp is not parseable ISO-8601")
527+
return normalized
530528

531529

532530
def _optional_nonempty_string(payload: dict, field_name: str) -> str | None:
@@ -536,6 +534,19 @@ def _optional_nonempty_string(payload: dict, field_name: str) -> str | None:
536534
return None
537535

538536

537+
def _timestamp_string(value: object, diagnostic_name: str) -> str:
538+
normalized = normalize_iso8601_utc_string(value)
539+
if normalized is None:
540+
raise RuntimeError(f"Deferred context payload {diagnostic_name} is not parseable ISO-8601")
541+
return normalized
542+
543+
544+
def _optional_timestamp_string(value: object) -> str | None:
545+
if value is None:
546+
return None
547+
return normalize_iso8601_utc_string(value) or str(value)
548+
549+
539550
def _diagnostic_payload(payload: dict, contract: DeferredIdentityContract, *, source_run_id: int, source_run_attempt: int, pr_number: int, source_event_key: str, source_object_id: int, actor_login: str, source_event_created_at: str) -> dict:
540551
diagnostic = {
541552
"source_run_id": source_run_id,
@@ -556,6 +567,8 @@ def _diagnostic_payload(payload: dict, contract: DeferredIdentityContract, *, so
556567
"source_dismissed_at",
557568
):
558569
value = _optional_nonempty_string(payload, field_name)
570+
if value is not None and field_name == "source_dismissed_at":
571+
value = normalize_iso8601_utc_string(value) or value
559572
if value is not None:
560573
diagnostic[field_name] = value
561574
actor_id = payload.get("source_actor_id", payload.get("comment_author_id", payload.get("actor_id")))
@@ -831,7 +844,7 @@ def parse_deferred_context_payload(payload: dict) -> DeferredReviewPayload | Def
831844
identity=identity,
832845
comment_id=comment_id,
833846
comment_body="",
834-
comment_created_at=str(payload["source_created_at"]),
847+
comment_created_at=_timestamp_string(payload["source_created_at"], "source_created_at"),
835848
comment_author=str(payload["actor_login"]),
836849
comment_author_id=comment_author_id,
837850
comment_user_type=str(payload.get("actor_user_type") or "User"),
@@ -853,7 +866,7 @@ def parse_deferred_context_payload(payload: dict) -> DeferredReviewPayload | Def
853866
identity=identity,
854867
comment_id=comment_id,
855868
comment_body=str(payload["comment_body"]),
856-
comment_created_at=str(payload["comment_created_at"]),
869+
comment_created_at=_timestamp_string(payload["comment_created_at"], "comment_created_at"),
857870
comment_author=str(payload["comment_author"]),
858871
comment_author_id=int(payload["comment_author_id"]),
859872
comment_user_type=str(payload["comment_user_type"]),
@@ -873,7 +886,7 @@ def parse_deferred_context_payload(payload: dict) -> DeferredReviewPayload | Def
873886
common = dict(
874887
identity=identity,
875888
review_id=review_id,
876-
source_submitted_at=(str(payload["source_submitted_at"]) if payload.get("source_submitted_at") is not None else None),
889+
source_submitted_at=_optional_timestamp_string(payload.get("source_submitted_at")),
877890
source_review_state=(str(payload["source_review_state"]) if payload.get("source_review_state") is not None else None),
878891
source_commit_id=(str(payload["source_commit_id"]) if payload.get("source_commit_id") is not None else None),
879892
actor_login=(str(payload["actor_login"]) if payload.get("actor_login") is not None else None),
@@ -883,7 +896,7 @@ def parse_deferred_context_payload(payload: dict) -> DeferredReviewPayload | Def
883896
return DeferredReviewSubmittedPayload(**common)
884897
return DeferredReviewDismissedPayload(
885898
**common,
886-
source_dismissed_at=(str(payload["source_dismissed_at"]) if payload.get("source_dismissed_at") is not None else None),
899+
source_dismissed_at=_optional_timestamp_string(payload.get("source_dismissed_at")),
887900
)
888901
raise RuntimeError("Unsupported deferred workflow_run payload")
889902

scripts/reviewer_bot_lib/reconcile_reads.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6-
from datetime import datetime, timezone
6+
7+
from .timestamps import normalize_iso8601_utc_string
78

89

910
class ReconcileReadError(RuntimeError):
@@ -48,13 +49,7 @@ def _valid_exact_timestamp(value: object) -> str | None:
4849
timestamp = value.strip()
4950
if "T" not in timestamp:
5051
return None
51-
try:
52-
parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
53-
except ValueError:
54-
return None
55-
if parsed.tzinfo is None:
56-
return parsed.replace(tzinfo=timezone.utc).isoformat()
57-
return timestamp
52+
return normalize_iso8601_utc_string(timestamp)
5853

5954

6055
def _dismissed_review_source_time(payload: dict | None) -> DismissalTimeResolution | None:
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Reviewer-bot timestamp normalization helpers."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime, timezone
6+
from typing import Any
7+
8+
9+
def parse_iso8601_utc(value: Any) -> datetime | None:
10+
if isinstance(value, datetime):
11+
timestamp = value
12+
elif isinstance(value, str) and value.strip():
13+
try:
14+
timestamp = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
15+
except ValueError:
16+
return None
17+
else:
18+
return None
19+
if timestamp.tzinfo is None:
20+
return timestamp.replace(tzinfo=timezone.utc)
21+
return timestamp.astimezone(timezone.utc)
22+
23+
24+
def normalize_iso8601_utc_string(value: Any) -> str | None:
25+
timestamp = parse_iso8601_utc(value)
26+
return timestamp.isoformat() if timestamp is not None else None

tests/integration/reviewer_bot/test_reconcile_workflow_run.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ def test_deferred_review_dismissal_replay_uses_source_dismissal_time(monkeypatch
294294
harness.stub_head_repair(changed=False)
295295

296296
assert harness.run(state) is True
297-
assert review["review_dismissal"]["accepted"]["timestamp"] == "2026-03-17T10:10:00Z"
297+
assert review["review_dismissal"]["accepted"]["timestamp"] == "2026-03-17T10:10:00+00:00"
298298
assert "pull_request_review_dismissed:12" in _reconciled_source_events(review)
299299
assert "pull_request_review_dismissed:12" not in _deferred_gaps(review)
300300
assert "issues/42/timeline?per_page=100&page=1" not in harness.github.requested_endpoints()
@@ -333,7 +333,7 @@ def test_deferred_review_dismissal_replay_uses_exact_timeline_dismissed_at(monke
333333
harness.stub_head_repair(changed=False)
334334

335335
assert harness.run(state) is True
336-
assert review["review_dismissal"]["accepted"]["timestamp"] == "2026-03-17T10:12:00Z"
336+
assert review["review_dismissal"]["accepted"]["timestamp"] == "2026-03-17T10:12:00+00:00"
337337
assert "pull_request_review_dismissed:12" in _reconciled_source_events(review)
338338

339339

@@ -385,7 +385,7 @@ def test_deferred_review_dismissal_replay_finds_exact_time_on_paginated_timeline
385385

386386
assert harness.run(state) is True
387387

388-
assert review["review_dismissal"]["accepted"]["timestamp"] == "2026-03-17T10:12:00Z"
388+
assert review["review_dismissal"]["accepted"]["timestamp"] == "2026-03-17T10:12:00+00:00"
389389
assert "issues/42/timeline?per_page=100&page=1" in harness.github.requested_endpoints()
390390
assert "issues/42/timeline?per_page=100&page=2" in harness.github.requested_endpoints()
391391

@@ -623,7 +623,7 @@ def test_deferred_comment_missing_live_object_preserves_source_time_freshness(mo
623623
assert state["active_reviews"]["42"]["reviewer_comment"]["accepted"] is None
624624
gap = state["active_reviews"]["42"]["sidecars"]["deferred_gaps"]["issue_comment:99"]
625625
assert gap["reason"] == "reconcile_failed_closed"
626-
assert gap["source_event_created_at"] == "2026-03-17T10:00:00Z"
626+
assert gap["source_event_created_at"] == "2026-03-17T10:00:00+00:00"
627627
assert gap["source_actor_login"] == "alice"
628628
assert gap["source_actor_id"] == 7001
629629
assert gap["source_actor_user_type"] == "User"
@@ -865,7 +865,7 @@ def test_deferred_review_comment_missing_live_object_preserves_source_time_fresh
865865
assert review["reviewer_comment"]["accepted"] is None
866866
gap = _deferred_gaps(review)["pull_request_review_comment:303"]
867867
assert gap["reason"] == "reconcile_failed_closed"
868-
assert gap["source_event_created_at"] == "2026-03-17T10:00:00Z"
868+
assert gap["source_event_created_at"] == "2026-03-17T10:00:00+00:00"
869869
assert gap["source_actor_login"] == "alice"
870870
assert gap["source_actor_id"] == 6
871871
assert gap["source_actor_user_type"] == "User"
@@ -1210,7 +1210,7 @@ def test_deferred_issue_comment_parse_failure_records_artifact_invalid_gap(monke
12101210
gap = _deferred_gaps(review)["issue_comment:212"]
12111211
assert gap["reason"] == "artifact_invalid"
12121212
assert gap["source_comment_id"] == 212
1213-
assert gap["source_event_created_at"] == "2026-03-17T10:00:00Z"
1213+
assert gap["source_event_created_at"] == "2026-03-17T10:00:00+00:00"
12141214

12151215

12161216
def test_deferred_issue_comment_parse_failure_rejects_mismatched_source_object_key(monkeypatch):
@@ -1259,7 +1259,7 @@ def test_deferred_review_submitted_parse_failure_records_artifact_invalid_gap(mo
12591259
gap = _deferred_gaps(review)["pull_request_review:13"]
12601260
assert gap["reason"] == "artifact_invalid"
12611261
assert gap["source_review_id"] == 13
1262-
assert gap["source_event_created_at"] == "2026-03-17T10:00:00Z"
1262+
assert gap["source_event_created_at"] == "2026-03-17T10:00:00+00:00"
12631263

12641264

12651265
def test_deferred_review_dismissed_parse_failure_records_artifact_invalid_gap(monkeypatch):
@@ -1283,7 +1283,7 @@ def test_deferred_review_dismissed_parse_failure_records_artifact_invalid_gap(mo
12831283
gap = _deferred_gaps(review)["pull_request_review_dismissed:14"]
12841284
assert gap["reason"] == "artifact_invalid"
12851285
assert gap["source_review_id"] == 14
1286-
assert gap["source_event_created_at"] == "2026-03-17T10:10:00Z"
1286+
assert gap["source_event_created_at"] == "2026-03-17T10:10:00+00:00"
12871287

12881288

12891289
def test_deferred_legacy_review_comment_hydrates_source_commit_id_from_live_comment(monkeypatch):

0 commit comments

Comments
 (0)