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
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ 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.

The same flow adds the Slack user to the Seqera `nf-core/megatests` workspace
(`checks/seqera.py`) before posting the welcome, and records the add in
DynamoDB. `scheduler/seqera_jobs.py` removes recorded users who have left the
GitHub org once a week (log-only while `SEQERA_REMOVAL_DRY_RUN` is on).

### On-call rotation

The on-call system manages a weekly rotation for `@core-team` members. All
Expand Down Expand Up @@ -132,6 +137,12 @@ members have been assigned in the current window, it cycles through again.
Note: The `PK=HACKATHON#<id> SK=META` pattern is no longer used. Hackathon
metadata is in YAML files. `db/hackathons.py` no longer exists.

### Seqera key patterns

- `PK=SEQERA#<workspace-id> SK=USER#<slack-user-id>` — a workspace participant
the bot added (GitHub username, email used, Seqera participant ID). Read by
the weekly removal sync in `scheduler/seqera_jobs.py`.

### On-call key patterns

- `PK=ONCALL#<week-start-date> SK=ROSTER` — weekly roster entry (assigned user,
Expand Down
14 changes: 11 additions & 3 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,9 @@ Invite a user to the nf-core GitHub organisation and add them to the

Right-click any message, select **More actions** > **Add to GitHub org** to
invite the message author. This reads their Slack profile for a GitHub username,
then sends the org invitation.
then sends the org invitation. On a "GitHub Invitation Request" workflow message
it reads the GitHub handle, and the optional Seqera Platform email, from the
form answers instead.

This works in threads (unlike slash commands, which Slack does not support in
threads).
Expand All @@ -354,8 +356,14 @@ threads).
`PUT /orgs/nf-core/memberships/{username}`
- Adds the user to the **contributors** team via
`PUT /orgs/nf-core/teams/contributors/memberships/{username}`
4. **Thread reply** — posts a **visible** reply with the result (not ephemeral,
so the original requester can see it too)
4. **Seqera workspace** — when the target is a known Slack user, adds them to
the `nf-core/megatests` workspace on Seqera Platform with read-only access
(new joiners and existing org members alike). Uses the Platform email from
the workflow form if given, otherwise their Slack email. See
[Deployment](deployment.md#seqera-megatests-workspace-access).
5. **Thread reply** — posts a **visible** reply with the result (not ephemeral,
so the original requester can see it too). The welcome mentions megatests
access when it was granted; the caller gets a DM with the Seqera outcome.

### Error Handling

Expand Down
57 changes: 57 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,65 @@ aws --profile nf-core --region eu-west-1 ssm put-parameter \
--name /nf-core-bot/SLACK_APP_TOKEN --type SecureString --value "xapp-..."
aws --profile nf-core --region eu-west-1 ssm put-parameter \
--name /nf-core-bot/GITHUB_TOKEN --type SecureString --value "github_pat_..."
aws --profile nf-core --region eu-west-1 ssm put-parameter \
--name /nf-core-bot/SEQERA_TOKEN --type SecureString --value "<Seqera access token>"
aws --profile nf-core --region eu-west-1 ssm put-parameter \
--name /nf-core-bot/SEQERA_ORG_ID --type SecureString --value "<nf-core org numeric ID>"
aws --profile nf-core --region eu-west-1 ssm put-parameter \
--name /nf-core-bot/SEQERA_WORKSPACE_ID --type SecureString --value "<megatests workspace numeric ID>"
```

#### Seqera megatests workspace access

The Seqera token needs permission to add and remove workspace participants.
Whenever `github add` (or the **Add to GitHub org** shortcut) runs for a Slack
user, the bot adds them to the `nf-core/megatests` workspace with the
`nf-core-read-only` custom role (`SEQERA_WORKSPACE_ROLE`). This happens for new
joiners and for people already in the GitHub org.

All three `SEQERA_*` parameters must exist in SSM **before** you deploy this
stack. They are injected as ECS secrets, so if any is missing the task fails to
start and the whole bot goes down, not just the Seqera step. (When running
locally, leaving the `SEQERA_*` environment variables unset skips the step
silently.)

- **Email used:** the answer to the optional Platform email question on the
"GitHub Invitation Request" workflow form (see below), otherwise the member's
Slack email. Add the `users:read.email` Slack bot scope and reinstall the
Slack app for the Slack email lookup to work.
- **Messages:** the public welcome tells new joiners they have read-only
megatests access. The core-team member who ran the command gets a DM with the
outcome: added, already a participant, or why it failed (no email on the Slack
profile, no Seqera account for that email, or a Seqera API error with its HTTP
status). Seqera's own error message is included in the DM. A 409 from the add
call is only reported as "already a participant" after a participant lookup
confirms it, because Seqera also uses 409 when an SSO organization won't
accept the user as a collaborator.
- **Records:** each successful add is stored in the bot's DynamoDB table
(`PK=SEQERA#<workspace-id> SK=USER#<slack-user-id>`, with the GitHub username,
email used and Seqera participant ID). People who were already participants,
or were added by hand, are not recorded.

**Workflow form question.** In Slack Workflow Builder, add an optional
short-answer question to the "GitHub Invitation Request" form, for example
_"Seqera Platform email (if different from your Slack email)"_. The bot picks up
any question whose text mentions "Platform" or "Seqera" and "email", and uses
the first email address in the answer. This is configured in Slack, not in this
repo.

**Weekly removal sync.** Every Monday at 09:00 UTC the bot checks each recorded
participant against the nf-core GitHub org
(`GET /orgs/nf-core/memberships/{username}`). Anyone with neither an active
membership nor a pending invitation is removed from the megatests workspace and
their record is deleted. Only people the bot recorded are touched, and only in
the configured workspace. GitHub or Seqera errors for a person are logged and
that person is skipped until the next run.

**Dry run.** `SEQERA_REMOVAL_DRY_RUN` (plain environment variable in
`infra/stacks/bot_stack.py`, default `true`) makes the removal sync log
`[dry run] Would remove …` instead of removing anyone. Leave it on for the first
week or two, check the CloudWatch logs, then set it to `false` and redeploy.

Optional — a Workspace Admin/Owner user token enables writing members' GitHub
usernames into their Slack profiles after an org invite (see
[Slack App Setup](slack-app-setup.md#optional-user-token-for-profile-write-back)).
Expand Down
1 change: 1 addition & 0 deletions docs/slack-app-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public URL.
| `commands` | Register and receive slash commands |
| `chat:write` | Post messages and thread replies |
| `users:read` | Look up user info |
| `users:read.email` | Read the user's email for Seqera workspace access |
| `users.profile:read` | Read GitHub username from Slack profile custom fields |
| `usergroups:read` | Check `@core-team` membership for permission gating |
| `im:write` | Open DMs to send confirmations, reminders, and registration messages |
Expand Down
6 changes: 6 additions & 0 deletions infra/stacks/bot_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
"SLACK_APP_TOKEN",
"SLACK_ADMIN_USER_TOKEN",
"GITHUB_TOKEN",
"SEQERA_TOKEN",
"SEQERA_ORG_ID",
"SEQERA_WORKSPACE_ID",
]

# CloudWatch log group for the container
Expand Down Expand Up @@ -158,6 +161,9 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
# Plain-text environment variables
environment={
"GITHUB_ORG": "nf-core",
"SEQERA_WORKSPACE_ROLE": "nf-core-read-only",
# Flip to "false" once the dry-run logs look right.
"SEQERA_REMOVAL_DRY_RUN": "true",
"AWS_DEFAULT_REGION": "eu-west-1",
"DYNAMODB_TABLE": "nf-core-bot",
"CORE_TEAM_USERGROUP_HANDLE": "core-team",
Expand Down
7 changes: 7 additions & 0 deletions src/nf_core_bot/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from nf_core_bot.proposals import spawn as spawn_proposal_task
from nf_core_bot.proposals.listener import handle_proposal_message, should_consider
from nf_core_bot.scheduler.oncall_jobs import run_oncall_scheduler
from nf_core_bot.scheduler.seqera_jobs import run_seqera_scheduler
from nf_core_bot.security.advisories import run_security_scheduler

logging.basicConfig(
Expand Down Expand Up @@ -175,6 +176,12 @@ async def _start() -> None:
security_task.add_done_callback(_background_tasks.discard)
logger.info("Security advisory scheduler started.")

# Launch the weekly Seqera workspace removal sync as a background task.
seqera_task = asyncio.create_task(run_seqera_scheduler())
_background_tasks.add(seqera_task)
seqera_task.add_done_callback(_background_tasks.discard)
logger.info("Seqera removal scheduler started.")

handler = AsyncSocketModeHandler(app, config.SLACK_APP_TOKEN)
await handler.start_async() # type: ignore[no-untyped-call]

Expand Down
24 changes: 24 additions & 0 deletions src/nf_core_bot/checks/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,30 @@ async def check_org_membership(username: str) -> GitHubResult:
)


async def get_org_membership_state(username: str) -> str | None:
"""Return *username*'s org membership state: ``"active"``, ``"pending"`` or ``None``.

``GET /orgs/{org}/memberships/{username}``

Unlike :func:`check_org_membership`, this sees pending invitations, so a
freshly invited user who hasn't accepted yet isn't mistaken for a leaver.
``None`` means GitHub returned 404 (not a member and no pending invite).
Raises ``RuntimeError`` on any other status so callers never treat an API
failure as "not a member".
"""
org = config.GITHUB_ORG
resp = await _github_get(f"/orgs/{org}/memberships/{username}")

if resp.status_code == 200:
state: str = resp.json().get("state", "unknown")
return state

if resp.status_code == 404:
return None

raise RuntimeError(f"Could not check membership for `{username}`: {resp.status_code} — {resp.text}")


async def check_user_exists(username: str) -> GitHubResult:
"""Verify that a GitHub user account exists.

Expand Down
Loading
Loading