33from __future__ import annotations
44
55from dataclasses import dataclass
6+ from datetime import datetime , timezone
67
78from . import assignment_flow
89from .config import TRANSITION_NOTICE_MARKER_PREFIX , TRANSITION_WARNING_MARKER_PREFIX
@@ -253,6 +254,18 @@ def build_reminder_delivery_persistence_result(
253254 )
254255
255256
257+ def _parse_reminder_timestamp (value : object ) -> datetime | None :
258+ if isinstance (value , datetime ):
259+ return value if value .tzinfo is not None else value .replace (tzinfo = timezone .utc )
260+ if not isinstance (value , str ) or not value .strip ():
261+ return None
262+ try :
263+ timestamp = datetime .fromisoformat (value .replace ("Z" , "+00:00" ))
264+ except ValueError :
265+ return None
266+ return timestamp if timestamp .tzinfo is not None else timestamp .replace (tzinfo = timezone .utc )
267+
268+
256269def derive_reminder_cadence_decision (
257270 response ,
258271 * ,
@@ -262,23 +275,38 @@ def derive_reminder_cadence_decision(
262275 review_deadline_days : int ,
263276 transition_period_days : int ,
264277) -> ReminderCadenceDecision :
265- del now , review_deadline_days , transition_period_days
266278 issue_number = int (getattr (response , "scope" , None ).issue_number or 0 ) if getattr (response , "scope" , None ) else 0
267279 reviewer = getattr (getattr (response , "scope" , None ), "reviewer" , None )
268280 effective_receipt = receipt if receipt is not None and receipt .receipt_kind != "none" else None
269281 legacy_duplicate_count = reminder_scan .baseline_count if reminder_scan is not None else 0
270282 exhausted = (effective_receipt is not None and effective_receipt .receipt_kind in {"transition" , "legacy_transition" }) or legacy_duplicate_count >= 2
283+ now_dt = _parse_reminder_timestamp (now )
284+ anchor_dt = _parse_reminder_timestamp (getattr (response , "anchor_timestamp" , None ))
285+ receipt_dt = _parse_reminder_timestamp (effective_receipt .created_at if effective_receipt else None )
286+ cadence_state = "exhausted" if exhausted else "not_started"
287+ may_post_warning = False
288+ may_post_transition = False
289+ if not exhausted and getattr (response , "response_state" , None ) == "awaiting_reviewer_response" :
290+ if effective_receipt is not None and effective_receipt .receipt_kind in {"warning" , "legacy_warning_or_reminder" }:
291+ if now_dt is not None and receipt_dt is not None and (now_dt - receipt_dt ).days >= transition_period_days :
292+ cadence_state = "transition_due"
293+ may_post_transition = True
294+ elif effective_receipt is None and now_dt is not None and anchor_dt is not None and (now_dt - anchor_dt ).days >= review_deadline_days :
295+ cadence_state = "warning_due"
296+ may_post_warning = True
297+ elif now_dt is None or (effective_receipt is None and anchor_dt is None ):
298+ cadence_state = "blocked"
271299 return ReminderCadenceDecision (
272300 issue_number = issue_number ,
273301 reviewer = reviewer ,
274302 scope = getattr (response , "scope" , None ),
275- cadence_state = "exhausted" if exhausted else "not_started" ,
303+ cadence_state = cadence_state ,
276304 exhaustion_reason = "legacy_duplicate_reminders_exhausted" if legacy_duplicate_count >= 2 else "transition_notice_sent" if exhausted else "not_exhausted" ,
277305 warning_receipt = effective_receipt if effective_receipt and effective_receipt .receipt_kind in {"warning" , "legacy_warning_or_reminder" } else None ,
278306 transition_receipt = effective_receipt if effective_receipt and effective_receipt .receipt_kind in {"transition" , "legacy_transition" } else None ,
279307 legacy_duplicate_count = legacy_duplicate_count ,
280- may_post_warning = not exhausted and effective_receipt is None ,
281- may_post_transition = not exhausted and effective_receipt is not None and effective_receipt . receipt_kind == "warning" ,
308+ may_post_warning = may_post_warning ,
309+ may_post_transition = may_post_transition ,
282310 must_project_reassignment_needed = exhausted ,
283311 )
284312
@@ -295,10 +323,10 @@ def decide_overdue_reminder(
295323 if getattr (response , "response_state" , None ) != "awaiting_reviewer_response" or cadence .must_project_reassignment_needed :
296324 action = "none"
297325 reason = cadence .exhaustion_reason or "not_awaiting_reviewer_response"
298- elif cadence .may_post_transition :
326+ elif cadence .cadence_state == "transition_due" and cadence . may_post_transition :
299327 action = "transition"
300328 reason = "transition_due"
301- elif cadence .may_post_warning :
329+ elif cadence .cadence_state == "warning_due" and cadence . may_post_warning :
302330 action = "warning"
303331 reason = "warning_due"
304332 else :
@@ -469,16 +497,16 @@ def _effective_response_with_cadence(bot, issue_number: int, review_data: dict,
469497 response_payload .setdefault ("issue_number" , issue_number )
470498 response_payload .setdefault ("current_reviewer" , review_data .get ("current_reviewer" ))
471499 response = reviewer_response_policy .to_reviewer_response_decision (response_payload )
500+ reminder_scan = _scan_live_reminder_comments (bot , issue_number )
472501 receipt = derive_reminder_scope_receipt (
473502 issue_number = issue_number ,
474503 reviewer = getattr (response .scope , "reviewer" , None ) if response .scope else review_data .get ("current_reviewer" ),
475504 head_sha = getattr (response .scope , "head_sha" , None ) if response .scope else None ,
476505 cycle_key = getattr (response .scope , "cycle_key" , None ) if response .scope else None ,
477506 scope_key = getattr (response .scope , "scope_key" , None ) if response .scope else None ,
478507 persisted_state = review_data ,
479- scanned_comments = (),
508+ scanned_comments = reminder_scan . records if reminder_scan is not None else (),
480509 )
481- reminder_scan = _scan_live_reminder_comments (bot , issue_number )
482510 cadence = derive_reminder_cadence_decision (
483511 response ,
484512 receipt = receipt ,
@@ -598,46 +626,15 @@ def evaluate_overdue_review_preview(bot, state: dict, issue_number: int) -> dict
598626 }
599627 if authority ["authority_status" ] != "tracked_reviewer_confirmed" :
600628 return preview
601- if cadence .must_project_reassignment_needed :
602- return preview
603- if response_name != "awaiting_reviewer_response" :
604- return preview
605- current_reviewer = review_data .get ("current_reviewer" )
606- anchor_timestamp = effective_response .anchor_timestamp
607- if not isinstance (current_reviewer , str ) or not current_reviewer .strip () or not isinstance (anchor_timestamp , str ) or not anchor_timestamp :
608- return preview
609- try :
610- now = bot .datetime .now (bot .timezone .utc )
611- anchor_dt = bot .datetime .fromisoformat (anchor_timestamp .replace ("Z" , "+00:00" ))
612- except (ValueError , AttributeError ):
613- return preview
614- transition_warning_sent = review_data .get ("transition_warning_sent" )
615- if isinstance (transition_warning_sent , str ) and transition_warning_sent :
616- try :
617- warning_dt = bot .datetime .fromisoformat (transition_warning_sent .replace ("Z" , "+00:00" ))
618- except (ValueError , AttributeError ):
619- return preview
620- if (now - warning_dt ).days < bot .TRANSITION_PERIOD_DAYS :
621- return preview
622- existing_notice = find_existing_transition_notice_result (
623- bot ,
624- issue_number ,
625- transition_warning_sent ,
626- current_reviewer ,
627- )
628- preview ["would_post_transition" ] = existing_notice .get ("status" ) == "missing"
629- return preview
630- if (now - anchor_dt ).days < bot .REVIEW_DEADLINE_DAYS :
631- return preview
632- existing_warning = _find_existing_warning_comment (
633- bot ,
634- issue_number ,
635- current_reviewer ,
636- anchor_timestamp ,
637- current_scope_key = current_scope_key ,
638- current_scope_basis = current_scope_basis ,
629+ reminder_decision = decide_overdue_reminder (
630+ effective_response ,
631+ cadence = cadence ,
632+ now = bot .datetime .now (bot .timezone .utc ),
633+ review_deadline_days = bot .REVIEW_DEADLINE_DAYS ,
634+ transition_period_days = bot .TRANSITION_PERIOD_DAYS ,
639635 )
640- preview ["would_post_warning" ] = existing_warning .get ("status" ) == "missing"
636+ preview ["would_post_warning" ] = reminder_decision .action == "warning"
637+ preview ["would_post_transition" ] = reminder_decision .action == "transition"
641638 return preview
642639
643640
@@ -661,9 +658,6 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]:
661658 if review_data .get ("review_completed_at" ):
662659 continue
663660
664- if review_data .get ("transition_notice_sent_at" ):
665- continue
666-
667661 current_reviewer = review_data .get ("current_reviewer" )
668662 if not current_reviewer :
669663 continue
@@ -756,30 +750,27 @@ def check_overdue_reviews(bot, state: dict) -> list[dict]:
756750 if days_since_activity < bot .REVIEW_DEADLINE_DAYS :
757751 continue
758752
759- transition_warning_sent = review_data .get ("transition_warning_sent" )
760- if transition_warning_sent :
761- try :
762- warning_dt = bot .datetime .fromisoformat (transition_warning_sent .replace ("Z" , "+00:00" ))
763- days_since_warning = (now - warning_dt ).days
764-
765- if days_since_warning >= bot .TRANSITION_PERIOD_DAYS :
766- overdue .append (
767- {
768- "issue_number" : issue_number ,
769- "reviewer" : current_reviewer ,
770- "days_overdue" : days_since_activity ,
771- "days_since_warning" : days_since_warning ,
772- "needs_warning" : False ,
773- "needs_transition" : True ,
774- "anchor_reason" : anchor_reason ,
775- "anchor_timestamp" : last_activity ,
776- "current_scope_key" : current_scope_key ,
777- "current_scope_basis" : current_scope_basis ,
778- }
779- )
780- except (ValueError , AttributeError ):
781- pass
782- else :
753+ if reminder_decision .action == "transition" :
754+ warning_dt = _parse_reminder_timestamp (
755+ reminder_decision .receipt .created_at if reminder_decision .receipt is not None else None
756+ )
757+ if warning_dt is None :
758+ continue
759+ overdue .append (
760+ {
761+ "issue_number" : issue_number ,
762+ "reviewer" : current_reviewer ,
763+ "days_overdue" : days_since_activity ,
764+ "days_since_warning" : (now - warning_dt ).days ,
765+ "needs_warning" : False ,
766+ "needs_transition" : True ,
767+ "anchor_reason" : anchor_reason ,
768+ "anchor_timestamp" : last_activity ,
769+ "current_scope_key" : current_scope_key ,
770+ "current_scope_basis" : current_scope_basis ,
771+ }
772+ )
773+ elif reminder_decision .action == "warning" :
783774 overdue .append (
784775 {
785776 "issue_number" : issue_number ,
0 commit comments