@@ -191,10 +191,84 @@ def find_upcoming_calls(mapping, days_ahead=7):
191191 return upcoming
192192
193193
194- def check_warnings (upcoming_calls , youtube_cache , youtube_enabled = True , youtube_error = None ):
194+ def find_expected_missing_calls (mapping , days_ahead = 7 ):
195+ """Find series where a meeting is expected but no occurrence exists."""
196+ now = datetime .now (timezone .utc )
197+ cutoff = now + timedelta (days = days_ahead )
198+ RATE_TO_DAYS = {"weekly" : 7 , "bi-weekly" : 14 , "monthly" : 28 }
199+ missing = []
200+
201+ for series_key , series_data in mapping .items ():
202+ if not isinstance (series_data , dict ):
203+ continue
204+ # Skip one-off calls
205+ if series_key .startswith ("one-off" ):
206+ continue
207+ # Only predictable rates
208+ occurrence_rate = series_data .get ("occurrence_rate" )
209+ if occurrence_rate not in RATE_TO_DAYS :
210+ continue
211+ # Must still be in call_series_config
212+ series_config = get_call_series_config (series_key )
213+ if not series_config :
214+ continue
215+
216+ occurrences = series_data .get ("occurrences" , [])
217+ if not occurrences :
218+ continue
219+
220+ # Parse all occurrence start times
221+ parsed_times = []
222+ for occ in occurrences :
223+ st = occ .get ("start_time" )
224+ if st :
225+ try :
226+ parsed_times .append (datetime .fromisoformat (st .replace ("Z" , "+00:00" )))
227+ except (ValueError , TypeError ):
228+ pass
229+ if not parsed_times :
230+ continue
231+
232+ latest = max (parsed_times )
233+ interval = timedelta (days = RATE_TO_DAYS [occurrence_rate ])
234+ tolerance = timedelta (days = 2 )
235+ display_name = series_config .get ("display_name" , series_key )
236+
237+ # Skip series that appear retired/paused (no meeting in 3+ intervals)
238+ if now - latest > 3 * interval :
239+ continue
240+
241+ # Project forward from latest occurrence
242+ expected = latest + interval
243+ while expected <= cutoff :
244+ # Only warn when the prior occurrence is already in the past;
245+ # no need to alert about the meeting *after* one that hasn't happened yet
246+ if expected >= now and (expected - interval ) < now :
247+ has_match = any (abs (t - expected ) <= tolerance for t in parsed_times )
248+ if not has_match :
249+ missing .append ({
250+ "series_key" : series_key ,
251+ "display_name" : display_name ,
252+ "expected_time" : expected ,
253+ "occurrence_rate" : occurrence_rate ,
254+ })
255+ expected += interval
256+
257+ missing .sort (key = lambda x : x ["expected_time" ])
258+ return missing
259+
260+
261+ def check_warnings (upcoming_calls , youtube_cache , youtube_enabled = True , youtube_error = None , missing_calls = None ):
195262 """Check for potential issues and return a list of warning strings."""
196263 warnings = []
197264
265+ # Warn about expected meetings with no issue created
266+ for mc in (missing_calls or []):
267+ expected_str = mc ["expected_time" ].strftime ("%A, %B %d" )
268+ warnings .append (
269+ f"{ mc ['display_name' ]} : Expected { mc ['occurrence_rate' ]} meeting ~{ expected_str } but no issue has been created"
270+ )
271+
198272 # Surface YouTube credential/API failure as a single top-level warning
199273 if youtube_error :
200274 warnings .append (f"YouTube API error (stream details unavailable): { youtube_error } " )
@@ -247,68 +321,68 @@ def check_warnings(upcoming_calls, youtube_cache, youtube_enabled=True, youtube_
247321 return warnings
248322
249323
250- def build_markdown (upcoming_calls , zoom_details_cache , youtube_cache , days_ahead = 7 , youtube_enabled = True , youtube_error = None ):
324+ def build_markdown (upcoming_calls , zoom_details_cache , youtube_cache , days_ahead = 7 , youtube_enabled = True , youtube_error = None , missing_calls = None ):
251325 """Build a Markdown-formatted report string."""
252326 now_str = datetime .now (timezone .utc ).strftime ("%Y-%m-%d %H:%M UTC" )
253327 lines = []
254328
255329 if not upcoming_calls :
256330 lines .append (f"No upcoming calls found in the next { days_ahead } days (as of { now_str } )." )
257- return "\n " .join (lines )
258-
259- lines .append (f"#### Upcoming Calls — Next { days_ahead } Days" )
260- lines .append (f"*Generated: { now_str } *" )
261-
262- for call in upcoming_calls :
263- # Title linked to issue
264- title = call ["title" ]
265- if call ["issue_number" ]:
266- issue_url = f"https://github.com/ethereum/pm/issues/{ call ['issue_number' ]} "
267- title = f"[{ title } ]({ issue_url } )"
268-
269- # Flag first occurrence of a new call series
270- is_new = call .get ("occurrence_number" ) == 1
271- new_badge = " :new: **NEW SERIES**" if is_new else ""
272-
273- lines .append ("" )
274- lines .append ("---" )
275- lines .append (f"**{ title } **{ new_badge } " )
276-
277- # Host line
278- zoom_info = zoom_details_cache .get (call ["meeting_id" ])
279- host_str = format_hosts (zoom_info )
280- if host_str :
281- lines .append (f"- { host_str } " )
331+ else :
332+ lines .append (f"#### Upcoming Calls — Next { days_ahead } Days" )
333+ lines .append (f"*Generated: { now_str } *" )
334+
335+ for call in upcoming_calls :
336+ # Title linked to issue
337+ title = call ["title" ]
338+ if call ["issue_number" ]:
339+ issue_url = f"https://github.com/ethereum/pm/issues/{ call ['issue_number' ]} "
340+ title = f"[{ title } ]({ issue_url } )"
341+
342+ # Flag first occurrence of a new call series
343+ is_new = call .get ("occurrence_number" ) == 1
344+ new_badge = " :new: **NEW SERIES**" if is_new else ""
345+
346+ lines .append ("" )
347+ lines .append ("---" )
348+ lines .append (f"**{ title } **{ new_badge } " )
349+
350+ # Host line
351+ zoom_info = zoom_details_cache .get (call ["meeting_id" ])
352+ host_str = format_hosts (zoom_info )
353+ if host_str :
354+ lines .append (f"- { host_str } " )
355+
356+ lines .append (
357+ f"- **Date/Time:** { call ['start_time' ].strftime ('%A, %B %d, %Y at %H:%M UTC' )} "
358+ )
282359
283- lines . append (
284- f"- **Date/Time:** { call [ 'start_time' ]. strftime ( '%A, %B %d, %Y at %H:%M UTC' ) } "
285- )
360+ # YouTube stream
361+ video_id = extract_video_id ( call . get ( "youtube_url" ))
362+ yt = youtube_cache . get ( video_id ) if video_id else None
286363
287- # YouTube stream
288- video_id = extract_video_id (call .get ("youtube_url" ))
289- yt = youtube_cache .get (video_id ) if video_id else None
290-
291- if yt :
292- yt_line = f"- **YouTube:** [{ yt ['title' ]} ]({ call ['youtube_url' ]} )"
293- details = []
294- if yt ["scheduled_start_time" ]:
295- details .append (
296- f"Scheduled: { yt ['scheduled_start_time' ].strftime ('%b %d at %H:%M UTC' )} "
297- )
298- details .append (f"Status: { yt ['broadcast_status' ]} " )
299- yt_line += f" — { ' | ' .join (details )} "
300- lines .append (yt_line )
301- elif call ["youtube_url" ] and youtube_error :
302- lines .append (f"- **YouTube:** [{ call ['youtube_url' ]} ]({ call ['youtube_url' ]} ) _(API error — see warnings)_" )
303- elif call ["youtube_url" ]:
304- lines .append (f"- **YouTube:** [{ call ['youtube_url' ]} ]({ call ['youtube_url' ]} )" )
305- else :
306- lines .append ("- **YouTube:** No stream scheduled" )
364+ if yt :
365+ yt_line = f"- **YouTube:** [{ yt ['title' ]} ]({ call ['youtube_url' ]} )"
366+ details = []
367+ if yt ["scheduled_start_time" ]:
368+ details .append (
369+ f"Scheduled: { yt ['scheduled_start_time' ].strftime ('%b %d at %H:%M UTC' )} "
370+ )
371+ details .append (f"Status: { yt ['broadcast_status' ]} " )
372+ yt_line += f" — { ' | ' .join (details )} "
373+ lines .append (yt_line )
374+ elif call ["youtube_url" ] and youtube_error :
375+ lines .append (f"- **YouTube:** [{ call ['youtube_url' ]} ]({ call ['youtube_url' ]} ) _(API error — see warnings)_" )
376+ elif call ["youtube_url" ]:
377+ lines .append (f"- **YouTube:** [{ call ['youtube_url' ]} ]({ call ['youtube_url' ]} )" )
378+ else :
379+ lines .append ("- **YouTube:** No stream scheduled" )
307380
308381 # Warnings
309382 warnings = check_warnings (
310383 upcoming_calls , youtube_cache ,
311384 youtube_enabled = youtube_enabled , youtube_error = youtube_error ,
385+ missing_calls = missing_calls ,
312386 )
313387 if warnings :
314388 lines .append ("" )
@@ -343,67 +417,66 @@ def send_to_webhook(webhook_url, markdown_text):
343417 print (f"\n Webhook: failed ({ resp .status_code } ): { resp .text } " )
344418
345419
346- def print_report (upcoming_calls , zoom_details_cache , youtube_cache , days_ahead = 7 , youtube_enabled = True , youtube_error = None ):
420+ def print_report (upcoming_calls , zoom_details_cache , youtube_cache , days_ahead = 7 , youtube_enabled = True , youtube_error = None , missing_calls = None ):
347421 """Print a plain-text report to the terminal."""
348422 now_str = datetime .now (timezone .utc ).strftime ("%Y-%m-%d %H:%M UTC" )
349423
350424 if not upcoming_calls :
351425 print (
352426 f"No upcoming calls found in the next { days_ahead } days (as of { now_str } )."
353427 )
354- return
428+ else :
429+ print (f"{ '=' * 60 } " )
430+ print (f" UPCOMING CALLS - Next { days_ahead } Days" )
431+ print (f" Generated: { now_str } " )
432+ print (f"{ '=' * 60 } " )
355433
356- print ( f" { '=' * 60 } " )
357- print ( f" UPCOMING CALLS - Next { days_ahead } Days" )
358- print ( f" Generated: { now_str } " )
359- print ( f" { '=' * 60 } " )
434+ for call in upcoming_calls :
435+ # Flag first occurrence of a new call series
436+ is_new = call . get ( "occurrence_number" ) == 1
437+ new_badge = " *** NEW SERIES ***" if is_new else ""
360438
361- for call in upcoming_calls :
362- # Flag first occurrence of a new call series
363- is_new = call . get ( "occurrence_number" ) == 1
364- new_badge = " *** NEW SERIES ***" if is_new else " "
439+ # Title with issue number inline
440+ title = call [ "title" ]
441+ if call [ "issue_number" ]:
442+ title = f" { title } (# { call [ 'issue_number' ] } ) "
365443
366- # Title with issue number inline
367- title = call ["title" ]
368- if call ["issue_number" ]:
369- title = f"{ title } (#{ call ['issue_number' ]} )"
444+ print (f"\n { '-' * 60 } " )
445+ print (f" { title } { new_badge } " )
370446
371- print (f"\n { '-' * 60 } " )
372- print (f" { title } { new_badge } " )
447+ # Host line
448+ zoom_info = zoom_details_cache .get (call ["meeting_id" ])
449+ host_str = format_hosts (zoom_info )
450+ if host_str :
451+ print (f" { host_str } " )
373452
374- # Host line
375- zoom_info = zoom_details_cache .get (call ["meeting_id" ])
376- host_str = format_hosts (zoom_info )
377- if host_str :
378- print (f" { host_str } " )
453+ print (
454+ f" Date/Time: { call ['start_time' ].strftime ('%A, %B %d, %Y at %H:%M UTC' )} "
455+ )
379456
380- print (
381- f" Date/Time: { call [ 'start_time' ]. strftime ( '%A, %B %d, %Y at %H:%M UTC' ) } "
382- )
457+ # YouTube stream
458+ video_id = extract_video_id ( call . get ( "youtube_url" ))
459+ yt = youtube_cache . get ( video_id ) if video_id else None
383460
384- # YouTube stream
385- video_id = extract_video_id (call .get ("youtube_url" ))
386- yt = youtube_cache .get (video_id ) if video_id else None
387-
388- if yt :
389- print (f" YouTube: { call ['youtube_url' ]} " )
390- print (f" Title: { yt ['title' ]} " )
391- if yt ["scheduled_start_time" ]:
392- print (
393- f" Scheduled: { yt ['scheduled_start_time' ].strftime ('%A, %B %d, %Y at %H:%M UTC' )} "
394- )
395- print (f" Status: { yt ['broadcast_status' ]} " )
396- elif call ["youtube_url" ] and youtube_error :
397- print (f" YouTube: { call ['youtube_url' ]} " )
398- print (f" (YouTube API error - see warnings)" )
399- elif call ["youtube_url" ]:
400- print (f" YouTube: { call ['youtube_url' ]} " )
401- print (f" (details not available)" )
402- else :
403- print (f" YouTube: No stream scheduled" )
461+ if yt :
462+ print (f" YouTube: { call ['youtube_url' ]} " )
463+ print (f" Title: { yt ['title' ]} " )
464+ if yt ["scheduled_start_time" ]:
465+ print (
466+ f" Scheduled: { yt ['scheduled_start_time' ].strftime ('%A, %B %d, %Y at %H:%M UTC' )} "
467+ )
468+ print (f" Status: { yt ['broadcast_status' ]} " )
469+ elif call ["youtube_url" ] and youtube_error :
470+ print (f" YouTube: { call ['youtube_url' ]} " )
471+ print (f" (YouTube API error - see warnings)" )
472+ elif call ["youtube_url" ]:
473+ print (f" YouTube: { call ['youtube_url' ]} " )
474+ print (f" (details not available)" )
475+ else :
476+ print (f" YouTube: No stream scheduled" )
404477
405478 # Warnings section
406- warnings = check_warnings (upcoming_calls , youtube_cache , youtube_enabled = youtube_enabled , youtube_error = youtube_error )
479+ warnings = check_warnings (upcoming_calls , youtube_cache , youtube_enabled = youtube_enabled , youtube_error = youtube_error , missing_calls = missing_calls )
407480 if warnings :
408481 print (f"\n { '=' * 60 } " )
409482 print (f" WARNINGS ({ len (warnings )} )" )
@@ -445,6 +518,7 @@ def main():
445518
446519 mapping = load_mapping ()
447520 upcoming = find_upcoming_calls (mapping , days_ahead = args .days )
521+ missing_calls = find_expected_missing_calls (mapping , days_ahead = args .days )
448522
449523 # Fetch Zoom details for each unique meeting ID
450524 zoom_details_cache = {}
@@ -480,6 +554,7 @@ def main():
480554 days_ahead = args .days ,
481555 youtube_enabled = not args .no_youtube ,
482556 youtube_error = youtube_error ,
557+ missing_calls = missing_calls ,
483558 )
484559
485560 # Always print to terminal
0 commit comments