From 7dd540ef1077eaeae5c6a38f6b35f778e7835f57 Mon Sep 17 00:00:00 2001 From: ManmathX Date: Thu, 30 Apr 2026 12:47:15 +0530 Subject: [PATCH 1/8] fix: make call reminder bots display dynamic time until meeting Signed-off-by: ManmathX --- .github/scripts/cron-calls-community.sh | 13 +- .github/scripts/cron-calls-office-hours.sh | 13 +- .../utils/compute-time-until-meeting.py | 72 ++++++ .../utils/test-cron-calls-time-computation.sh | 237 ++++++++++++++++++ 4 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/utils/compute-time-until-meeting.py create mode 100755 .github/scripts/utils/test-cron-calls-time-computation.sh diff --git a/.github/scripts/cron-calls-community.sh b/.github/scripts/cron-calls-community.sh index 311bd4a67..52b444393 100644 --- a/.github/scripts/cron-calls-community.sh +++ b/.github/scripts/cron-calls-community.sh @@ -6,6 +6,12 @@ DRY_RUN="${DRY_RUN:-true}" ANCHOR_DATE="2025-11-12" MEETING_LINK="https://zoom-lfx.platform.linuxfoundation.org/meeting/92041330205?password=2f345bee-0c14-4dd5-9883-06fbc9c60581" CALENDAR_LINK="https://zoom-lfx.platform.linuxfoundation.org/meetings/hiero?view=week" +MEETING_HOUR="${MEETING_HOUR:-14}" # 14:00 UTC — change this to adjust the meeting time + +if ! [[ "$MEETING_HOUR" =~ ^[0-9]+$ ]] || [ "$MEETING_HOUR" -lt 0 ] || [ "$MEETING_HOUR" -gt 23 ]; then + echo "ERROR: MEETING_HOUR must be an integer between 0 and 23, got '$MEETING_HOUR'." + exit 1 +fi CANCELLED_DATES=( "2025-12-24" @@ -63,15 +69,18 @@ if [ -z "$ISSUE_DATA" ] || [ "$ISSUE_DATA" = "[]" ]; then exit 0 fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TIME_UNTIL_MEETING=$(python3 "$SCRIPT_DIR/utils/compute-time-until-meeting.py" "$MEETING_HOUR") + COMMENT_BODY=$(cat < [MOCK_TIME] + +Arguments: + MEETING_HOUR Meeting hour in UTC (0-23) + MOCK_TIME Optional mock time in HH:MM or HH:MM:SS format (for testing) + +Output: + A human-readable string like "3 hours and 7 minutes" printed to stdout. +""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from math import ceil + + +def _parse_mock_time(mock_time): + """Parse a mock time string into (hour, minute, second) components.""" + parts = list(map(int, mock_time.split(":"))) + second = parts[2] if len(parts) > 2 else 0 + return parts[0], parts[1], second + + +def _pluralize(value, singular): + """Return singular or plural form based on value.""" + return singular if value == 1 else f"{singular}s" + + +def _format_duration(hours, minutes): + """Format hours and minutes into a human-readable string.""" + if hours > 0 and minutes > 0: + return f"{hours} {_pluralize(hours, 'hour')} and {minutes} {_pluralize(minutes, 'minute')}" + if hours > 0: + return f"{hours} {_pluralize(hours, 'hour')}" + return f"{minutes} {_pluralize(minutes, 'minute')}" + + +def compute_time_until_meeting(meeting_hour, mock_time=None): + """Compute the human-readable time remaining until the meeting. + + Args: + meeting_hour: The meeting hour in UTC (0-23). + mock_time: Optional mock time string in "HH:MM" or "HH:MM:SS" format. + + Returns: + A string like "3 hours and 7 minutes". + """ + now = datetime.now(timezone.utc) + + if mock_time is not None: + hour, minute, second = _parse_mock_time(mock_time) + now = now.replace(hour=hour, minute=minute, second=second, microsecond=0) + + meeting = now.replace(hour=meeting_hour, minute=0, second=0, microsecond=0) + diff = meeting - now + total_minutes = max(ceil(diff.total_seconds() / 60), 0) + + return _format_duration(total_minutes // 60, total_minutes % 60) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python3 compute-time-until-meeting.py [MOCK_TIME]", file=sys.stderr) + sys.exit(1) + + meeting_hour = int(sys.argv[1]) + mock_time = sys.argv[2] if len(sys.argv) > 2 else None + print(compute_time_until_meeting(meeting_hour, mock_time)) diff --git a/.github/scripts/utils/test-cron-calls-time-computation.sh b/.github/scripts/utils/test-cron-calls-time-computation.sh new file mode 100755 index 000000000..19c392a74 --- /dev/null +++ b/.github/scripts/utils/test-cron-calls-time-computation.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Test suite for cron-calls dynamic time computation +# Validates the shared compute-time-until-meeting.py helper used in +# cron-calls-community.sh and cron-calls-office-hours.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HELPER="$SCRIPT_DIR/compute-time-until-meeting.py" + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Test counters +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 + +print_result() { + local test_name="$1" + local result="$2" + local message="${3:-}" + + TESTS_RUN=$((TESTS_RUN + 1)) + + if [[ "$result" == "PASS" ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e "${GREEN}✓ PASS${NC}: $test_name" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + echo -e "${RED}✗ FAIL${NC}: $test_name" + if [[ -n "$message" ]]; then + echo -e " ${YELLOW}→${NC} $message" + fi + fi +} + +# Test 1: Standard case — 4 hours before meeting +test_standard_4_hours() { + echo "" + echo "Test 1: Standard case — 4 hours before meeting (10:00 → 14:00)" + echo "================================================================" + + local result + result=$(python3 "$HELPER" 14 "10:00") + + if [[ "$result" == "4 hours" ]]; then + print_result "4 hours before meeting" "PASS" + else + print_result "4 hours before meeting" "FAIL" "Expected '4 hours', got '$result'" + fi +} + +# Test 2: Delayed run — 3 hours and 7 minutes remaining +test_delayed_run() { + echo "" + echo "Test 2: Delayed run — 3 hours 7 minutes remaining (10:53 → 14:00)" + echo "===================================================================" + + local result + result=$(python3 "$HELPER" 14 "10:53") + + if [[ "$result" == "3 hours and 7 minutes" ]]; then + print_result "Delayed run shows 3 hours and 7 minutes" "PASS" + else + print_result "Delayed run shows 3 hours and 7 minutes" "FAIL" "Expected '3 hours and 7 minutes', got '$result'" + fi +} + +# Test 3: Just minutes remaining +test_just_minutes() { + echo "" + echo "Test 3: Just minutes remaining (13:45 → 14:00)" + echo "================================================" + + local result + result=$(python3 "$HELPER" 14 "13:45") + + if [[ "$result" == "15 minutes" ]]; then + print_result "15 minutes remaining" "PASS" + else + print_result "15 minutes remaining" "FAIL" "Expected '15 minutes', got '$result'" + fi +} + +# Test 4: Exact whole hours +test_exact_hours() { + echo "" + echo "Test 4: Exact whole hours (12:00 → 14:00)" + echo "===========================================" + + local result + result=$(python3 "$HELPER" 14 "12:00") + + if [[ "$result" == "2 hours" ]]; then + print_result "Exactly 2 hours remaining" "PASS" + else + print_result "Exactly 2 hours remaining" "FAIL" "Expected '2 hours', got '$result'" + fi +} + +# Test 5: Already past meeting time — clamped to 0 +test_past_meeting_time() { + echo "" + echo "Test 5: Already past meeting time (15:00 → 14:00)" + echo "===================================================" + + local result + result=$(python3 "$HELPER" 14 "15:00") + + if [[ "$result" == "0 minutes" ]]; then + print_result "Past meeting time shows 0 minutes" "PASS" + else + print_result "Past meeting time shows 0 minutes" "FAIL" "Expected '0 minutes', got '$result'" + fi +} + +# Test 6: Singular hour — 1 hour remaining +test_singular_hour() { + echo "" + echo "Test 6: Singular hour (13:00 → 14:00)" + echo "=======================================" + + local result + result=$(python3 "$HELPER" 14 "13:00") + + if [[ "$result" == "1 hour" ]]; then + print_result "Singular hour formatting" "PASS" + else + print_result "Singular hour formatting" "FAIL" "Expected '1 hour', got '$result'" + fi +} + +# Test 7: Singular minute — 1 hour and 1 minute +test_singular_minute() { + echo "" + echo "Test 7: Singular minute (12:59 → 14:00)" + echo "=========================================" + + local result + result=$(python3 "$HELPER" 14 "12:59") + + if [[ "$result" == "1 hour and 1 minute" ]]; then + print_result "Singular hour and minute formatting" "PASS" + else + print_result "Singular hour and minute formatting" "FAIL" "Expected '1 hour and 1 minute', got '$result'" + fi +} + +# Test 8: Different meeting hour +test_different_meeting_hour() { + echo "" + echo "Test 8: Different meeting hour (08:30 → 12:00)" + echo "================================================" + + local result + result=$(python3 "$HELPER" 12 "08:30") + + if [[ "$result" == "3 hours and 30 minutes" ]]; then + print_result "Different meeting hour works" "PASS" + else + print_result "Different meeting hour works" "FAIL" "Expected '3 hours and 30 minutes', got '$result'" + fi +} + +# Test 9: Rounding with seconds — ceil rounds partial minutes up +test_rounding_with_seconds() { + echo "" + echo "Test 9: Rounding with seconds (10:53:30 → 14:00) rounds up" + echo "============================================================" + + local result + result=$(python3 "$HELPER" 14 "10:53:30") + + if [[ "$result" == "3 hours and 7 minutes" ]]; then + print_result "Partial minute rounds up correctly" "PASS" + else + print_result "Partial minute rounds up correctly" "FAIL" "Expected '3 hours and 7 minutes', got '$result'" + fi +} + +# Test 10: Seconds just past the minute boundary +test_seconds_just_past_boundary() { + echo "" + echo "Test 10: Seconds just past boundary (13:00:01 → 14:00) rounds up to 60 min" + echo "============================================================================" + + local result + result=$(python3 "$HELPER" 14 "13:00:01") + + if [[ "$result" == "1 hour" ]]; then + print_result "Just past boundary rounds up to full hour" "PASS" + else + print_result "Just past boundary rounds up to full hour" "FAIL" "Expected '1 hour', got '$result'" + fi +} + +main() { + echo "==============================================" + echo " Cron Calls Time Computation - Test Suite" + echo "==============================================" + + if [[ ! -f "$HELPER" ]]; then + echo -e "${RED}ERROR: Helper script not found at $HELPER${NC}" + exit 1 + fi + + test_standard_4_hours + test_delayed_run + test_just_minutes + test_exact_hours + test_past_meeting_time + test_singular_hour + test_singular_minute + test_different_meeting_hour + test_rounding_with_seconds + test_seconds_just_past_boundary + + echo "" + echo "==============================================" + echo " Test Summary" + echo "==============================================" + echo "Total tests run: $TESTS_RUN" + echo -e "${GREEN}Tests passed: $TESTS_PASSED${NC}" + if [[ $TESTS_FAILED -gt 0 ]]; then + echo -e "${RED}Tests failed: $TESTS_FAILED${NC}" + exit 1 + else + echo -e "${GREEN}All tests passed!${NC}" + exit 0 + fi +} + +main "$@" From 4025334ade8dddc360d892ce78302ea54598bf3e Mon Sep 17 00:00:00 2001 From: ManmathX Date: Fri, 1 May 2026 17:32:16 +0530 Subject: [PATCH 2/8] Fix UTC boundaries and already started edge cases for cron calls Signed-off-by: ManmathX --- .github/scripts/cron-calls-community.sh | 8 +++- .github/scripts/cron-calls-office-hours.sh | 8 +++- .../test-cron-calls-time-computation.sh | 25 ++++++++++-- .../utils/compute-time-until-meeting.py | 39 +++++++++++++++---- 4 files changed, 67 insertions(+), 13 deletions(-) rename .github/scripts/{utils => tests}/test-cron-calls-time-computation.sh (89%) diff --git a/.github/scripts/cron-calls-community.sh b/.github/scripts/cron-calls-community.sh index 52b444393..417adaf1f 100644 --- a/.github/scripts/cron-calls-community.sh +++ b/.github/scripts/cron-calls-community.sh @@ -72,10 +72,16 @@ fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TIME_UNTIL_MEETING=$(python3 "$SCRIPT_DIR/utils/compute-time-until-meeting.py" "$MEETING_HOUR") +if [ "$TIME_UNTIL_MEETING" = "has already started" ]; then + TIME_PHRASE="has already started" +else + TIME_PHRASE="will begin in approximately $TIME_UNTIL_MEETING" +fi + COMMENT_BODY=$(cat < 02:00 next day) +test_utc_day_boundary() { + echo "" + echo "Test 11: UTC day boundary (23:00 → 02:00 next day)" + echo "====================================================" + + local result + result=$(python3 "$HELPER" 2 "23:00") + + if [[ "$result" == "3 hours" ]]; then + print_result "UTC day boundary adds 1 day" "PASS" + else + print_result "UTC day boundary adds 1 day" "FAIL" "Expected '3 hours', got '$result'" + fi +} + main() { echo "==============================================" echo " Cron Calls Time Computation - Test Suite" @@ -218,6 +234,7 @@ main() { test_different_meeting_hour test_rounding_with_seconds test_seconds_just_past_boundary + test_utc_day_boundary echo "" echo "==============================================" diff --git a/.github/scripts/utils/compute-time-until-meeting.py b/.github/scripts/utils/compute-time-until-meeting.py index 4af061658..59910a943 100644 --- a/.github/scripts/utils/compute-time-until-meeting.py +++ b/.github/scripts/utils/compute-time-until-meeting.py @@ -14,15 +14,25 @@ from __future__ import annotations import sys -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from math import ceil def _parse_mock_time(mock_time): """Parse a mock time string into (hour, minute, second) components.""" - parts = list(map(int, mock_time.split(":"))) - second = parts[2] if len(parts) > 2 else 0 - return parts[0], parts[1], second + parts = mock_time.split(":") + if len(parts) not in (2, 3): + raise ValueError("MOCK_TIME must be in HH:MM or HH:MM:SS format.") + try: + hour = int(parts[0]) + minute = int(parts[1]) + second = int(parts[2]) if len(parts) == 3 else 0 + except ValueError as exc: + raise ValueError("MOCK_TIME must contain only numeric components.") from exc + + if not (0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= second <= 59): + raise ValueError("MOCK_TIME values must be within valid UTC ranges.") + return hour, minute, second def _pluralize(value, singular): @@ -56,7 +66,16 @@ def compute_time_until_meeting(meeting_hour, mock_time=None): now = now.replace(hour=hour, minute=minute, second=second, microsecond=0) meeting = now.replace(hour=meeting_hour, minute=0, second=0, microsecond=0) + + # If the computed meeting time is more than 12 hours in the past, + # it means the cron job is running late the day before the meeting. + if (meeting - now).total_seconds() < -12 * 3600: + meeting += timedelta(days=1) + diff = meeting - now + if diff.total_seconds() <= 0: + return "has already started" + total_minutes = max(ceil(diff.total_seconds() / 60), 0) return _format_duration(total_minutes // 60, total_minutes % 60) @@ -67,6 +86,12 @@ def compute_time_until_meeting(meeting_hour, mock_time=None): print("Usage: python3 compute-time-until-meeting.py [MOCK_TIME]", file=sys.stderr) sys.exit(1) - meeting_hour = int(sys.argv[1]) - mock_time = sys.argv[2] if len(sys.argv) > 2 else None - print(compute_time_until_meeting(meeting_hour, mock_time)) + try: + meeting_hour = int(sys.argv[1]) + if not 0 <= meeting_hour <= 23: + raise ValueError("MEETING_HOUR must be an integer between 0 and 23.") + mock_time = sys.argv[2] if len(sys.argv) > 2 else None + print(compute_time_until_meeting(meeting_hour, mock_time)) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(1) From 0778d46a63c0837716e02bd63ca3e8160200990d Mon Sep 17 00:00:00 2001 From: ManmathX Date: Sun, 3 May 2026 22:29:47 +0530 Subject: [PATCH 3/8] Fix dynamic call reminder time computation - Address feedback from @exploreriii: - Rewrite bash test suite in Python using unittest for better maintainability. - Skip posting a comment if the meeting has already started; instead, log and exit gracefully. - Remove heuristics and pass the explicit date from the shell script for cleaner time calculation. - Fix Copilot observation: Replace the brittle -12h rollover heuristic with an explicit date to correctly handle morning meetings. - Add missing edge case test for early morning meetings (10:00 run for a 02:00 meeting). - Remove unused timedelta import. Signed-off-by: ManmathX --- .github/scripts/cron-calls-community.sh | 9 +- .github/scripts/cron-calls-office-hours.sh | 9 +- .../tests/test-cron-calls-time-computation.sh | 254 ------------------ .../tests/test_compute_time_until_meeting.py | 211 +++++++++++++++ .../utils/compute-time-until-meeting.py | 43 ++- 5 files changed, 250 insertions(+), 276 deletions(-) delete mode 100755 .github/scripts/tests/test-cron-calls-time-computation.sh create mode 100644 .github/scripts/tests/test_compute_time_until_meeting.py diff --git a/.github/scripts/cron-calls-community.sh b/.github/scripts/cron-calls-community.sh index 417adaf1f..0f89a8596 100644 --- a/.github/scripts/cron-calls-community.sh +++ b/.github/scripts/cron-calls-community.sh @@ -70,14 +70,15 @@ if [ -z "$ISSUE_DATA" ] || [ "$ISSUE_DATA" = "[]" ]; then fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -TIME_UNTIL_MEETING=$(python3 "$SCRIPT_DIR/utils/compute-time-until-meeting.py" "$MEETING_HOUR") +TIME_UNTIL_MEETING=$(python3 "$SCRIPT_DIR/utils/compute-time-until-meeting.py" "$TODAY" "$MEETING_HOUR") if [ "$TIME_UNTIL_MEETING" = "has already started" ]; then - TIME_PHRASE="has already started" -else - TIME_PHRASE="will begin in approximately $TIME_UNTIL_MEETING" + echo "Meeting has already started. Skipping reminder." + exit 0 fi +TIME_PHRASE="will begin in approximately $TIME_UNTIL_MEETING" + COMMENT_BODY=$(cat < 02:00 next day) -test_utc_day_boundary() { - echo "" - echo "Test 11: UTC day boundary (23:00 → 02:00 next day)" - echo "====================================================" - - local result - result=$(python3 "$HELPER" 2 "23:00") - - if [[ "$result" == "3 hours" ]]; then - print_result "UTC day boundary adds 1 day" "PASS" - else - print_result "UTC day boundary adds 1 day" "FAIL" "Expected '3 hours', got '$result'" - fi -} - -main() { - echo "==============================================" - echo " Cron Calls Time Computation - Test Suite" - echo "==============================================" - - if [[ ! -f "$HELPER" ]]; then - echo -e "${RED}ERROR: Helper script not found at $HELPER${NC}" - exit 1 - fi - - test_standard_4_hours - test_delayed_run - test_just_minutes - test_exact_hours - test_past_meeting_time - test_singular_hour - test_singular_minute - test_different_meeting_hour - test_rounding_with_seconds - test_seconds_just_past_boundary - test_utc_day_boundary - - echo "" - echo "==============================================" - echo " Test Summary" - echo "==============================================" - echo "Total tests run: $TESTS_RUN" - echo -e "${GREEN}Tests passed: $TESTS_PASSED${NC}" - if [[ $TESTS_FAILED -gt 0 ]]; then - echo -e "${RED}Tests failed: $TESTS_FAILED${NC}" - exit 1 - else - echo -e "${GREEN}All tests passed!${NC}" - exit 0 - fi -} - -main "$@" diff --git a/.github/scripts/tests/test_compute_time_until_meeting.py b/.github/scripts/tests/test_compute_time_until_meeting.py new file mode 100644 index 000000000..fd2acebcd --- /dev/null +++ b/.github/scripts/tests/test_compute_time_until_meeting.py @@ -0,0 +1,211 @@ +"""Unit tests for compute-time-until-meeting.py helper. + +Run with: + python3 -m unittest .github/scripts/tests/test_compute_time_until_meeting.py -v + +Or from the tests directory: + python3 -m unittest test_compute_time_until_meeting -v +""" + +import os +import sys +import unittest +from datetime import datetime, timezone, timedelta + +# Add the utils directory to sys.path so we can import the helper +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "utils")) + +from importlib import import_module + +# Import the module using importlib since the filename has hyphens +_mod = import_module("compute-time-until-meeting") +compute_time_until_meeting = _mod.compute_time_until_meeting +_format_duration = _mod._format_duration +_pluralize = _mod._pluralize +_parse_mock_time = _mod._parse_mock_time + + +class TestComputeTimeUntilMeeting(unittest.TestCase): + """Tests for the compute_time_until_meeting function.""" + + def test_standard_4_hours_before(self): + """Standard case: 10:00 run for 14:00 meeting = 4 hours.""" + result = compute_time_until_meeting("today", 14, "10:00") + self.assertEqual(result, "4 hours") + + def test_delayed_run_3h7m(self): + """Delayed run: 10:53 run for 14:00 meeting = 3 hours and 7 minutes.""" + result = compute_time_until_meeting("today", 14, "10:53") + self.assertEqual(result, "3 hours and 7 minutes") + + def test_just_minutes_remaining(self): + """Only minutes left: 13:45 run for 14:00 meeting = 15 minutes.""" + result = compute_time_until_meeting("today", 14, "13:45") + self.assertEqual(result, "15 minutes") + + def test_exact_whole_hours(self): + """Exact hours: 12:00 run for 14:00 meeting = 2 hours.""" + result = compute_time_until_meeting("today", 14, "12:00") + self.assertEqual(result, "2 hours") + + def test_past_meeting_time(self): + """Past meeting: 15:00 run for 14:00 meeting = has already started.""" + result = compute_time_until_meeting("today", 14, "15:00") + self.assertEqual(result, "has already started") + + def test_singular_hour(self): + """Singular: 13:00 run for 14:00 meeting = 1 hour (not '1 hours').""" + result = compute_time_until_meeting("today", 14, "13:00") + self.assertEqual(result, "1 hour") + + def test_singular_minute(self): + """Singular: 12:59 run for 14:00 meeting = 1 hour and 1 minute.""" + result = compute_time_until_meeting("today", 14, "12:59") + self.assertEqual(result, "1 hour and 1 minute") + + def test_different_meeting_hour(self): + """Different hour: 08:30 run for 12:00 meeting = 3 hours and 30 minutes.""" + result = compute_time_until_meeting("today", 12, "08:30") + self.assertEqual(result, "3 hours and 30 minutes") + + def test_rounding_with_seconds(self): + """Seconds should ceil: 10:53:30 run for 14:00 = 3 hours and 7 minutes.""" + result = compute_time_until_meeting("today", 14, "10:53:30") + self.assertEqual(result, "3 hours and 7 minutes") + + def test_seconds_just_past_boundary(self): + """Just past minute: 13:00:01 run for 14:00 rounds up to 1 hour.""" + result = compute_time_until_meeting("today", 14, "13:00:01") + self.assertEqual(result, "1 hour") + + def test_utc_day_boundary(self): + """Cross-day: 23:00 run for 02:00 next day = 3 hours.""" + tomorrow = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%d") + result = compute_time_until_meeting(tomorrow, 2, "23:00") + self.assertEqual(result, "3 hours") + + def test_same_day_morning_meeting_already_passed(self): + """Same-day early hour: 10:00 run with MEETING_HOUR=2 = has already started. + + This is the key edge case that the old -12h rollover heuristic got wrong. + """ + result = compute_time_until_meeting("today", 2, "10:00") + self.assertEqual(result, "has already started") + + def test_explicit_date(self): + """Explicit date: passing a specific YYYY-MM-DD date works correctly.""" + tomorrow = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%d") + result = compute_time_until_meeting(tomorrow, 14, "10:00") + self.assertIn("hour", result) + + def test_meeting_at_exact_time(self): + """Exact meeting time: 14:00 run for 14:00 meeting = has already started.""" + result = compute_time_until_meeting("today", 14, "14:00") + self.assertEqual(result, "has already started") + + def test_invalid_date_format(self): + """Invalid date format raises ValueError.""" + with self.assertRaises(ValueError): + compute_time_until_meeting("not-a-date", 14, "10:00") + + def test_midnight_meeting_hour_0(self): + """Edge of range: MEETING_HOUR=0, run at 22:00 yesterday = 2 hours.""" + tomorrow = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%d") + result = compute_time_until_meeting(tomorrow, 0, "22:00") + self.assertEqual(result, "2 hours") + + def test_late_meeting_hour_23(self): + """Edge of range: MEETING_HOUR=23, run at 20:00 = 3 hours.""" + result = compute_time_until_meeting("today", 23, "20:00") + self.assertEqual(result, "3 hours") + + def test_one_minute_remaining(self): + """Edge: 13:59 run for 14:00 meeting = 1 minute.""" + result = compute_time_until_meeting("today", 14, "13:59") + self.assertEqual(result, "1 minute") + + def test_cli_invocation(self): + """CLI: calling via subprocess produces expected output.""" + import subprocess + helper_path = os.path.join(os.path.dirname(__file__), "..", "utils", "compute-time-until-meeting.py") + result = subprocess.run( + ["python3", helper_path, "today", "14", "10:00"], + capture_output=True, text=True + ) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout.strip(), "4 hours") + + def test_cli_missing_args(self): + """CLI: missing arguments should exit with error.""" + import subprocess + helper_path = os.path.join(os.path.dirname(__file__), "..", "utils", "compute-time-until-meeting.py") + result = subprocess.run( + ["python3", helper_path], + capture_output=True, text=True + ) + self.assertEqual(result.returncode, 1) + + +class TestFormatDuration(unittest.TestCase): + """Tests for the _format_duration helper.""" + + def test_hours_and_minutes(self): + self.assertEqual(_format_duration(3, 7), "3 hours and 7 minutes") + + def test_only_hours(self): + self.assertEqual(_format_duration(4, 0), "4 hours") + + def test_only_minutes(self): + self.assertEqual(_format_duration(0, 15), "15 minutes") + + def test_singular_hour(self): + self.assertEqual(_format_duration(1, 0), "1 hour") + + def test_singular_minute(self): + self.assertEqual(_format_duration(0, 1), "1 minute") + + def test_singular_both(self): + self.assertEqual(_format_duration(1, 1), "1 hour and 1 minute") + + +class TestPluralize(unittest.TestCase): + """Tests for the _pluralize helper.""" + + def test_singular(self): + self.assertEqual(_pluralize(1, "hour"), "hour") + + def test_plural(self): + self.assertEqual(_pluralize(2, "hour"), "hours") + + def test_zero_is_plural(self): + self.assertEqual(_pluralize(0, "minute"), "minutes") + + +class TestParseMockTime(unittest.TestCase): + """Tests for the _parse_mock_time helper.""" + + def test_valid_hh_mm(self): + self.assertEqual(_parse_mock_time("10:30"), (10, 30, 0)) + + def test_valid_hh_mm_ss(self): + self.assertEqual(_parse_mock_time("10:30:45"), (10, 30, 45)) + + def test_invalid_format(self): + with self.assertRaises(ValueError): + _parse_mock_time("10") + + def test_non_numeric(self): + with self.assertRaises(ValueError): + _parse_mock_time("ab:cd") + + def test_out_of_range_hour(self): + with self.assertRaises(ValueError): + _parse_mock_time("25:00") + + def test_out_of_range_minute(self): + with self.assertRaises(ValueError): + _parse_mock_time("10:60") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/utils/compute-time-until-meeting.py b/.github/scripts/utils/compute-time-until-meeting.py index 59910a943..a0b86e19c 100644 --- a/.github/scripts/utils/compute-time-until-meeting.py +++ b/.github/scripts/utils/compute-time-until-meeting.py @@ -1,9 +1,10 @@ """Compute human-readable time remaining until a meeting. Usage: - python3 compute-time-until-meeting.py [MOCK_TIME] + python3 compute-time-until-meeting.py [MOCK_TIME] Arguments: + MEETING_DATE The date of the meeting in YYYY-MM-DD format, or 'today' MEETING_HOUR Meeting hour in UTC (0-23) MOCK_TIME Optional mock time in HH:MM or HH:MM:SS format (for testing) @@ -14,7 +15,7 @@ from __future__ import annotations import sys -from datetime import datetime, timezone, timedelta +from datetime import datetime, timezone from math import ceil @@ -49,10 +50,11 @@ def _format_duration(hours, minutes): return f"{minutes} {_pluralize(minutes, 'minute')}" -def compute_time_until_meeting(meeting_hour, mock_time=None): +def compute_time_until_meeting(meeting_date_str, meeting_hour, mock_time=None): """Compute the human-readable time remaining until the meeting. Args: + meeting_date_str: The meeting date as "YYYY-MM-DD" or "today". meeting_hour: The meeting hour in UTC (0-23). mock_time: Optional mock time string in "HH:MM" or "HH:MM:SS" format. @@ -65,12 +67,24 @@ def compute_time_until_meeting(meeting_hour, mock_time=None): hour, minute, second = _parse_mock_time(mock_time) now = now.replace(hour=hour, minute=minute, second=second, microsecond=0) - meeting = now.replace(hour=meeting_hour, minute=0, second=0, microsecond=0) - - # If the computed meeting time is more than 12 hours in the past, - # it means the cron job is running late the day before the meeting. - if (meeting - now).total_seconds() < -12 * 3600: - meeting += timedelta(days=1) + if meeting_date_str.lower() == "today": + meeting_date = now.date() + else: + try: + meeting_date = datetime.strptime(meeting_date_str, "%Y-%m-%d").date() + except ValueError as exc: + raise ValueError("MEETING_DATE must be in YYYY-MM-DD format or 'today'.") from exc + + meeting = datetime( + year=meeting_date.year, + month=meeting_date.month, + day=meeting_date.day, + hour=meeting_hour, + minute=0, + second=0, + microsecond=0, + tzinfo=timezone.utc, + ) diff = meeting - now if diff.total_seconds() <= 0: @@ -82,16 +96,17 @@ def compute_time_until_meeting(meeting_hour, mock_time=None): if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: python3 compute-time-until-meeting.py [MOCK_TIME]", file=sys.stderr) + if len(sys.argv) < 3: + print("Usage: python3 compute-time-until-meeting.py [MOCK_TIME]", file=sys.stderr) sys.exit(1) try: - meeting_hour = int(sys.argv[1]) + meeting_date_str = sys.argv[1] + meeting_hour = int(sys.argv[2]) if not 0 <= meeting_hour <= 23: raise ValueError("MEETING_HOUR must be an integer between 0 and 23.") - mock_time = sys.argv[2] if len(sys.argv) > 2 else None - print(compute_time_until_meeting(meeting_hour, mock_time)) + mock_time = sys.argv[3] if len(sys.argv) > 3 else None + print(compute_time_until_meeting(meeting_date_str, meeting_hour, mock_time)) except ValueError as exc: print(f"ERROR: {exc}", file=sys.stderr) sys.exit(1) From 916e8e901419c9345e43b63ee9ffa2ba57cff992 Mon Sep 17 00:00:00 2001 From: ManmathX Date: Mon, 4 May 2026 17:44:23 +0530 Subject: [PATCH 4/8] Fix Codacy: explicitly set check param in subprocess.run calls Signed-off-by: ManmathX --- .github/scripts/tests/test_compute_time_until_meeting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/tests/test_compute_time_until_meeting.py b/.github/scripts/tests/test_compute_time_until_meeting.py index fd2acebcd..03caa8c2d 100644 --- a/.github/scripts/tests/test_compute_time_until_meeting.py +++ b/.github/scripts/tests/test_compute_time_until_meeting.py @@ -130,7 +130,7 @@ def test_cli_invocation(self): helper_path = os.path.join(os.path.dirname(__file__), "..", "utils", "compute-time-until-meeting.py") result = subprocess.run( ["python3", helper_path, "today", "14", "10:00"], - capture_output=True, text=True + capture_output=True, text=True, check=True ) self.assertEqual(result.returncode, 0) self.assertEqual(result.stdout.strip(), "4 hours") @@ -141,7 +141,7 @@ def test_cli_missing_args(self): helper_path = os.path.join(os.path.dirname(__file__), "..", "utils", "compute-time-until-meeting.py") result = subprocess.run( ["python3", helper_path], - capture_output=True, text=True + capture_output=True, text=True, check=False ) self.assertEqual(result.returncode, 1) From 5ba02ae20c4ce493224d043d0c0414934aff2ec3 Mon Sep 17 00:00:00 2001 From: ManmathX Date: Mon, 4 May 2026 17:49:30 +0530 Subject: [PATCH 5/8] Fix Codacy: add future annotations import, sort import blocks Signed-off-by: ManmathX --- .github/scripts/tests/test_compute_time_until_meeting.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/scripts/tests/test_compute_time_until_meeting.py b/.github/scripts/tests/test_compute_time_until_meeting.py index 03caa8c2d..f5ee11f92 100644 --- a/.github/scripts/tests/test_compute_time_until_meeting.py +++ b/.github/scripts/tests/test_compute_time_until_meeting.py @@ -7,16 +7,17 @@ python3 -m unittest test_compute_time_until_meeting -v """ +from __future__ import annotations + import os import sys import unittest -from datetime import datetime, timezone, timedelta +from datetime import datetime, timedelta, timezone +from importlib import import_module # Add the utils directory to sys.path so we can import the helper sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "utils")) -from importlib import import_module - # Import the module using importlib since the filename has hyphens _mod = import_module("compute-time-until-meeting") compute_time_until_meeting = _mod.compute_time_until_meeting From 0e90008eb8c305071febbfca4fa94340bbbfe63a Mon Sep 17 00:00:00 2001 From: ManmathX Date: Mon, 4 May 2026 17:52:27 +0530 Subject: [PATCH 6/8] Fix Codacy: separate import and from-import blocks (I001) Signed-off-by: ManmathX --- .github/scripts/tests/test_compute_time_until_meeting.py | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/scripts/tests/test_compute_time_until_meeting.py b/.github/scripts/tests/test_compute_time_until_meeting.py index f5ee11f92..3e4af2d97 100644 --- a/.github/scripts/tests/test_compute_time_until_meeting.py +++ b/.github/scripts/tests/test_compute_time_until_meeting.py @@ -12,6 +12,7 @@ import os import sys import unittest + from datetime import datetime, timedelta, timezone from importlib import import_module From 0860e1e3fbaf4190e9661cdbc2af311fbeb93cc9 Mon Sep 17 00:00:00 2001 From: ManmathX Date: Mon, 4 May 2026 17:55:58 +0530 Subject: [PATCH 7/8] Fix Codacy: apply ruff isort formatting (I001) Signed-off-by: ManmathX --- .github/scripts/tests/test_compute_time_until_meeting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/tests/test_compute_time_until_meeting.py b/.github/scripts/tests/test_compute_time_until_meeting.py index 3e4af2d97..16b173ede 100644 --- a/.github/scripts/tests/test_compute_time_until_meeting.py +++ b/.github/scripts/tests/test_compute_time_until_meeting.py @@ -12,10 +12,10 @@ import os import sys import unittest - from datetime import datetime, timedelta, timezone from importlib import import_module + # Add the utils directory to sys.path so we can import the helper sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "utils")) From 844aa66bc814d1a7089c655658f4011584eabae1 Mon Sep 17 00:00:00 2001 From: ManmathX Date: Tue, 5 May 2026 18:59:29 +0530 Subject: [PATCH 8/8] Fix pre-commit: apply ruff format to test file Signed-off-by: ManmathX --- .../scripts/tests/test_compute_time_until_meeting.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/scripts/tests/test_compute_time_until_meeting.py b/.github/scripts/tests/test_compute_time_until_meeting.py index 16b173ede..e75efb380 100644 --- a/.github/scripts/tests/test_compute_time_until_meeting.py +++ b/.github/scripts/tests/test_compute_time_until_meeting.py @@ -129,10 +129,10 @@ def test_one_minute_remaining(self): def test_cli_invocation(self): """CLI: calling via subprocess produces expected output.""" import subprocess + helper_path = os.path.join(os.path.dirname(__file__), "..", "utils", "compute-time-until-meeting.py") result = subprocess.run( - ["python3", helper_path, "today", "14", "10:00"], - capture_output=True, text=True, check=True + ["python3", helper_path, "today", "14", "10:00"], capture_output=True, text=True, check=True ) self.assertEqual(result.returncode, 0) self.assertEqual(result.stdout.strip(), "4 hours") @@ -140,11 +140,9 @@ def test_cli_invocation(self): def test_cli_missing_args(self): """CLI: missing arguments should exit with error.""" import subprocess + helper_path = os.path.join(os.path.dirname(__file__), "..", "utils", "compute-time-until-meeting.py") - result = subprocess.run( - ["python3", helper_path], - capture_output=True, text=True, check=False - ) + result = subprocess.run(["python3", helper_path], capture_output=True, text=True, check=False) self.assertEqual(result.returncode, 1)