diff --git a/.github/ACDbot/modules/gcal.py b/.github/ACDbot/modules/gcal.py index e2173bcb3..dc70df618 100644 --- a/.github/ACDbot/modules/gcal.py +++ b/.github/ACDbot/modules/gcal.py @@ -85,6 +85,34 @@ def build_calendar_add_link(summary, start_time, duration_minutes, description=" return f"https://www.google.com/calendar/render?{urlencode(params)}" +ICS_BASE_URL = os.getenv("ICS_BASE_URL", "https://ps.ethereum.foundation") + + +def build_ics_download_link(summary, start_time, duration_minutes, description=""): + """Build a link to download an .ics file for the event via ps.ethereum.foundation. + + Args: + summary: Event title + start_time: ISO-formatted datetime string + duration_minutes: Event duration in minutes + description: Optional event description + """ + if not start_time: + return None + start_dt = parse_iso_datetime(start_time) + if start_dt is None: + print(f"[WARN] build_ics_download_link: Failed to parse start_time: {start_time}") + return None + params = { + "title": summary, + "start": start_dt.strftime('%Y%m%dT%H%M%SZ'), + "duration": str(duration_minutes), + } + if description: + params["description"] = description + return f"{ICS_BASE_URL}/api/ics?{urlencode(params)}" + + def render_calendar_comment_line(start_time, summary, duration, issue_url, zoom_url=None): """Build the calendar comment line with View and Add to Calendar links. @@ -109,14 +137,20 @@ def render_calendar_comment_line(start_time, summary, duration, issue_url, zoom_ details_parts = [f"Issue: {issue_url}"] if zoom_url: details_parts.insert(0, f"Meeting: {zoom_url}") - add_link = build_calendar_add_link(summary, start_time, duration, "\n\n".join(details_parts)) + description = "\n\n".join(details_parts) + add_link = build_calendar_add_link(summary, start_time, duration, description) + ics_link = build_ics_download_link(summary, start_time, duration, description) - if view_link and add_link: - return f"✅ **Calendar**: [View]({view_link}) | [Add to Calendar]({add_link})" - if add_link: - return f"✅ **Calendar**: [Add to Calendar]({add_link})" + links = [] if view_link: - return f"✅ **Calendar**: [View]({view_link})" + links.append(f"[View]({view_link})") + if add_link: + links.append(f"[Add to Calendar]({add_link})") + if ics_link: + links.append(f"[Download .ics]({ics_link})") + + if links: + return f"✅ **Calendar**: {' | '.join(links)}" print(f"[ERROR] render_calendar_comment_line: Failed to build calendar links for start_time: {start_time}") return "❌ **Calendar**: No calendar event found" diff --git a/.github/ACDbot/tests/unit/test_gcal_links.py b/.github/ACDbot/tests/unit/test_gcal_links.py index 4afa00769..e25d548b6 100644 --- a/.github/ACDbot/tests/unit/test_gcal_links.py +++ b/.github/ACDbot/tests/unit/test_gcal_links.py @@ -21,7 +21,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'modules')) -from gcal import build_calendar_view_link, build_calendar_add_link, PROTOCOL_CALENDAR_ID +from gcal import build_calendar_view_link, build_calendar_add_link, build_ics_download_link, PROTOCOL_CALENDAR_ID # Do not leave mocked google/pytz in sys.modules: other test modules import real libraries. for _key in _gcal_mock_keys: @@ -107,5 +107,50 @@ def test_invalid_start_time(self): self.assertIsNone(build_calendar_add_link("Test", "garbage", 60)) +class TestBuildIcsDownloadLink(unittest.TestCase): + + def test_basic(self): + result = build_ics_download_link("ACD Call", "2026-04-02T14:00:00Z", 60) + self.assertIsNotNone(result) + parsed = urlparse(result) + params = parse_qs(parsed.query) + self.assertIn("/api/ics", parsed.path) + self.assertEqual(params["title"], ["ACD Call"]) + self.assertEqual(params["start"], ["20260402T140000Z"]) + self.assertEqual(params["duration"], ["60"]) + + def test_with_description(self): + result = build_ics_download_link("Test", "2026-04-02T14:00:00Z", 90, "Issue: https://example.com") + params = parse_qs(urlparse(result).query) + self.assertEqual(params["description"], ["Issue: https://example.com"]) + self.assertEqual(params["duration"], ["90"]) + + def test_no_description(self): + result = build_ics_download_link("Test", "2026-04-02T14:00:00Z", 60) + params = parse_qs(urlparse(result).query) + self.assertNotIn("description", params) + + def test_none_start_time(self): + self.assertIsNone(build_ics_download_link("Test", None, 60)) + + def test_empty_start_time(self): + self.assertIsNone(build_ics_download_link("Test", "", 60)) + + def test_invalid_start_time(self): + self.assertIsNone(build_ics_download_link("Test", "garbage", 60)) + + def test_custom_base_url(self): + with patch.dict(os.environ, {"ICS_BASE_URL": "https://preview.example.com"}, clear=False): + # Need to reimport to pick up the env var since it's read at module level + import gcal + original = gcal.ICS_BASE_URL + gcal.ICS_BASE_URL = "https://preview.example.com" + try: + result = build_ics_download_link("Test", "2026-04-02T14:00:00Z", 60) + self.assertTrue(result.startswith("https://preview.example.com/api/ics")) + finally: + gcal.ICS_BASE_URL = original + + if __name__ == '__main__': unittest.main()