diff --git a/README.md b/README.md index c42535b..b1f4da4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # nf-core-bot -A Slack bot for the nf-core community — hackathon registration and GitHub -organisation tooling. +A Slack bot for the nf-core community — hackathon registration, GitHub +organisation tooling, and on-call rotation scheduling. Built with [Slack Bolt for Python](https://slack.dev/bolt-python/), hosted on AWS ECS Fargate + DynamoDB. @@ -32,6 +32,28 @@ sends them an invite, with membership in the _Collaborators_ team. The slash commands `/nf-core github add` are mostly for convenience when replying elsewhere in Slack. +## On-call Rotation + +The bot manages a weekly on-call rotation for `@core-team` members. All on-call +commands require `@core-team` membership. + +```bash +/nf-core on-call help # On-call help +/nf-core on-call list # Upcoming schedule +/nf-core on-call me # Your upcoming weeks +/nf-core on-call switch [YYYY-MM-DD] # Swap your next week +/nf-core on-call skip # Skip your next week +/nf-core on-call unavailable YYYY-MM-DD YYYY-MM-DD # Mark a range unavailable +/nf-core on-call unavailable list # List your unavailable ranges +/nf-core on-call unavailable remove YYYY-MM-DD YYYY-MM-DD # Remove a range +/nf-core on-call calendar all|me # Subscribable iCal URL +/nf-core on-call reboot # Wipe & rebuild schedule +``` + +The scheduler picks weekly assignees by round-robin (queue-front priority for +people who previously skipped), DMs reminders ahead of each shift, and skips +weeks where the assignee is marked unavailable. + ## Hackathon Registrations These commands run a hackathon registration system _within Slack_. This is diff --git a/docs/commands.md b/docs/commands.md index cb5c93f..25f51c5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -350,13 +350,138 @@ threads). --- +## On-call Commands + +These commands manage the weekly on-call rotation for the `@core-team`. All +require `@core-team` Slack user group membership. + +### `on-call help` + +``` +/nf-core on-call help +``` + +Show on-call command help. + +### `on-call list` + +``` +/nf-core on-call list +``` + +Show the upcoming on-call schedule, with the assigned user and status (e.g. +`scheduled`, `swapped`, `skipped`) for each week. + +### `on-call me` + +``` +/nf-core on-call me +``` + +Show the calling user's upcoming on-call weeks. + +### `on-call switch` + +``` +/nf-core on-call switch [YYYY-MM-DD] +``` + +Swap the caller's next on-call week with another week: + +- With no argument, swaps with the week immediately after the caller's next + assignment. +- With a date, swaps with the week containing that date. + +Both parties receive a DM confirming the swap. + +### `on-call skip` + +``` +/nf-core on-call skip +``` + +Skip the caller's next on-call week. The bot picks a replacement (queue-front +priority first, then round-robin) and DMs both parties. The caller is moved to +the front of the queue so they're prioritised for the next available week. + +If no replacement is available, the bot suggests using `on-call switch` +instead. + +### `on-call unavailable` + +``` +/nf-core on-call unavailable YYYY-MM-DD YYYY-MM-DD +``` + +Mark the caller as unavailable for a date range (inclusive). If any of the +caller's existing on-call weeks overlap the range, the bot tries to find a +replacement automatically and DMs them. + +**Example:** + +``` +/nf-core on-call unavailable 2026-07-01 2026-07-14 +``` + +### `on-call unavailable list` + +``` +/nf-core on-call unavailable list +``` + +List the caller's stored unavailability ranges (current and future only — +expired ranges are filtered out). Each entry shows the formatted range and the +raw ISO dates in backticks for easy copy/paste into the `remove` command. + +### `on-call unavailable remove` + +``` +/nf-core on-call unavailable remove YYYY-MM-DD YYYY-MM-DD +``` + +Remove a previously stored unavailability range. Both dates must exactly match +an existing range (use `on-call unavailable list` to see the stored values). If +no matching range is found the bot reports it without making changes. + +**Example:** + +``` +/nf-core on-call unavailable remove 2026-07-01 2026-07-14 +``` + +### `on-call reboot` + +``` +/nf-core on-call reboot +``` + +Wipe the entire on-call schedule (roster + round-robin state) and rebuild it +from scratch by extending the roster forward. Use with care. + +### `on-call calendar` + +``` +/nf-core on-call calendar all +/nf-core on-call calendar me +``` + +Get a subscribable iCal URL for the on-call schedule: + +- `all` — every week in the rotation +- `me` — only the caller's weeks + +The URL is obfuscated with a per-team token; the calendar updates automatically +when the schedule changes. + +--- + ## Permission Model | Level | Who | Access | | ------------------ | -------------------------------------------- | ------------------------------------------------------------------------ | | **User** | Everyone | `/hackathon list`, `/hackathon register/edit/cancel`, `/hackathon sites` | | **Site organiser** | Per-hackathon, per-site (stored in DynamoDB) | `/hackathon export` | -| **Admin** | `@core-team` Slack user group members | All commands including `/hackathon admin *`, `/nf-core github *` | +| **Admin** | `@core-team` Slack user group members | All commands including `/hackathon admin *`, `/nf-core github *`, `/nf-core on-call *` | Admin membership is checked via `usergroups.users.list` and cached for 5 minutes. diff --git a/src/nf_core_bot/commands/help.py b/src/nf_core_bot/commands/help.py index df50470..1c8658d 100644 --- a/src/nf_core_bot/commands/help.py +++ b/src/nf_core_bot/commands/help.py @@ -41,6 +41,8 @@ ("on-call switch YYYY-MM-DD", "Swap your next on-call week with the specified week", "admin"), ("on-call skip", "Skip your next on-call week (a replacement is assigned)", "admin"), ("on-call unavailable YYYY-MM-DD YYYY-MM-DD", "Mark yourself as unavailable for a date range", "admin"), + ("on-call unavailable list", "List your unavailable date ranges", "admin"), + ("on-call unavailable remove YYYY-MM-DD YYYY-MM-DD", "Remove a previously set unavailable date range", "admin"), ("on-call reboot", "Wipe and rebuild the on-call schedule from scratch", "admin"), ("on-call calendar all", "Get a subscribable iCal URL for the full on-call schedule", "admin"), ("on-call calendar me", "Get a subscribable iCal URL for your on-call weeks", "admin"), diff --git a/src/nf_core_bot/commands/oncall/helpers.py b/src/nf_core_bot/commands/oncall/helpers.py index 5e5abe8..46d4f91 100644 --- a/src/nf_core_bot/commands/oncall/helpers.py +++ b/src/nf_core_bot/commands/oncall/helpers.py @@ -15,11 +15,22 @@ def current_week_start() -> str: return monday_of_week(datetime.date.today()).isoformat() +def today_iso() -> str: + """Return today's date as ``YYYY-MM-DD``.""" + return datetime.date.today().isoformat() + + +def format_date_range(start: str, end: str) -> str: + """Format an ISO date range as ``Apr 6 – Apr 12``.""" + s = datetime.date.fromisoformat(start) + e = datetime.date.fromisoformat(end) + return f"{s.strftime('%b %-d')} – {e.strftime('%b %-d')}" + + def format_week_range(week_start: str) -> str: """Format a week as ``Apr 6 – Apr 12``.""" - start = datetime.date.fromisoformat(week_start) - end = start + datetime.timedelta(days=6) - return f"{start.strftime('%b %-d')} – {end.strftime('%b %-d')}" + end = (datetime.date.fromisoformat(week_start) + datetime.timedelta(days=6)).isoformat() + return format_date_range(week_start, end) def parse_date_arg(text: str) -> datetime.date: diff --git a/src/nf_core_bot/commands/oncall/unavailable.py b/src/nf_core_bot/commands/oncall/unavailable.py index 10df838..bced865 100644 --- a/src/nf_core_bot/commands/oncall/unavailable.py +++ b/src/nf_core_bot/commands/oncall/unavailable.py @@ -1,4 +1,11 @@ -"""``/nf-core on-call unavailable `` — mark the caller as unavailable.""" +"""``/nf-core on-call unavailable`` — manage the caller's unavailability ranges. + +Subcommands: + +* ``unavailable YYYY-MM-DD YYYY-MM-DD`` — mark the caller as unavailable. +* ``unavailable list`` — list the caller's stored unavailability ranges. +* ``unavailable remove YYYY-MM-DD YYYY-MM-DD`` — remove a previously stored range. +""" from __future__ import annotations @@ -9,14 +16,18 @@ from nf_core_bot.calendar.generator import safe_regenerate_calendars from nf_core_bot.commands.oncall.helpers import ( current_week_start, + format_date_range, format_week_range, parse_date_arg, + today_iso, ) from nf_core_bot.commands.oncall.skip import find_skip_replacement from nf_core_bot.db.oncall import ( add_to_queue_front, add_unavailability, list_roster, + list_unavailability, + remove_unavailability, update_roster_assignment, ) @@ -26,20 +37,47 @@ logger = logging.getLogger(__name__) +USAGE = ( + "Usage:\n" + " `/nf-core on-call unavailable YYYY-MM-DD YYYY-MM-DD` — mark a date range as unavailable\n" + " `/nf-core on-call unavailable list` — list your unavailable date ranges\n" + " `/nf-core on-call unavailable remove YYYY-MM-DD YYYY-MM-DD` — remove a date range" +) + async def handle_oncall_unavailable( respond: Respond, client: AsyncWebClient, user_id: str, args: list[str], +) -> None: + """Dispatch to the correct unavailable subcommand based on *args*.""" + + if not args: + await respond(text=USAGE, response_type="ephemeral") + return + + sub = args[0].lower() + if sub == "list": + await _handle_list(respond, user_id) + return + if sub == "remove": + await _handle_remove(respond, user_id, args[1:]) + return + + await _handle_add(respond, client, user_id, args) + + +async def _handle_add( + respond: Respond, + client: AsyncWebClient, + user_id: str, + args: list[str], ) -> None: """Mark the caller as unavailable between two dates (inclusive).""" if len(args) < 2: - await respond( - text="Usage: `/nf-core on-call unavailable YYYY-MM-DD YYYY-MM-DD`", - response_type="ephemeral", - ) + await respond(text=USAGE, response_type="ephemeral") return try: @@ -69,9 +107,7 @@ async def handle_oncall_unavailable( # Store the unavailability await add_unavailability(user_id, start_str, end_str) - msg_parts: list[str] = [ - f":calendar: Marked you as unavailable *{start.strftime('%b %-d')} – {end.strftime('%b %-d')}*." - ] + msg_parts: list[str] = [f":calendar: Marked you as unavailable *{format_date_range(start_str, end_str)}*."] # Check if the caller is already assigned any weeks that overlap roster = await list_roster(from_date=current_week_start()) @@ -121,6 +157,71 @@ async def handle_oncall_unavailable( await safe_regenerate_calendars(client) +async def _handle_list(respond: Respond, user_id: str) -> None: + """Show the caller's stored unavailability ranges (only current/future).""" + + today = today_iso() + entries = await list_unavailability(user_id) + upcoming = sorted((e for e in entries if e["end_date"] >= today), key=lambda e: e["start_date"]) + + if not upcoming: + await respond( + text="You have no upcoming unavailable date ranges.", + response_type="ephemeral", + ) + return + + lines = [ + f"• *{format_date_range(e['start_date'], e['end_date'])}* `{e['start_date']} {e['end_date']}`" + for e in upcoming + ] + header = ":calendar: *Your upcoming unavailable date ranges:*\n\n" + footer = ( + "\n\nTo remove a range, run " + "`/nf-core on-call unavailable remove YYYY-MM-DD YYYY-MM-DD` " + "with the dates shown in backticks." + ) + await respond(text=header + "\n".join(lines) + footer, response_type="ephemeral") + + +async def _handle_remove(respond: Respond, user_id: str, args: list[str]) -> None: + """Remove a stored unavailability range matching the given start/end dates.""" + + if len(args) < 2: + await respond( + text="Usage: `/nf-core on-call unavailable remove YYYY-MM-DD YYYY-MM-DD`", + response_type="ephemeral", + ) + return + + try: + start = parse_date_arg(args[0]) + end = parse_date_arg(args[1]) + except ValueError as exc: + await respond(text=str(exc), response_type="ephemeral") + return + + start_str = start.isoformat() + end_str = end.isoformat() + + try: + await remove_unavailability(user_id, start_str, end_str) + except ValueError: + await respond( + text=( + f"No unavailability range found for *{format_date_range(start_str, end_str)}*. " + "Run `/nf-core on-call unavailable list` to see your ranges." + ), + response_type="ephemeral", + ) + return + + await respond( + text=f":white_check_mark: Removed unavailability *{format_date_range(start_str, end_str)}*.", + response_type="ephemeral", + ) + + def _week_overlaps(week_start: str, range_start: str, range_end: str) -> bool: """Check if a week (Mon–Sun) overlaps with a date range.""" ws = datetime.date.fromisoformat(week_start) diff --git a/src/nf_core_bot/db/oncall.py b/src/nf_core_bot/db/oncall.py index 62d3664..a6cd40a 100644 --- a/src/nf_core_bot/db/oncall.py +++ b/src/nf_core_bot/db/oncall.py @@ -227,7 +227,7 @@ def _put() -> None: async def remove_unavailability(user_id: str, start_date: str, end_date: str) -> None: - """Remove a specific unavailability entry.""" + """Remove a specific unavailability entry. Raises ValueError if it doesn't exist.""" table = get_table() def _delete() -> None: @@ -235,10 +235,14 @@ def _delete() -> None: Key={ "PK": _unavail_pk(user_id), "SK": _unavail_sk(start_date, end_date), - } + }, + ConditionExpression=Attr("PK").exists(), ) - await asyncio.to_thread(_delete) + try: + await asyncio.to_thread(_delete) + except table.meta.client.exceptions.ConditionalCheckFailedException as exc: + raise ValueError(f"No unavailability entry for {user_id} {start_date}–{end_date}") from exc async def list_unavailability(user_id: str) -> list[dict[str, Any]]: diff --git a/tests/test_oncall_commands.py b/tests/test_oncall_commands.py index 2720922..39ef6d4 100644 --- a/tests/test_oncall_commands.py +++ b/tests/test_oncall_commands.py @@ -334,13 +334,13 @@ async def test_auto_skips_overlapping_assignment(self, mock_find, ddb_table, res mock_find.return_value = "U333" - await put_roster_entry("2026-04-06", "U111") + await put_roster_entry("2026-06-01", "U111") - with patch("nf_core_bot.commands.oncall.unavailable.current_week_start", return_value="2026-04-01"): - await handle_oncall_unavailable(respond, client, "U111", ["2026-04-05", "2026-04-12"]) + with patch("nf_core_bot.commands.oncall.unavailable.current_week_start", return_value="2026-05-25"): + await handle_oncall_unavailable(respond, client, "U111", ["2026-05-31", "2026-06-07"]) # The overlapping week should be reassigned - entry = await get_roster_entry("2026-04-06") + entry = await get_roster_entry("2026-06-01") assert entry is not None assert entry["assigned_user_id"] == "U333" assert entry["status"] == "skipped" @@ -352,15 +352,123 @@ async def test_warns_when_no_replacement(self, mock_find, ddb_table, respond, cl mock_find.return_value = None - await put_roster_entry("2026-04-06", "U111") + await put_roster_entry("2026-06-01", "U111") - with patch("nf_core_bot.commands.oncall.unavailable.current_week_start", return_value="2026-04-01"): - await handle_oncall_unavailable(respond, client, "U111", ["2026-04-05", "2026-04-12"]) + with patch("nf_core_bot.commands.oncall.unavailable.current_week_start", return_value="2026-05-25"): + await handle_oncall_unavailable(respond, client, "U111", ["2026-05-31", "2026-06-07"]) text = respond.call_args[1]["text"] assert "no replacement" in text.lower() +class TestOncallUnavailableList: + async def test_no_entries(self, ddb_table, respond, client) -> None: + from nf_core_bot.commands.oncall.unavailable import handle_oncall_unavailable + + await handle_oncall_unavailable(respond, client, "U111", ["list"]) + + text = respond.call_args[1]["text"] + assert "no upcoming unavailable" in text.lower() + + async def test_lists_upcoming_only(self, ddb_table, respond, client) -> None: + from nf_core_bot.commands.oncall.unavailable import handle_oncall_unavailable + from nf_core_bot.db.oncall import add_unavailability + + # Past, current, and future ranges + await add_unavailability("U111", "2020-01-01", "2020-01-15") + await add_unavailability("U111", "2026-05-01", "2026-05-15") + await add_unavailability("U111", "2026-06-01", "2026-06-10") + # Different user — must not appear + await add_unavailability("U222", "2026-05-20", "2026-05-25") + + with patch("nf_core_bot.commands.oncall.unavailable.today_iso", return_value="2026-05-02"): + await handle_oncall_unavailable(respond, client, "U111", ["list"]) + + text = respond.call_args[1]["text"] + assert "May 1" in text + assert "Jun 1" in text + # Past entry must be filtered out + assert "2020" not in text + # Other user's range must not appear + assert "May 20" not in text + # Hint about removal + assert "remove" in text.lower() + + async def test_list_sorted_by_start(self, ddb_table, respond, client) -> None: + from nf_core_bot.commands.oncall.unavailable import handle_oncall_unavailable + from nf_core_bot.db.oncall import add_unavailability + + await add_unavailability("U111", "2026-08-01", "2026-08-05") + await add_unavailability("U111", "2026-06-01", "2026-06-05") + await add_unavailability("U111", "2026-07-01", "2026-07-05") + + with patch("nf_core_bot.commands.oncall.unavailable.today_iso", return_value="2026-05-02"): + await handle_oncall_unavailable(respond, client, "U111", ["list"]) + + text = respond.call_args[1]["text"] + assert text.index("Jun 1") < text.index("Jul 1") < text.index("Aug 1") + + +class TestOncallUnavailableRemove: + async def test_missing_args(self, ddb_table, respond, client) -> None: + from nf_core_bot.commands.oncall.unavailable import handle_oncall_unavailable + + await handle_oncall_unavailable(respond, client, "U111", ["remove"]) + assert "Usage" in respond.call_args[1]["text"] + + async def test_missing_end_arg(self, ddb_table, respond, client) -> None: + from nf_core_bot.commands.oncall.unavailable import handle_oncall_unavailable + + await handle_oncall_unavailable(respond, client, "U111", ["remove", "2026-05-01"]) + assert "Usage" in respond.call_args[1]["text"] + + async def test_invalid_dates(self, ddb_table, respond, client) -> None: + from nf_core_bot.commands.oncall.unavailable import handle_oncall_unavailable + + await handle_oncall_unavailable(respond, client, "U111", ["remove", "bad", "dates"]) + assert "not a valid date" in respond.call_args[1]["text"].lower() + + async def test_no_matching_range(self, ddb_table, respond, client) -> None: + from nf_core_bot.commands.oncall.unavailable import handle_oncall_unavailable + from nf_core_bot.db.oncall import add_unavailability + + await add_unavailability("U111", "2026-05-01", "2026-05-15") + + await handle_oncall_unavailable(respond, client, "U111", ["remove", "2026-06-01", "2026-06-10"]) + + text = respond.call_args[1]["text"] + assert "no unavailability range found" in text.lower() + + async def test_removes_matching_range(self, ddb_table, respond, client) -> None: + from nf_core_bot.commands.oncall.unavailable import handle_oncall_unavailable + from nf_core_bot.db.oncall import add_unavailability, list_unavailability + + await add_unavailability("U111", "2026-05-01", "2026-05-15") + await add_unavailability("U111", "2026-06-01", "2026-06-10") + + await handle_oncall_unavailable(respond, client, "U111", ["remove", "2026-05-01", "2026-05-15"]) + + entries = await list_unavailability("U111") + assert len(entries) == 1 + assert entries[0]["start_date"] == "2026-06-01" + + text = respond.call_args[1]["text"] + assert "removed" in text.lower() + + async def test_only_removes_callers_range(self, ddb_table, respond, client) -> None: + """Removing must not touch another user's identical range.""" + from nf_core_bot.commands.oncall.unavailable import handle_oncall_unavailable + from nf_core_bot.db.oncall import add_unavailability, list_unavailability + + await add_unavailability("U111", "2026-05-01", "2026-05-15") + await add_unavailability("U222", "2026-05-01", "2026-05-15") + + await handle_oncall_unavailable(respond, client, "U111", ["remove", "2026-05-01", "2026-05-15"]) + + assert await list_unavailability("U111") == [] + assert len(await list_unavailability("U222")) == 1 + + # --------------------------------------------------------------------------- # on-call reboot # ---------------------------------------------------------------------------