From 427bfe4f56df7305b96d5e4e515547a9daa50674 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Mon, 1 Jun 2026 14:37:19 -0700 Subject: [PATCH] feat: Store-backed GitHub/Slack user mapping (self-service + admin) (#1369) * Replace hardcoded GitHub-email map with Store-backed user mapping Move the static GITHUB_USER_EMAIL_MAP to a Store-backed bidirectional mapping (GitHub login <-> work email <-> optional Slack ID) with an in-process cache, self-service onboarding, and admin management. - agent/dashboard/user_mappings.py: Store CRUD + login/email/slack-id indexes, sync cache readers for hot paths, async fallthrough, and a bulk_import that preserves existing richer records. - Migrate all read sites (auth.py, agent_overrides.py, authorship.py, github_comments.py, webapp.py x2) off the dict. - Unmapped Slack tags now run on the GitHub App installation token (use_installation_token_fallback) and get an ephemeral "link your GitHub account" prompt carrying the Slack id + email via a signed account-link token threaded through the OAuth state. - OAuth callback completes a self-service (org-gated) mapping from that token, falling back to the verified GitHub email. - Admin CRUD endpoints + one-time legacy import; dashboard UI section. - Legacy dict retained only as the import payload (no longer read). Tests: mapping store, account-link round-trip + completion, mapped vs unmapped Slack flows; existing trust-gate tests updated to prime cache. * Address review: cold-cache email resolution + stale alias de-indexing - agent_overrides: add resolve_login_from_email_async that falls through to the Store on a cold cache; use it at the async repo-resolution call sites (Slack repo config, Linear comment, owner-metadata) so a mapped user still resolves to their GitHub login + dashboard default_repo on a fresh worker. - user_mappings.upsert_mapping: de-index the existing login before re-indexing so a changed email/Slack id no longer leaves stale aliases resolving to the login in-process. - Tests for both fixes; update Slack repo-config test to patch the async resolver. --- INSTALLATION.md | 12 +- agent/dashboard/agent_overrides.py | 27 ++- agent/dashboard/oauth.py | 55 ++++- agent/dashboard/routes.py | 100 +++++++- agent/dashboard/user_mappings.py | 326 ++++++++++++++++++++++++++ agent/utils/auth.py | 16 +- agent/utils/authorship.py | 6 +- agent/utils/github_comments.py | 5 +- agent/utils/github_user_email_map.py | 12 +- agent/webapp.py | 62 ++++- tests/test_account_link.py | 50 ++++ tests/test_account_link_completion.py | 54 +++++ tests/test_github_comment_prompts.py | 46 ++-- tests/test_github_issue_webhook.py | 31 ++- tests/test_github_token_ttl.py | 6 +- tests/test_slack_context.py | 103 +++++++- tests/test_user_mappings.py | 157 +++++++++++++ ui/src/lib/api.ts | 29 +++ ui/src/routes/admin.tsx | 140 ++++++++++- 19 files changed, 1171 insertions(+), 66 deletions(-) create mode 100644 agent/dashboard/user_mappings.py create mode 100644 tests/test_account_link.py create mode 100644 tests/test_account_link_completion.py create mode 100644 tests/test_user_mappings.py diff --git a/INSTALLATION.md b/INSTALLATION.md index 9b4ed879..b9141683 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -207,13 +207,11 @@ GitHub triggering works automatically once your GitHub App is set up (step 3). U - Tag `@openswe` in issue comments for follow-up instructions - Tag `@openswe` in PR review comments to have it address review feedback -To control which GitHub users can trigger the agent, add them to the `GITHUB_USER_EMAIL_MAP` in `agent/utils/github_user_email_map.py`: +Which GitHub users can trigger the agent is controlled by the **user mapping** (GitHub login ⇄ work email ⇄ optional Slack ID), stored in the LangGraph Store rather than in code. Manage it in the dashboard under **Admin → User mappings**: -```python -GITHUB_USER_EMAIL_MAP = { - "their-github-username": "their-email@example.com", -} -``` +- **Add / update** a single mapping (GitHub login + work email, plus an optional Slack user ID). +- **Import legacy mapping** — a one-time button that seeds the Store from the historical `agent/utils/github_user_email_map.py` dict (records already present are left untouched). Run this once after deploying so existing users keep working; the dict is no longer read at runtime. +- Users can also **self-onboard**: when an unmapped person tags Open SWE in Slack, the agent runs with limited (GitHub App installation) permissions and posts a "link your GitHub account" prompt. Completing the org-gated GitHub OAuth login records a `self` mapping (carrying the originating Slack ID and work email). Self-signup is therefore bounded by the same `ALLOWED_GITHUB_ORGS` gate as dashboard login. You should also configure which GitHub organizations and/or repositories the agent is allowed to operate on. You can specify allowed orgs, specific `owner/repo` pairs, or both: @@ -547,7 +545,7 @@ The `langgraph.json` at the project root already defines the graph entry point a ### Agent not responding to comments -- For GitHub: ensure the comment or issue contains `@openswe` (case-insensitive), and the commenter's GitHub username is in `GITHUB_USER_EMAIL_MAP` +- For GitHub: ensure the comment or issue contains `@openswe` (case-insensitive), and the commenter has a user mapping (Admin → User mappings; see "Configure triggering surfaces"). If you just migrated, run the one-time **Import legacy mapping** button. - For Linear: ensure the comment contains `@openswe` (case-insensitive) - For Slack: ensure the bot is invited to the channel and the message is an `@mention` - Check server logs for webhook processing errors diff --git a/agent/dashboard/agent_overrides.py b/agent/dashboard/agent_overrides.py index e672ff2e..ea83bf13 100644 --- a/agent/dashboard/agent_overrides.py +++ b/agent/dashboard/agent_overrides.py @@ -8,22 +8,31 @@ from typing import Any import httpx from langgraph_sdk import get_client -from ..utils.github_user_email_map import GITHUB_USER_EMAIL_MAP from .options import SUPPORTED_MODEL_IDS, model_supports_effort, provider_fallback_pair from .profiles import PROFILES_NAMESPACE +from .user_mappings import cached_login_for_email, login_for_email logger = logging.getLogger(__name__) def resolve_login_from_email(email: str | None) -> str | None: - """Reverse-lookup ``GITHUB_USER_EMAIL_MAP`` for the GitHub login of an email.""" - if not isinstance(email, str) or not email.strip(): - return None - normalized = email.strip().lower() - for gh_login, mapped in GITHUB_USER_EMAIL_MAP.items(): - if mapped.lower() == normalized: - return gh_login - return None + """Reverse-lookup the user-mapping store for the GitHub login of an email. + + Reads the in-process mapping cache (sync). When the cache is cold the + lookup misses; the webhook path that triggers a run primes the cache via + :func:`agent.dashboard.user_mappings.refresh_cache` beforehand. + """ + return cached_login_for_email(email) + + +async def resolve_login_from_email_async(email: str | None) -> str | None: + """Async reverse-lookup that falls through to the Store on a cold cache. + + Use this from webhook/repo-resolution paths that may run on a freshly + started worker before the user-mapping cache has been primed, so a mapped + user still resolves to their GitHub login (and dashboard ``default_repo``). + """ + return await login_for_email(email if isinstance(email, str) else None) def resolve_github_login(config: dict[str, Any]) -> str | None: diff --git a/agent/dashboard/oauth.py b/agent/dashboard/oauth.py index 7fc370cd..68c939a9 100644 --- a/agent/dashboard/oauth.py +++ b/agent/dashboard/oauth.py @@ -10,7 +10,7 @@ import secrets import time from datetime import UTC, datetime, timedelta from typing import Any -from urllib.parse import urlparse +from urllib.parse import quote, urlparse import httpx import jwt @@ -148,14 +148,16 @@ def hash_state_nonce(nonce: str) -> str: return hmac.new(_secret().encode(), nonce.encode(), hashlib.sha256).hexdigest() -def issue_state(*, redirect_to: str, nonce_hash: str) -> str: +def issue_state(*, redirect_to: str, nonce_hash: str, link: str | None = None) -> str: now = int(time.time()) - payload = { + payload: dict[str, Any] = { "nonce_hash": nonce_hash, "redirect_to": redirect_to, "iat": now, "exp": now + STATE_TTL_SECONDS, } + if link: + payload["link"] = link return jwt.encode(payload, _secret(), algorithm=JWT_ALG) @@ -166,6 +168,53 @@ def decode_state(state: str) -> dict[str, Any]: raise HTTPException(400, f"invalid state: {e}") from e +LINK_TTL_SECONDS = 7 * 24 * 60 * 60 + + +def issue_account_link(*, slack_user_id: str | None, work_email: str | None) -> str: + """Sign a short-lived token carrying the Slack identity to map after login. + + Threaded through the OAuth ``state`` so the callback can attach the + resolved ``github_login`` to the originating Slack user/email in one step. + """ + now = int(time.time()) + payload = { + "kind": "account_link", + "slack_user_id": slack_user_id or None, + "work_email": work_email or None, + "iat": now, + "exp": now + LINK_TTL_SECONDS, + } + return jwt.encode(payload, _secret(), algorithm=JWT_ALG) + + +def decode_account_link(token: str) -> dict[str, Any] | None: + """Decode an account-link token; return ``None`` if absent/invalid/expired.""" + if not token: + return None + try: + payload = jwt.decode(token, _secret(), algorithms=[JWT_ALG]) + except jwt.PyJWTError as e: + logger.warning("invalid account-link token: %s", e) + return None + if payload.get("kind") != "account_link": + return None + return payload + + +def build_account_link_url(*, slack_user_id: str | None, work_email: str | None) -> str | None: + """Return the dashboard login URL that links a Slack identity on completion. + + Returns ``None`` when ``DASHBOARD_API_BASE_URL`` isn't configured (login + can't be initiated), so callers can skip the prompt cleanly. + """ + api_base = os.environ.get("DASHBOARD_API_BASE_URL", "").rstrip("/") + if not api_base: + return None + token = issue_account_link(slack_user_id=slack_user_id, work_email=work_email) + return f"{api_base}/dashboard/api/auth/login?link={quote(token, safe='')}" + + def require_session(request: Request) -> dict[str, Any]: token = request.cookies.get(COOKIE_NAME) if not token: diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 6f06df2e..7b01d38f 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -23,6 +23,7 @@ from .oauth import ( SESSION_TTL_SECONDS, STATE_COOKIE_NAME, STATE_TTL_SECONDS, + decode_account_link, decode_state, enforce_org_login_gate, exchange_code, @@ -74,6 +75,12 @@ from .thread_api import ( send_dashboard_message, stream_dashboard_thread, ) +from .user_mappings import ( + bulk_import, + delete_mapping, + list_mappings, + upsert_mapping, +) logger = logging.getLogger(__name__) @@ -144,14 +151,25 @@ def _clear_state_cookie(response: Response) -> None: @router.get("/auth/login") -async def auth_login(request: Request, redirect_to: str | None = None) -> RedirectResponse: +async def auth_login( + request: Request, + redirect_to: str | None = None, + link: str | None = None, +) -> RedirectResponse: client_id = os.environ.get("GITHUB_APP_CLIENT_ID", "") if not client_id: raise HTTPException(500, "GITHUB_APP_CLIENT_ID not configured") safe_redirect = sanitize_redirect_to(redirect_to) or _frontend_base_url() + # Only carry a structurally valid account-link token onward. + link_token = link if (link and decode_account_link(link)) else None + nonce = new_state_nonce() - state = issue_state(redirect_to=safe_redirect, nonce_hash=hash_state_nonce(nonce)) + state = issue_state( + redirect_to=safe_redirect, + nonce_hash=hash_state_nonce(nonce), + link=link_token, + ) redirect_uri = f"{_api_base_url()}/dashboard/api/auth/callback" url = ( "https://github.com/login/oauth/authorize" @@ -192,6 +210,7 @@ async def auth_callback(request: Request, code: str, state: str) -> RedirectResp await enforce_org_login_gate(login) await upsert_access_token_from_github_response(login, email or "", token_data) + await _complete_account_mapping(login, email, state_payload.get("link")) session_jwt = issue_session(login=login, email=email, avatar_url=user.get("avatar_url")) response = RedirectResponse(redirect_to, status_code=302) @@ -200,6 +219,33 @@ async def auth_callback(request: Request, code: str, state: str) -> RedirectResp return response +async def _complete_account_mapping(login: str, github_email: str | None, link_token: Any) -> None: + """Create/refresh the user mapping after a successful org-gated login. + + Self-service signup: the user is already org-gated (only members reach + here), so we record a ``source="self"`` mapping. The Slack identity (user + id + work email) is carried in the signed account-link token when the flow + started from an unmapped Slack mention; otherwise we fall back to the + user's verified GitHub email. + """ + link = decode_account_link(link_token) if isinstance(link_token, str) else None + slack_user_id = link.get("slack_user_id") if link else None + work_email = (link.get("work_email") if link else None) or github_email + if not work_email: + logger.warning("No work email available to map GitHub login %r", login) + return + try: + await upsert_mapping( + github_login=login, + work_email=work_email, + slack_user_id=slack_user_id if isinstance(slack_user_id, str) else None, + source="self", + status="active", + ) + except Exception: # noqa: BLE001 + logger.warning("Failed to persist self-service mapping for %r", login, exc_info=True) + + @router.post("/auth/logout") async def auth_logout() -> Response: response = Response(status_code=204) @@ -306,6 +352,56 @@ async def api_set_enabled_review_repo( return {"repos": repos} +class UserMappingUpsert(BaseModel): + github_login: str + work_email: str + slack_user_id: str | None = None + + +@router.get("/admin/user-mappings") +async def admin_list_user_mappings( + _admin: dict[str, Any] = _ADMIN_DEP, +) -> list[dict[str, Any]]: + return await list_mappings() + + +@router.put("/admin/user-mappings") +async def admin_upsert_user_mapping( + body: UserMappingUpsert, + _admin: dict[str, Any] = _ADMIN_DEP, +) -> dict[str, Any]: + try: + return await upsert_mapping( + github_login=body.github_login, + work_email=body.work_email, + slack_user_id=body.slack_user_id, + source="admin", + status="active", + ) + except ValueError as e: + raise HTTPException(400, str(e)) from e + + +@router.delete("/admin/user-mappings/{github_login}") +async def admin_delete_user_mapping( + github_login: str, + _admin: dict[str, Any] = _ADMIN_DEP, +) -> dict[str, bool]: + deleted = await delete_mapping(github_login) + return {"deleted": deleted} + + +@router.post("/admin/user-mappings/import") +async def admin_import_user_mappings( + _admin: dict[str, Any] = _ADMIN_DEP, +) -> dict[str, int]: + """One-time seed of the legacy hardcoded GitHub→email map into the Store.""" + from ..utils.github_user_email_map import GITHUB_USER_EMAIL_MAP + + created = await bulk_import(GITHUB_USER_EMAIL_MAP, source="hardcoded") + return {"created": created} + + def _next_link_url(link_header: str | None) -> str | None: if not link_header: return None diff --git a/agent/dashboard/user_mappings.py b/agent/dashboard/user_mappings.py new file mode 100644 index 00000000..984c4bfe --- /dev/null +++ b/agent/dashboard/user_mappings.py @@ -0,0 +1,326 @@ +"""Store-backed bidirectional GitHub ⇄ work-email ⇄ Slack-id user mapping. + +Replaces the static ``GITHUB_USER_EMAIL_MAP`` dict. The canonical record is +keyed by GitHub login in the ``["user_mappings"]`` LangGraph Store namespace:: + + { + "github_login": "octocat", + "work_email": "octo@example.com", + "slack_user_id": "U123" | None, + "source": "hardcoded" | "self" | "admin", + "status": "active" | "pending", + "created_at": "...", "updated_at": "...", + } + +Lookups happen on hot paths, some of which are synchronous (commit-author +resolution, comment trust-gating). To serve those without an event loop we +keep an in-process cache of ``{login, email, slack_user_id} -> record`` that +async readers refresh from the Store. The cache is best-effort: a cold cache +falls back to an async Store read where the call site allows it, and sync +call sites degrade to "unmapped" (the same conservative behavior as a missing +dict entry). +""" + +from __future__ import annotations + +import logging +import threading +from datetime import UTC, datetime +from typing import Any, Literal + +import httpx +from langgraph_sdk import get_client + +logger = logging.getLogger(__name__) + +USER_MAPPINGS_NAMESPACE: list[str] = ["user_mappings"] + +MappingSource = Literal["hardcoded", "self", "admin"] +MappingStatus = Literal["active", "pending"] + + +def _client(): + return get_client() + + +def _now() -> str: + return datetime.now(UTC).isoformat() + + +def _norm_login(login: str | None) -> str: + return login.strip() if isinstance(login, str) else "" + + +def _norm_email(email: str | None) -> str: + return email.strip().lower() if isinstance(email, str) else "" + + +def _norm_slack_id(slack_user_id: str | None) -> str: + return slack_user_id.strip() if isinstance(slack_user_id, str) else "" + + +# --------------------------------------------------------------------------- +# In-process cache +# --------------------------------------------------------------------------- + +_cache_lock = threading.RLock() +_by_login: dict[str, dict[str, Any]] = {} +_by_email: dict[str, dict[str, Any]] = {} +_by_slack_id: dict[str, dict[str, Any]] = {} +_cache_loaded = False + + +def _index_record(record: dict[str, Any]) -> None: + login = _norm_login(record.get("github_login")) + if not login: + return + with _cache_lock: + _by_login[login.lower()] = record + email = _norm_email(record.get("work_email")) + if email: + _by_email[email] = record + slack_id = _norm_slack_id(record.get("slack_user_id")) + if slack_id: + _by_slack_id[slack_id] = record + + +def _deindex_login(login: str) -> None: + with _cache_lock: + existing = _by_login.pop(login.lower(), None) + if not existing: + return + email = _norm_email(existing.get("work_email")) + if email and _by_email.get(email) is existing: + _by_email.pop(email, None) + slack_id = _norm_slack_id(existing.get("slack_user_id")) + if slack_id and _by_slack_id.get(slack_id) is existing: + _by_slack_id.pop(slack_id, None) + + +def prime_cache(records: list[dict[str, Any]]) -> None: + """Replace the in-process cache with ``records`` (used after a Store load).""" + global _cache_loaded + with _cache_lock: + _by_login.clear() + _by_email.clear() + _by_slack_id.clear() + for record in records: + if isinstance(record, dict): + _index_record(record) + with _cache_lock: + _cache_loaded = True + + +def clear_cache() -> None: + """Drop the in-process cache (forces a reload on next refresh). Test aid.""" + global _cache_loaded + with _cache_lock: + _by_login.clear() + _by_email.clear() + _by_slack_id.clear() + _cache_loaded = False + + +# --------------------------------------------------------------------------- +# Sync cache readers (hot paths without an event loop) +# --------------------------------------------------------------------------- + + +def cached_email_for_login(login: str | None) -> str | None: + norm = _norm_login(login) + if not norm: + return None + with _cache_lock: + record = _by_login.get(norm.lower()) + email = _norm_email(record.get("work_email")) if record else "" + return email or None + + +def cached_login_for_email(email: str | None) -> str | None: + norm = _norm_email(email) + if not norm: + return None + with _cache_lock: + record = _by_email.get(norm) + return _norm_login(record.get("github_login")) or None if record else None + + +def cached_login_for_slack_id(slack_user_id: str | None) -> str | None: + norm = _norm_slack_id(slack_user_id) + if not norm: + return None + with _cache_lock: + record = _by_slack_id.get(norm) + return _norm_login(record.get("github_login")) or None if record else None + + +def is_login_mapped(login: str | None) -> bool: + """Whether ``login`` has an active mapping in the cache (trust-gate use).""" + norm = _norm_login(login) + if not norm: + return False + with _cache_lock: + record = _by_login.get(norm.lower()) + return bool(record) and record.get("status", "active") == "active" + + +# --------------------------------------------------------------------------- +# Store access +# --------------------------------------------------------------------------- + + +def _record_from_item(item: Any) -> dict[str, Any] | None: + if item is None: + return None + value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None) + return value if isinstance(value, dict) else None + + +async def _load_all_records() -> list[dict[str, Any]]: + result = await _client().store.search_items(USER_MAPPINGS_NAMESPACE, limit=1000) + items = result.get("items") if isinstance(result, dict) else getattr(result, "items", []) + out: list[dict[str, Any]] = [] + for item in items or []: + record = _record_from_item(item) + if record: + out.append(record) + return out + + +async def refresh_cache() -> list[dict[str, Any]]: + """Load every mapping from the Store and replace the in-process cache.""" + records = await _load_all_records() + prime_cache(records) + return records + + +async def _ensure_cache_loaded() -> None: + with _cache_lock: + loaded = _cache_loaded + if not loaded: + try: + await refresh_cache() + except Exception as e: # noqa: BLE001 + logger.debug("user mapping cache load failed: %s", e) + + +async def get_mapping(login: str) -> dict[str, Any] | None: + norm = _norm_login(login) + if not norm: + return None + try: + item = await _client().store.get_item(USER_MAPPINGS_NAMESPACE, norm.lower()) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return None + logger.warning("user mapping lookup failed for %s: %s", norm, e) + return None + return _record_from_item(item) + + +async def list_mappings() -> list[dict[str, Any]]: + try: + records = await _load_all_records() + except Exception as e: # noqa: BLE001 + logger.debug("user mapping list failed: %s", e) + return [] + prime_cache(records) + return sorted(records, key=lambda r: _norm_login(r.get("github_login")).lower()) + + +async def email_for_login(login: str | None) -> str | None: + """Async login→email with cache fallthrough to the Store.""" + cached = cached_email_for_login(login) + if cached is not None: + return cached + await _ensure_cache_loaded() + return cached_email_for_login(login) + + +async def login_for_email(email: str | None) -> str | None: + """Async email→login with cache fallthrough to the Store.""" + cached = cached_login_for_email(email) + if cached is not None: + return cached + await _ensure_cache_loaded() + return cached_login_for_email(email) + + +async def login_for_slack_id(slack_user_id: str | None) -> str | None: + cached = cached_login_for_slack_id(slack_user_id) + if cached is not None: + return cached + await _ensure_cache_loaded() + return cached_login_for_slack_id(slack_user_id) + + +async def upsert_mapping( + *, + github_login: str, + work_email: str, + slack_user_id: str | None = None, + source: MappingSource = "admin", + status: MappingStatus = "active", +) -> dict[str, Any]: + """Create or update a mapping keyed by GitHub login.""" + login = _norm_login(github_login) + if not login: + raise ValueError("github_login is required") + email = _norm_email(work_email) + if not email: + raise ValueError("work_email is required") + + existing = await get_mapping(login) or {} + record: dict[str, Any] = { + "github_login": login, + "work_email": email, + "slack_user_id": _norm_slack_id(slack_user_id) or existing.get("slack_user_id") or None, + "source": source, + "status": status, + "created_at": existing.get("created_at") or _now(), + "updated_at": _now(), + } + await _client().store.put_item(USER_MAPPINGS_NAMESPACE, login.lower(), record) + _deindex_login(login) + _index_record(record) + return record + + +async def delete_mapping(github_login: str) -> bool: + login = _norm_login(github_login) + if not login: + return False + try: + await _client().store.delete_item(USER_MAPPINGS_NAMESPACE, login.lower()) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + _deindex_login(login) + return False + raise + _deindex_login(login) + return True + + +async def bulk_import(mapping: dict[str, str], *, source: MappingSource = "hardcoded") -> int: + """Seed the Store from a ``{github_login: work_email}`` dict. + + Existing records are left untouched so re-running the import never + downgrades a richer (self/admin) record back to ``hardcoded``. Returns the + number of records newly created. + """ + created = 0 + for raw_login, raw_email in mapping.items(): + login = _norm_login(raw_login) + email = _norm_email(raw_email) + if not login or not email: + continue + if await get_mapping(login): + continue + await upsert_mapping( + github_login=login, + work_email=email, + source=source, + status="active", + ) + created += 1 + return created diff --git a/agent/utils/auth.py b/agent/utils/auth.py index 3dd19058..a42508ae 100644 --- a/agent/utils/auth.py +++ b/agent/utils/auth.py @@ -16,7 +16,6 @@ from langgraph_sdk import get_client from ..encryption import encrypt_token from .github_app import get_github_app_installation_token_with_expiry from .github_token import get_github_token_from_thread -from .github_user_email_map import GITHUB_USER_EMAIL_MAP from .linear import comment_on_linear_issue from .slack import post_slack_ephemeral_message, post_slack_thread_reply @@ -406,6 +405,17 @@ async def resolve_github_token( logger.error("Missing source for thread %s; cannot route auth failure responses", thread_id) raise RuntimeError(f"GitHub auth failed for thread {thread_id}: missing source") + # Unmapped Slack/Linear users still get a run on the GitHub App installation + # token (the webhook posts a "link your account" prompt separately). This + # keeps the agent responsive while self-service onboarding completes. + if configurable.get("use_installation_token_fallback"): + cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread( + thread_id + ) + if cached_token and cached_encrypted: + return cached_token, cached_encrypted, cached_expires_at + return await _resolve_bot_installation_token(thread_id) + try: if source == "github": cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread( @@ -414,7 +424,9 @@ async def resolve_github_token( if cached_token and cached_encrypted: return cached_token, cached_encrypted, cached_expires_at github_login = configurable.get("github_login") - email = GITHUB_USER_EMAIL_MAP.get(github_login or "") + from ..dashboard.user_mappings import email_for_login + + email = await email_for_login(github_login) if not email: raise ValueError(f"No email mapping found for GitHub user '{github_login}'") return await save_encrypted_token_from_email(email, source) diff --git a/agent/utils/authorship.py b/agent/utils/authorship.py index 224a0bcf..b15566d0 100644 --- a/agent/utils/authorship.py +++ b/agent/utils/authorship.py @@ -8,8 +8,6 @@ from typing import Any import httpx -from .github_user_email_map import GITHUB_USER_EMAIL_MAP - logger = logging.getLogger(__name__) OPEN_SWE_BOT_NAME = "open-swe[bot]" @@ -84,8 +82,10 @@ def _identity_from_config(config: dict[str, Any]) -> CollaboratorIdentity | None github_login = _normalize_text(configurable.get("github_login")) if github_login: github_user_id = configurable.get("github_user_id") + from ..dashboard.user_mappings import cached_email_for_login + commit_email = _github_noreply_email(github_login, github_user_id) or _normalize_text( - GITHUB_USER_EMAIL_MAP.get(github_login) + cached_email_for_login(github_login) ) if commit_email: return CollaboratorIdentity( diff --git a/agent/utils/github_comments.py b/agent/utils/github_comments.py index 425ee04e..16723895 100644 --- a/agent/utils/github_comments.py +++ b/agent/utils/github_comments.py @@ -12,7 +12,6 @@ from typing import Any import httpx from .github_token import GitHubAuthError -from .github_user_email_map import GITHUB_USER_EMAIL_MAP logger = logging.getLogger(__name__) @@ -111,7 +110,9 @@ def sanitize_github_comment_body(body: str) -> str: def format_github_comment_body_for_prompt(author: str, body: str) -> str: """Format a GitHub comment body for prompt inclusion.""" sanitized_body = sanitize_github_comment_body(body) - if author in GITHUB_USER_EMAIL_MAP: + from ..dashboard.user_mappings import is_login_mapped + + if is_login_mapped(author): return sanitized_body return ( diff --git a/agent/utils/github_user_email_map.py b/agent/utils/github_user_email_map.py index f616a709..70bb5e76 100644 --- a/agent/utils/github_user_email_map.py +++ b/agent/utils/github_user_email_map.py @@ -1,7 +1,13 @@ -"""Mapping of GitHub usernames to LangSmith email addresses. +"""Legacy GitHub-username → LangSmith-email mapping. -Add entries here as: - "github-username": "user@example.com", +This dict is **no longer read at runtime**. The live mapping is the +Store-backed bidirectional record managed in +``agent/dashboard/user_mappings.py`` (Admin → User mappings in the +dashboard). This file is retained only as the payload for the one-time +``POST /dashboard/api/admin/user-mappings/import`` bulk import (the +"Import legacy mapping" admin button), which seeds the Store with +``source="hardcoded"`` entries. It can be deleted once every deployment has +run that import. """ GITHUB_USER_EMAIL_MAP: dict[str, str] = { diff --git a/agent/webapp.py b/agent/webapp.py index c2dba64b..98371879 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -22,11 +22,20 @@ from langgraph_sdk.client import LangGraphClient from .dashboard import router as dashboard_router from .dashboard.agent_overrides import ( get_profile_default_repo, - resolve_login_from_email, + resolve_login_from_email_async, ) from .dashboard.enabled_repos import is_review_repo_enabled +from .dashboard.oauth import build_account_link_url from .dashboard.profiles import get_profile from .dashboard.team_settings import get_team_settings +from .dashboard.user_mappings import ( + email_for_login, + login_for_email, + login_for_slack_id, +) +from .dashboard.user_mappings import ( + refresh_cache as refresh_user_mapping_cache, +) from .reviewer_findings import ( REVIEWER_THREAD_KIND, Finding, @@ -68,7 +77,6 @@ from .utils.github_comments import ( ) from .utils.github_org_membership import INTERNAL_BOT_LOGINS, is_user_active_org_member from .utils.github_token import get_github_token_from_thread, invalidate_cached_github_token -from .utils.github_user_email_map import GITHUB_USER_EMAIL_MAP from .utils.linear import post_linear_trace_comment from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block @@ -81,6 +89,7 @@ from .utils.slack import ( get_slack_user_info, get_slack_user_names, parse_github_pr_url, + post_slack_ephemeral_message, post_slack_thread_reply, post_slack_trace_reply, resolve_slack_links_in_context, @@ -499,7 +508,7 @@ async def upsert_agent_thread_owner_metadata( mirror the owner-identifying fields onto the thread here. """ now_ms = int(datetime.now(UTC).timestamp() * 1000) - resolved_login = github_login or resolve_login_from_email(user_email) or "" + resolved_login = github_login or await resolve_login_from_email_async(user_email) or "" metadata: dict[str, Any] = {"source": source, "updated_at_ms": now_ms} if isinstance(repo_config, dict) and repo_config.get("owner") and repo_config.get("name"): metadata["repo"] = repo_config @@ -584,7 +593,9 @@ async def get_slack_repo_config( if isinstance(slack_user, dict) else None ) - profile_repo = await get_profile_default_repo(resolve_login_from_email(slack_email)) + profile_repo = await get_profile_default_repo( + await resolve_login_from_email_async(slack_email) + ) if profile_repo: logger.info( "Applying dashboard default_repo for Slack user %s: %s/%s", @@ -857,6 +868,25 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915 await post_linear_trace_comment(issue_id, thread_id, triggering_comment_id) +async def _post_account_link_prompt( + channel_id: str, thread_ts: str, user_id: str, user_email: str | None +) -> None: + """Prompt an unmapped Slack user to link their GitHub account (ephemeral).""" + link_url = build_account_link_url(slack_user_id=user_id, work_email=user_email) + if not link_url: + logger.debug("Account-link URL unavailable (DASHBOARD_API_BASE_URL unset); skipping prompt") + return + text = ( + "👋 I don't have your GitHub account linked yet, so I'm running with limited " + "(bot) permissions. Link your account so I can act on your behalf:\n" + f"<{link_url}|Link your GitHub account>" + ) + try: + await post_slack_ephemeral_message(channel_id, user_id, text, thread_ts=thread_ts) + except Exception: # noqa: BLE001 + logger.debug("Failed to post account-link prompt to Slack", exc_info=True) + + async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[str, str]) -> None: """Process a Slack app mention by creating a run or queuing a mid-run message.""" channel_id = event_data.get("channel_id", "") @@ -879,6 +909,12 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st thread_id = generate_thread_id_from_slack_thread(channel_id, thread_ts) + # Prime the user-mapping cache so login/email/slack-id lookups below are warm. + try: + await refresh_user_mapping_cache() + except Exception: # noqa: BLE001 + logger.debug("Could not refresh user mapping cache for Slack mention", exc_info=True) + user_email = None user_name = "" if user_id: @@ -969,6 +1005,11 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st if image_block: content_blocks.append(image_block) + mapped_login = await login_for_slack_id(user_id) + if not mapped_login and user_email: + mapped_login = await login_for_email(user_email) + is_user_mapped = bool(mapped_login) + configurable: dict[str, Any] = { "repo": repo_config, "slack_thread": { @@ -982,6 +1023,11 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st "user_email": user_email, "source": "slack", } + if mapped_login: + configurable["github_login"] = mapped_login + else: + # Unmapped: run on the installation token and prompt the user to link. + configurable["use_installation_token_fallback"] = True langgraph_client = get_client(url=LANGGRAPH_URL) is_first_mention = not await _thread_exists(thread_id) @@ -1026,6 +1072,8 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st thread_id, ) run_id = run.get("run_id") + if is_first_mention and not is_user_mapped and user_id: + await _post_account_link_prompt(channel_id, thread_ts, user_id, user_email) if is_first_mention: trace_message_ts = await post_slack_trace_reply(channel_id, thread_ts, thread_id) await set_slack_assistant_status(channel_id, thread_ts) @@ -1180,7 +1228,7 @@ async def linear_webhook( # noqa: PLR0911, PLR0912, PLR0915 comment_user_email = (data.get("user") or {}).get("email") try: profile_repo = await get_profile_default_repo( - resolve_login_from_email(comment_user_email) + await resolve_login_from_email_async(comment_user_email) ) except Exception: # noqa: BLE001 logger.exception("Failed to apply dashboard default_repo for Linear user") @@ -2345,7 +2393,7 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) -> comment = payload.get("comment") or payload.get("review", {}) is_review_request, _pr_url_override = parse_github_review_command(comment.get("body") or "") - email = GITHUB_USER_EMAIL_MAP.get(github_login, "") + email = await email_for_login(github_login) or "" if email: github_token = await _get_or_resolve_thread_github_token(thread_id, email) elif is_review_request: @@ -2627,7 +2675,7 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None logger.warning("Missing GitHub issue id/number, skipping") return - email = GITHUB_USER_EMAIL_MAP.get(github_login, "") + email = await email_for_login(github_login) or "" if not email: logger.warning("No email mapping for GitHub user '%s', skipping", github_login) return diff --git a/tests/test_account_link.py b/tests/test_account_link.py new file mode 100644 index 00000000..1f0c5060 --- /dev/null +++ b/tests/test_account_link.py @@ -0,0 +1,50 @@ +"""Tests for the Slack→GitHub account-link OAuth threading.""" + +from __future__ import annotations + +import pytest + +from agent.dashboard import oauth + + +@pytest.fixture(autouse=True) +def _jwt_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DASHBOARD_JWT_SECRET", "test-secret") + + +def test_account_link_round_trip() -> None: + token = oauth.issue_account_link(slack_user_id="U123", work_email="dev@x.com") + payload = oauth.decode_account_link(token) + assert payload is not None + assert payload["slack_user_id"] == "U123" + assert payload["work_email"] == "dev@x.com" + assert payload["kind"] == "account_link" + + +def test_decode_account_link_rejects_garbage() -> None: + assert oauth.decode_account_link("") is None + assert oauth.decode_account_link("not-a-jwt") is None + + +def test_decode_account_link_rejects_wrong_kind() -> None: + # A session token is a valid JWT but not an account-link token. + session = oauth.issue_session(login="x", email="x@x.com", avatar_url=None) + assert oauth.decode_account_link(session) is None + + +def test_build_account_link_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DASHBOARD_API_BASE_URL", "https://api.example.com/") + url = oauth.build_account_link_url(slack_user_id="U1", work_email="d@x.com") + assert url is not None + assert url.startswith("https://api.example.com/dashboard/api/auth/login?link=") + # The embedded token must decode back to the same identity. + token = url.split("link=", 1)[1] + from urllib.parse import unquote + + payload = oauth.decode_account_link(unquote(token)) + assert payload["slack_user_id"] == "U1" + + +def test_build_account_link_url_none_without_base(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DASHBOARD_API_BASE_URL", raising=False) + assert oauth.build_account_link_url(slack_user_id="U1", work_email="d@x.com") is None diff --git a/tests/test_account_link_completion.py b/tests/test_account_link_completion.py new file mode 100644 index 00000000..4a4bdeac --- /dev/null +++ b/tests/test_account_link_completion.py @@ -0,0 +1,54 @@ +"""Tests for self-service mapping completion in the OAuth callback.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from agent.dashboard import oauth, routes + + +@pytest.fixture(autouse=True) +def _jwt_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DASHBOARD_JWT_SECRET", "test-secret") + + +@pytest.mark.asyncio +async def test_completion_uses_link_token_slack_identity(monkeypatch: pytest.MonkeyPatch) -> None: + mock_upsert = AsyncMock() + monkeypatch.setattr(routes, "upsert_mapping", mock_upsert) + link = oauth.issue_account_link(slack_user_id="U999", work_email="slack@x.com") + + await routes._complete_account_mapping("octo", "gh@x.com", link) + + mock_upsert.assert_awaited_once() + kwargs = mock_upsert.await_args.kwargs + assert kwargs["github_login"] == "octo" + # Slack work email from the link token wins over the GitHub email. + assert kwargs["work_email"] == "slack@x.com" + assert kwargs["slack_user_id"] == "U999" + assert kwargs["source"] == "self" + + +@pytest.mark.asyncio +async def test_completion_falls_back_to_github_email(monkeypatch: pytest.MonkeyPatch) -> None: + mock_upsert = AsyncMock() + monkeypatch.setattr(routes, "upsert_mapping", mock_upsert) + + await routes._complete_account_mapping("octo", "gh@x.com", None) + + kwargs = mock_upsert.await_args.kwargs + assert kwargs["work_email"] == "gh@x.com" + assert kwargs["slack_user_id"] is None + assert kwargs["source"] == "self" + + +@pytest.mark.asyncio +async def test_completion_noop_without_any_email(monkeypatch: pytest.MonkeyPatch) -> None: + mock_upsert = AsyncMock() + monkeypatch.setattr(routes, "upsert_mapping", mock_upsert) + + await routes._complete_account_mapping("octo", None, None) + + mock_upsert.assert_not_awaited() diff --git a/tests/test_github_comment_prompts.py b/tests/test_github_comment_prompts.py index 9f5240a5..fa893b7f 100644 --- a/tests/test_github_comment_prompts.py +++ b/tests/test_github_comment_prompts.py @@ -118,26 +118,34 @@ def test_build_pr_prompt_sanitizes_reserved_tags_from_comment_body() -> None: def test_build_github_issue_prompt_only_wraps_external_comments() -> None: - prompt = webapp.build_github_issue_prompt( - {"owner": "langchain-ai", "name": "open-swe"}, - 42, - "12345", - "Fix the flaky test", - "The test is failing intermittently.", - [ - { - "author": "bracesproul", - "body": "Internal guidance", - "created_at": "2026-03-09T00:00:00Z", - }, - { - "author": "external-user", - "body": "Try running this script", - "created_at": "2026-03-09T00:01:00Z", - }, - ], - github_login="octocat", + from agent.dashboard import user_mappings + + user_mappings.prime_cache( + [{"github_login": "bracesproul", "work_email": "brace@x.com", "status": "active"}] ) + try: + prompt = webapp.build_github_issue_prompt( + {"owner": "langchain-ai", "name": "open-swe"}, + 42, + "12345", + "Fix the flaky test", + "The test is failing intermittently.", + [ + { + "author": "bracesproul", + "body": "Internal guidance", + "created_at": "2026-03-09T00:00:00Z", + }, + { + "author": "external-user", + "body": "Try running this script", + "created_at": "2026-03-09T00:01:00Z", + }, + ], + github_login="octocat", + ) + finally: + user_mappings.clear_cache() assert "**bracesproul:**\nInternal guidance" in prompt assert "**external-user:**" in prompt diff --git a/tests/test_github_issue_webhook.py b/tests/test_github_issue_webhook.py index 538d1c54..bbabccd2 100644 --- a/tests/test_github_issue_webhook.py +++ b/tests/test_github_issue_webhook.py @@ -87,7 +87,15 @@ def test_build_github_issue_prompt_includes_issue_context() -> None: def test_build_github_issue_followup_prompt_only_includes_comment() -> None: - prompt = webapp.build_github_issue_followup_prompt("bracesproul", "Please handle this") + from agent.dashboard import user_mappings + + user_mappings.prime_cache( + [{"github_login": "bracesproul", "work_email": "brace@x.com", "status": "active"}] + ) + try: + prompt = webapp.build_github_issue_followup_prompt("bracesproul", "Please handle this") + finally: + user_mappings.clear_cache() assert prompt == "**bracesproul:**\nPlease handle this" assert "## Repository" not in prompt @@ -1178,7 +1186,7 @@ def test_process_github_pr_comment_review_request_without_email_uses_app_token( captured["triggered"] = {"args": args, "kwargs": kwargs} monkeypatch.setattr(webapp, "extract_pr_context", fake_extract_pr_context) - monkeypatch.setattr(webapp, "GITHUB_USER_EMAIL_MAP", {}) + monkeypatch.setattr(webapp, "email_for_login", lambda login: asyncio.sleep(0, result=None)) monkeypatch.setattr( webapp, "get_github_app_installation_token_with_expiry", fake_get_app_token_with_expiry ) @@ -1258,7 +1266,13 @@ def test_process_github_issue_uses_resolved_user_token_for_reaction(monkeypatch) monkeypatch.setattr(webapp, "fetch_issue_comments", fake_fetch_issue_comments) monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active) monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient()) - monkeypatch.setattr(webapp, "GITHUB_USER_EMAIL_MAP", {"octocat": "octocat@example.com"}) + monkeypatch.setattr( + webapp, + "email_for_login", + lambda login: asyncio.sleep( + 0, result="octocat@example.com" if login == "octocat" else None + ), + ) asyncio.run( webapp.process_github_issue( @@ -1333,9 +1347,16 @@ def test_process_github_issue_existing_thread_uses_followup_prompt(monkeypatch) monkeypatch.setattr(webapp, "fetch_issue_comments", fake_fetch_issue_comments) monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active) monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient()) - monkeypatch.setattr(webapp, "GITHUB_USER_EMAIL_MAP", {"octocat": "octocat@example.com"}) monkeypatch.setattr( - github_comments, "GITHUB_USER_EMAIL_MAP", {"octocat": "octocat@example.com"} + webapp, + "email_for_login", + lambda login: asyncio.sleep( + 0, result="octocat@example.com" if login == "octocat" else None + ), + ) + monkeypatch.setattr( + "agent.dashboard.user_mappings.is_login_mapped", + lambda login: login == "octocat", ) asyncio.run( diff --git a/tests/test_github_token_ttl.py b/tests/test_github_token_ttl.py index b2df9e79..70ba4022 100644 --- a/tests/test_github_token_ttl.py +++ b/tests/test_github_token_ttl.py @@ -276,7 +276,11 @@ def test_process_github_pr_comment_invalidates_and_reauths_on_401( monkeypatch.setattr(webapp, "react_to_github_comment", fake_react) monkeypatch.setattr(webapp, "fetch_pr_comments_since_last_tag", fake_fetch_pr_comments) monkeypatch.setattr(webapp, "_trigger_or_queue_run", fake_trigger_or_queue_run) - monkeypatch.setattr(webapp, "GITHUB_USER_EMAIL_MAP", {"octo": "octo@example.com"}) + monkeypatch.setattr( + webapp, + "email_for_login", + lambda login: asyncio.sleep(0, result="octo@example.com" if login == "octo" else None), + ) asyncio.run( webapp.process_github_pr_comment( diff --git a/tests/test_slack_context.py b/tests/test_slack_context.py index 2c10d1c3..f008acc9 100644 --- a/tests/test_slack_context.py +++ b/tests/test_slack_context.py @@ -382,7 +382,7 @@ def test_get_slack_repo_config_applies_profile_default_repo( async def fake_get_slack_user_info(user_id: str) -> dict: return {"profile": {"email": "mason@example.com"}} - def fake_resolve_login_from_email(email: str | None) -> str | None: + async def fake_resolve_login_from_email_async(email: str | None) -> str | None: return "mason" async def fake_get_profile_default_repo(login: str | None) -> dict[str, str] | None: @@ -391,7 +391,9 @@ def test_get_slack_repo_config_applies_profile_default_repo( monkeypatch.setattr(webapp, "get_client", lambda url: _FakeClient(threads_client)) monkeypatch.setattr(webapp, "get_slack_user_info", fake_get_slack_user_info) - monkeypatch.setattr(webapp, "resolve_login_from_email", fake_resolve_login_from_email) + monkeypatch.setattr( + webapp, "resolve_login_from_email_async", fake_resolve_login_from_email_async + ) monkeypatch.setattr(webapp, "get_profile_default_repo", fake_get_profile_default_repo) repo = asyncio.run(webapp.get_slack_repo_config("C123", "1.234", slack_user_id="U123")) @@ -673,3 +675,100 @@ def test_process_slack_mention_queues_active_thread_message( queued_payload = captured["queued"]["message_content"] assert queued_payload["image_urls"] == ["https://example.com/image.png"] assert "## Latest Mention Request\ninclude this screenshot" in queued_payload["text"] + + +def test_process_slack_mention_unmapped_user_uses_fallback_and_prompts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unmapped Slack user runs on the installation token and is prompted to link.""" + from agent.dashboard import user_mappings + + captured: dict[str, object] = {} + _setup_slack_mention_fakes(monkeypatch, captured) + user_mappings.clear_cache() + + async def fake_thread_exists(thread_id: str) -> bool: + return False + + async def fake_refresh_cache() -> list: + return [] + + async def fake_login_for_slack_id(slack_user_id): + return None + + async def fake_login_for_email(email): + return None + + async def fake_post_prompt(channel_id, thread_ts, user_id, user_email) -> None: + captured["prompt"] = {"user_id": user_id, "user_email": user_email} + + monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists) + monkeypatch.setattr(webapp, "refresh_user_mapping_cache", fake_refresh_cache) + monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id) + monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email) + monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt) + + asyncio.run( + webapp.process_slack_mention( + { + "channel_id": "C123", + "thread_ts": "1700000000.000100", + "event_ts": "1700000000.000200", + "user_id": "U123", + "text": "<@UBOT> do the thing", + "bot_user_id": "UBOT", + }, + {"owner": "langchain-ai", "name": "open-swe"}, + ) + ) + + run_create = captured["run_create"] + configurable = run_create["kwargs"]["config"]["configurable"] + assert configurable["use_installation_token_fallback"] is True + assert "github_login" not in configurable + assert captured["prompt"] == {"user_id": "U123", "user_email": "mason@example.com"} + + +def test_process_slack_mention_mapped_user_no_prompt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A mapped Slack user runs as themselves with no link prompt and no fallback.""" + captured: dict[str, object] = {} + _setup_slack_mention_fakes(monkeypatch, captured) + + async def fake_thread_exists(thread_id: str) -> bool: + return False + + async def fake_refresh_cache() -> list: + return [] + + async def fake_login_for_slack_id(slack_user_id): + return "mason-gh" if slack_user_id == "U123" else None + + async def fake_post_prompt(*args, **kwargs) -> None: + captured["prompt"] = True + + monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists) + monkeypatch.setattr(webapp, "refresh_user_mapping_cache", fake_refresh_cache) + monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id) + monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt) + + asyncio.run( + webapp.process_slack_mention( + { + "channel_id": "C123", + "thread_ts": "1700000000.000100", + "event_ts": "1700000000.000200", + "user_id": "U123", + "text": "<@UBOT> do the thing", + "bot_user_id": "UBOT", + }, + {"owner": "langchain-ai", "name": "open-swe"}, + ) + ) + + run_create = captured["run_create"] + configurable = run_create["kwargs"]["config"]["configurable"] + assert configurable["github_login"] == "mason-gh" + assert "use_installation_token_fallback" not in configurable + assert "prompt" not in captured diff --git a/tests/test_user_mappings.py b/tests/test_user_mappings.py new file mode 100644 index 00000000..e7d152f8 --- /dev/null +++ b/tests/test_user_mappings.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from agent.dashboard import user_mappings as um + + +class _FakeStore: + """Minimal in-memory stand-in for the LangGraph Store.""" + + def __init__(self) -> None: + self.items: dict[tuple[tuple[str, ...], str], dict[str, Any]] = {} + + async def get_item(self, namespace: list[str], key: str): + value = self.items.get((tuple(namespace), key)) + return {"value": value} if value is not None else None + + async def put_item(self, namespace: list[str], key: str, value: dict[str, Any]) -> None: + self.items[(tuple(namespace), key)] = value + + async def delete_item(self, namespace: list[str], key: str) -> None: + self.items.pop((tuple(namespace), key), None) + + async def search_items(self, namespace: list[str], *, limit: int = 1000): + ns = tuple(namespace) + items = [{"value": v} for (n, _k), v in self.items.items() if n == ns] + return {"items": items[:limit]} + + +class _FakeClient: + def __init__(self, store: _FakeStore) -> None: + self.store = store + + +@pytest.fixture() +def fake_store(monkeypatch: pytest.MonkeyPatch) -> _FakeStore: + store = _FakeStore() + monkeypatch.setattr(um, "_client", lambda: _FakeClient(store)) + um.clear_cache() + return store + + +@pytest.mark.asyncio +async def test_upsert_and_bidirectional_lookup(fake_store: _FakeStore) -> None: + await um.upsert_mapping( + github_login="Octocat", + work_email="OCTO@example.com", + slack_user_id="U123", + source="admin", + ) + # Login lookups are case-insensitive; email is normalized to lowercase. + assert await um.email_for_login("octocat") == "octo@example.com" + assert await um.login_for_email("octo@example.com") == "Octocat" + assert await um.login_for_slack_id("U123") == "Octocat" + + +@pytest.mark.asyncio +async def test_cache_readers_after_refresh(fake_store: _FakeStore) -> None: + await um.upsert_mapping(github_login="dev", work_email="dev@x.com", source="admin") + um.clear_cache() + await um.refresh_cache() + assert um.cached_email_for_login("dev") == "dev@x.com" + assert um.cached_login_for_email("dev@x.com") == "dev" + assert um.is_login_mapped("dev") is True + assert um.is_login_mapped("ghost") is False + + +@pytest.mark.asyncio +async def test_pending_status_not_trusted(fake_store: _FakeStore) -> None: + await um.upsert_mapping( + github_login="newbie", work_email="n@x.com", source="self", status="pending" + ) + um.clear_cache() + await um.refresh_cache() + assert um.is_login_mapped("newbie") is False + + +@pytest.mark.asyncio +async def test_delete_removes_record_and_indexes(fake_store: _FakeStore) -> None: + await um.upsert_mapping(github_login="gone", work_email="g@x.com", source="admin") + assert await um.email_for_login("gone") == "g@x.com" + deleted = await um.delete_mapping("gone") + assert deleted is True + assert um.cached_email_for_login("gone") is None + assert await um.get_mapping("gone") is None + + +@pytest.mark.asyncio +async def test_resolve_login_from_email_async_cold_cache( + fake_store: _FakeStore, monkeypatch: pytest.MonkeyPatch +) -> None: + # Mapped user must resolve even on a cold worker (cache not yet primed), + # because repo-resolution call sites run before the cache is refreshed. + from agent.dashboard import agent_overrides + + monkeypatch.setattr(agent_overrides, "login_for_email", um.login_for_email) + await um.upsert_mapping(github_login="cold", work_email="cold@x.com", source="admin") + um.clear_cache() + + assert await agent_overrides.resolve_login_from_email_async("cold@x.com") == "cold" + + +@pytest.mark.asyncio +async def test_update_deindexes_stale_email_and_slack_id(fake_store: _FakeStore) -> None: + # An update that changes the email/slack id must not leave the old aliases + # resolving to this login in the in-process cache. + await um.upsert_mapping( + github_login="mover", + work_email="old@x.com", + slack_user_id="UOLD", + source="admin", + ) + await um.upsert_mapping( + github_login="mover", + work_email="new@x.com", + slack_user_id="UNEW", + source="admin", + ) + + assert um.cached_login_for_email("old@x.com") is None + assert um.cached_login_for_slack_id("UOLD") is None + assert um.cached_login_for_email("new@x.com") == "mover" + assert um.cached_login_for_slack_id("UNEW") == "mover" + + +@pytest.mark.asyncio +async def test_bulk_import_skips_existing(fake_store: _FakeStore) -> None: + # Pre-existing richer record must survive a legacy re-import. + await um.upsert_mapping(github_login="keep", work_email="keep@x.com", source="self") + created = await um.bulk_import( + {"keep": "legacy@x.com", "fresh": "fresh@x.com", "bad": ""}, + source="hardcoded", + ) + assert created == 1 + keep = await um.get_mapping("keep") + assert keep["work_email"] == "keep@x.com" + assert keep["source"] == "self" + fresh = await um.get_mapping("fresh") + assert fresh["source"] == "hardcoded" + + +@pytest.mark.asyncio +async def test_upsert_requires_login_and_email(fake_store: _FakeStore) -> None: + with pytest.raises(ValueError): + await um.upsert_mapping(github_login="", work_email="x@x.com") + with pytest.raises(ValueError): + await um.upsert_mapping(github_login="x", work_email="") + + +@pytest.mark.asyncio +async def test_list_mappings_sorted(fake_store: _FakeStore) -> None: + await um.upsert_mapping(github_login="zeta", work_email="z@x.com", source="admin") + await um.upsert_mapping(github_login="alpha", work_email="a@x.com", source="admin") + listed = await um.list_mappings() + assert [m["github_login"] for m in listed] == ["alpha", "zeta"] diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 450c4b3b..10511601 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -111,6 +111,22 @@ export interface TeamSettings { updated_at?: string | null; } +export interface UserMapping { + github_login: string; + work_email: string; + slack_user_id?: string | null; + source?: string; + status?: string; + created_at?: string; + updated_at?: string; +} + +export interface UserMappingUpsert { + github_login: string; + work_email: string; + slack_user_id?: string | null; +} + export interface Repository { full_name: string; private: boolean; @@ -195,6 +211,19 @@ export const api = { method: "PUT", body: JSON.stringify(body), }), + adminListUserMappings: () => request>("/admin/user-mappings"), + adminSaveUserMapping: (body: UserMappingUpsert) => + request("/admin/user-mappings", { + method: "PUT", + body: JSON.stringify(body), + }), + adminDeleteUserMapping: (github_login: string) => + request<{ deleted: boolean }>( + `/admin/user-mappings/${encodeURIComponent(github_login)}`, + { method: "DELETE" }, + ), + adminImportUserMappings: () => + request<{ created: number }>("/admin/user-mappings/import", { method: "POST" }), logout: () => request("/auth/logout", { method: "POST" }), }; diff --git a/ui/src/routes/admin.tsx b/ui/src/routes/admin.tsx index 2e58d571..0cdeb987 100644 --- a/ui/src/routes/admin.tsx +++ b/ui/src/routes/admin.tsx @@ -2,10 +2,17 @@ import { Navigate, createFileRoute } from "@tanstack/react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; -import type { ModelOption, Profile, ProfileUpdate, TeamSettings } from "@/lib/api"; +import type { + ModelOption, + Profile, + ProfileUpdate, + TeamSettings, + UserMapping, +} from "@/lib/api"; import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"; import { ProfileForm } from "@/components/ProfileForm"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -68,6 +75,8 @@ function AdminPage() { > + +
@@ -112,6 +121,135 @@ function AdminPage() { ); } +function UserMappingsSection({ enabled }: { enabled: boolean }) { + const qc = useQueryClient(); + const [login, setLogin] = useState(""); + const [email, setEmail] = useState(""); + const [slackId, setSlackId] = useState(""); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const mappings = useQuery({ + queryKey: ["adminUserMappings"], + queryFn: api.adminListUserMappings, + enabled, + }); + + const invalidate = () => + void qc.invalidateQueries({ queryKey: ["adminUserMappings"] }); + + const save = useMutation({ + mutationFn: () => + api.adminSaveUserMapping({ + github_login: login.trim(), + work_email: email.trim(), + slack_user_id: slackId.trim() || null, + }), + onSuccess: () => { + setLogin(""); + setEmail(""); + setSlackId(""); + setError(null); + setNotice(null); + invalidate(); + }, + onError: (e: Error) => setError(e.message), + }); + + const remove = useMutation({ + mutationFn: (gh: string) => api.adminDeleteUserMapping(gh), + onSuccess: invalidate, + onError: (e: Error) => setError(e.message), + }); + + const importLegacy = useMutation({ + mutationFn: api.adminImportUserMappings, + onSuccess: (res) => { + setNotice(`Imported ${res.created} legacy mapping(s).`); + setError(null); + invalidate(); + }, + onError: (e: Error) => setError(e.message), + }); + + return ( + +
+
+ setLogin(e.target.value)} + /> + setEmail(e.target.value)} + /> + setSlackId(e.target.value)} + /> + +
+ +
+ + {notice && {notice}} + {error && {error}} +
+ +
+ {mappings.isLoading ? ( + + ) : !mappings.data?.length ? ( +

No mappings yet.

+ ) : ( + mappings.data.map((m: UserMapping) => ( +
+
+ {m.github_login} + + {m.work_email} + {m.slack_user_id ? ` · ${m.slack_user_id}` : ""} + {m.source ? ` · ${m.source}` : ""} + +
+ +
+ )) + )} +
+
+
+ ); +} + function GlobalDefaultsSection({ models }: { models: Array }) { const qc = useQueryClient(); const settings = useQuery({