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 issue comments for follow-up instructions
- Tag `@openswe` in PR review comments to have it address review feedback - 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 - **Add / update** a single mapping (GitHub login + work email, plus an optional Slack user ID).
GITHUB_USER_EMAIL_MAP = { - **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.
"their-github-username": "their-email@example.com", - 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: 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 ### 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 Linear: ensure the comment contains `@openswe` (case-insensitive)
- For Slack: ensure the bot is invited to the channel and the message is an `@mention` - For Slack: ensure the bot is invited to the channel and the message is an `@mention`
- Check server logs for webhook processing errors - Check server logs for webhook processing errors

View file

@ -8,22 +8,31 @@ from typing import Any
import httpx import httpx
from langgraph_sdk import get_client 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 .options import SUPPORTED_MODEL_IDS, model_supports_effort, provider_fallback_pair
from .profiles import PROFILES_NAMESPACE from .profiles import PROFILES_NAMESPACE
from .user_mappings import cached_login_for_email, login_for_email
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def resolve_login_from_email(email: str | None) -> str | None: def resolve_login_from_email(email: str | None) -> str | None:
"""Reverse-lookup ``GITHUB_USER_EMAIL_MAP`` for the GitHub login of an email.""" """Reverse-lookup the user-mapping store for the GitHub login of an email.
if not isinstance(email, str) or not email.strip():
return None Reads the in-process mapping cache (sync). When the cache is cold the
normalized = email.strip().lower() lookup misses; the webhook path that triggers a run primes the cache via
for gh_login, mapped in GITHUB_USER_EMAIL_MAP.items(): :func:`agent.dashboard.user_mappings.refresh_cache` beforehand.
if mapped.lower() == normalized: """
return gh_login return cached_login_for_email(email)
return None
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: def resolve_github_login(config: dict[str, Any]) -> str | None:

View file

@ -10,7 +10,7 @@ import secrets
import time import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import quote, urlparse
import httpx import httpx
import jwt import jwt
@ -148,14 +148,16 @@ def hash_state_nonce(nonce: str) -> str:
return hmac.new(_secret().encode(), nonce.encode(), hashlib.sha256).hexdigest() 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()) now = int(time.time())
payload = { payload: dict[str, Any] = {
"nonce_hash": nonce_hash, "nonce_hash": nonce_hash,
"redirect_to": redirect_to, "redirect_to": redirect_to,
"iat": now, "iat": now,
"exp": now + STATE_TTL_SECONDS, "exp": now + STATE_TTL_SECONDS,
} }
if link:
payload["link"] = link
return jwt.encode(payload, _secret(), algorithm=JWT_ALG) 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 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]: def require_session(request: Request) -> dict[str, Any]:
token = request.cookies.get(COOKIE_NAME) token = request.cookies.get(COOKIE_NAME)
if not token: if not token:

View file

@ -23,6 +23,7 @@ from .oauth import (
SESSION_TTL_SECONDS, SESSION_TTL_SECONDS,
STATE_COOKIE_NAME, STATE_COOKIE_NAME,
STATE_TTL_SECONDS, STATE_TTL_SECONDS,
decode_account_link,
decode_state, decode_state,
enforce_org_login_gate, enforce_org_login_gate,
exchange_code, exchange_code,
@ -74,6 +75,12 @@ from .thread_api import (
send_dashboard_message, send_dashboard_message,
stream_dashboard_thread, stream_dashboard_thread,
) )
from .user_mappings import (
bulk_import,
delete_mapping,
list_mappings,
upsert_mapping,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -144,14 +151,25 @@ def _clear_state_cookie(response: Response) -> None:
@router.get("/auth/login") @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", "") client_id = os.environ.get("GITHUB_APP_CLIENT_ID", "")
if not client_id: if not client_id:
raise HTTPException(500, "GITHUB_APP_CLIENT_ID not configured") raise HTTPException(500, "GITHUB_APP_CLIENT_ID not configured")
safe_redirect = sanitize_redirect_to(redirect_to) or _frontend_base_url() 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() 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" redirect_uri = f"{_api_base_url()}/dashboard/api/auth/callback"
url = ( url = (
"https://github.com/login/oauth/authorize" "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 enforce_org_login_gate(login)
await upsert_access_token_from_github_response(login, email or "", token_data) 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")) session_jwt = issue_session(login=login, email=email, avatar_url=user.get("avatar_url"))
response = RedirectResponse(redirect_to, status_code=302) response = RedirectResponse(redirect_to, status_code=302)
@ -200,6 +219,33 @@ async def auth_callback(request: Request, code: str, state: str) -> RedirectResp
return response 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") @router.post("/auth/logout")
async def auth_logout() -> Response: async def auth_logout() -> Response:
response = Response(status_code=204) response = Response(status_code=204)
@ -306,6 +352,56 @@ async def api_set_enabled_review_repo(
return {"repos": repos} 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: def _next_link_url(link_header: str | None) -> str | None:
if not link_header: if not link_header:
return None 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 ..encryption import encrypt_token
from .github_app import get_github_app_installation_token_with_expiry from .github_app import get_github_app_installation_token_with_expiry
from .github_token import get_github_token_from_thread 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 .linear import comment_on_linear_issue
from .slack import post_slack_ephemeral_message, post_slack_thread_reply 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) 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") 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: try:
if source == "github": if source == "github":
cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread( 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: if cached_token and cached_encrypted:
return cached_token, cached_encrypted, cached_expires_at return cached_token, cached_encrypted, cached_expires_at
github_login = configurable.get("github_login") 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: if not email:
raise ValueError(f"No email mapping found for GitHub user '{github_login}'") raise ValueError(f"No email mapping found for GitHub user '{github_login}'")
return await save_encrypted_token_from_email(email, source) return await save_encrypted_token_from_email(email, source)

View file

@ -8,8 +8,6 @@ from typing import Any
import httpx import httpx
from .github_user_email_map import GITHUB_USER_EMAIL_MAP
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
OPEN_SWE_BOT_NAME = "open-swe[bot]" 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")) github_login = _normalize_text(configurable.get("github_login"))
if github_login: if github_login:
github_user_id = configurable.get("github_user_id") 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( 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: if commit_email:
return CollaboratorIdentity( return CollaboratorIdentity(

View file

@ -12,7 +12,6 @@ from typing import Any
import httpx import httpx
from .github_token import GitHubAuthError from .github_token import GitHubAuthError
from .github_user_email_map import GITHUB_USER_EMAIL_MAP
logger = logging.getLogger(__name__) 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: def format_github_comment_body_for_prompt(author: str, body: str) -> str:
"""Format a GitHub comment body for prompt inclusion.""" """Format a GitHub comment body for prompt inclusion."""
sanitized_body = sanitize_github_comment_body(body) 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 sanitized_body
return ( 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: This dict is **no longer read at runtime**. The live mapping is the
"github-username": "user@example.com", 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] = { 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 import router as dashboard_router
from .dashboard.agent_overrides import ( from .dashboard.agent_overrides import (
get_profile_default_repo, get_profile_default_repo,
resolve_login_from_email, resolve_login_from_email_async,
) )
from .dashboard.enabled_repos import is_review_repo_enabled 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.profiles import get_profile
from .dashboard.team_settings import get_team_settings 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 ( from .reviewer_findings import (
REVIEWER_THREAD_KIND, REVIEWER_THREAD_KIND,
Finding, 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_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_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 import post_linear_trace_comment
from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block 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_info,
get_slack_user_names, get_slack_user_names,
parse_github_pr_url, parse_github_pr_url,
post_slack_ephemeral_message,
post_slack_thread_reply, post_slack_thread_reply,
post_slack_trace_reply, post_slack_trace_reply,
resolve_slack_links_in_context, 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. mirror the owner-identifying fields onto the thread here.
""" """
now_ms = int(datetime.now(UTC).timestamp() * 1000) 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} 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"): if isinstance(repo_config, dict) and repo_config.get("owner") and repo_config.get("name"):
metadata["repo"] = repo_config metadata["repo"] = repo_config
@ -584,7 +593,9 @@ async def get_slack_repo_config(
if isinstance(slack_user, dict) if isinstance(slack_user, dict)
else None 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: if profile_repo:
logger.info( logger.info(
"Applying dashboard default_repo for Slack user %s: %s/%s", "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) 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: 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.""" """Process a Slack app mention by creating a run or queuing a mid-run message."""
channel_id = event_data.get("channel_id", "") 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) 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_email = None
user_name = "" user_name = ""
if user_id: if user_id:
@ -969,6 +1005,11 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
if image_block: if image_block:
content_blocks.append(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] = { configurable: dict[str, Any] = {
"repo": repo_config, "repo": repo_config,
"slack_thread": { "slack_thread": {
@ -982,6 +1023,11 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
"user_email": user_email, "user_email": user_email,
"source": "slack", "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) langgraph_client = get_client(url=LANGGRAPH_URL)
is_first_mention = not await _thread_exists(thread_id) 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, thread_id,
) )
run_id = run.get("run_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: if is_first_mention:
trace_message_ts = await post_slack_trace_reply(channel_id, thread_ts, thread_id) trace_message_ts = await post_slack_trace_reply(channel_id, thread_ts, thread_id)
await set_slack_assistant_status(channel_id, thread_ts) 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") comment_user_email = (data.get("user") or {}).get("email")
try: try:
profile_repo = await get_profile_default_repo( 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 except Exception: # noqa: BLE001
logger.exception("Failed to apply dashboard default_repo for Linear user") 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", {}) comment = payload.get("comment") or payload.get("review", {})
is_review_request, _pr_url_override = parse_github_review_command(comment.get("body") or "") 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: if email:
github_token = await _get_or_resolve_thread_github_token(thread_id, email) github_token = await _get_or_resolve_thread_github_token(thread_id, email)
elif is_review_request: 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") logger.warning("Missing GitHub issue id/number, skipping")
return return
email = GITHUB_USER_EMAIL_MAP.get(github_login, "") email = await email_for_login(github_login) or ""
if not email: if not email:
logger.warning("No email mapping for GitHub user '%s', skipping", github_login) logger.warning("No email mapping for GitHub user '%s', skipping", github_login)
return 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: def test_build_github_issue_prompt_only_wraps_external_comments() -> None:
prompt = webapp.build_github_issue_prompt( from agent.dashboard import user_mappings
{"owner": "langchain-ai", "name": "open-swe"},
42, user_mappings.prime_cache(
"12345", [{"github_login": "bracesproul", "work_email": "brace@x.com", "status": "active"}]
"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",
) )
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 "**bracesproul:**\nInternal guidance" in prompt
assert "**external-user:**" 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: 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 prompt == "**bracesproul:**\nPlease handle this"
assert "## Repository" not in prompt 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} captured["triggered"] = {"args": args, "kwargs": kwargs}
monkeypatch.setattr(webapp, "extract_pr_context", fake_extract_pr_context) 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( monkeypatch.setattr(
webapp, "get_github_app_installation_token_with_expiry", fake_get_app_token_with_expiry 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, "fetch_issue_comments", fake_fetch_issue_comments)
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active) monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient()) 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( asyncio.run(
webapp.process_github_issue( 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, "fetch_issue_comments", fake_fetch_issue_comments)
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active) monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient()) monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClient())
monkeypatch.setattr(webapp, "GITHUB_USER_EMAIL_MAP", {"octocat": "octocat@example.com"})
monkeypatch.setattr( 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( 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, "react_to_github_comment", fake_react)
monkeypatch.setattr(webapp, "fetch_pr_comments_since_last_tag", fake_fetch_pr_comments) 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, "_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( asyncio.run(
webapp.process_github_pr_comment( 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: async def fake_get_slack_user_info(user_id: str) -> dict:
return {"profile": {"email": "mason@example.com"}} 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" return "mason"
async def fake_get_profile_default_repo(login: str | None) -> dict[str, str] | None: 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_client", lambda url: _FakeClient(threads_client))
monkeypatch.setattr(webapp, "get_slack_user_info", fake_get_slack_user_info) 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) 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")) 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"] queued_payload = captured["queued"]["message_content"]
assert queued_payload["image_urls"] == ["https://example.com/image.png"] assert queued_payload["image_urls"] == ["https://example.com/image.png"]
assert "## Latest Mention Request\ninclude this screenshot" in queued_payload["text"] 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; 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 { export interface Repository {
full_name: string; full_name: string;
private: boolean; private: boolean;
@ -195,6 +211,19 @@ export const api = {
method: "PUT", method: "PUT",
body: JSON.stringify(body), 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" }), 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react"; 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 { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
import { ProfileForm } from "@/components/ProfileForm"; import { ProfileForm } from "@/components/ProfileForm";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -68,6 +75,8 @@ function AdminPage() {
> >
<GlobalDefaultsSection models={options.data?.models ?? []} /> <GlobalDefaultsSection models={options.data?.models ?? []} />
<UserMappingsSection enabled={!!session.data.is_admin} />
<SettingsSection title="Per-user profiles"> <SettingsSection title="Per-user profiles">
<div className="grid grid-cols-1 gap-0 md:grid-cols-[260px_1fr]"> <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"> <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> }) { function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
const qc = useQueryClient(); const qc = useQueryClient();
const settings = useQuery({ const settings = useQuery({