2424COUNTED_CONCLUSIONS = {"success" , "failure" }
2525
2626
27+ def bucket_count (lookback_days ):
28+ """Return the number of trend buckets for a given lookback window.
29+
30+ Uses natural time units so bucket boundaries are semantically meaningful:
31+ - daily for windows up to 14 days
32+ - weekly for windows up to 90 days
33+ - ~monthly (28-day) for longer windows
34+ """
35+ if lookback_days <= 14 :
36+ return lookback_days
37+ elif lookback_days <= 90 :
38+ return lookback_days // 7
39+ else :
40+ return lookback_days // 28
41+
42+
43+ def trend_indicator (buckets ):
44+ """Compare first-half vs second-half failure rate and return an arrow + delta string."""
45+ mid = len (buckets ) // 2
46+ early , recent = buckets [:mid ], buckets [mid :]
47+ e_runs = sum (b ["runs" ] for b in early )
48+ e_fails = sum (b ["failures" ] for b in early )
49+ r_runs = sum (b ["runs" ] for b in recent )
50+ r_fails = sum (b ["failures" ] for b in recent )
51+ if e_runs == 0 or r_runs == 0 :
52+ return "—"
53+ delta = (r_fails / r_runs - e_fails / e_runs ) * 100
54+ if abs (delta ) < 1.0 :
55+ return f"→ { delta :+.1f} %"
56+ return f"{ '↑' if delta > 0 else '↓' } { delta :+.1f} %"
57+
58+
2759def _headers (token ):
2860 return {
2961 "Authorization" : f"Bearer { token } " ,
@@ -45,7 +77,7 @@ def _urlopen(req):
4577 if retry_after :
4678 wait = int (retry_after ) + 5
4779 elif reset :
48- wait = max (0 , int (reset ) - int (time .time ())) + 5
80+ wait = max (0 , int (reset ) - int (time .time ())) + 5 # Seconds until reset + 5
4981 else :
5082 wait = 60
5183 print (f"Rate limited (HTTP { e .code } ). Waiting { wait } s before retry..." , file = sys .stderr )
@@ -122,7 +154,9 @@ def build_report(stats, lookback_days, top_n, now):
122154 table_lines = []
123155 for (workflow , job ), s in rows :
124156 rate = s ["failures" ] / s ["runs" ] * 100
125- table_lines .append (f"| { workflow } | { job } | { s ['runs' ]} | { s ['failures' ]} | { rate :.1f} % |" )
157+ min_runs = len (s ["buckets" ]) * 2
158+ trend = trend_indicator (s ["buckets" ]) if s ["runs" ] >= min_runs else "—"
159+ table_lines .append (f"| { workflow } | { job } | { s ['runs' ]} | { s ['failures' ]} | { rate :.1f} % | { trend } |" )
126160
127161 # Top N by absolute failure count
128162 top = sorted (stats .items (), key = lambda x : x [1 ]["failures" ], reverse = True )[:top_n ]
@@ -144,10 +178,16 @@ def build_report(stats, lookback_days, top_n, now):
144178 "" ,
145179 "### Job Failure Rates" ,
146180 "" ,
147- "| Workflow | Job | Runs | Failures | Rate |" ,
148- "|----------|-----|------|----------|------|" ,
181+ "| Workflow | Job | Runs | Failures | Rate | Trend | " ,
182+ "|----------|-----|------|----------|------|-------| " ,
149183 * table_lines ,
150184 "" ,
185+ f"_Trend: the { lookback_days } -day window is divided into equal time buckets"
186+ " (daily for ≤ 14 days, weekly for ≤ 90 days, ~monthly beyond that)."
187+ " The failure rate in the first half of those buckets is compared to the second half:"
188+ " ↑ = getting worse, ↓ = improving, → = stable (< 1 pp change)."
189+ " — = fewer than 2 runs per bucket on average; not enough data._" ,
190+ "" ,
151191 f"### Top { top_n } Most Failing Jobs" ,
152192 "" ,
153193 * top_lines ,
@@ -176,7 +216,9 @@ def main():
176216 top_jobs = int (top_jobs_str )
177217
178218 now = datetime .now (timezone .utc )
179- since = (now - timedelta (days = lookback_days )).strftime ("%Y-%m-%dT%H:%M:%SZ" )
219+ since_dt = now - timedelta (days = lookback_days )
220+ since = since_dt .strftime ("%Y-%m-%dT%H:%M:%SZ" )
221+ num_buckets = bucket_count (lookback_days )
180222
181223 print (f"Fetching workflow runs since { since } ..." )
182224 runs = get_runs (token , repo , since )
@@ -186,21 +228,35 @@ def main():
186228 print ("No runs found. Skipping report." )
187229 return
188230
189- # Aggregate: (workflow_name, job_name) -> {runs, failures}
231+ # Aggregate: (workflow_name, job_name) -> {runs, failures, buckets }
190232 # Only "success" and "failure" conclusions are counted; skipped/cancelled are excluded.
191- stats = defaultdict (lambda : {"runs" : 0 , "failures" : 0 })
233+ # Buckets divide the lookback window into equal time slices (oldest → newest) for trend tracking.
234+ stats = defaultdict (lambda : {
235+ "runs" : 0 ,
236+ "failures" : 0 ,
237+ "buckets" : [{"runs" : 0 , "failures" : 0 } for _ in range (num_buckets )],
238+ })
239+ window_secs = (now - since_dt ).total_seconds ()
192240
193241 for i , run in enumerate (runs , start = 1 ):
194242 print (f" Fetching jobs for run { i } /{ len (runs )} (id={ run ['id' ]} )..." )
243+ run_dt = datetime .fromisoformat (run ["created_at" ].replace ("Z" , "+00:00" ))
244+ elapsed = (run_dt - since_dt ).total_seconds () # seconds from window start to this run
245+ # clamp: elapsed==window_secs would produce index num_buckets
246+ bucket_idx = min (int (elapsed / window_secs * num_buckets ), num_buckets - 1 )
247+ bucket_idx = max (0 , bucket_idx ) # clamp: clock skew can make elapsed slightly negative
248+
195249 jobs = get_jobs (token , repo , run ["id" ])
196250 for job in jobs :
197251 conclusion = job .get ("conclusion" )
198252 if conclusion not in COUNTED_CONCLUSIONS :
199253 continue
200254 key = (run ["name" ], job ["name" ])
201255 stats [key ]["runs" ] += 1
256+ stats [key ]["buckets" ][bucket_idx ]["runs" ] += 1
202257 if conclusion == "failure" :
203258 stats [key ]["failures" ] += 1
259+ stats [key ]["buckets" ][bucket_idx ]["failures" ] += 1
204260
205261 if not stats :
206262 print ("No job data collected. Skipping report." )
@@ -218,63 +274,6 @@ def main():
218274 post_comment (token , repo , issue_number , report )
219275 print ("Report generated successfully." )
220276
221- ## TESTS ##
222-
223- import unittest
224- from unittest .mock import MagicMock , patch
225-
226-
227- class _Tests (unittest .TestCase ):
228-
229- @patch ("time.sleep" )
230- @patch ("urllib.request.urlopen" )
231- def test_rate_limit_retries_with_wait (self , mock_urlopen , mock_sleep ):
232- """_urlopen sleeps Retry-After + 5s on 429 then retries successfully."""
233- import http .client
234- msg = http .client .HTTPMessage ()
235- msg ["Retry-After" ] = "10"
236- resp = MagicMock ()
237- resp .read .return_value = b'{"ok": true}'
238- resp .__enter__ = lambda s : s
239- resp .__exit__ = MagicMock (return_value = False )
240- mock_urlopen .side_effect = [
241- urllib .error .HTTPError ("https://api.github.com/test" , 429 , "Too Many Requests" , msg , None ),
242- resp ,
243- ]
244- result = _urlopen (urllib .request .Request ("https://api.github.com/test" ))
245- mock_sleep .assert_called_once_with (15 ) # Retry-After(10) + 5
246- self .assertEqual (result , b'{"ok": true}' )
247-
248- def test_build_report_structure_and_totals (self ):
249- """build_report produces a markdown table and correct summary totals."""
250- stats = defaultdict (lambda : {"runs" : 0 , "failures" : 0 })
251- stats [("Tests" , "build" )]["runs" ] = 10
252- stats [("Tests" , "build" )]["failures" ] = 3
253- now = datetime (2026 , 1 , 1 , 9 , 0 , 0 , tzinfo = timezone .utc )
254- report = build_report (stats , 30 , 5 , now )
255- self .assertIn ("| Workflow | Job | Runs | Failures | Rate |" , report )
256- self .assertIn ("| Tests | build | 10 | 3 | 30.0% |" , report )
257- self .assertIn ("**Total job runs:** 10" , report )
258- self .assertIn ("**Total failures:** 3" , report )
259- self .assertIn ("**Overall failure rate:** 30.0%" , report )
260-
261- @patch ("ci_health_report.post_comment" )
262- @patch ("ci_health_report.get_jobs" )
263- @patch ("ci_health_report.get_runs" )
264- def test_skipped_and_cancelled_not_counted (self , mock_runs , mock_jobs , mock_comment ):
265- """skipped and cancelled conclusions are excluded from run and failure counts."""
266- mock_runs .return_value = [{"id" : 1 , "name" : "Tests" }]
267- mock_jobs .return_value = [
268- {"name" : "build" , "conclusion" : "success" },
269- {"name" : "build" , "conclusion" : "failure" },
270- {"name" : "build" , "conclusion" : "skipped" },
271- {"name" : "build" , "conclusion" : "cancelled" },
272- ]
273- with patch .dict ("os.environ" , {"GH_TOKEN" : "tok" , "GH_REPO" : "o/r" , "REPORT_ISSUE" : "1" , "LOOKBACK_DAYS" : "30" , "TOP_JOBS" : "5" }):
274- main ()
275- report = mock_comment .call_args [0 ][3 ]
276- self .assertIn ("| Tests | build | 2 | 1 |" , report )
277-
278277
279278if __name__ == "__main__" :
280279 main ()
0 commit comments