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.
This commit is contained in:
Johannes du Plessis 2026-06-01 14:37:19 -07:00 • committed by GitHub
parent 9a8b2d9984
commit 427bfe4f56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1171 additions and 66 deletions

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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(

View file

@ -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 (

View file

@ -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] = {

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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(

View file

@ -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(

View file

@ -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

157
tests/test_user_mappings.py Normal file
View file

@ -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"]

View file

@ -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<Array<UserMapping>>("/admin/user-mappings"),
adminSaveUserMapping: (body: UserMappingUpsert) =>
request<UserMapping>("/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<void>("/auth/logout", { method: "POST" }),
};

View file

@ -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() {
>
<GlobalDefaultsSection models={options.data?.models ?? []} />
<UserMappingsSection enabled={!!session.data.is_admin} />
<SettingsSection title="Per-user profiles">
<div className="grid grid-cols-1 gap-0 md:grid-cols-[260px_1fr]">
<div className="flex flex-col gap-0.5 border-b border-border p-2 md:border-b-0 md:border-r">
@ -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<string | null>(null);
const [notice, setNotice] = useState<string | null>(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 (
<SettingsSection
title="User mappings"
description="Link GitHub logins to work emails (and Slack IDs) so tagged users run as themselves."
>
<div className="flex flex-col gap-3 p-4">
<div className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_1fr_auto]">
<Input
placeholder="github-login"
value={login}
onChange={(e) => setLogin(e.target.value)}
/>
<Input
placeholder="work@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
placeholder="Slack ID (optional)"
value={slackId}
onChange={(e) => setSlackId(e.target.value)}
/>
<Button
onClick={() => save.mutate()}
disabled={!login.trim() || !email.trim() || save.isPending}
>
{save.isPending ? "Saving…" : "Add / Update"}
</Button>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => importLegacy.mutate()}
disabled={importLegacy.isPending}
>
{importLegacy.isPending ? "Importing…" : "Import legacy mapping"}
</Button>
{notice && <span className="text-xs text-muted-foreground">{notice}</span>}
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
<div className="flex flex-col gap-0.5">
{mappings.isLoading ? (
<Skeleton className="h-32" />
) : !mappings.data?.length ? (
<p className="text-xs text-muted-foreground">No mappings yet.</p>
) : (
mappings.data.map((m: UserMapping) => (
<div
key={m.github_login}
className="flex items-center justify-between gap-2 border-b border-border py-1.5 text-sm last:border-b-0"
>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{m.github_login}</span>
<span className="truncate text-xs text-muted-foreground">
{m.work_email}
{m.slack_user_id ? ` · ${m.slack_user_id}` : ""}
{m.source ? ` · ${m.source}` : ""}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => remove.mutate(m.github_login)}
disabled={remove.isPending}
>
Remove
</Button>
</div>
))
)}
</div>
</div>
</SettingsSection>
);
}
function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
const qc = useQueryClient();
const settings = useQuery({