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
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ DynamoDB stores only sites, organisers, and registrations.
`commands/github/invite_flow.py` contains the shared org-invite + team-add logic
used by both the slash command (`add_member.py`) and the message shortcut
(`add_member_shortcut.py`). Both pass a `reply` callback for message delivery.
Channel replies are wrapped in `_safe_reply()` which catches exceptions; on
failure, `_reply_or_dm()` falls back to DMing the caller. On success the caller
always receives a DM confirmation as a failsafe.

### On-call rotation

Expand Down Expand Up @@ -111,7 +114,7 @@ members have been assigned in the current window, it cycles through again.
- Two-tier permissions: `@core-team` Slack user group = global admin, site
organisers = scoped to their hackathon site
- All bot responses are ephemeral (only visible to the caller) unless explicitly
posting to a channel (e.g. `github add-member` posts visible thread replies)
posting to a channel (e.g. `github add` posts visible thread replies)
- Form YAML supports `options_from: sites` for dynamic option lists populated
from DynamoDB, and `options_from: countries` for type-ahead country search
- GitHub API calls use a fine-grained PAT with `admin:org` scope
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,27 @@ AWS ECS Fargate + DynamoDB.
This app adds two slash-commands which can be used by anyone in the nf-core
slack.

All responses are **ephemeral** (only visible to you), except
`github add-member` which posts visible thread replies.
All responses are **ephemeral** (only visible to you), except `github add` which
posts visible thread replies.

See [docs/commands.md](docs/commands.md) for the full command reference.

## General Automation

```bash
/nf-core help # General help
/nf-core github add-member @user # Invite to nf-core GitHub org
/nf-core github add-member <username> # Invite by GitHub username
/nf-core help # General help
/nf-core github add @user # Invite to nf-core GitHub org
/nf-core github add <username> # Invite by GitHub username
```

The `github add-member` functionality works best when coming from the
The `github add` functionality works best when coming from the
`#github-invitations` channel: right-click any message → **More actions** →
**Add to GitHub org** to invite the message author.

This automatically finds the GitHub username from the Slack workflow message and
sends them an invite, with membership in the _Collaborators_ team.

The slach commands `/nf-core github add-member` are mostly for convenience when
The slash commands `/nf-core github add` are mostly for convenience when
replying elsewhere in Slack.

## Hackathon Registrations
Expand Down
10 changes: 5 additions & 5 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,10 @@ These commands manage nf-core GitHub organisation membership. All require

Show GitHub command help.

### `github add-member`
### `github add`

```
/nf-core github add-member <@slack-user|github-username>
/nf-core github add <@slack-user|github-username>
```

Invite a user to the nf-core GitHub organisation and add them to the
Expand All @@ -304,8 +304,8 @@ Invite a user to the nf-core GitHub organisation and add them to the
**Examples:**

```
/nf-core github add-member @alice
/nf-core github add-member octocat
/nf-core github add @alice
/nf-core github add octocat
```

### Message Shortcut: Add to GitHub org
Expand All @@ -319,7 +319,7 @@ threads).

**Permissions:** `@core-team` only.

### How `add-member` Works
### How `add` Works

1. **Permission check** — verifies you're in the `@core-team` Slack user group
2. **Target resolution** — determines who to invite:
Expand Down
2 changes: 1 addition & 1 deletion docs/slack-app-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public URL.
- **Request URL:** `https://example.com/slack/events` (Socket Mode ignores
this, but Slack requires a value)
- **Short Description:** `nf-core community bot`
- **Usage Hint:** `[help | github add-member | hackathon register]`
- **Usage Hint:** `[help | github add | hackathon register]`
- **Escape channels, users, and links sent to your app:** check this box
4. Click **Save**

Expand Down
29 changes: 19 additions & 10 deletions src/nf_core_bot/commands/github/add_member.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""``/nf-core github add-member`` — invite a user to the nf-core GitHub org.
"""``/nf-core github add`` — invite a user to the nf-core GitHub org.

Usage (explicit Slack mention):
``/nf-core github add-member @slack-user``
``/nf-core github add @slack-user``

Usage (explicit GitHub username):
``/nf-core github add-member octocat``
``/nf-core github add octocat``

To invite the author of a specific message, use the "Add to GitHub org"
message shortcut (right-click a message → More actions) instead.
Expand Down Expand Up @@ -39,7 +39,7 @@ async def handle_add_member(
command: dict[str, str],
args: list[str],
) -> None:
"""Handle ``/nf-core github add-member [target]``."""
"""Handle ``/nf-core github add [target]``."""
await ack()

# ── 1. Permission check ──────────────────────────────────────────
Expand All @@ -66,7 +66,7 @@ async def handle_add_member(
target_user_id = mention_match.group(1)
github_username = await get_github_username(client, target_user_id)
if github_username is None:
await _warn_missing_github(client, channel_id, thread_ts, target_user_id)
await _warn_missing_github(client, channel_id, thread_ts, target_user_id, respond)
return
else:
# Treat it as a plain GitHub username — validate first
Expand All @@ -81,7 +81,7 @@ async def handle_add_member(
else:
# No argument provided
await respond(
"Usage: `/nf-core github add-member [@user | github-username]`\n\n"
"Usage: `/nf-core github add [@user | github-username]`\n\n"
"You can also right-click a message and use *More actions → Add to GitHub org* "
"to invite the message author.",
response_type="ephemeral",
Expand All @@ -95,7 +95,7 @@ async def handle_add_member(
async def _reply(text: str) -> None:
await _thread_reply(client, channel_id, thread_ts, text)

await invite_and_greet(github_username, user_id, _reply, greeting_user_id=target_user_id)
await invite_and_greet(github_username, user_id, _reply, greeting_user_id=target_user_id, client=client)


# ── Helpers ──────────────────────────────────────────────────────────
Expand All @@ -106,16 +106,25 @@ async def _warn_missing_github(
channel_id: str,
thread_ts: str,
target_user_id: str,
respond: Respond | None = None,
) -> None:
"""Post a visible thread reply telling the user to add their GitHub username."""
"""Post a visible thread reply telling the user to add their GitHub username.

Falls back to an ephemeral *respond()* if the channel reply fails.
"""
text = (
f"<@{target_user_id}> — please add your GitHub username to your Slack profile!\n"
"Go to your profile → *Edit profile* → fill in the *GitHub* field.\n"
"<https://slack.com/help/articles/204092246-Edit-your-profile|How to edit your Slack profile>\n\n"
"Once done, a core-team member can re-run: "
"`/nf-core github add-member <github-username>`"
"`/nf-core github add <github-username>`"
)
await _thread_reply(client, channel_id, thread_ts, text)
try:
await _thread_reply(client, channel_id, thread_ts, text)
except Exception:
logger.exception("Channel reply failed in _warn_missing_github")
if respond:
await respond(text, response_type="ephemeral")


async def _thread_reply(
Expand Down
12 changes: 8 additions & 4 deletions src/nf_core_bot/commands/github/add_member_shortcut.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,13 @@ async def handle_add_member_shortcut(
"Go to your profile → *Edit profile* → fill in the *GitHub* field.\n"
"<https://slack.com/help/articles/204092246-Edit-your-profile|How to edit your Slack profile>\n\n"
"Once done, a core-team member can try this action again, or use: "
"`/nf-core github add-member <github-username>`"
"`/nf-core github add <github-username>`"
)
await client.chat_postMessage(channel=channel_id, thread_ts=thread_ts, text=text)
try:
await client.chat_postMessage(channel=channel_id, thread_ts=thread_ts, text=text)
except Exception:
logger.exception("Channel reply failed when warning about missing GitHub username")
await client.chat_postEphemeral(channel=channel_id, user=caller_id, text=text)
return
else:
# Workflow/bot message — try to extract GitHub handle from the text
Expand All @@ -116,7 +120,7 @@ async def handle_add_member_shortcut(
user=caller_id,
text=(
"Couldn't find a GitHub username in this message.\n"
"Use `/nf-core github add-member <github-username>` instead."
"Use `/nf-core github add <github-username>` instead."
),
)
return
Expand All @@ -131,4 +135,4 @@ async def handle_add_member_shortcut(
async def _reply(text: str) -> None:
await client.chat_postMessage(channel=channel_id, thread_ts=thread_ts, text=text)

await invite_and_greet(github_username, caller_id, _reply, greeting_user_id=greeting_user_id)
await invite_and_greet(github_username, caller_id, _reply, greeting_user_id=greeting_user_id, client=client)
63 changes: 58 additions & 5 deletions src/nf_core_bot/commands/github/invite_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,53 +10,106 @@
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable

from slack_sdk.web.async_client import AsyncWebClient

logger = logging.getLogger(__name__)


async def _safe_reply(reply: Callable[[str], Awaitable[None]], text: str) -> bool:
"""Call *reply* and return ``True``. If *reply* raises, log and return ``False``."""
try:
await reply(text)
return True
except Exception:
logger.exception("Channel reply failed")
return False


async def _dm_caller(client: AsyncWebClient, caller_user_id: str, text: str) -> None:
"""Send a DM to the person who triggered the command (best-effort)."""
try:
resp = await client.conversations_open(users=[caller_user_id])
dm_channel = resp["channel"]["id"]
await client.chat_postMessage(channel=dm_channel, text=text)
except Exception:
logger.exception("Failed to DM caller %s", caller_user_id)


async def _reply_or_dm(
reply: Callable[[str], Awaitable[None]],
client: AsyncWebClient,
caller_user_id: str,
text: str,
) -> None:
"""Try the channel *reply*; fall back to a DM if it fails."""
if not await _safe_reply(reply, text):
await _dm_caller(client, caller_user_id, text)


async def invite_and_greet(
github_username: str,
caller_user_id: str,
reply: Callable[[str], Awaitable[None]],
greeting_user_id: str | None = None,
*,
client: AsyncWebClient,
) -> bool:
"""Invite *github_username* to the nf-core org and contributors team.

*reply* is called with message text for errors and the final greeting.
If *reply* raises (e.g. ``channel_not_found``), the error is logged and
the caller is notified via DM as a fallback.

Returns ``True`` on success.
"""
# ── 1. Org invite ────────────────────────────────────────────────
try:
org_result = await invite_to_org(github_username)
except Exception:
logger.exception("Network error inviting %s to org", github_username)
await reply(f"Failed to reach the GitHub API while inviting `{github_username}`. Please try again later.")
msg = f"Failed to reach the GitHub API while inviting `{github_username}`. Please try again later."
await _reply_or_dm(reply, client, caller_user_id, msg)
return False

if not org_result.ok:
await reply(f"Failed to invite `{github_username}` to the nf-core GitHub org:\n>{org_result.message}")
msg = f"Failed to invite `{github_username}` to the nf-core GitHub org:\n>{org_result.message}"
await _reply_or_dm(reply, client, caller_user_id, msg)
return False

# ── 2. Team add ──────────────────────────────────────────────────
try:
team_result = await add_to_team(github_username)
except Exception:
logger.exception("Network error adding %s to team", github_username)
await reply(
msg = (
f"Invited `{github_username}` to the org, but failed to reach the GitHub API "
"when adding to the contributors team. Please try again later."
)
await _reply_or_dm(reply, client, caller_user_id, msg)
return False

if not team_result.ok:
await reply(
msg = (
f"Invited `{github_username}` to the org, but failed to add to the "
f"contributors team:\n>{team_result.message}"
)
await _reply_or_dm(reply, client, caller_user_id, msg)
return False

# ── 3. Success greeting ──────────────────────────────────────────
greeting = f"Hi <@{greeting_user_id}>, " if greeting_user_id else f"Hi `{github_username}`, "
await reply(
msg = (
f"{greeting}<@{caller_user_id}> has just added you to the nf-core GitHub organisation, "
"welcome! :tada:\n\n"
"You should have received an invite — you can either check your e-mail "
"or click on this link to accept: https://github.com/orgs/nf-core/invitation"
)
await _safe_reply(reply, msg)

# Always DM the caller as a failsafe confirmation
dm_text = (
f"Done! `{github_username}` has been invited to the nf-core GitHub org and added to the contributors team."
)
await _dm_caller(client, caller_user_id, dm_text)

return True
4 changes: 2 additions & 2 deletions src/nf_core_bot/commands/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
]

GITHUB_COMMANDS: list[tuple[str, str, str]] = [
("github add-member @user", "Invite a Slack user to nf-core GitHub org", "admin"),
("github add-member <username>", "Invite a GitHub user to nf-core GitHub org", "admin"),
("github add @user", "Invite a Slack user to nf-core GitHub org", "admin"),
("github add <username>", "Invite a GitHub user to nf-core GitHub org", "admin"),
]

ONCALL_COMMANDS: list[tuple[str, str, str]] = [
Expand Down
2 changes: 1 addition & 1 deletion src/nf_core_bot/commands/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ async def _route_admin(
# ── GitHub dispatch ──────────────────────────────────────────────────

_GITHUB_DISPATCH: dict[str, object] = {
"add-member": handle_add_member,
"add": handle_add_member,
}


Expand Down
27 changes: 27 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Reusable test helpers for Slack client mocking."""

from __future__ import annotations

from unittest.mock import AsyncMock


def make_slack_client() -> AsyncMock:
"""Build an ``AsyncWebClient`` mock whose ``conversations_open`` returns a DM channel."""
client = AsyncMock()
client.conversations_open.return_value = {"channel": {"id": "D_DM"}}
return client


def channel_messages(client: AsyncMock, channel: str = "C_CHAN") -> list[str]:
"""Return all ``chat_postMessage`` texts sent to *channel*."""
texts: list[str] = []
for call in client.chat_postMessage.call_args_list:
ch = call.kwargs.get("channel", call.args[0] if call.args else "")
if ch == channel:
texts.append(call.kwargs.get("text", ""))
return texts


def dm_messages(client: AsyncMock) -> list[str]:
"""Return all ``chat_postMessage`` texts sent to the DM channel."""
return channel_messages(client, "D_DM")
Loading
Loading