11from dataclasses import dataclass
22from datetime import datetime
33from enum import Enum
4- from typing import List , Optional , Set , Union
4+ from typing import List , Optional , Set , Tuple , Union
55
66
77class SignalStatus (Enum ):
@@ -78,23 +78,22 @@ def __init__(self, head_sha: str, events: List[SignalEvent]):
7878 self .head_sha = head_sha
7979 # enforce events ordered by time, oldest first
8080 self .events = sorted (events , key = lambda e : e .started_at ) if events else []
81- self .statuses = {event .status for event in self .events }
82-
83- def has_status (self , status : SignalStatus ) -> bool :
84- """Check if any event has the specified status."""
85- return status in self .statuses
81+ # counts by status
82+ self .statuses = {}
83+ for e in self .events :
84+ self .statuses [e .status ] = self .statuses .get (e .status , 0 ) + 1
8685
8786 @property
8887 def has_pending (self ) -> bool :
89- return self . has_status ( SignalStatus .PENDING )
88+ return SignalStatus .PENDING in self . statuses
9089
9190 @property
9291 def has_success (self ) -> bool :
93- return self . has_status ( SignalStatus .SUCCESS )
92+ return SignalStatus .SUCCESS in self . statuses
9493
9594 @property
9695 def has_failure (self ) -> bool :
97- return self . has_status ( SignalStatus .FAILURE )
96+ return SignalStatus .FAILURE in self . statuses
9897
9998 def events_by_status (self , status : SignalStatus ) -> List [SignalEvent ]:
10099 """Get all events with the specified status."""
@@ -105,6 +104,38 @@ def __iter__(self):
105104 return iter (self .events )
106105
107106
107+ @dataclass
108+ class PartitionedCommits :
109+ """
110+ Represents the result of partitioning commits based on an autorevert pattern.
111+ """
112+
113+ def __init__ (
114+ self ,
115+ failed : List [SignalCommit ],
116+ unknown : List [SignalCommit ],
117+ successful : List [SignalCommit ],
118+ ):
119+ self .failed = failed
120+ self .unknown = unknown
121+ self .successful = successful
122+
123+ def failure_events_count (self ) -> int :
124+ return sum (c .statuses .get (SignalStatus .FAILURE , 0 ) for c in self .failed )
125+
126+ def success_events_count (self ) -> int :
127+ return sum (c .statuses .get (SignalStatus .SUCCESS , 0 ) for c in self .successful )
128+
129+
130+ class InfraCheckResult (Enum ):
131+ """Outcome of infra check based on partitioned commits."""
132+
133+ CONFIRMED = "confirmed" # failure bracketed by two successes (not infra)
134+ PENDING = "pending" # pending events could still form the sandwich
135+ RESTART_SUCCESS = "restart_success" # no success after any failure
136+ RESTART_FAILURE = "restart_failure" # no failure after any success
137+
138+
108139class Signal :
109140 """A refined, column-like view of raw CI data for pattern detection.
110141
@@ -141,127 +172,129 @@ def detect_flaky(self) -> bool:
141172 for commit in self .commits
142173 )
143174
144- def confirm_not_an_infra_issue (self ) -> Optional [bool ]:
145- """
146- Considers pairs of commits: an older one with two successful jobs,
147- and a newer one (not necessarily an immediate successor) with a
148- failure.
149- Checks if there is a "sandwich" pattern where:
150- - The failure of the newer commit is between two successes of the older commit (time-wise).
151-
152- The goal of this it to eliminate the possibility of transient infra issue.
153-
154- Note: in the real world this relies on the previously checked invariants:
155- * no flakiness - older commit will not have failures if it has successful job
156- * not recovered - there is a newer commit with failure that is
157- followed by an older commit with at least one success
158-
159- Returns:
160- True if such a pattern exists, meaning the failure is likely
161- not an infra issue (previously successful signal stays stable),
162- False means no bracketing successes were observed; we can’t
163- rule out infra, so prefer restarts (i.e. "not enough data",
164- given "no flakiness" invariant is true).
165- None is "Maybe", meaning the result depends on the resolution
166- of the existing pending job.
167- """
168- if len (self .commits ) < 2 :
169- return False
170-
171- maybe = False
172-
173- # Iterate through commits, looking for a newer commit with failure
174- for i in range (0 , len (self .commits ) - 1 ):
175- nc = self .commits [i ]
176-
177- # Check all older commits before this one
178- for j in range (i + 1 , len (self .commits )):
179- oc = self .commits [j ]
180- # Check if this older commit has two successful jobs
181- oc_successes = oc .events_by_status (SignalStatus .SUCCESS )
182- oc_pending = oc .events_by_status (SignalStatus .PENDING )
183-
184- if len (oc_successes ) >= 2 :
185- # Check if the failure of the newer commit is between the two successes of the older commit
186- # Events are ordered by time within each commit, oldest events first
187- for e in nc .events :
188- if not ( # between failures
189- oc_successes [0 ].started_at
190- < e .started_at
191- < oc_successes [- 1 ].started_at
192- ):
193- continue
194-
195- if e .is_failure :
196- # We have a sandwich pattern
197- return True
198- elif e .is_pending :
199- # We have a pending job, possible pattern, cannot confirm yet
200- maybe = True
201-
202- elif (
203- len (oc_successes ) == 1
204- and len (oc_pending ) > 1
205- and oc_successes [0 ].started_at < oc_pending [- 1 ].started_at
206- # If there is only one success and multiple pending jobs, we cannot confirm the sandwich
207- and any (
208- oc_successes [0 ].started_at
209- < e .started_at
210- < oc_pending [- 1 ].started_at
211- and (e .is_failure or e .is_pending )
212- for e in nc .events
213- )
214- ):
215- maybe = True
216-
217- return None if maybe else False
218-
219175 def has_successes (self ) -> bool :
220176 """
221177 Checks if there is at least one successful event in the signal.
222178 """
223179 return any (commit .has_success for commit in self .commits )
224180
225- def detect_autorevert_pattern (self ) -> Optional [AutorevertPattern ]:
181+ def partition_by_autorevert_pattern (self ) -> Optional [PartitionedCommits ]:
182+ """
183+ Partition the most recent commit history into three lists:
184+ - Failed commits before the first potential breakage
185+ - Pending / missing signal commits in the middle (if any)
186+ - Successful commits after the breakage (up to the next breakage, if any)
187+
188+ Preserves the original order of commits (newest to oldest).
189+
190+ The useful invariant this establishes:
191+ - pending commits in the "failed" list are expected to resolve to failure
192+ - pending commits in the "successful" list are expected to resolve to success
193+ - pending commits in the "unknown" list could resolve either way
194+ - commits with the missing signal (that we need to trigger) would fall into the "unknown" list
226195 """
227- Detect first autorevert pattern in the Signal.
196+ if len (self .commits ) < 2 :
197+ return None
228198
229- Pattern: 3 consecutive commits where:
230- - 2 newer commits have failure
231- - 1 older commit doesn't have this failure
199+ failed = []
200+ successful = []
232201
233- Note:
234- in real world relies on the previously checked invariants, such as:
235- no flakiness, no infra issues, etc.
202+ picking_failed = True # simple state machine
236203
237- Returns:
238- First detected autorevert pattern if exists, None otherwise.
239- """
240- # Commits are ordered newest -> older
241- if len (self .commits ) < 3 :
204+ # first broadly partition into failed and successful
205+ for c in self .commits :
206+ if c .has_success :
207+ picking_failed = False
208+ elif c .has_failure and not picking_failed :
209+ # encountered a failure after the streak of successes
210+ # this indicates another older pattern which we don't care about
211+ break
212+
213+ if picking_failed :
214+ failed .append (c )
215+ else :
216+ successful .append (c )
217+
218+ # further partition failed into failed and unknown (pending/missing)
219+ unknown = []
220+ while failed and not failed [- 1 ].has_failure :
221+ unknown .append (failed .pop ())
222+
223+ unknown .reverse ()
224+
225+ if not failed or not successful :
242226 return None
243227
244- for i in range (1 , len (self .commits ) - 1 ):
245- suspected_commit1 = self .commits [i ]
246- newer_commit = self .commits [i - 1 ]
247- successful_base_commit = self .commits [i + 1 ]
248-
249- if (
250- newer_commit .has_failure
251- and suspected_commit1 .has_failure
252- and successful_base_commit .has_success
253- ):
254- return AutorevertPattern (
255- pattern_detected = True ,
256- workflow_name = self .workflow_name ,
257- newer_commits = [
258- newer_commit .head_sha ,
259- suspected_commit1 .head_sha ,
260- ],
261- older_commit = successful_base_commit .head_sha ,
262- )
263-
264- return None
228+ return PartitionedCommits (failed = failed , unknown = unknown , successful = successful )
229+
230+ def confirm_not_an_infra_issue (
231+ self , partition : PartitionedCommits
232+ ) -> InfraCheckResult :
233+ """
234+ Checks if there is a "sandwich" pattern where:
235+ - The failure of the newer commit is between (time-wise) two successes from the older commits.
236+
237+ The goal of this it to rule out the transient infra / outside issue
238+ (i.e. issue not caused by the change in the newer commit).
239+
240+ Invariants established:
241+ - CONFIRMED: at least one failure (newer commit) timestamp lies strictly between two
242+ actual success timestamps (older commits).
243+ - PENDING: success-like and failure-like time ranges overlap, but no
244+ confirmed sandwich yet; pending events could complete the sandwich.
245+ - RESTART_SUCCESS: no success-like event occurs after any failure-like
246+ event (ranges do not overlap in that direction).
247+ - RESTART_FAILURE: no failure-like event occurs after any success-like
248+ event (ranges do not overlap in that direction).
249+
250+ Notes:
251+ - success-like = SUCCESS or PENDING; failure-like = FAILURE or PENDING.
252+ - Only commits from the failed/successful partitions are considered;
253+ unknown is ignored here.
254+ - Flakiness is assumed to be ruled out upstream.
255+ """
256+
257+ def bounds (
258+ commits : List [SignalCommit ], keep
259+ ) -> Tuple [Optional [datetime ], Optional [datetime ]]:
260+ lo : Optional [datetime ] = None
261+ hi : Optional [datetime ] = None
262+ for c in commits :
263+ for e in c .events :
264+ if keep (e ):
265+ t = e .started_at
266+ if lo is None or t < lo :
267+ lo = t
268+ if hi is None or t > hi :
269+ hi = t
270+ return lo , hi
271+
272+ # success-like includes pending; actual-success excludes pending
273+ min_succ_like , max_succ_like = bounds (
274+ partition .successful , lambda e : e .is_success or e .is_pending
275+ )
276+ min_succ , max_succ = bounds (partition .successful , lambda e : e .is_success )
277+ # failure-like includes pending
278+ min_fail_like , max_fail_like = bounds (
279+ partition .failed , lambda e : e .is_failure or e .is_pending
280+ )
281+
282+ # Strict ordering without overlap → restart
283+ if min_succ_like is None or max_succ_like <= min_fail_like :
284+ return InfraCheckResult .RESTART_SUCCESS
285+ if min_fail_like is None or max_fail_like <= min_succ_like :
286+ return InfraCheckResult .RESTART_FAILURE
287+
288+ # Confirmed: any actual failure of the newer commit falls strictly
289+ # between two actual successes of the older commit
290+ if min_succ is not None and max_succ is not None and min_succ < max_succ :
291+ for c in partition .failed :
292+ for e in c .events :
293+ if e .is_failure and (min_succ < e .started_at < max_succ ):
294+ return InfraCheckResult .CONFIRMED
295+
296+ # Overlap exists, but not confirmed yet → pending
297+ return InfraCheckResult .PENDING
265298
266299 def process_valid_autorevert_pattern (
267300 self ,
@@ -278,16 +311,47 @@ def process_valid_autorevert_pattern(
278311 if self .detect_flaky () or self .detect_fixed () or not self .has_successes ():
279312 return None
280313
281- infra_failure = self .confirm_not_an_infra_issue ()
282- if infra_failure is None :
283- # If we have pending jobs, we cannot confirm the pattern yet
314+ partition = self .partition_by_autorevert_pattern ()
315+ if partition is None :
284316 return None
285317
286- if not infra_failure :
287- # not enough data to confirm the pattern, need to issue restarts
288- # TODO find a commit to restart
289- pass
318+ restart_commits = set ()
319+
320+ # close gaps in the signal (greedily for now)
321+ for c in partition .unknown :
322+ if not c .events :
323+ restart_commits .add (c .head_sha )
324+
325+ infra_check_result = self .confirm_not_an_infra_issue (partition )
326+ # note re: event_count < 2:
327+ # this is a confidence heuristic to detect flakiness, can adjust as needed
328+ if (
329+ infra_check_result == InfraCheckResult .RESTART_FAILURE
330+ or partition .failure_events_count () < 2
331+ ):
332+ # restarting oldest failed
333+ restart_commits .add (partition .failed [- 1 ].head_sha )
334+ elif (
335+ infra_check_result == InfraCheckResult .RESTART_SUCCESS
336+ or partition .success_events_count () < 2
337+ ):
338+ # restarting newest successful
339+ restart_commits .add (partition .successful [0 ].head_sha )
340+
341+ if restart_commits :
342+ return RestartCommits (commit_shas = restart_commits )
343+
344+ if infra_check_result != InfraCheckResult .CONFIRMED :
345+ return None
290346
291- # TODO close the gaps in the signal (find commits to restart)
347+ if partition .unknown :
348+ # there are still pending/missing commits in the unknown partition
349+ return None
292350
293- return self .detect_autorevert_pattern ()
351+ # all invariants validated, confirmed not infra, pattern exists
352+ return AutorevertPattern (
353+ pattern_detected = True ,
354+ workflow_name = self .workflow_name ,
355+ newer_commits = [c .head_sha for c in partition .failed [- 2 :]],
356+ older_commit = partition .successful [0 ].head_sha ,
357+ )
0 commit comments