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.
2026-06-01 14:37:19 -07:00
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
# 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:
|
2026-06-04 12:58:30 -07:00
|
|
|
await um.upsert_mapping(github_login="dev", work_email="dev@x.com")
|
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.
2026-06-01 14:37:19 -07:00
|
|
|
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:
|
2026-06-04 12:58:30 -07:00
|
|
|
await um.upsert_mapping(github_login="newbie", work_email="n@x.com", status="pending")
|
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.
2026-06-01 14:37:19 -07:00
|
|
|
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:
|
2026-06-04 12:58:30 -07:00
|
|
|
await um.upsert_mapping(github_login="gone", work_email="g@x.com")
|
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.
2026-06-01 14:37:19 -07:00
|
|
|
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)
|
2026-06-04 12:58:30 -07:00
|
|
|
await um.upsert_mapping(github_login="cold", work_email="cold@x.com")
|
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.
2026-06-01 14:37:19 -07:00
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
await um.upsert_mapping(
|
|
|
|
|
github_login="mover",
|
|
|
|
|
work_email="new@x.com",
|
|
|
|
|
slack_user_id="UNEW",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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_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:
|
2026-06-04 12:58:30 -07:00
|
|
|
await um.upsert_mapping(github_login="zeta", work_email="z@x.com")
|
|
|
|
|
await um.upsert_mapping(github_login="alpha", work_email="a@x.com")
|
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.
2026-06-01 14:37:19 -07:00
|
|
|
listed = await um.list_mappings()
|
|
|
|
|
assert [m["github_login"] for m in listed] == ["alpha", "zeta"]
|