Skip to content

Commit bdc32df

Browse files
committed
acdbot: add missing issues in upcoming_calls
1 parent 3f03df7 commit bdc32df

File tree

1 file changed

+170
-95
lines changed

1 file changed

+170
-95
lines changed

.github/ACDbot/scripts/upcoming_calls.py

Lines changed: 170 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -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"\nWebhook: 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

Comments
 (0)