open-swe/agent/dashboard/user_mappings.py
Adam Moussa a30ce2ab40
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
feat: managed LangGraph Cloud + Vercel migration (PR2 — code fixes + docs) (#65)
* fix(dashboard): managed-cloud OAuth hardening + admin user-mapping endpoint

Prepare the dashboard backend for the managed LangGraph Cloud + Vercel
runtime, where the API is HTTPS and cross-site from the UI.

- OAuth redirect_uri (#2): coerce a schemeless DASHBOARD_API_BASE_URL to
  https:// in _api_base_url() so GitHub stops rejecting login with
  "redirect_uri not associated with this application". _cookie_security()
  now treats a schemeless (managed) value as Secure; SameSite=None too,
  consistent with the coerced scheme.
- OAuth state cookie (#3): document that osw_oauth_state is host-only by
  design (a Domain cookie is unsafe across *.vercel.app, a public suffix),
  so login must always start on the stable alias to avoid "oauth state
  mismatch". Operational contract; no behavioral change.
- Admin user mappings (#4): add POST /admin/user-mappings so an admin can
  set the github_login -> work_email link from the dashboard instead of a
  raw Store write. New "admin" MappingSource provenance value.

* fix(webapp): refresh user-mapping cache on GitHub webhook paths

On managed LangGraph Cloud the backend runs multiple replicas, so the
per-process GitHub<->work-email mapping cache can be stale on the replica
handling a webhook (a mapping created on another replica is invisible
until refresh). process_github_pr_comment and process_github_issue now
refresh the cache from the durable Store before resolving the author's
email, matching the existing Slack mention path (process_slack_mention).

* perf(webapp): defer deepagents import to speed custom-app cold start

The custom FastAPI app (agent.webapp:app, the langgraph.json http.app)
pulled deepagents -> langchain_anthropic -> anthropic into its import
graph via dashboard.routes, only to build skill/chat seed files. Defer
those create_file_data imports into the functions that use them. Removes
deepagents/langchain_anthropic/anthropic from app import entirely and
roughly halves module-import wall time (~0.6-0.8s -> ~0.35s warm; larger
cold-start saving since native anthropic init is skipped). Behavior
identical. (reviewer_diff already imports deepagents under TYPE_CHECKING.)

* feat(ui): set work_email user mappings from the admin dashboard

Add an "Add / update" form to the admin User mappings section and the
adminUpsertUserMapping API client method, wiring the new
POST /admin/user-mappings endpoint. Admins can now create or update a
github_login -> work_email mapping directly instead of waiting for the
user to self-connect Slack.

* docs: document managed LangGraph Cloud + Vercel deployment

- INSTALLATION §10: add the managed production env triad (LANGGRAPH_URL,
  DASHBOARD_BASE_URL + DASHBOARD_API_BASE_URL with https://, empty
  VITE_DASHBOARD_API_BASE_URL for same-origin), the stable-alias login
  and vercel.json stable-deployment-URL requirements, multi-replica cache
  note, plus redirect_uri-scheme and oauth-state-mismatch troubleshooting.
  Refresh the langgraph.json snippet to all six graphs.
- README: reframe deployment around the managed migration; link the plan.
- deploy/MIGRATION.md: import the self-hosted -> managed migration plan.
2026-06-29 19:58:38 -04:00

301 lines
9.3 KiB
Python

"""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": "slack_oauth",
"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["slack_oauth", "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 = "slack_oauth",
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