Skip to content
Merged
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
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down
127 changes: 126 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions src/nf_core_bot/commands/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
17 changes: 14 additions & 3 deletions src/nf_core_bot/commands/oncall/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
117 changes: 109 additions & 8 deletions src/nf_core_bot/commands/oncall/unavailable.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
"""``/nf-core on-call unavailable <start> <end>`` — 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

Expand All @@ -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,
)

Expand All @@ -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:
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading