mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 09:13:14 +00:00
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:
parent
9a8b2d9984
commit
427bfe4f56
19 changed files with 1171 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
326
agent/dashboard/user_mappings.py
Normal file
326
agent/dashboard/user_mappings.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
50
tests/test_account_link.py
Normal file
50
tests/test_account_link.py
Normal 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
|
||||
54
tests/test_account_link_completion.py
Normal file
54
tests/test_account_link_completion.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
157
tests/test_user_mappings.py
Normal 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"]
|
||||
|
|
@ -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" }),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue