From 77dcd73b19a116315f543aa2bf8db86a4f2f0515 Mon Sep 17 00:00:00 2001 From: adamrtalbot <12817534+adamrtalbot@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:32:00 +0200 Subject: [PATCH 1/4] feat: sync joiners to Seqera workspace --- docs/deployment.md | 13 +++++ docs/slack-app-setup.md | 1 + infra/stacks/bot_stack.py | 4 ++ src/nf_core_bot/checks/seqera.py | 50 +++++++++++++++++++ .../commands/github/invite_flow.py | 28 +++++++++++ src/nf_core_bot/config.py | 5 ++ 6 files changed, 101 insertions(+) create mode 100644 src/nf_core_bot/checks/seqera.py diff --git a/docs/deployment.md b/docs/deployment.md index 7770b26..508791f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -70,8 +70,21 @@ 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 "" +aws --profile nf-core --region eu-west-1 ssm put-parameter \ + --name /nf-core-bot/SEQERA_ORG_ID --type SecureString --value "" +aws --profile nf-core --region eu-west-1 ssm put-parameter \ + --name /nf-core-bot/SEQERA_WORKSPACE_ID --type SecureString --value "" ``` +The Seqera token needs permission to add workspace participants. On a successful +nf-core GitHub invitation, the bot uses the Slack member's email and assigns +the `nf-core-read-only` custom role in the `nf-core/megatests` workspace. Users +must already have a Seqera account matching their Slack email. Add the +`users:read.email` Slack bot scope and reinstall the Slack app for the email +lookup to work. + 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)). diff --git a/docs/slack-app-setup.md b/docs/slack-app-setup.md index e38cd7d..63959c4 100644 --- a/docs/slack-app-setup.md +++ b/docs/slack-app-setup.md @@ -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 | diff --git a/infra/stacks/bot_stack.py b/infra/stacks/bot_stack.py index 5e4f4b3..60a7f2d 100644 --- a/infra/stacks/bot_stack.py +++ b/infra/stacks/bot_stack.py @@ -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 @@ -158,6 +161,7 @@ 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", "AWS_DEFAULT_REGION": "eu-west-1", "DYNAMODB_TABLE": "nf-core-bot", "CORE_TEAM_USERGROUP_HANDLE": "core-team", diff --git a/src/nf_core_bot/checks/seqera.py b/src/nf_core_bot/checks/seqera.py new file mode 100644 index 0000000..ef5558f --- /dev/null +++ b/src/nf_core_bot/checks/seqera.py @@ -0,0 +1,50 @@ +"""Add nf-core joiners to the configured Seqera workspace.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import httpx + +from nf_core_bot import config + +if TYPE_CHECKING: + from slack_sdk.web.async_client import AsyncWebClient + +logger = logging.getLogger(__name__) + + +async def add_to_megatests(client: AsyncWebClient, slack_user_id: str) -> bool: + """Add a Slack user's email to the Seqera workspace with its configured role.""" + token = config.SEQERA_TOKEN + org_id = config.SEQERA_ORG_ID + workspace_id = config.SEQERA_WORKSPACE_ID + if not all((token, org_id, workspace_id)): + logger.info("Seqera workspace sync is not configured; skipping user %s", slack_user_id) + return False + + response = await client.users_info(user=slack_user_id) + email = response.get("user", {}).get("profile", {}).get("email") + if not email: + logger.warning("Slack user %s has no visible email; cannot sync to Seqera", slack_user_id) + return False + + url = f"https://api.cloud.seqera.io/orgs/{org_id}/workspaces/{workspace_id}/participants/add" + async with httpx.AsyncClient(timeout=15) as http: + result = await http.put( + url, + headers={"Authorization": f"Bearer {token}"}, + json={"userNameOrEmail": email, "role": config.SEQERA_WORKSPACE_ROLE}, + ) + if result.is_success: + logger.info("Added Slack user %s to Seqera workspace %s", slack_user_id, workspace_id) + return True + + logger.warning( + "Seqera workspace sync failed for Slack user %s: HTTP %s: %s", + slack_user_id, + result.status_code, + result.text, + ) + return False diff --git a/src/nf_core_bot/commands/github/invite_flow.py b/src/nf_core_bot/commands/github/invite_flow.py index 426a410..5826f00 100644 --- a/src/nf_core_bot/commands/github/invite_flow.py +++ b/src/nf_core_bot/commands/github/invite_flow.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from nf_core_bot.checks.github import add_to_team, check_org_membership, check_user_exists, invite_to_org +from nf_core_bot.checks.seqera import add_to_megatests from nf_core_bot.checks.slack_profile import set_github_username if TYPE_CHECKING: @@ -47,6 +48,15 @@ async def _reply_or_dm( await _dm_caller(client, caller_user_id, text) +async def _sync_seqera(client: AsyncWebClient, user_id: str) -> bool: + """Best-effort Seqera sync for a Slack user.""" + try: + return await add_to_megatests(client, user_id) + except Exception: + logger.exception("Failed to sync Slack user %s to Seqera", user_id) + return False + + async def invite_and_greet( github_username: str, caller_user_id: str, @@ -102,6 +112,14 @@ async def invite_and_greet( if membership.ok: msg = f"`{github_username}` is already a member of the nf-core GitHub org — nothing to do." await _reply_or_dm(reply, client, caller_user_id, msg) + if greeting_user_id is not None: + synced = await _sync_seqera(client, greeting_user_id) + status = "added" if synced else "could not be added" + await _dm_caller( + client, + caller_user_id, + f"Seqera sync: <@{greeting_user_id}> {status} to the megatests workspace.", + ) return False # ── 1. Org invite ──────────────────────────────────────────────── @@ -157,10 +175,20 @@ async def invite_and_greet( if greeting_user_id is not None: await set_github_username(client, greeting_user_id, github_username) + seqera_synced = False + if greeting_user_id is not None: + seqera_synced = await _sync_seqera(client, greeting_user_id) + # 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." ) + if greeting_user_id is not None: + dm_text += ( + " They were also added to the Seqera megatests workspace." + if seqera_synced + else " Seqera workspace sync did not complete; check the bot logs and add them manually if needed." + ) await _dm_caller(client, caller_user_id, dm_text) return True diff --git a/src/nf_core_bot/config.py b/src/nf_core_bot/config.py index b014d36..d8006b7 100644 --- a/src/nf_core_bot/config.py +++ b/src/nf_core_bot/config.py @@ -22,6 +22,11 @@ # GitHub "GITHUB_TOKEN": None, "GITHUB_ORG": "nf-core", + # Seqera Platform workspace sync (all optional; disabled without credentials/IDs). + "SEQERA_TOKEN": None, + "SEQERA_ORG_ID": None, + "SEQERA_WORKSPACE_ID": None, + "SEQERA_WORKSPACE_ROLE": "nf-core-read-only", # DynamoDB "DYNAMODB_TABLE": "nf-core-bot", "DYNAMODB_ENDPOINT": None, # None → real AWS From 0e8f7410785007078f0d82fff5d84227155a2480 Mon Sep 17 00:00:00 2001 From: adamrtalbot <12817534+adamrtalbot@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:48:46 +0200 Subject: [PATCH 2/4] fix: satisfy pre-commit checks --- docs/deployment.md | 6 +++--- src/nf_core_bot/checks/seqera.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 508791f..c9d447e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -79,9 +79,9 @@ aws --profile nf-core --region eu-west-1 ssm put-parameter \ ``` The Seqera token needs permission to add workspace participants. On a successful -nf-core GitHub invitation, the bot uses the Slack member's email and assigns -the `nf-core-read-only` custom role in the `nf-core/megatests` workspace. Users -must already have a Seqera account matching their Slack email. Add the +nf-core GitHub invitation, the bot uses the Slack member's email and assigns the +`nf-core-read-only` custom role in the `nf-core/megatests` workspace. Users must +already have a Seqera account matching their Slack email. Add the `users:read.email` Slack bot scope and reinstall the Slack app for the email lookup to work. diff --git a/src/nf_core_bot/checks/seqera.py b/src/nf_core_bot/checks/seqera.py index ef5558f..01a3ba3 100644 --- a/src/nf_core_bot/checks/seqera.py +++ b/src/nf_core_bot/checks/seqera.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import httpx @@ -24,7 +24,7 @@ async def add_to_megatests(client: AsyncWebClient, slack_user_id: str) -> bool: logger.info("Seqera workspace sync is not configured; skipping user %s", slack_user_id) return False - response = await client.users_info(user=slack_user_id) + response: Any = await client.users_info(user=slack_user_id) email = response.get("user", {}).get("profile", {}).get("email") if not email: logger.warning("Slack user %s has no visible email; cannot sync to Seqera", slack_user_id) From 24cc24213ac243ab827c2b6fec7d9120d5ab91d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 11:26:15 +0000 Subject: [PATCH 3/4] feat: report Seqera outcomes and remove leavers weekly - Return a SeqeraResult with a reason (not configured, no email, no account, API error) instead of a bool; stay silent when unconfigured. - Add to the workspace before the welcome and mention megatests access; existing-member replies no longer say "nothing to do" after an add. - Use an optional Platform email from the GitHub Invitation Request form, falling back to the Slack email. - Record each add in DynamoDB (PK=SEQERA# SK=USER#). - Weekly job removes recorded users with no active or pending GitHub org membership; log-only while SEQERA_REMOVAL_DRY_RUN is on (default). - Tests and docs for the above. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A4T79rC8VmKnzeJDnnFnge --- CLAUDE.md | 11 + docs/commands.md | 14 +- docs/deployment.md | 48 ++- infra/stacks/bot_stack.py | 2 + src/nf_core_bot/app.py | 7 + src/nf_core_bot/checks/github.py | 24 ++ src/nf_core_bot/checks/seqera.py | 178 ++++++++-- .../commands/github/add_member_shortcut.py | 39 ++- .../commands/github/invite_flow.py | 83 +++-- src/nf_core_bot/config.py | 2 + src/nf_core_bot/db/seqera.py | 112 +++++++ src/nf_core_bot/scheduler/seqera_jobs.py | 118 +++++++ tests/test_invite_flow_seqera.py | 183 ++++++++++ tests/test_seqera.py | 315 ++++++++++++++++++ 14 files changed, 1076 insertions(+), 60 deletions(-) create mode 100644 src/nf_core_bot/db/seqera.py create mode 100644 src/nf_core_bot/scheduler/seqera_jobs.py create mode 100644 tests/test_invite_flow_seqera.py create mode 100644 tests/test_seqera.py diff --git a/CLAUDE.md b/CLAUDE.md index bfe6079..e46361a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -132,6 +137,12 @@ members have been assigned in the current window, it cycles through again. Note: The `PK=HACKATHON# SK=META` pattern is no longer used. Hackathon metadata is in YAML files. `db/hackathons.py` no longer exists. +### Seqera key patterns + +- `PK=SEQERA# SK=USER#` — 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# SK=ROSTER` — weekly roster entry (assigned user, diff --git a/docs/commands.md b/docs/commands.md index c36ee9a..9084a66 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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). @@ -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 diff --git a/docs/deployment.md b/docs/deployment.md index c9d447e..47fcbad 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -78,12 +78,48 @@ aws --profile nf-core --region eu-west-1 ssm put-parameter \ --name /nf-core-bot/SEQERA_WORKSPACE_ID --type SecureString --value "" ``` -The Seqera token needs permission to add workspace participants. On a successful -nf-core GitHub invitation, the bot uses the Slack member's email and assigns the -`nf-core-read-only` custom role in the `nf-core/megatests` workspace. Users must -already have a Seqera account matching their Slack email. Add the -`users:read.email` Slack bot scope and reinstall the Slack app for the email -lookup to work. +#### 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. If any of the three `SEQERA_*` +parameters is missing, the step is skipped 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). +- **Records:** each successful add is stored in the bot's DynamoDB table + (`PK=SEQERA# SK=USER#`, 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 diff --git a/infra/stacks/bot_stack.py b/infra/stacks/bot_stack.py index 60a7f2d..983364f 100644 --- a/infra/stacks/bot_stack.py +++ b/infra/stacks/bot_stack.py @@ -162,6 +162,8 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: 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", diff --git a/src/nf_core_bot/app.py b/src/nf_core_bot/app.py index 055674d..9cbf3d8 100644 --- a/src/nf_core_bot/app.py +++ b/src/nf_core_bot/app.py @@ -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( @@ -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] diff --git a/src/nf_core_bot/checks/github.py b/src/nf_core_bot/checks/github.py index c670ea6..99de755 100644 --- a/src/nf_core_bot/checks/github.py +++ b/src/nf_core_bot/checks/github.py @@ -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. diff --git a/src/nf_core_bot/checks/seqera.py b/src/nf_core_bot/checks/seqera.py index 01a3ba3..c96097c 100644 --- a/src/nf_core_bot/checks/seqera.py +++ b/src/nf_core_bot/checks/seqera.py @@ -1,50 +1,182 @@ -"""Add nf-core joiners to the configured Seqera workspace.""" +"""Seqera Platform workspace access for nf-core joiners. + +New joiners are added to the configured workspace (``nf-core/megatests``) with +a read-only custom role, and each add is recorded in DynamoDB so the weekly +removal job (``scheduler/seqera_jobs.py``) can take access away again when +someone leaves the nf-core GitHub org. + +API reference: https://cloud.seqera.io/openapi/seqera-api-latest.yml +(``CreateWorkspaceParticipant`` / ``DeleteWorkspaceParticipant``). +""" from __future__ import annotations +import enum import logging +from dataclasses import dataclass from typing import TYPE_CHECKING, Any import httpx from nf_core_bot import config +from nf_core_bot.db.seqera import put_member if TYPE_CHECKING: from slack_sdk.web.async_client import AsyncWebClient logger = logging.getLogger(__name__) +SEQERA_API_URL = "https://api.cloud.seqera.io" + + +class SeqeraStatus(enum.Enum): + """Why a workspace add did or didn't happen.""" + + ADDED = "added" + ALREADY_PARTICIPANT = "already_participant" + NOT_CONFIGURED = "not_configured" + NO_EMAIL = "no_email" + NO_ACCOUNT = "no_account" + API_ERROR = "api_error" + + +@dataclass +class SeqeraResult: + """Outcome of a Seqera workspace add.""" + + status: SeqeraStatus + message: str + email: str | None = None + + @property + def has_access(self) -> bool: + """``True`` when the user is now a workspace participant.""" + return self.status in (SeqeraStatus.ADDED, SeqeraStatus.ALREADY_PARTICIPANT) -async def add_to_megatests(client: AsyncWebClient, slack_user_id: str) -> bool: - """Add a Slack user's email to the Seqera workspace with its configured role.""" + +@dataclass +class _Settings: + token: str + org_id: str + workspace_id: str + + +def _settings() -> _Settings | None: + """Return the Seqera settings, or ``None`` when sync isn't configured.""" token = config.SEQERA_TOKEN org_id = config.SEQERA_ORG_ID workspace_id = config.SEQERA_WORKSPACE_ID - if not all((token, org_id, workspace_id)): - logger.info("Seqera workspace sync is not configured; skipping user %s", slack_user_id) - return False + if not (token and org_id and workspace_id): + return None + return _Settings(token=token, org_id=org_id, workspace_id=workspace_id) + + +def removal_dry_run() -> bool: + """``True`` when the weekly removal job should only log (the default).""" + return (config.SEQERA_REMOVAL_DRY_RUN or "true").strip().lower() not in ("0", "false", "no", "off") + + +def _participants_url(settings: _Settings) -> str: + return f"{SEQERA_API_URL}/orgs/{settings.org_id}/workspaces/{settings.workspace_id}/participants" + +def _error_message(resp: httpx.Response) -> str: + """Pull ``message`` out of a Seqera ``ErrorResponse``, falling back to the raw body.""" + try: + body = resp.json() + except ValueError: + return resp.text + if isinstance(body, dict) and body.get("message"): + return str(body["message"]) + return resp.text + + +async def _slack_email(client: AsyncWebClient, slack_user_id: str) -> str | None: response: Any = await client.users_info(user=slack_user_id) - email = response.get("user", {}).get("profile", {}).get("email") + email: str | None = response.get("user", {}).get("profile", {}).get("email") + return email + + +async def add_to_megatests( + client: AsyncWebClient, + slack_user_id: str, + github_username: str, + email: str | None = None, +) -> SeqeraResult: + """Add a Slack user to the Seqera workspace with the configured role. + + *email* is the Seqera Platform email from the invitation form, if given; + otherwise the user's Slack email is used. Successful adds are recorded in + DynamoDB for the removal job. Never raises for HTTP errors — the reason is + returned in the :class:`SeqeraResult` instead. + """ + settings = _settings() + if settings is None: + logger.info("Seqera workspace sync is not configured; skipping user %s", slack_user_id) + return SeqeraResult(SeqeraStatus.NOT_CONFIGURED, "Seqera workspace sync is not configured.") + + if not email: + email = await _slack_email(client, slack_user_id) if not email: logger.warning("Slack user %s has no visible email; cannot sync to Seqera", slack_user_id) - return False + return SeqeraResult(SeqeraStatus.NO_EMAIL, "no email address was found on their Slack profile") + + try: + async with httpx.AsyncClient(timeout=15) as http: + resp = await http.put( + f"{_participants_url(settings)}/add", + headers={"Authorization": f"Bearer {settings.token}"}, + json={"userNameOrEmail": email, "role": config.SEQERA_WORKSPACE_ROLE}, + ) + except httpx.HTTPError as e: + logger.exception("Network error adding Slack user %s to Seqera", slack_user_id) + return SeqeraResult(SeqeraStatus.API_ERROR, f"could not reach the Seqera API ({type(e).__name__})", email) + + if resp.status_code == 409: + logger.info("Slack user %s (%s) is already in Seqera workspace %s", slack_user_id, email, settings.workspace_id) + return SeqeraResult(SeqeraStatus.ALREADY_PARTICIPANT, "already a participant of the workspace", email) + + if resp.status_code == 404: + logger.warning("No Seqera account for Slack user %s (%s): %s", slack_user_id, email, _error_message(resp)) + return SeqeraResult(SeqeraStatus.NO_ACCOUNT, f"no Seqera Platform account was found for `{email}`", email) + + if not resp.is_success: + detail = _error_message(resp) + logger.warning( + "Seqera workspace sync failed for Slack user %s: HTTP %s: %s", slack_user_id, resp.status_code, detail + ) + return SeqeraResult(SeqeraStatus.API_ERROR, f"the Seqera API returned HTTP {resp.status_code}: {detail}", email) + + logger.info("Added Slack user %s to Seqera workspace %s", slack_user_id, settings.workspace_id) + participant_id = (resp.json().get("participant") or {}).get("participantId") + if participant_id is None: + logger.warning("Seqera add for %s returned no participantId; not recording it", slack_user_id) + else: + try: + await put_member(settings.workspace_id, slack_user_id, github_username, email, int(participant_id)) + except Exception: + # The add itself worked; failing to record only means the removal + # job won't manage this person. + logger.exception("Failed to record Seqera participant for Slack user %s", slack_user_id) + return SeqeraResult(SeqeraStatus.ADDED, "added to the workspace", email) + + +async def remove_participant(participant_id: int) -> bool: + """Remove a participant from the configured workspace. + + Returns ``True`` when the participant is gone (including when Seqera + reports they were already removed). Raises ``RuntimeError`` when sync + isn't configured or the API call fails. + """ + settings = _settings() + if settings is None: + raise RuntimeError("Seqera workspace sync is not configured") - url = f"https://api.cloud.seqera.io/orgs/{org_id}/workspaces/{workspace_id}/participants/add" async with httpx.AsyncClient(timeout=15) as http: - result = await http.put( - url, - headers={"Authorization": f"Bearer {token}"}, - json={"userNameOrEmail": email, "role": config.SEQERA_WORKSPACE_ROLE}, + resp = await http.delete( + f"{_participants_url(settings)}/{participant_id}", + headers={"Authorization": f"Bearer {settings.token}"}, ) - if result.is_success: - logger.info("Added Slack user %s to Seqera workspace %s", slack_user_id, workspace_id) + if resp.is_success or resp.status_code == 404: return True - - logger.warning( - "Seqera workspace sync failed for Slack user %s: HTTP %s: %s", - slack_user_id, - result.status_code, - result.text, - ) - return False + raise RuntimeError(f"Seqera API returned HTTP {resp.status_code}: {_error_message(resp)}") diff --git a/src/nf_core_bot/commands/github/add_member_shortcut.py b/src/nf_core_bot/commands/github/add_member_shortcut.py index 04c4876..9357380 100644 --- a/src/nf_core_bot/commands/github/add_member_shortcut.py +++ b/src/nf_core_bot/commands/github/add_member_shortcut.py @@ -4,7 +4,8 @@ For regular user messages, reads the author's GitHub username from their Slack profile. For workflow/bot messages (e.g. the "GitHub Invitation Request" -workflow), parses the message text to extract the GitHub handle. +workflow), parses the message text to extract the GitHub handle, plus an +optional Seqera Platform email used for megatests workspace access. This works in threads (unlike slash commands), making it the preferred way to invite someone based on a message they posted. @@ -41,6 +42,19 @@ _WORKFLOW_REQUESTER_RE = re.compile(r"By\s+<@(U[A-Z0-9]+)(?:\|[^>]*)?>", re.IGNORECASE) +# Pattern to capture the answer to the optional Seqera Platform email question, +# e.g. "*Seqera Platform email (if different from your Slack email)*\nme@x.org". +# Any question line mentioning "Platform" or "Seqera" and "email" (in any order) matches, so +# the exact wording in Workflow Builder can change without breaking this. +_PLATFORM_EMAIL_QUESTION_RE = re.compile( + r"^(?=[^\n\r]*\b(?:Platform|Seqera)\b)(?=[^\n\r]*\be-?mail\b)[^\n\r]*[\n\r]+[ \t]*([^\n\r]+)", + re.IGNORECASE | re.MULTILINE, +) + +# Slack renders emails as ""; this finds the address either way. +_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") + + def _handle_from_answer(answer: str) -> str | None: """Mine a single GitHub handle out of a free-text answer. @@ -75,6 +89,16 @@ def _extract_github_handle_from_text(text: str) -> str | None: return None +def _extract_platform_email_from_text(text: str) -> str | None: + """Return the optional Seqera Platform email from workflow message text, if one was given.""" + match = _PLATFORM_EMAIL_QUESTION_RE.search(text) + if match: + email = _EMAIL_RE.search(match.group(1)) + if email: + return email.group(0) + return None + + def _extract_requester_from_text(text: str) -> str | None: """Try to extract the Slack user ID of the person who submitted the workflow.""" match = _WORKFLOW_REQUESTER_RE.search(text) @@ -119,6 +143,9 @@ async def handle_add_member_shortcut( # workflow requester (the person who filled in the form). greeting_user_id: str | None = None + # Seqera Platform email from the workflow form; falls back to the Slack email. + seqera_email: str | None = None + if target_user_id is not None: # Regular user message — resolve from Slack profile greeting_user_id = target_user_id @@ -141,6 +168,7 @@ async def handle_add_member_shortcut( # Workflow/bot message — try to extract GitHub handle from the text github_username = _extract_github_handle_from_text(message_text) greeting_user_id = _extract_requester_from_text(message_text) + seqera_email = _extract_platform_email_from_text(message_text) if github_username is None: await client.chat_postEphemeral( channel=channel_id, @@ -162,4 +190,11 @@ 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, client=client) + await invite_and_greet( + github_username, + caller_id, + _reply, + greeting_user_id=greeting_user_id, + client=client, + seqera_email=seqera_email, + ) diff --git a/src/nf_core_bot/commands/github/invite_flow.py b/src/nf_core_bot/commands/github/invite_flow.py index 5826f00..ab36b77 100644 --- a/src/nf_core_bot/commands/github/invite_flow.py +++ b/src/nf_core_bot/commands/github/invite_flow.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from nf_core_bot.checks.github import add_to_team, check_org_membership, check_user_exists, invite_to_org -from nf_core_bot.checks.seqera import add_to_megatests +from nf_core_bot.checks.seqera import SeqeraResult, SeqeraStatus, add_to_megatests from nf_core_bot.checks.slack_profile import set_github_username if TYPE_CHECKING: @@ -48,13 +48,30 @@ async def _reply_or_dm( await _dm_caller(client, caller_user_id, text) -async def _sync_seqera(client: AsyncWebClient, user_id: str) -> bool: - """Best-effort Seqera sync for a Slack user.""" +_MEGATESTS_WELCOME = "You've also been given read-only access to the nf-core megatests workspace on Seqera Platform." + + +async def _sync_seqera(client: AsyncWebClient, user_id: str, github_username: str, email: str | None) -> SeqeraResult: + """Best-effort Seqera workspace add for a Slack user.""" try: - return await add_to_megatests(client, user_id) - except Exception: + return await add_to_megatests(client, user_id, github_username, email) + except Exception as e: logger.exception("Failed to sync Slack user %s to Seqera", user_id) - return False + return SeqeraResult(SeqeraStatus.API_ERROR, f"unexpected error ({type(e).__name__})") + + +def _seqera_dm(result: SeqeraResult, user_id: str) -> str | None: + """Describe a Seqera outcome for the caller's DM, or ``None`` when sync isn't configured.""" + if result.status is SeqeraStatus.NOT_CONFIGURED: + return None + if result.status is SeqeraStatus.ADDED: + return f"<@{user_id}> was also added to the Seqera megatests workspace as `{result.email}`." + if result.status is SeqeraStatus.ALREADY_PARTICIPANT: + return f"<@{user_id}> already has access to the Seqera megatests workspace." + return ( + f"Couldn't add <@{user_id}> to the Seqera megatests workspace: {result.message}. " + "Add them manually in Seqera Platform if needed." + ) async def invite_and_greet( @@ -64,6 +81,7 @@ async def invite_and_greet( greeting_user_id: str | None = None, *, client: AsyncWebClient, + seqera_email: str | None = None, ) -> bool: """Invite *github_username* to the nf-core org and contributors team. @@ -71,6 +89,9 @@ async def invite_and_greet( If *reply* raises (e.g. ``channel_not_found``), the error is logged and the caller is notified via DM as a fallback. + When *greeting_user_id* is known, the user is also added to the Seqera + megatests workspace, using *seqera_email* if given or their Slack email. + Returns ``True`` on success. """ # ── 0a. Verify the account actually exists ─────────────────────── @@ -110,16 +131,25 @@ async def invite_and_greet( return False if membership.ok: - msg = f"`{github_username}` is already a member of the nf-core GitHub org — nothing to do." - await _reply_or_dm(reply, client, caller_user_id, msg) + seqera: SeqeraResult | None = None if greeting_user_id is not None: - synced = await _sync_seqera(client, greeting_user_id) - status = "added" if synced else "could not be added" - await _dm_caller( - client, - caller_user_id, - f"Seqera sync: <@{greeting_user_id}> {status} to the megatests workspace.", - ) + seqera = await _sync_seqera(client, greeting_user_id, github_username, seqera_email) + + msg = f"`{github_username}` is already a member of the nf-core GitHub org" + if seqera is None or seqera.status is SeqeraStatus.NOT_CONFIGURED: + msg += " — nothing to do." + elif seqera.status is SeqeraStatus.ADDED: + msg += f". <@{greeting_user_id}> has been added to the nf-core megatests workspace on Seqera Platform." + elif seqera.status is SeqeraStatus.ALREADY_PARTICIPANT: + msg += " and already has access to the nf-core megatests workspace on Seqera Platform — nothing to do." + else: + msg += "." + await _reply_or_dm(reply, client, caller_user_id, msg) + + if seqera is not None and greeting_user_id is not None: + dm = _seqera_dm(seqera, greeting_user_id) + if dm is not None: + await _dm_caller(client, caller_user_id, dm) return False # ── 1. Org invite ──────────────────────────────────────────────── @@ -159,7 +189,12 @@ async def invite_and_greet( await _reply_or_dm(reply, client, caller_user_id, msg) return False - # ── 3. Success greeting ────────────────────────────────────────── + # ── 3. Seqera workspace (before the greeting so it can mention it) ─ + seqera_result: SeqeraResult | None = None + if greeting_user_id is not None: + seqera_result = await _sync_seqera(client, greeting_user_id, github_username, seqera_email) + + # ── 4. Success greeting ────────────────────────────────────────── greeting = f"Hi <@{greeting_user_id}>, " if greeting_user_id else f"Hi `{github_username}`, " msg = ( f"{greeting}<@{caller_user_id}> has just added you to the nf-core GitHub organisation, " @@ -167,6 +202,8 @@ async def invite_and_greet( "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" ) + if seqera_result is not None and seqera_result.has_access: + msg += f"\n\n{_MEGATESTS_WELCOME}" await _safe_reply(reply, msg) # Populate the invitee's "GitHub (auto)" profile field with the handle they @@ -175,20 +212,14 @@ async def invite_and_greet( if greeting_user_id is not None: await set_github_username(client, greeting_user_id, github_username) - seqera_synced = False - if greeting_user_id is not None: - seqera_synced = await _sync_seqera(client, greeting_user_id) - # 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." ) - if greeting_user_id is not None: - dm_text += ( - " They were also added to the Seqera megatests workspace." - if seqera_synced - else " Seqera workspace sync did not complete; check the bot logs and add them manually if needed." - ) + if seqera_result is not None and greeting_user_id is not None: + seqera_dm = _seqera_dm(seqera_result, greeting_user_id) + if seqera_dm is not None: + dm_text += f"\n{seqera_dm}" await _dm_caller(client, caller_user_id, dm_text) return True diff --git a/src/nf_core_bot/config.py b/src/nf_core_bot/config.py index d8006b7..f263bbb 100644 --- a/src/nf_core_bot/config.py +++ b/src/nf_core_bot/config.py @@ -27,6 +27,8 @@ "SEQERA_ORG_ID": None, "SEQERA_WORKSPACE_ID": None, "SEQERA_WORKSPACE_ROLE": "nf-core-read-only", + # Weekly removal job only logs who it would remove until set to "false". + "SEQERA_REMOVAL_DRY_RUN": "true", # DynamoDB "DYNAMODB_TABLE": "nf-core-bot", "DYNAMODB_ENDPOINT": None, # None → real AWS diff --git a/src/nf_core_bot/db/seqera.py b/src/nf_core_bot/db/seqera.py new file mode 100644 index 0000000..410f619 --- /dev/null +++ b/src/nf_core_bot/db/seqera.py @@ -0,0 +1,112 @@ +"""DynamoDB operations for Seqera workspace participants added by the bot. + +Only people the bot itself added are recorded, so the weekly removal job +(``scheduler/seqera_jobs.py``) never touches participants added by hand. +""" + +from __future__ import annotations + +import asyncio +import datetime +from dataclasses import dataclass +from typing import Any + +from boto3.dynamodb.conditions import Key + +from nf_core_bot.db.client import get_table + +SEQERA_PK_PREFIX = "SEQERA#" +USER_SK_PREFIX = "USER#" + + +@dataclass +class SeqeraMember: + """A workspace participant the bot added.""" + + workspace_id: str + slack_user_id: str + github_username: str + email: str + participant_id: int + added_at: str + + +def _pk(workspace_id: str) -> str: + """PK for a workspace's bot-added participants: ``SEQERA#``.""" + return f"{SEQERA_PK_PREFIX}{workspace_id}" + + +def _sk(slack_user_id: str) -> str: + return f"{USER_SK_PREFIX}{slack_user_id}" + + +def _now_iso() -> str: + return datetime.datetime.now(datetime.UTC).isoformat() + + +async def put_member( + workspace_id: str, + slack_user_id: str, + github_username: str, + email: str, + participant_id: int, +) -> None: + """Record that the bot added *slack_user_id* to *workspace_id* (overwrites).""" + table = get_table() + + def _put() -> None: + table.put_item( + Item={ + "PK": _pk(workspace_id), + "SK": _sk(slack_user_id), + "workspace_id": workspace_id, + "slack_user_id": slack_user_id, + "github_username": github_username, + "email": email, + "participant_id": participant_id, + "added_at": _now_iso(), + } + ) + + await asyncio.to_thread(_put) + + +async def list_members(workspace_id: str) -> list[SeqeraMember]: + """Return every participant the bot added to *workspace_id*.""" + table = get_table() + + def _query() -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + kwargs: dict[str, Any] = { + "KeyConditionExpression": Key("PK").eq(_pk(workspace_id)) & Key("SK").begins_with(USER_SK_PREFIX) + } + while True: + resp = table.query(**kwargs) + items.extend(resp.get("Items", [])) + if "LastEvaluatedKey" not in resp: + break + kwargs["ExclusiveStartKey"] = resp["LastEvaluatedKey"] + return items + + items = await asyncio.to_thread(_query) + return [ + SeqeraMember( + workspace_id=item["workspace_id"], + slack_user_id=item["slack_user_id"], + github_username=item["github_username"], + email=item["email"], + participant_id=int(item["participant_id"]), + added_at=item["added_at"], + ) + for item in items + ] + + +async def delete_member(workspace_id: str, slack_user_id: str) -> None: + """Forget a bot-added participant (after removing them from the workspace).""" + table = get_table() + + def _delete() -> None: + table.delete_item(Key={"PK": _pk(workspace_id), "SK": _sk(slack_user_id)}) + + await asyncio.to_thread(_delete) diff --git a/src/nf_core_bot/scheduler/seqera_jobs.py b/src/nf_core_bot/scheduler/seqera_jobs.py new file mode 100644 index 0000000..077804c --- /dev/null +++ b/src/nf_core_bot/scheduler/seqera_jobs.py @@ -0,0 +1,118 @@ +"""Background job: remove people who left the nf-core GitHub org from the Seqera workspace. + +Launched as an ``asyncio.Task`` from ``app.py`` at startup, alongside the +on-call scheduler. Once a week it checks every participant the bot added +(recorded in DynamoDB by ``checks/seqera.py``) against the nf-core GitHub org +and removes anyone who is no longer a member or invitee from the megatests +workspace. Participants added by hand are never recorded, so never touched. + +While ``SEQERA_REMOVAL_DRY_RUN`` is on (the default) it only logs who it would +remove. +""" + +from __future__ import annotations + +import asyncio +import datetime +import logging +from dataclasses import dataclass, field + +from nf_core_bot import config +from nf_core_bot.checks.github import get_org_membership_state +from nf_core_bot.checks.seqera import removal_dry_run, remove_participant +from nf_core_bot.db.seqera import delete_member, list_members + +logger = logging.getLogger(__name__) + +# Interval between scheduler loop iterations (seconds). Short enough that the +# target hour is never skipped, the weekly guard stops it running twice. +LOOP_INTERVAL = 600 + +# Monday at this hour (UTC) — an hour after the on-call weekly jobs. +TARGET_HOUR_UTC = 9 + +_last_weekly_run: str | None = None + + +@dataclass +class RemovalSummary: + """What a removal run did, for logging and tests.""" + + dry_run: bool + checked: int = 0 + removed: list[str] = field(default_factory=list) + would_remove: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + +async def run_seqera_scheduler() -> None: + """Long-running loop: run the Seqera removal sync once a week.""" + logger.info("Seqera removal scheduler started") + while True: + try: + await _tick() + except Exception: + logger.exception("Seqera removal scheduler tick failed") + await asyncio.sleep(LOOP_INTERVAL) + + +async def _tick(now_utc: datetime.datetime | None = None) -> None: + global _last_weekly_run # noqa: PLW0603 + + if now_utc is None: + now_utc = datetime.datetime.now(datetime.UTC) + today_str = now_utc.date().isoformat() + if now_utc.weekday() == 0 and now_utc.hour == TARGET_HOUR_UTC and _last_weekly_run != today_str: + _last_weekly_run = today_str + await sync_seqera_removals() + + +async def sync_seqera_removals() -> RemovalSummary | None: + """Remove bot-added participants who are no longer in the nf-core GitHub org. + + Returns ``None`` when Seqera sync isn't configured. Any GitHub or Seqera + error for one person is logged and skipped, never treated as "left". + """ + workspace_id = config.SEQERA_WORKSPACE_ID + if not (config.SEQERA_TOKEN and config.SEQERA_ORG_ID and workspace_id): + logger.info("Seqera workspace sync is not configured; skipping removal sync") + return None + + summary = RemovalSummary(dry_run=removal_dry_run()) + members = await list_members(workspace_id) + for member in members: + summary.checked += 1 + label = f"{member.github_username} ({member.email}, Slack {member.slack_user_id})" + try: + state = await get_org_membership_state(member.github_username) + except Exception: + logger.exception("Could not check GitHub org membership for %s; skipping", label) + summary.errors.append(label) + continue + if state is not None: + continue + + if summary.dry_run: + logger.info("[dry run] Would remove %s from Seqera workspace %s", label, workspace_id) + summary.would_remove.append(label) + continue + + try: + await remove_participant(member.participant_id) + await delete_member(workspace_id, member.slack_user_id) + except Exception: + logger.exception("Failed to remove %s from Seqera workspace %s", label, workspace_id) + summary.errors.append(label) + continue + logger.info("Removed %s from Seqera workspace %s (left the GitHub org)", label, workspace_id) + summary.removed.append(label) + + logger.info( + "Seqera removal sync%s: checked %d, removed %d, would remove %d, errors %d", + " (dry run)" if summary.dry_run else "", + summary.checked, + len(summary.removed), + len(summary.would_remove), + len(summary.errors), + ) + return summary diff --git a/tests/test_invite_flow_seqera.py b/tests/test_invite_flow_seqera.py new file mode 100644 index 0000000..7172333 --- /dev/null +++ b/tests/test_invite_flow_seqera.py @@ -0,0 +1,183 @@ +"""Tests for the Seqera workspace step of nf_core_bot.commands.github.invite_flow.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +import pytest + +from nf_core_bot.checks.github import GitHubResult +from nf_core_bot.checks.seqera import SeqeraResult, SeqeraStatus +from nf_core_bot.commands.github.add_member_shortcut import ( + _extract_platform_email_from_text, + handle_add_member_shortcut, +) +from nf_core_bot.commands.github.invite_flow import invite_and_greet +from tests.helpers import dm_messages, make_slack_client + +if TYPE_CHECKING: + from collections.abc import Iterator + +_ADDED = SeqeraResult(SeqeraStatus.ADDED, "added to the workspace", "me@example.org") +_ALREADY = SeqeraResult(SeqeraStatus.ALREADY_PARTICIPANT, "already a participant", "me@example.org") +_NOT_CONFIGURED = SeqeraResult(SeqeraStatus.NOT_CONFIGURED, "not configured") +_NO_ACCOUNT = SeqeraResult(SeqeraStatus.NO_ACCOUNT, "no Seqera Platform account was found for `me@example.org`") + + +@pytest.fixture +def github_new_member() -> Iterator[None]: + with ( + patch( + "nf_core_bot.commands.github.invite_flow.check_org_membership", + return_value=GitHubResult(ok=False, message="not a member"), + ), + patch("nf_core_bot.commands.github.invite_flow.invite_to_org", return_value=GitHubResult(ok=True, message="")), + patch("nf_core_bot.commands.github.invite_flow.add_to_team", return_value=GitHubResult(ok=True, message="")), + patch("nf_core_bot.commands.github.invite_flow.set_github_username"), + ): + yield + + +@pytest.fixture +def github_existing_member() -> Iterator[None]: + with patch( + "nf_core_bot.commands.github.invite_flow.check_org_membership", + return_value=GitHubResult(ok=True, message="already a member"), + ): + yield + + +async def _run(seqera: SeqeraResult, greeting_user_id: str | None = "U_NEW") -> tuple[list[str], list[str], AsyncMock]: + """Run the invite flow; return (channel replies, caller DMs, add_to_megatests mock).""" + client = make_slack_client() + replies: list[str] = [] + + async def _reply(text: str) -> None: + replies.append(text) + + with patch("nf_core_bot.commands.github.invite_flow.add_to_megatests", return_value=seqera) as mock_add: + await invite_and_greet( + "octocat", + "U_ADMIN", + _reply, + greeting_user_id=greeting_user_id, + client=client, + seqera_email="me@example.org", + ) + return replies, dm_messages(client), mock_add + + +class TestNewJoiner: + async def test_welcome_mentions_megatests_access(self, github_new_member: None) -> None: + replies, dms, mock_add = await _run(_ADDED) + + mock_add.assert_awaited_once() + assert mock_add.await_args is not None + assert mock_add.await_args.args[1:] == ("U_NEW", "octocat", "me@example.org") + assert len(replies) == 1 + assert "nf-core/invitation" in replies[0] + assert "read-only access to the nf-core megatests workspace" in replies[0] + assert any("added to the Seqera megatests workspace as `me@example.org`" in d for d in dms) + + async def test_failure_explained_in_dm_not_welcome(self, github_new_member: None) -> None: + replies, dms, _ = await _run(_NO_ACCOUNT) + + assert "megatests" not in replies[0] + assert any("Couldn't add <@U_NEW>" in d and "no Seqera Platform account" in d for d in dms) + + async def test_not_configured_is_silent(self, github_new_member: None) -> None: + replies, dms, _ = await _run(_NOT_CONFIGURED) + + assert "megatests" not in replies[0] + assert dms + assert not any("Seqera" in d for d in dms) + + async def test_no_slack_user_skips_seqera(self, github_new_member: None) -> None: + replies, _, mock_add = await _run(_ADDED, greeting_user_id=None) + + mock_add.assert_not_awaited() + assert "megatests" not in replies[0] + + +class TestExistingMember: + async def test_added_says_so_publicly(self, github_existing_member: None) -> None: + replies, dms, _ = await _run(_ADDED) + + assert len(replies) == 1 + assert "already a member of the nf-core GitHub org" in replies[0] + assert "nothing to do" not in replies[0] + assert "<@U_NEW> has been added to the nf-core megatests workspace" in replies[0] + assert any("as `me@example.org`" in d for d in dms) + + async def test_already_participant(self, github_existing_member: None) -> None: + replies, _, _ = await _run(_ALREADY) + + assert "already has access to the nf-core megatests workspace" in replies[0] + assert "nothing to do" in replies[0] + + async def test_failure_does_not_claim_nothing_to_do(self, github_existing_member: None) -> None: + replies, dms, _ = await _run(_NO_ACCOUNT) + + assert "nothing to do" not in replies[0] + assert any("Couldn't add <@U_NEW>" in d for d in dms) + + async def test_not_configured_keeps_original_message(self, github_existing_member: None) -> None: + replies, dms, _ = await _run(_NOT_CONFIGURED) + + assert replies == ["`octocat` is already a member of the nf-core GitHub org — nothing to do."] + assert dms == [] + + async def test_unexpected_exception_reported(self, github_existing_member: None) -> None: + client = make_slack_client() + replies: list[str] = [] + + async def _reply(text: str) -> None: + replies.append(text) + + with patch("nf_core_bot.commands.github.invite_flow.add_to_megatests", side_effect=ValueError("boom")): + await invite_and_greet("octocat", "U_ADMIN", _reply, greeting_user_id="U_NEW", client=client) + + assert any("unexpected error (ValueError)" in d for d in dm_messages(client)) + + +# ── Workflow form: optional Platform email ─────────────────────────── + + +class TestExtractPlatformEmail: + def test_mailto_link(self) -> None: + text = ( + "By <@U1> at March 13th, 2026\n*Which is your GitHub handle?*\noctocat\n" + "*Seqera Platform email (if different from your Slack email)*\n" + ) + assert _extract_platform_email_from_text(text) == "me@x.org" + + def test_plain_email(self) -> None: + assert _extract_platform_email_from_text("*What email do you use for Platform?*\nme@x.org") == "me@x.org" + + def test_non_email_answer(self) -> None: + assert _extract_platform_email_from_text("*Seqera Platform email*\nsame as Slack") is None + + def test_question_missing(self) -> None: + assert _extract_platform_email_from_text("*Which is your GitHub handle?*\noctocat") is None + + +class TestShortcutPassesPlatformEmail: + @patch("nf_core_bot.commands.github.add_member_shortcut.is_core_team", return_value=True) + @patch("nf_core_bot.commands.github.add_member_shortcut.invite_and_greet") + async def test_email_forwarded(self, mock_invite: AsyncMock, _perm: AsyncMock) -> None: + text = ( + "By <@U0REQ> at March 13th, 2026\n*Which is your GitHub handle?*\noctocat\n" + "*Seqera Platform email*\n" + ) + shortcut = { + "user": {"id": "U_ADMIN"}, + "channel": {"id": "C_CHAN"}, + "message": {"bot_id": "B1", "ts": "1.2", "text": text}, + } + + await handle_add_member_shortcut(AsyncMock(), shortcut, make_slack_client()) + + assert mock_invite.await_args is not None + assert mock_invite.await_args.kwargs["greeting_user_id"] == "U0REQ" + assert mock_invite.await_args.kwargs["seqera_email"] == "me@x.org" diff --git a/tests/test_seqera.py b/tests/test_seqera.py new file mode 100644 index 0000000..0ef3ebb --- /dev/null +++ b/tests/test_seqera.py @@ -0,0 +1,315 @@ +"""Tests for Seqera workspace access: checks.seqera, db.seqera and the removal job.""" + +from __future__ import annotations + +import datetime +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from nf_core_bot.checks.github import get_org_membership_state +from nf_core_bot.checks.seqera import SeqeraStatus, add_to_megatests, remove_participant +from nf_core_bot.db.seqera import delete_member, list_members, put_member +from nf_core_bot.scheduler import seqera_jobs +from tests.helpers import make_slack_client + +Handler = Callable[[httpx.Request], httpx.Response] + + +@pytest.fixture +def seqera_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SEQERA_TOKEN", "seqera-token") + monkeypatch.setenv("SEQERA_ORG_ID", "11") + monkeypatch.setenv("SEQERA_WORKSPACE_ID", "22") + + +def _mock_seqera(monkeypatch: pytest.MonkeyPatch, handler: Handler) -> list[httpx.Request]: + """Route the module's httpx client through *handler*; return the captured requests.""" + requests: list[httpx.Request] = [] + + def _record(request: httpx.Request) -> httpx.Response: + requests.append(request) + return handler(request) + + real_client = httpx.AsyncClient + + def _factory(**kwargs: Any) -> httpx.AsyncClient: + return real_client(transport=httpx.MockTransport(_record), **kwargs) + + monkeypatch.setattr("nf_core_bot.checks.seqera.httpx.AsyncClient", _factory) + return requests + + +def _slack_client(email: str | None = "slack@example.org") -> AsyncMock: + client = make_slack_client() + profile = {"email": email} if email else {} + client.users_info.return_value = {"user": {"profile": profile}} + return client + + +# ── add_to_megatests ───────────────────────────────────────────────── + + +class TestAddToMegatests: + async def test_not_configured_makes_no_calls(self, monkeypatch: pytest.MonkeyPatch) -> None: + requests = _mock_seqera(monkeypatch, lambda r: httpx.Response(200)) + client = _slack_client() + + result = await add_to_megatests(client, "U1", "octocat") + + assert result.status is SeqeraStatus.NOT_CONFIGURED + assert not requests + client.users_info.assert_not_awaited() + + async def test_added_and_recorded(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, ddb_table: Any) -> None: + requests = _mock_seqera(monkeypatch, lambda r: httpx.Response(200, json={"participant": {"participantId": 99}})) + + result = await add_to_megatests(_slack_client(), "U1", "octocat") + + assert result.status is SeqeraStatus.ADDED + assert result.has_access + assert result.email == "slack@example.org" + assert requests[0].method == "PUT" + assert str(requests[0].url) == "https://api.cloud.seqera.io/orgs/11/workspaces/22/participants/add" + assert requests[0].headers["Authorization"] == "Bearer seqera-token" + assert b'"userNameOrEmail":"slack@example.org"' in requests[0].content.replace(b" ", b"") + assert b'"role":"nf-core-read-only"' in requests[0].content.replace(b" ", b"") + + members = await list_members("22") + assert len(members) == 1 + assert members[0].slack_user_id == "U1" + assert members[0].github_username == "octocat" + assert members[0].email == "slack@example.org" + assert members[0].participant_id == 99 + + async def test_form_email_overrides_slack_email( + self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, ddb_table: Any + ) -> None: + requests = _mock_seqera(monkeypatch, lambda r: httpx.Response(200, json={"participant": {"participantId": 1}})) + client = _slack_client() + + result = await add_to_megatests(client, "U1", "octocat", "platform@example.org") + + assert result.email == "platform@example.org" + assert b"platform@example.org" in requests[0].content + client.users_info.assert_not_awaited() + + async def test_no_email(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None) -> None: + requests = _mock_seqera(monkeypatch, lambda r: httpx.Response(200)) + + result = await add_to_megatests(_slack_client(email=None), "U1", "octocat") + + assert result.status is SeqeraStatus.NO_EMAIL + assert not requests + + async def test_already_participant_not_recorded( + self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, ddb_table: Any + ) -> None: + _mock_seqera(monkeypatch, lambda r: httpx.Response(409, json={"message": "Duplicate element"})) + + result = await add_to_megatests(_slack_client(), "U1", "octocat") + + assert result.status is SeqeraStatus.ALREADY_PARTICIPANT + assert result.has_access + assert await list_members("22") == [] + + async def test_no_account(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None) -> None: + _mock_seqera(monkeypatch, lambda r: httpx.Response(404, json={"message": "User not found"})) + + result = await add_to_megatests(_slack_client(), "U1", "octocat") + + assert result.status is SeqeraStatus.NO_ACCOUNT + assert not result.has_access + assert "slack@example.org" in result.message + + async def test_api_error_includes_status_and_message( + self, monkeypatch: pytest.MonkeyPatch, seqera_env: None + ) -> None: + _mock_seqera(monkeypatch, lambda r: httpx.Response(403, json={"message": "Operation not allowed"})) + + result = await add_to_megatests(_slack_client(), "U1", "octocat") + + assert result.status is SeqeraStatus.API_ERROR + assert "403" in result.message + assert "Operation not allowed" in result.message + + async def test_network_error(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None) -> None: + def _raise(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("boom", request=request) + + _mock_seqera(monkeypatch, _raise) + + result = await add_to_megatests(_slack_client(), "U1", "octocat") + + assert result.status is SeqeraStatus.API_ERROR + assert "ConnectError" in result.message + + +# ── remove_participant ─────────────────────────────────────────────── + + +class TestRemoveParticipant: + @pytest.mark.parametrize("status", [204, 404]) + async def test_gone(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, status: int) -> None: + requests = _mock_seqera(monkeypatch, lambda r: httpx.Response(status)) + + assert await remove_participant(99) is True + assert requests[0].method == "DELETE" + assert str(requests[0].url) == "https://api.cloud.seqera.io/orgs/11/workspaces/22/participants/99" + + async def test_error_raises(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None) -> None: + _mock_seqera(monkeypatch, lambda r: httpx.Response(500, json={"message": "nope"})) + + with pytest.raises(RuntimeError, match="500"): + await remove_participant(99) + + +# ── db.seqera ──────────────────────────────────────────────────────── + + +class TestSeqeraDb: + async def test_put_list_delete(self, ddb_table: Any) -> None: + await put_member("22", "U1", "octocat", "a@example.org", 1) + await put_member("22", "U2", "hubot", "b@example.org", 2) + await put_member("33", "U3", "other", "c@example.org", 3) + + members = await list_members("22") + assert sorted(m.slack_user_id for m in members) == ["U1", "U2"] + + await delete_member("22", "U1") + assert [m.slack_user_id for m in await list_members("22")] == ["U2"] + + +# ── get_org_membership_state ───────────────────────────────────────── + + +class TestGetOrgMembershipState: + @pytest.mark.parametrize( + ("status", "body", "expected"), + [(200, {"state": "active"}, "active"), (200, {"state": "pending"}, "pending"), (404, None, None)], + ) + async def test_states( + self, monkeypatch: pytest.MonkeyPatch, status: int, body: dict[str, str] | None, expected: str | None + ) -> None: + async def mock_get(path: str) -> httpx.Response: + assert path == "/orgs/nf-core/memberships/octocat" + return httpx.Response(status, json=body, request=httpx.Request("GET", "https://api.github.com")) + + monkeypatch.setattr("nf_core_bot.checks.github._github_get", mock_get) + assert await get_org_membership_state("octocat") == expected + + async def test_other_status_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + async def mock_get(path: str) -> httpx.Response: + return httpx.Response(502, text="bad gateway", request=httpx.Request("GET", "https://api.github.com")) + + monkeypatch.setattr("nf_core_bot.checks.github._github_get", mock_get) + with pytest.raises(RuntimeError): + await get_org_membership_state("octocat") + + +# ── Weekly removal job ─────────────────────────────────────────────── + + +async def _seed_members() -> None: + await put_member("22", "U_ACTIVE", "active-user", "a@example.org", 1) + await put_member("22", "U_PENDING", "pending-user", "p@example.org", 2) + await put_member("22", "U_LEFT", "left-user", "l@example.org", 3) + await put_member("22", "U_ERR", "error-user", "e@example.org", 4) + + +async def _membership(username: str) -> str | None: + states = {"active-user": "active", "pending-user": "pending", "left-user": None} + if username not in states: + raise RuntimeError("GitHub API down") + return states[username] + + +class TestSeqeraRemovalSync: + async def test_not_configured(self, ddb_table: Any) -> None: + assert await seqera_jobs.sync_seqera_removals() is None + + @patch("nf_core_bot.scheduler.seqera_jobs.remove_participant") + @patch("nf_core_bot.scheduler.seqera_jobs.get_org_membership_state", side_effect=_membership) + async def test_dry_run_only_logs( + self, _state: AsyncMock, mock_remove: AsyncMock, seqera_env: None, ddb_table: Any + ) -> None: + await _seed_members() + + summary = await seqera_jobs.sync_seqera_removals() + + assert summary is not None + assert summary.dry_run is True + assert summary.checked == 4 + assert [label.split()[0] for label in summary.would_remove] == ["left-user"] + assert summary.removed == [] + assert len(summary.errors) == 1 + mock_remove.assert_not_awaited() + assert len(await list_members("22")) == 4 + + @patch("nf_core_bot.scheduler.seqera_jobs.remove_participant", return_value=True) + @patch("nf_core_bot.scheduler.seqera_jobs.get_org_membership_state", side_effect=_membership) + async def test_live_removes_only_leavers( + self, + _state: AsyncMock, + mock_remove: AsyncMock, + seqera_env: None, + ddb_table: Any, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("SEQERA_REMOVAL_DRY_RUN", "false") + await _seed_members() + + summary = await seqera_jobs.sync_seqera_removals() + + assert summary is not None + assert summary.dry_run is False + assert [label.split()[0] for label in summary.removed] == ["left-user"] + mock_remove.assert_awaited_once_with(3) + remaining = sorted(m.slack_user_id for m in await list_members("22")) + assert remaining == ["U_ACTIVE", "U_ERR", "U_PENDING"] + + @patch("nf_core_bot.scheduler.seqera_jobs.remove_participant", side_effect=RuntimeError("HTTP 500")) + @patch("nf_core_bot.scheduler.seqera_jobs.get_org_membership_state", return_value=None) + async def test_failed_removal_keeps_record( + self, + _state: AsyncMock, + _remove: AsyncMock, + seqera_env: None, + ddb_table: Any, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("SEQERA_REMOVAL_DRY_RUN", "false") + await put_member("22", "U_LEFT", "left-user", "l@example.org", 3) + + summary = await seqera_jobs.sync_seqera_removals() + + assert summary is not None + assert summary.removed == [] + assert len(summary.errors) == 1 + assert len(await list_members("22")) == 1 + + +class TestSeqeraTick: + @patch("nf_core_bot.scheduler.seqera_jobs.sync_seqera_removals") + async def test_runs_once_on_monday_target_hour(self, mock_sync: AsyncMock, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(seqera_jobs, "_last_weekly_run", None) + monday = datetime.datetime(2026, 10, 19, seqera_jobs.TARGET_HOUR_UTC, 5, tzinfo=datetime.UTC) + + await seqera_jobs._tick(monday) + await seqera_jobs._tick(monday + datetime.timedelta(minutes=10)) + + mock_sync.assert_awaited_once() + + @patch("nf_core_bot.scheduler.seqera_jobs.sync_seqera_removals") + async def test_skips_other_times(self, mock_sync: AsyncMock, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(seqera_jobs, "_last_weekly_run", None) + tuesday = datetime.datetime(2026, 10, 20, seqera_jobs.TARGET_HOUR_UTC, 5, tzinfo=datetime.UTC) + monday_early = datetime.datetime(2026, 10, 19, 3, 0, tzinfo=datetime.UTC) + + await seqera_jobs._tick(tuesday) + await seqera_jobs._tick(monday_early) + + mock_sync.assert_not_awaited() From e716614d6634786f9c8ddc9771f18f48c12424d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 13:08:34 +0000 Subject: [PATCH 4/4] fix: confirm Seqera 409s and document required SSM params - A 409 from participants/add also means "collaborator not permitted for organization with active SSO", so only report "already a participant" after a participant search finds the email; otherwise report the 409 and Seqera's message. - Include Seqera's own message in the "no account" (404) result, since that mapping isn't in the spec and is unconfirmed. - Docs: the SEQERA_* SSM parameters are ECS secrets and must exist before deploying, or the task fails to start. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A4T79rC8VmKnzeJDnnFnge --- docs/deployment.md | 14 +++++++--- src/nf_core_bot/checks/seqera.py | 41 +++++++++++++++++++++++++--- tests/test_seqera.py | 46 +++++++++++++++++++++++++++++++- 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 47fcbad..5335b7d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -84,8 +84,13 @@ 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. If any of the three `SEQERA_*` -parameters is missing, the step is skipped silently. +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 @@ -95,7 +100,10 @@ parameters is missing, the step is skipped silently. 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). + 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# SK=USER#`, with the GitHub username, email used and Seqera participant ID). People who were already participants, diff --git a/src/nf_core_bot/checks/seqera.py b/src/nf_core_bot/checks/seqera.py index c96097c..e6a518d 100644 --- a/src/nf_core_bot/checks/seqera.py +++ b/src/nf_core_bot/checks/seqera.py @@ -97,6 +97,24 @@ async def _slack_email(client: AsyncWebClient, slack_user_id: str) -> str | None return email +async def _is_participant(settings: _Settings, email: str) -> bool: + """Return ``True`` if *email* is already a participant of the workspace. + + ``GET .../participants?search=`` — used to tell a duplicate apart + from the other 409 case ("collaborator not permitted for organization + with active SSO"). Raises on an HTTP error. + """ + async with httpx.AsyncClient(timeout=15) as http: + resp = await http.get( + _participants_url(settings), + headers={"Authorization": f"Bearer {settings.token}"}, + params={"search": email}, + ) + resp.raise_for_status() + participants = resp.json().get("participants") or [] + return any((p.get("email") or "").lower() == email.lower() for p in participants) + + async def add_to_megatests( client: AsyncWebClient, slack_user_id: str, @@ -133,12 +151,27 @@ async def add_to_megatests( return SeqeraResult(SeqeraStatus.API_ERROR, f"could not reach the Seqera API ({type(e).__name__})", email) if resp.status_code == 409: - logger.info("Slack user %s (%s) is already in Seqera workspace %s", slack_user_id, email, settings.workspace_id) - return SeqeraResult(SeqeraStatus.ALREADY_PARTICIPANT, "already a participant of the workspace", email) + # The spec lists 409 for both a duplicate and an SSO-blocked collaborator, + # so confirm the user really is a participant before saying so. + try: + already = await _is_participant(settings, email) + except httpx.HTTPError: + logger.exception("Could not look up Seqera participant %s after a 409", email) + already = False + if already: + logger.info( + "Slack user %s (%s) is already in Seqera workspace %s", slack_user_id, email, settings.workspace_id + ) + return SeqeraResult(SeqeraStatus.ALREADY_PARTICIPANT, "already a participant of the workspace", email) if resp.status_code == 404: - logger.warning("No Seqera account for Slack user %s (%s): %s", slack_user_id, email, _error_message(resp)) - return SeqeraResult(SeqeraStatus.NO_ACCOUNT, f"no Seqera Platform account was found for `{email}`", email) + # Not in the spec for this endpoint; unconfirmed whether Seqera uses it + # for an unknown email, so pass its own message through as well. + detail = _error_message(resp) + logger.warning("No Seqera account for Slack user %s (%s): %s", slack_user_id, email, detail) + return SeqeraResult( + SeqeraStatus.NO_ACCOUNT, f"no Seqera Platform account was found for `{email}` ({detail})", email + ) if not resp.is_success: detail = _error_message(resp) diff --git a/tests/test_seqera.py b/tests/test_seqera.py index 0ef3ebb..5de376b 100644 --- a/tests/test_seqera.py +++ b/tests/test_seqera.py @@ -43,6 +43,17 @@ def _factory(**kwargs: Any) -> httpx.AsyncClient: return requests +def _conflict_then_search(participants: list[dict[str, Any]]) -> Handler: + """409 on the add, then *participants* from the participant search.""" + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "PUT": + return httpx.Response(409, json={"message": "collaborator not permitted for organization with active SSO"}) + return httpx.Response(200, json={"participants": participants, "totalSize": len(participants)}) + + return handler + + def _slack_client(email: str | None = "slack@example.org") -> AsyncMock: client = make_slack_client() profile = {"email": email} if email else {} @@ -108,13 +119,45 @@ async def test_no_email(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None) async def test_already_participant_not_recorded( self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, ddb_table: Any ) -> None: - _mock_seqera(monkeypatch, lambda r: httpx.Response(409, json={"message": "Duplicate element"})) + requests = _mock_seqera( + monkeypatch, + _conflict_then_search([{"email": "Slack@Example.org", "participantId": 5}]), + ) result = await add_to_megatests(_slack_client(), "U1", "octocat") assert result.status is SeqeraStatus.ALREADY_PARTICIPANT assert result.has_access assert await list_members("22") == [] + assert requests[1].method == "GET" + assert requests[1].url.params["search"] == "slack@example.org" + + async def test_conflict_without_participant_is_error( + self, monkeypatch: pytest.MonkeyPatch, seqera_env: None + ) -> None: + _mock_seqera(monkeypatch, _conflict_then_search([])) + + result = await add_to_megatests(_slack_client(), "U1", "octocat") + + assert result.status is SeqeraStatus.API_ERROR + assert not result.has_access + assert "409" in result.message + assert "collaborator not permitted" in result.message + + async def test_conflict_with_failed_lookup_is_error( + self, monkeypatch: pytest.MonkeyPatch, seqera_env: None + ) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "PUT": + return httpx.Response(409, json={"message": "Duplicate element"}) + return httpx.Response(500) + + _mock_seqera(monkeypatch, handler) + + result = await add_to_megatests(_slack_client(), "U1", "octocat") + + assert result.status is SeqeraStatus.API_ERROR + assert "409" in result.message async def test_no_account(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None) -> None: _mock_seqera(monkeypatch, lambda r: httpx.Response(404, json={"message": "User not found"})) @@ -124,6 +167,7 @@ async def test_no_account(self, monkeypatch: pytest.MonkeyPatch, seqera_env: Non assert result.status is SeqeraStatus.NO_ACCOUNT assert not result.has_access assert "slack@example.org" in result.message + assert "User not found" in result.message async def test_api_error_includes_status_and_message( self, monkeypatch: pytest.MonkeyPatch, seqera_env: None