From 9ba3be7ac6438cfc4d5c9102301f894f3838a18c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 12:02:50 +0000 Subject: [PATCH] Add /nf-core seqera add|remove|list command Admins can now manage Seqera Platform access by named group, for corrections, email mismatches and groups other than megatests. - Groups are SeqeraGroup entries in code (workspace + role, or org team); megatests keeps its workspace ID and role from config. - add/remove/list live in checks/seqera.py; the joiner auto-sync and the weekly removal job reuse them, and the removal job now covers every group. - --email overrides the Slack email. Each add is recorded in DynamoDB, and remove only touches access the bot recorded. - Restricted to @core-team in the router; ephemeral replies; help text and docs updated. Closes #25 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F424GbMXjfgHf7HfNRpUj9 --- CLAUDE.md | 24 +- docs/commands.md | 59 +++- docs/deployment.md | 17 +- src/nf_core_bot/checks/seqera.py | 248 ++++++++++++---- src/nf_core_bot/commands/help.py | 25 ++ src/nf_core_bot/commands/router.py | 57 +++- src/nf_core_bot/commands/seqera/__init__.py | 1 + src/nf_core_bot/commands/seqera/manage.py | 164 ++++++++++ src/nf_core_bot/db/seqera.py | 105 ++++--- src/nf_core_bot/scheduler/seqera_jobs.py | 82 ++--- tests/test_seqera.py | 40 ++- tests/test_seqera_command.py | 314 ++++++++++++++++++++ 12 files changed, 967 insertions(+), 169 deletions(-) create mode 100644 src/nf_core_bot/commands/seqera/__init__.py create mode 100644 src/nf_core_bot/commands/seqera/manage.py create mode 100644 tests/test_seqera_command.py diff --git a/CLAUDE.md b/CLAUDE.md index e46361a..a1fb1b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,10 +44,10 @@ Slack ←→ ECS Fargate (Bolt app) ←→ DynamoDB ←→ GitHub API ### Three slash-command namespaces share one router -`/nf-core` handles top-level help, GitHub commands, and on-call commands. -`/hackathon` handles all hackathon commands. Both are registered in `app.py` and -delegate to `commands/router.py` which parses subcommands and dispatches to -handlers. +`/nf-core` handles top-level help, GitHub, on-call, security and Seqera +commands. `/hackathon` handles all hackathon commands. Both are registered in +`app.py` and delegate to `commands/router.py` which parses subcommands and +dispatches to handlers. ### Multi-step modal flow (the critical cross-file path) @@ -88,6 +88,15 @@ The same flow adds the Slack user to the Seqera `nf-core/megatests` workspace 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). +### Seqera access commands + +`/nf-core seqera add|remove|list` (`commands/seqera/manage.py`, `@core-team` +only, checked in `_route_seqera()`) manages Seqera access by named group. Groups +are `SeqeraGroup` entries (workspace + role, or org team) returned by +`seqera_groups()` in `checks/seqera.py`: `megatests` from config plus +`SEQERA_GROUPS`. Add, remove and the weekly removal sync all go through +`checks/seqera.py` and only touch access the bot recorded in DynamoDB. + ### On-call rotation The on-call system manages a weekly rotation for `@core-team` members. All @@ -140,8 +149,11 @@ 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`. + the bot added (GitHub username, email used, Seqera participant ID, group + name). Read by `/nf-core seqera` and the weekly removal sync in + `scheduler/seqera_jobs.py`. +- `PK=SEQERA#TEAM# SK=USER#` — the same for an + organisation team (`participant_id` holds the team member ID). ### On-call key patterns diff --git a/docs/commands.md b/docs/commands.md index 9084a66..ea59d77 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -531,15 +531,64 @@ of every open advisory (`triage`, `draft`, or `published` — excludes ephemerally, so anyone there can see the result. Falls back to DMing you if the bot can't post where the command was run (e.g. a channel it isn't a member of). +## Seqera Platform Commands + +These commands manage access to Seqera Platform by named group. All require +`@core-team` Slack user group membership. Replies are ephemeral. + +Groups are defined in code: `megatests` (the `nf-core/megatests` workspace with +the read-only role, configured as described in +[Deployment](deployment.md#seqera-megatests-workspace-access)) plus any entries +in `SEQERA_GROUPS` in `src/nf_core_bot/checks/seqera.py`. A group targets either +a workspace (with a role) or an organisation team. + +### `seqera help` + +``` +/nf-core seqera help +``` + +Show Seqera command help. + +### `seqera add` + +``` +/nf-core seqera add @user [--email foo@bar.com] +``` + +Add a Slack user to a group. Uses their Slack email unless `--email` gives their +Seqera Platform email. The user needs a GitHub username on their Slack profile, +so the weekly removal sync can check they are still in the nf-core GitHub org. +Each add is recorded in DynamoDB. + +### `seqera remove` + +``` +/nf-core seqera remove @user +``` + +Remove a Slack user from a group. Only works for access the bot recorded; remove +anything granted by hand in Seqera Platform. + +### `seqera list` + +``` +/nf-core seqera list +/nf-core seqera list @user +``` + +With no argument, lists the groups and how many members the bot added to each. +With a user, lists the groups the bot added them to and the email used. + --- ## Permission Model -| Level | Who | Access | -| ------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| **User** | Everyone | `/hackathon list`, `/hackathon register/edit/cancel`, `/hackathon sites` | -| **Site organiser** | Per-hackathon, per-site (stored in DynamoDB) | `/hackathon export` | -| **Admin** | `@core-team` Slack user group members | All commands including `/hackathon admin *`, `/nf-core github *`, `/nf-core on-call *`, `/nf-core security *` | +| Level | Who | Access | +| ------------------ | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| **User** | Everyone | `/hackathon list`, `/hackathon register/edit/cancel`, `/hackathon sites` | +| **Site organiser** | Per-hackathon, per-site (stored in DynamoDB) | `/hackathon export` | +| **Admin** | `@core-team` Slack user group members | All commands including `/hackathon admin *`, `/nf-core github *`, `/nf-core on-call *`, `/nf-core security *`, `/nf-core seqera *` | Admin membership is checked via `usergroups.users.list` and cached for 5 minutes. diff --git a/docs/deployment.md b/docs/deployment.md index 47fcbad..6d75005 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -108,13 +108,20 @@ 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. +**Other groups.** `/nf-core seqera add|remove|list` manages access for +`megatests` and any other group listed in `SEQERA_GROUPS` in +`src/nf_core_bot/checks/seqera.py` (a workspace ID and role, or an organisation +team ID). Adding a group is a code change; only the token and org ID live in +SSM. The token needs permission to add and remove members of every group's +workspace or team. See [Commands](commands.md#seqera-platform-commands). + **Weekly removal sync.** Every Monday at 09:00 UTC the bot checks each recorded -participant against the nf-core GitHub org +member of every group 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. +membership nor a pending invitation is removed from that group and their record +is deleted. Only people the bot recorded are touched, and only in the configured +groups. 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 diff --git a/src/nf_core_bot/checks/seqera.py b/src/nf_core_bot/checks/seqera.py index c96097c..78a6171 100644 --- a/src/nf_core_bot/checks/seqera.py +++ b/src/nf_core_bot/checks/seqera.py @@ -1,12 +1,18 @@ -"""Seqera Platform workspace access for nf-core joiners. +"""Seqera Platform access management. -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. +Access is granted through named *groups* (see :data:`SEQERA_GROUPS` / +:func:`seqera_groups`): each maps a friendly name to a Seqera target, either a +workspace (with a role) or an organisation team. The joiner auto-sync in +``commands/github/invite_flow.py`` adds people to ``megatests``; the +``/nf-core seqera`` command (``commands/seqera/``) adds, removes and lists +access for any group. + +Every add is recorded in DynamoDB so ``list``, ``remove`` and the weekly +removal job (``scheduler/seqera_jobs.py``) know which access the bot manages. API reference: https://cloud.seqera.io/openapi/seqera-api-latest.yml -(``CreateWorkspaceParticipant`` / ``DeleteWorkspaceParticipant``). +(``CreateWorkspaceParticipant`` / ``DeleteWorkspaceParticipant`` / +``CreateOrganizationTeamMember`` / ``DeleteOrganizationTeamMember``). """ from __future__ import annotations @@ -19,7 +25,7 @@ import httpx from nf_core_bot import config -from nf_core_bot.db.seqera import put_member +from nf_core_bot.db.seqera import SeqeraMember, delete_member, get_member, put_member if TYPE_CHECKING: from slack_sdk.web.async_client import AsyncWebClient @@ -28,12 +34,17 @@ SEQERA_API_URL = "https://api.cloud.seqera.io" +MEGATESTS = "megatests" + class SeqeraStatus(enum.Enum): - """Why a workspace add did or didn't happen.""" + """Why an add or remove did or didn't happen.""" ADDED = "added" ALREADY_PARTICIPANT = "already_participant" + REMOVED = "removed" + NOT_MANAGED = "not_managed" + UNKNOWN_GROUP = "unknown_group" NOT_CONFIGURED = "not_configured" NO_EMAIL = "no_email" NO_ACCOUNT = "no_account" @@ -42,7 +53,7 @@ class SeqeraStatus(enum.Enum): @dataclass class SeqeraResult: - """Outcome of a Seqera workspace add.""" + """Outcome of a Seqera add or remove.""" status: SeqeraStatus message: str @@ -50,25 +61,70 @@ class SeqeraResult: @property def has_access(self) -> bool: - """``True`` when the user is now a workspace participant.""" + """``True`` when the user is now in the group.""" return self.status in (SeqeraStatus.ADDED, SeqeraStatus.ALREADY_PARTICIPANT) +@dataclass(frozen=True) +class SeqeraGroup: + """A Seqera access target: a workspace with a role, or an organisation team. + + Set either ``workspace_id`` (and ``role``) or ``team_id``. + """ + + description: str + workspace_id: str | None = None + role: str | None = None + team_id: str | None = None + + @property + def target_key(self) -> str | None: + """DynamoDB key for this target: ```` or ``TEAM#``; ``None`` if unset.""" + if self.workspace_id: + return self.workspace_id + if self.team_id: + return f"TEAM#{self.team_id}" + return None + + +# Groups beyond megatests, keyed by the name used in ``/nf-core seqera``. +# IDs are the numeric Seqera IDs, as strings. Changes go through PR review. +# For example: +# "maintainers": SeqeraGroup("nf-core maintainers team", team_id="1234"), +# "dev": SeqeraGroup("nf-core/dev workspace (launch)", workspace_id="5678", role="launch"), +SEQERA_GROUPS: dict[str, SeqeraGroup] = {} + + +def seqera_groups() -> dict[str, SeqeraGroup]: + """Return every group by name. + + ``megatests`` keeps its workspace ID and role in config (the + ``SEQERA_WORKSPACE_ID`` param and ``SEQERA_WORKSPACE_ROLE``), as set up for + the joiner auto-sync. The rest come from :data:`SEQERA_GROUPS`. + """ + return { + MEGATESTS: SeqeraGroup( + "nf-core/megatests workspace (read-only)", + workspace_id=config.SEQERA_WORKSPACE_ID, + role=config.SEQERA_WORKSPACE_ROLE, + ), + **SEQERA_GROUPS, + } + + @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.""" + """Return the org-wide Seqera settings, or ``None`` when Seqera isn't configured.""" token = config.SEQERA_TOKEN org_id = config.SEQERA_ORG_ID - workspace_id = config.SEQERA_WORKSPACE_ID - if not (token and org_id and workspace_id): + if not (token and org_id): return None - return _Settings(token=token, org_id=org_id, workspace_id=workspace_id) + return _Settings(token=token, org_id=org_id) def removal_dry_run() -> bool: @@ -76,8 +132,12 @@ def removal_dry_run() -> bool: 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 _participants_url(settings: _Settings, workspace_id: str) -> str: + return f"{SEQERA_API_URL}/orgs/{settings.org_id}/workspaces/{workspace_id}/participants" + + +def _team_members_url(settings: _Settings, team_id: str) -> str: + return f"{SEQERA_API_URL}/orgs/{settings.org_id}/teams/{team_id}/members" def _error_message(resp: httpx.Response) -> str: @@ -97,44 +157,58 @@ async def _slack_email(client: AsyncWebClient, slack_user_id: str) -> str | None return email -async def add_to_megatests( +async def add_to_group( client: AsyncWebClient, slack_user_id: str, github_username: str, + group_name: str, email: str | None = None, ) -> SeqeraResult: - """Add a Slack user to the Seqera workspace with the configured role. + """Add a Slack user to the Seqera group *group_name*. - *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. + *email* is the user's Seqera Platform email, if known; otherwise their + Slack email is used. Successful adds are recorded in DynamoDB. Never + raises for HTTP errors — the reason is returned in the + :class:`SeqeraResult` instead. """ + group = seqera_groups().get(group_name) + if group is None: + return SeqeraResult(SeqeraStatus.UNKNOWN_GROUP, f"there is no Seqera group called `{group_name}`") + 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.") + target_key = group.target_key + if settings is None or target_key is None: + logger.info("Seqera group %s is not configured; skipping user %s", group_name, slack_user_id) + return SeqeraResult(SeqeraStatus.NOT_CONFIGURED, f"the Seqera group `{group_name}` 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) + logger.warning("Slack user %s has no visible email; cannot add to Seqera", slack_user_id) return SeqeraResult(SeqeraStatus.NO_EMAIL, "no email address was found on their Slack profile") + headers = {"Authorization": f"Bearer {settings.token}"} 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}, - ) + if group.workspace_id: + resp = await http.put( + f"{_participants_url(settings, group.workspace_id)}/add", + headers=headers, + json={"userNameOrEmail": email, "role": group.role}, + ) + else: + resp = await http.post( + _team_members_url(settings, str(group.team_id)), + headers=headers, + json={"userNameOrEmail": email}, + ) except httpx.HTTPError as e: - logger.exception("Network error adding Slack user %s to Seqera", slack_user_id) + logger.exception("Network error adding Slack user %s to Seqera group %s", slack_user_id, group_name) 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) + logger.info("Slack user %s (%s) is already in Seqera group %s", slack_user_id, email, group_name) + return SeqeraResult(SeqeraStatus.ALREADY_PARTICIPANT, f"already in `{group_name}`", email) if resp.status_code == 404: logger.warning("No Seqera account for Slack user %s (%s): %s", slack_user_id, email, _error_message(resp)) @@ -143,40 +217,98 @@ async def add_to_megatests( 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 + "Seqera add to %s failed for %s: HTTP %s: %s", group_name, 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) + logger.info("Added Slack user %s to Seqera group %s", slack_user_id, group_name) + body = resp.json() + if group.workspace_id: + seqera_id = (body.get("participant") or {}).get("participantId") + else: + seqera_id = (body.get("member") or {}).get("memberId") + if seqera_id is None: + logger.warning("Seqera add for %s to %s returned no ID; not recording it", slack_user_id, group_name) else: try: - await put_member(settings.workspace_id, slack_user_id, github_username, email, int(participant_id)) + await put_member(target_key, slack_user_id, github_username, email, int(seqera_id), group=group_name) 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) + # The add itself worked; failing to record only means the bot + # won't manage this person's access. + logger.exception("Failed to record Seqera add for Slack user %s", slack_user_id) + return SeqeraResult(SeqeraStatus.ADDED, f"added to `{group_name}`", 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 megatests workspace (the joiner auto-sync).""" + return await add_to_group(client, slack_user_id, github_username, MEGATESTS, email) -async def remove_participant(participant_id: int) -> bool: - """Remove a participant from the configured workspace. +async def remove_record(group: SeqeraGroup, member: SeqeraMember) -> None: + """Remove a recorded member from *group* in Seqera, then delete the record. - 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. + Seqera reporting them as already gone (404) counts as removed. Raises + ``RuntimeError`` when Seqera isn't configured or the API call fails; the + record is kept in that case. """ settings = _settings() - if settings is None: - raise RuntimeError("Seqera workspace sync is not configured") + if settings is None or group.target_key is None: + raise RuntimeError("Seqera group is not configured") + + if group.workspace_id: + url = f"{_participants_url(settings, group.workspace_id)}/{member.participant_id}" + else: + url = f"{_team_members_url(settings, str(group.team_id))}/{member.participant_id}/delete" async with httpx.AsyncClient(timeout=15) as http: - resp = await http.delete( - f"{_participants_url(settings)}/{participant_id}", - headers={"Authorization": f"Bearer {settings.token}"}, + resp = await http.delete(url, headers={"Authorization": f"Bearer {settings.token}"}) + if not (resp.is_success or resp.status_code == 404): + raise RuntimeError(f"Seqera API returned HTTP {resp.status_code}: {_error_message(resp)}") + await delete_member(group.target_key, member.slack_user_id) + + +async def remove_from_group(slack_user_id: str, group_name: str) -> SeqeraResult: + """Remove a Slack user from the Seqera group *group_name*. + + Only access the bot recorded can be removed this way; anything else is + reported as :attr:`SeqeraStatus.NOT_MANAGED`. Never raises — the reason is + returned in the :class:`SeqeraResult` instead. + """ + group = seqera_groups().get(group_name) + if group is None: + return SeqeraResult(SeqeraStatus.UNKNOWN_GROUP, f"there is no Seqera group called `{group_name}`") + target_key = group.target_key + if _settings() is None or target_key is None: + return SeqeraResult(SeqeraStatus.NOT_CONFIGURED, f"the Seqera group `{group_name}` is not configured") + + member = await get_member(target_key, slack_user_id) + if member is None: + return SeqeraResult( + SeqeraStatus.NOT_MANAGED, + f"the bot has no record of adding them to `{group_name}`; remove them in Seqera Platform instead", ) - if resp.is_success or resp.status_code == 404: - return True - raise RuntimeError(f"Seqera API returned HTTP {resp.status_code}: {_error_message(resp)}") + + try: + await remove_record(group, member) + except Exception as e: + logger.exception("Failed to remove Slack user %s from Seqera group %s", slack_user_id, group_name) + return SeqeraResult(SeqeraStatus.API_ERROR, str(e), member.email) + logger.info("Removed Slack user %s from Seqera group %s", slack_user_id, group_name) + return SeqeraResult(SeqeraStatus.REMOVED, f"removed from `{group_name}`", member.email) + + +async def groups_for_user(slack_user_id: str) -> list[tuple[str, SeqeraMember]]: + """Return ``(group name, record)`` for every configured group the bot added *slack_user_id* to.""" + found: list[tuple[str, SeqeraMember]] = [] + for name, group in seqera_groups().items(): + if group.target_key is None: + continue + member = await get_member(group.target_key, slack_user_id) + if member is not None: + found.append((name, member)) + return found diff --git a/src/nf_core_bot/commands/help.py b/src/nf_core_bot/commands/help.py index a23db54..fafbaac 100644 --- a/src/nf_core_bot/commands/help.py +++ b/src/nf_core_bot/commands/help.py @@ -52,6 +52,13 @@ ("security summary", "Summarise all open GitHub security advisories across nf-core repos", "admin"), ] +SEQERA_COMMANDS: list[tuple[str, str, str]] = [ + ("seqera add @user [--email foo@bar.com]", "Give a Slack user access to a Seqera group", "admin"), + ("seqera remove @user ", "Remove access the bot granted for a Seqera group", "admin"), + ("seqera list", "List the Seqera groups", "admin"), + ("seqera list @user", "List the Seqera groups the bot added a user to", "admin"), +] + GENERAL_COMMANDS: list[tuple[str, str, str]] = [ ("help", "Show this help message", "all"), ( @@ -62,6 +69,7 @@ ("on-call help", "Show on-call rotation commands (@core-team only)", "admin"), ("github help", "Show GitHub commands (@core-team only)", "admin"), ("security help", "Show security commands (@core-team only)", "admin"), + ("seqera help", "Show Seqera Platform access commands (@core-team only)", "admin"), ] @@ -192,3 +200,20 @@ async def handle_security_help( sections.append(_format_commands(SECURITY_COMMANDS)) await respond("\n".join(sections), response_type="ephemeral") + + +async def handle_seqera_help( + respond: Respond, +) -> None: + """Seqera help: ``/nf-core seqera help``. + + Only visible to ``@core-team`` members (permission checked in router). + """ + sections = ["*Seqera Platform commands* (`@core-team` only)\n"] + sections.append(_format_commands(SEQERA_COMMANDS)) + sections.append( + "\n`--email` sets the Seqera Platform email when it differs from their Slack email. " + "`remove` only works for access the bot granted." + ) + + await respond("\n".join(sections), response_type="ephemeral") diff --git a/src/nf_core_bot/commands/router.py b/src/nf_core_bot/commands/router.py index 39d5470..07aa854 100644 --- a/src/nf_core_bot/commands/router.py +++ b/src/nf_core_bot/commands/router.py @@ -2,7 +2,8 @@ Two slash commands share this router: -* ``/nf-core `` — top-level help, GitHub commands +* ``/nf-core `` — top-level help, GitHub, on-call, proposals, + security and Seqera commands * ``/hackathon `` — all hackathon commands ``app.py`` registers both commands and delegates to the appropriate @@ -36,6 +37,7 @@ handle_help, handle_oncall_help, handle_security_help, + handle_seqera_help, ) from nf_core_bot.commands.oncall.calendar_cmd import handle_oncall_calendar from nf_core_bot.commands.oncall.list_cmd import handle_oncall_list @@ -46,6 +48,7 @@ from nf_core_bot.commands.oncall.unavailable import handle_oncall_unavailable from nf_core_bot.commands.proposals.review import handle_proposals_review from nf_core_bot.commands.security.summary import handle_security_summary +from nf_core_bot.commands.seqera.manage import handle_seqera_add, handle_seqera_list, handle_seqera_remove from nf_core_bot.permissions.checks import is_core_team, is_core_team_or_maintainers if TYPE_CHECKING: @@ -120,6 +123,11 @@ async def dispatch( await _route_security(ack, respond, client, user_id, command, rest) return + # ── Seqera Platform commands ───────────────────────────────────── + if sub == "seqera": + await _route_seqera(ack, respond, client, user_id, rest) + return + # ── Unknown ────────────────────────────────────────────────────── await ack() await respond( @@ -398,3 +406,50 @@ async def _route_security( f"Unknown security command: `{sub}`. Run `/nf-core security help` for options.", response_type="ephemeral", ) + + +# ── Seqera dispatch ────────────────────────────────────────────────── + +_SEQERA_DISPATCH: dict[str, object] = { + "add": handle_seqera_add, + "remove": handle_seqera_remove, + "list": handle_seqera_list, +} + + +async def _route_seqera( + ack: Ack, + respond: Respond, + client: AsyncWebClient, + user_id: str, + tokens: list[str], +) -> None: + """Dispatch ``/nf-core seqera [args…]``. + + Restricted to ``@core-team`` members. + """ + await ack() + + if not await is_core_team(client, user_id): + await respond( + "Sorry, Seqera commands are restricted to `@core-team` members.", + response_type="ephemeral", + ) + return + + if not tokens or tokens[0].lower() == "help": + await handle_seqera_help(respond) + return + + sub = tokens[0].lower() + rest = tokens[1:] + + handler = _SEQERA_DISPATCH.get(sub) + if handler is None: + await respond( + f"Unknown seqera command: `{sub}`. Run `/nf-core seqera help` for options.", + response_type="ephemeral", + ) + return + + await handler(respond, client, user_id, rest) # type: ignore[operator] diff --git a/src/nf_core_bot/commands/seqera/__init__.py b/src/nf_core_bot/commands/seqera/__init__.py new file mode 100644 index 0000000..5f94ae9 --- /dev/null +++ b/src/nf_core_bot/commands/seqera/__init__.py @@ -0,0 +1 @@ +"""Seqera Platform access commands.""" diff --git a/src/nf_core_bot/commands/seqera/manage.py b/src/nf_core_bot/commands/seqera/manage.py new file mode 100644 index 0000000..1bf7c11 --- /dev/null +++ b/src/nf_core_bot/commands/seqera/manage.py @@ -0,0 +1,164 @@ +"""``/nf-core seqera add|remove|list`` — manage Seqera Platform access by group. + +Usage:: + + /nf-core seqera add @user [--email foo@bar.com] + /nf-core seqera remove @user + /nf-core seqera list [@user] + +Groups are defined in code (``checks/seqera.py``). The router acks and checks +``@core-team`` membership before dispatching, so these handlers don't. +""" + +from __future__ import annotations + +import logging +import re +from typing import TYPE_CHECKING + +from nf_core_bot.checks.seqera import ( + SeqeraStatus, + add_to_group, + groups_for_user, + remove_from_group, + seqera_groups, +) +from nf_core_bot.checks.slack_profile import get_github_username +from nf_core_bot.db.seqera import list_members + +if TYPE_CHECKING: + from slack_bolt.context.respond.async_respond import AsyncRespond as Respond + from slack_sdk.web.async_client import AsyncWebClient + +logger = logging.getLogger(__name__) + +# Matches a Slack user mention like <@U01234ABC> or <@U01234ABC|username> +_SLACK_MENTION_RE = re.compile(r"^<@(U[A-Z0-9]+)(?:\|[^>]*)?>$") + +# Slack may send 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,}") + +ADD_USAGE = "Usage: `/nf-core seqera add @user [--email foo@bar.com]`" +REMOVE_USAGE = "Usage: `/nf-core seqera remove @user `" + + +def _mention(token: str) -> str | None: + match = _SLACK_MENTION_RE.match(token) + return match.group(1) if match else None + + +def _parse_email(args: list[str]) -> tuple[list[str], str | None, bool]: + """Pull ``--email `` / ``--email=`` out of *args*. + + Returns ``(remaining args, email, ok)``; ``ok`` is ``False`` when + ``--email`` was given without a valid address. + """ + remaining: list[str] = [] + email: str | None = None + i = 0 + while i < len(args): + token = args[i] + if token == "--email" or token.startswith("--email="): + value = token.partition("=")[2] + if not value and i + 1 < len(args): + i += 1 + value = args[i] + match = _EMAIL_RE.search(value) + if match is None: + return remaining, None, False + email = match.group(0) + else: + remaining.append(token) + i += 1 + return remaining, email, True + + +def _group_list() -> str: + return ", ".join(f"`{name}`" for name in seqera_groups()) + + +async def handle_seqera_add(respond: Respond, client: AsyncWebClient, user_id: str, args: list[str]) -> None: + """Add a Slack user to a Seqera group.""" + args, email, ok = _parse_email(args) + if not ok: + await respond(f"`--email` needs a valid email address. {ADD_USAGE}", response_type="ephemeral") + return + target = _mention(args[0]) if len(args) == 2 else None + if target is None: + await respond(ADD_USAGE, response_type="ephemeral") + return + group_name = args[1].lower() + if group_name not in seqera_groups(): + await respond(f"Unknown Seqera group `{group_name}`. Groups: {_group_list()}.", response_type="ephemeral") + return + + # The removal job checks the GitHub username against the org, so it's required. + github_username = await get_github_username(client, target) + if github_username is None: + await respond( + f"<@{target}> has no GitHub username on their Slack profile. Add one first, so the weekly " + "removal sync can check they're still in the nf-core GitHub org.", + response_type="ephemeral", + ) + return + + result = await add_to_group(client, target, github_username, group_name, email) + logger.info("%s ran seqera add %s %s: %s", user_id, target, group_name, result.status.value) + if result.status is SeqeraStatus.ADDED: + text = f":white_check_mark: Added <@{target}> to `{group_name}` as `{result.email}`." + elif result.status is SeqeraStatus.ALREADY_PARTICIPANT: + text = f"<@{target}> is already in `{group_name}` (`{result.email}`) — nothing to do." + else: + text = f":x: Couldn't add <@{target}> to `{group_name}`: {result.message}." + if result.status is SeqeraStatus.NO_ACCOUNT: + text += " Use `--email` if their Seqera Platform email differs from their Slack email." + await respond(text, response_type="ephemeral") + + +async def handle_seqera_remove(respond: Respond, client: AsyncWebClient, user_id: str, args: list[str]) -> None: + """Remove a Slack user from a Seqera group the bot added them to.""" + target = _mention(args[0]) if len(args) == 2 else None + if target is None: + await respond(REMOVE_USAGE, response_type="ephemeral") + return + group_name = args[1].lower() + + result = await remove_from_group(target, group_name) + logger.info("%s ran seqera remove %s %s: %s", user_id, target, group_name, result.status.value) + if result.status is SeqeraStatus.REMOVED: + text = f":white_check_mark: Removed <@{target}> from `{group_name}`." + elif result.status is SeqeraStatus.UNKNOWN_GROUP: + text = f"Unknown Seqera group `{group_name}`. Groups: {_group_list()}." + else: + text = f":x: Couldn't remove <@{target}> from `{group_name}`: {result.message}." + await respond(text, response_type="ephemeral") + + +async def handle_seqera_list(respond: Respond, client: AsyncWebClient, user_id: str, args: list[str]) -> None: + """List the groups, or the groups the bot added one user to.""" + if args: + target = _mention(args[0]) if len(args) == 1 else None + if target is None: + await respond("Usage: `/nf-core seqera list [@user]`", response_type="ephemeral") + return + found = await groups_for_user(target) + if not found: + await respond( + f"The bot hasn't added <@{target}> to any Seqera group. " + "Access granted in Seqera Platform directly isn't tracked here.", + response_type="ephemeral", + ) + return + lines = [f"*Seqera groups the bot added <@{target}> to*"] + lines += [f"`{name}` — as `{member.email}`, added {member.added_at[:10]}" for name, member in found] + await respond("\n".join(lines), response_type="ephemeral") + return + + lines = ["*Seqera groups*"] + for name, group in seqera_groups().items(): + if group.target_key is None: + lines.append(f"`{name}` — {group.description} _(not configured)_") + continue + count = len(await list_members(group.target_key)) + lines.append(f"`{name}` — {group.description}: {count} member{'s' if count != 1 else ''} added by the bot") + await respond("\n".join(lines), response_type="ephemeral") diff --git a/src/nf_core_bot/db/seqera.py b/src/nf_core_bot/db/seqera.py index 410f619..3276817 100644 --- a/src/nf_core_bot/db/seqera.py +++ b/src/nf_core_bot/db/seqera.py @@ -1,7 +1,11 @@ -"""DynamoDB operations for Seqera workspace participants added by the bot. +"""DynamoDB operations for Seqera access the bot granted. -Only people the bot itself added are recorded, so the weekly removal job -(``scheduler/seqera_jobs.py``) never touches participants added by hand. +Only people the bot itself added are recorded, so ``/nf-core seqera remove`` +and the weekly removal job (``scheduler/seqera_jobs.py``) never touch access +granted by hand. + +Records are keyed by *target*: a workspace ID, or ``TEAM#`` for an +organisation team (see ``SeqeraGroup.target_key`` in ``checks/seqera.py``). """ from __future__ import annotations @@ -21,19 +25,21 @@ @dataclass class SeqeraMember: - """A workspace participant the bot added.""" + """Access the bot granted to one Slack user for one target.""" - workspace_id: str + target: str slack_user_id: str github_username: str email: str + # Workspace participant ID, or team member ID for a team target. participant_id: int added_at: str + group: str | None = None -def _pk(workspace_id: str) -> str: - """PK for a workspace's bot-added participants: ``SEQERA#``.""" - return f"{SEQERA_PK_PREFIX}{workspace_id}" +def _pk(target: str) -> str: + """PK for a target's bot-added members: ``SEQERA#``.""" + return f"{SEQERA_PK_PREFIX}{target}" def _sk(slack_user_id: str) -> str: @@ -44,41 +50,68 @@ def _now_iso() -> str: return datetime.datetime.now(datetime.UTC).isoformat() +def _from_item(item: dict[str, Any]) -> SeqeraMember: + return SeqeraMember( + target=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"], + group=item.get("group"), + ) + + async def put_member( - workspace_id: str, + target: str, slack_user_id: str, github_username: str, email: str, participant_id: int, + group: str | None = None, ) -> None: - """Record that the bot added *slack_user_id* to *workspace_id* (overwrites).""" + """Record that the bot added *slack_user_id* to *target* (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(), - } - ) + item: dict[str, Any] = { + "PK": _pk(target), + "SK": _sk(slack_user_id), + # Attribute name predates team targets; it holds any target key. + "workspace_id": target, + "slack_user_id": slack_user_id, + "github_username": github_username, + "email": email, + "participant_id": participant_id, + "added_at": _now_iso(), + } + if group is not None: + item["group"] = group + table.put_item(Item=item) await asyncio.to_thread(_put) -async def list_members(workspace_id: str) -> list[SeqeraMember]: - """Return every participant the bot added to *workspace_id*.""" +async def get_member(target: str, slack_user_id: str) -> SeqeraMember | None: + """Return the record for *slack_user_id* in *target*, or ``None``.""" + table = get_table() + + def _get() -> dict[str, Any] | None: + item: dict[str, Any] | None = table.get_item(Key={"PK": _pk(target), "SK": _sk(slack_user_id)}).get("Item") + return item + + item = await asyncio.to_thread(_get) + return _from_item(item) if item else None + + +async def list_members(target: str) -> list[SeqeraMember]: + """Return every member the bot added to *target*.""" 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) + "KeyConditionExpression": Key("PK").eq(_pk(target)) & Key("SK").begins_with(USER_SK_PREFIX) } while True: resp = table.query(**kwargs) @@ -89,24 +122,14 @@ def _query() -> list[dict[str, Any]]: 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).""" + return [_from_item(item) for item in items] + + +async def delete_member(target: str, slack_user_id: str) -> None: + """Forget a bot-added member (after removing them in Seqera).""" table = get_table() def _delete() -> None: - table.delete_item(Key={"PK": _pk(workspace_id), "SK": _sk(slack_user_id)}) + table.delete_item(Key={"PK": _pk(target), "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 index 077804c..50d6695 100644 --- a/src/nf_core_bot/scheduler/seqera_jobs.py +++ b/src/nf_core_bot/scheduler/seqera_jobs.py @@ -1,10 +1,10 @@ -"""Background job: remove people who left the nf-core GitHub org from the Seqera workspace. +"""Background job: remove people who left the nf-core GitHub org from Seqera. 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. +on-call scheduler. Once a week it checks every member the bot added to any +Seqera group (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 that group. Access granted by hand is never recorded, so never touched. While ``SEQERA_REMOVAL_DRY_RUN`` is on (the default) it only logs who it would remove. @@ -19,8 +19,8 @@ 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 +from nf_core_bot.checks.seqera import SeqeraGroup, removal_dry_run, remove_record, seqera_groups +from nf_core_bot.db.seqera import SeqeraMember, list_members logger = logging.getLogger(__name__) @@ -68,44 +68,21 @@ async def _tick(now_utc: datetime.datetime | None = None) -> None: async def sync_seqera_removals() -> RemovalSummary | None: - """Remove bot-added participants who are no longer in the nf-core GitHub org. + """Remove bot-added members who are no longer in the nf-core GitHub org, from every group. - Returns ``None`` when Seqera sync isn't configured. Any GitHub or Seqera - error for one person is logged and skipped, never treated as "left". + Returns ``None`` when Seqera 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") + if not (config.SEQERA_TOKEN and config.SEQERA_ORG_ID): + logger.info("Seqera 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: + for group_name, group in seqera_groups().items(): + if group.target_key is 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) + for member in await list_members(group.target_key): + await _check_member(summary, group_name, group, member) logger.info( "Seqera removal sync%s: checked %d, removed %d, would remove %d, errors %d", @@ -116,3 +93,30 @@ async def sync_seqera_removals() -> RemovalSummary | None: len(summary.errors), ) return summary + + +async def _check_member(summary: RemovalSummary, group_name: str, group: SeqeraGroup, member: SeqeraMember) -> None: + summary.checked += 1 + label = f"{member.github_username} ({member.email}, Slack {member.slack_user_id}, {group_name})" + 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) + return + if state is not None: + return + + if summary.dry_run: + logger.info("[dry run] Would remove %s from Seqera", label) + summary.would_remove.append(label) + return + + try: + await remove_record(group, member) + except Exception: + logger.exception("Failed to remove %s from Seqera", label) + summary.errors.append(label) + return + logger.info("Removed %s from Seqera (left the GitHub org)", label) + summary.removed.append(label) diff --git a/tests/test_seqera.py b/tests/test_seqera.py index 0ef3ebb..2800ae7 100644 --- a/tests/test_seqera.py +++ b/tests/test_seqera.py @@ -1,4 +1,4 @@ -"""Tests for Seqera workspace access: checks.seqera, db.seqera and the removal job.""" +"""Tests for Seqera access: checks.seqera, db.seqera and the removal job.""" from __future__ import annotations @@ -11,8 +11,8 @@ 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.checks.seqera import SeqeraStatus, add_to_megatests, remove_record, seqera_groups +from nf_core_bot.db.seqera import delete_member, get_member, list_members, put_member from nf_core_bot.scheduler import seqera_jobs from tests.helpers import make_slack_client @@ -148,23 +148,36 @@ def _raise(request: httpx.Request) -> httpx.Response: assert "ConnectError" in result.message -# ── remove_participant ─────────────────────────────────────────────── +# ── remove_record ──────────────────────────────────────────────────── -class TestRemoveParticipant: +class TestRemoveRecord: @pytest.mark.parametrize("status", [204, 404]) - async def test_gone(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, status: int) -> None: + async def test_gone_deletes_record( + self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, ddb_table: Any, status: int + ) -> None: requests = _mock_seqera(monkeypatch, lambda r: httpx.Response(status)) + await put_member("22", "U1", "octocat", "a@example.org", 99) + member = await get_member("22", "U1") + assert member is not None + + await remove_record(seqera_groups()["megatests"], member) - 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" + assert await get_member("22", "U1") is None - async def test_error_raises(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None) -> None: + async def test_error_raises_and_keeps_record( + self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, ddb_table: Any + ) -> None: _mock_seqera(monkeypatch, lambda r: httpx.Response(500, json={"message": "nope"})) + await put_member("22", "U1", "octocat", "a@example.org", 99) + member = await get_member("22", "U1") + assert member is not None with pytest.raises(RuntimeError, match="500"): - await remove_participant(99) + await remove_record(seqera_groups()["megatests"], member) + assert await get_member("22", "U1") is not None # ── db.seqera ──────────────────────────────────────────────────────── @@ -231,7 +244,7 @@ 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.remove_record") @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 @@ -249,17 +262,16 @@ async def test_dry_run_only_logs( 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") + requests = _mock_seqera(monkeypatch, lambda r: httpx.Response(204)) await _seed_members() summary = await seqera_jobs.sync_seqera_removals() @@ -267,11 +279,11 @@ async def test_live_removes_only_leavers( 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) + assert [(r.method, r.url.path) for r in requests] == [("DELETE", "/orgs/11/workspaces/22/participants/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.remove_record", 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, diff --git a/tests/test_seqera_command.py b/tests/test_seqera_command.py new file mode 100644 index 0000000..be42384 --- /dev/null +++ b/tests/test_seqera_command.py @@ -0,0 +1,314 @@ +"""Tests for Seqera groups (team targets, remove, list) and the /nf-core seqera command.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from nf_core_bot.checks import seqera +from nf_core_bot.checks.seqera import SeqeraGroup, SeqeraStatus, add_to_group, groups_for_user, remove_from_group +from nf_core_bot.commands import router +from nf_core_bot.commands.seqera import manage +from nf_core_bot.db.seqera import get_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] + +TEAM = SeqeraGroup("test team", team_id="77") + + +@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") + monkeypatch.setattr(seqera, "SEQERA_GROUPS", {"team": TEAM}) + + +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 + + +# ── checks.seqera: groups ──────────────────────────────────────────── + + +class TestGroups: + def test_megatests_from_config(self, seqera_env: None) -> None: + groups = seqera.seqera_groups() + assert groups["megatests"].workspace_id == "22" + assert groups["megatests"].role == "nf-core-read-only" + assert groups["megatests"].target_key == "22" + assert groups["team"].target_key == "TEAM#77" + + def test_unconfigured_megatests_has_no_target(self) -> None: + assert seqera.seqera_groups()["megatests"].target_key is None + + +class TestAddToGroup: + async def test_unknown_group(self, seqera_env: None) -> None: + result = await add_to_group(_slack_client(), "U1", "octocat", "nope") + assert result.status is SeqeraStatus.UNKNOWN_GROUP + + async def test_team_add_recorded(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, ddb_table: Any) -> None: + requests = _mock_seqera(monkeypatch, lambda r: httpx.Response(200, json={"member": {"memberId": 5}})) + + result = await add_to_group(_slack_client(), "U1", "octocat", "team", "me@example.org") + + assert result.status is SeqeraStatus.ADDED + assert requests[0].method == "POST" + assert str(requests[0].url) == "https://api.cloud.seqera.io/orgs/11/teams/77/members" + assert b'"userNameOrEmail":"me@example.org"' in requests[0].content.replace(b" ", b"") + member = await get_member("TEAM#77", "U1") + assert member is not None + assert member.participant_id == 5 + assert member.group == "team" + + +class TestRemoveFromGroup: + async def test_not_managed(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, ddb_table: Any) -> None: + requests = _mock_seqera(monkeypatch, lambda r: httpx.Response(204)) + + result = await remove_from_group("U1", "megatests") + + assert result.status is SeqeraStatus.NOT_MANAGED + assert not requests + + async def test_removes_team_member(self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, ddb_table: Any) -> None: + requests = _mock_seqera(monkeypatch, lambda r: httpx.Response(204)) + await put_member("TEAM#77", "U1", "octocat", "a@example.org", 5, group="team") + + result = await remove_from_group("U1", "team") + + assert result.status is SeqeraStatus.REMOVED + assert requests[0].method == "DELETE" + assert str(requests[0].url) == "https://api.cloud.seqera.io/orgs/11/teams/77/members/5/delete" + assert await get_member("TEAM#77", "U1") is None + + async def test_api_error_keeps_record( + self, monkeypatch: pytest.MonkeyPatch, seqera_env: None, ddb_table: Any + ) -> None: + _mock_seqera(monkeypatch, lambda r: httpx.Response(403, json={"message": "Operation not allowed"})) + await put_member("22", "U1", "octocat", "a@example.org", 9) + + result = await remove_from_group("U1", "megatests") + + assert result.status is SeqeraStatus.API_ERROR + assert "403" in result.message + assert await get_member("22", "U1") is not None + + async def test_not_configured(self, ddb_table: Any) -> None: + result = await remove_from_group("U1", "megatests") + assert result.status is SeqeraStatus.NOT_CONFIGURED + + +class TestGroupsForUser: + async def test_lists_recorded_groups(self, seqera_env: None, ddb_table: Any) -> None: + await put_member("22", "U1", "octocat", "a@example.org", 9) + await put_member("TEAM#77", "U1", "octocat", "a@example.org", 5, group="team") + await put_member("22", "U2", "hubot", "b@example.org", 10) + + found = await groups_for_user("U1") + + assert [name for name, _ in found] == ["megatests", "team"] + + +class TestRemovalSyncTeams: + async def test_dry_run_covers_team_groups(self, seqera_env: None, ddb_table: Any) -> None: + await put_member("TEAM#77", "U1", "left-user", "a@example.org", 5, group="team") + + with patch("nf_core_bot.scheduler.seqera_jobs.get_org_membership_state", return_value=None): + summary = await seqera_jobs.sync_seqera_removals() + + assert summary is not None + assert summary.checked == 1 + assert len(summary.would_remove) == 1 + assert "team" in summary.would_remove[0] + + +# ── commands.seqera.manage ─────────────────────────────────────────── + + +class TestParseEmail: + @pytest.mark.parametrize( + "args", + [ + ["<@U1>", "megatests", "--email", "me@x.org"], + ["<@U1>", "--email=me@x.org", "megatests"], + ["<@U1>", "megatests", "--email", ""], + ], + ) + def test_forms(self, args: list[str]) -> None: + assert manage._parse_email(args) == (["<@U1>", "megatests"], "me@x.org", True) + + @pytest.mark.parametrize("args", [["<@U1>", "megatests", "--email"], ["<@U1>", "megatests", "--email", "nope"]]) + def test_invalid(self, args: list[str]) -> None: + assert manage._parse_email(args)[2] is False + + +class TestAddCommand: + async def test_usage_on_bad_args(self, seqera_env: None) -> None: + respond = AsyncMock() + await manage.handle_seqera_add(respond, AsyncMock(), "U_CALLER", ["octocat", "megatests"]) + assert respond.await_args.args[0] == manage.ADD_USAGE + + async def test_unknown_group(self, seqera_env: None) -> None: + respond = AsyncMock() + await manage.handle_seqera_add(respond, AsyncMock(), "U_CALLER", ["<@U1>", "nope"]) + assert "Unknown Seqera group `nope`" in respond.await_args.args[0] + assert "`megatests`" in respond.await_args.args[0] + + async def test_requires_github_username(self, seqera_env: None) -> None: + respond = AsyncMock() + with ( + patch.object(manage, "get_github_username", new=AsyncMock(return_value=None)), + patch.object(manage, "add_to_group", new=AsyncMock()) as mock_add, + ): + await manage.handle_seqera_add(respond, AsyncMock(), "U_CALLER", ["<@U1>", "megatests"]) + assert "no GitHub username" in respond.await_args.args[0] + mock_add.assert_not_awaited() + + async def test_added_with_email_override(self, seqera_env: None) -> None: + respond = AsyncMock() + client = AsyncMock() + result = seqera.SeqeraResult(SeqeraStatus.ADDED, "added", "me@x.org") + with ( + patch.object(manage, "get_github_username", new=AsyncMock(return_value="octocat")), + patch.object(manage, "add_to_group", new=AsyncMock(return_value=result)) as mock_add, + ): + await manage.handle_seqera_add( + respond, client, "U_CALLER", ["<@U1|alice>", "MEGATESTS", "--email", "me@x.org"] + ) + mock_add.assert_awaited_once_with(client, "U1", "octocat", "megatests", "me@x.org") + assert "Added <@U1> to `megatests` as `me@x.org`" in respond.await_args.args[0] + assert respond.await_args.kwargs["response_type"] == "ephemeral" + + async def test_no_account_suggests_email(self, seqera_env: None) -> None: + respond = AsyncMock() + result = seqera.SeqeraResult(SeqeraStatus.NO_ACCOUNT, "no Seqera Platform account was found", "s@x.org") + with ( + patch.object(manage, "get_github_username", new=AsyncMock(return_value="octocat")), + patch.object(manage, "add_to_group", new=AsyncMock(return_value=result)), + ): + await manage.handle_seqera_add(respond, AsyncMock(), "U_CALLER", ["<@U1>", "megatests"]) + assert "--email" in respond.await_args.args[0] + + +class TestRemoveCommand: + async def test_usage(self) -> None: + respond = AsyncMock() + await manage.handle_seqera_remove(respond, AsyncMock(), "U_CALLER", ["<@U1>"]) + assert respond.await_args.args[0] == manage.REMOVE_USAGE + + async def test_removed(self) -> None: + respond = AsyncMock() + result = seqera.SeqeraResult(SeqeraStatus.REMOVED, "removed") + with patch.object(manage, "remove_from_group", new=AsyncMock(return_value=result)) as mock_remove: + await manage.handle_seqera_remove(respond, AsyncMock(), "U_CALLER", ["<@U1>", "megatests"]) + mock_remove.assert_awaited_once_with("U1", "megatests") + assert "Removed <@U1> from `megatests`" in respond.await_args.args[0] + + async def test_not_managed(self) -> None: + respond = AsyncMock() + result = seqera.SeqeraResult(SeqeraStatus.NOT_MANAGED, "the bot has no record") + with patch.object(manage, "remove_from_group", new=AsyncMock(return_value=result)): + await manage.handle_seqera_remove(respond, AsyncMock(), "U_CALLER", ["<@U1>", "megatests"]) + assert "Couldn't remove" in respond.await_args.args[0] + assert "no record" in respond.await_args.args[0] + + +class TestListCommand: + async def test_all_groups(self, seqera_env: None, ddb_table: Any) -> None: + await put_member("22", "U1", "octocat", "a@example.org", 9) + respond = AsyncMock() + + await manage.handle_seqera_list(respond, AsyncMock(), "U_CALLER", []) + + text = respond.await_args.args[0] + assert "`megatests` — nf-core/megatests workspace (read-only): 1 member added by the bot" in text + assert "`team` — test team: 0 members added by the bot" in text + + async def test_unconfigured_group(self, ddb_table: Any) -> None: + respond = AsyncMock() + await manage.handle_seqera_list(respond, AsyncMock(), "U_CALLER", []) + assert "_(not configured)_" in respond.await_args.args[0] + + async def test_user_groups(self, seqera_env: None, ddb_table: Any) -> None: + await put_member("22", "U1", "octocat", "a@example.org", 9) + respond = AsyncMock() + + await manage.handle_seqera_list(respond, AsyncMock(), "U_CALLER", ["<@U1>"]) + + text = respond.await_args.args[0] + assert "`megatests` — as `a@example.org`" in text + assert "team" not in text.split("\n", 1)[1] + + async def test_user_with_no_groups(self, seqera_env: None, ddb_table: Any) -> None: + respond = AsyncMock() + await manage.handle_seqera_list(respond, AsyncMock(), "U_CALLER", ["<@U1>"]) + assert "hasn't added <@U1>" in respond.await_args.args[0] + assert await list_members("22") == [] + + +# ── Router gate ────────────────────────────────────────────────────── + + +class TestRouter: + async def test_dispatch_routes_seqera(self) -> None: + with patch.object(router, "_route_seqera", new=AsyncMock()) as mock_route: + await router.dispatch(AsyncMock(), AsyncMock(), AsyncMock(), {"text": "seqera list", "user_id": "U1"}) + assert mock_route.await_args.args[-1] == ["list"] + + async def test_non_member_denied(self) -> None: + ack, respond = AsyncMock(), AsyncMock() + with patch.object(router, "is_core_team", new=AsyncMock(return_value=False)): + await router._route_seqera(ack, respond, AsyncMock(), "U1", ["list"]) + ack.assert_awaited_once() + assert "restricted to `@core-team`" in respond.await_args.args[0] + + async def test_member_dispatched(self) -> None: + ack, respond, client = AsyncMock(), AsyncMock(), AsyncMock() + handler = AsyncMock() + with ( + patch.object(router, "is_core_team", new=AsyncMock(return_value=True)), + patch.dict(router._SEQERA_DISPATCH, {"add": handler}), + ): + await router._route_seqera(ack, respond, client, "U1", ["add", "<@U2>", "megatests"]) + ack.assert_awaited_once() + handler.assert_awaited_once_with(respond, client, "U1", ["<@U2>", "megatests"]) + + async def test_help(self) -> None: + ack, respond = AsyncMock(), AsyncMock() + with patch.object(router, "is_core_team", new=AsyncMock(return_value=True)): + await router._route_seqera(ack, respond, AsyncMock(), "U1", []) + assert "seqera add @user " in respond.await_args.args[0] + + async def test_unknown_subcommand(self) -> None: + ack, respond = AsyncMock(), AsyncMock() + with patch.object(router, "is_core_team", new=AsyncMock(return_value=True)): + await router._route_seqera(ack, respond, AsyncMock(), "U1", ["bogus"]) + assert "Unknown seqera command" in respond.await_args.args[0]