Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions .github/ACDbot/modules/gcal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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"
Expand Down
47 changes: 46 additions & 1 deletion .github/ACDbot/tests/unit/test_gcal_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()