diff --git a/INSTALLATION.md b/INSTALLATION.md index b9141683..96443d2e 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -209,8 +209,7 @@ GitHub triggering works automatically once your GitHub App is set up (step 3). U 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**: -- **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. +- **Add / update** a single mapping (GitHub login + work email, plus an optional Slack user ID). The list is paged (20 per page). - 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: @@ -545,7 +544,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 has a user mapping (Admin → User mappings; see "Configure triggering surfaces"). If you just migrated, run the one-time **Import legacy mapping** button. +- 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"). Add any missing user with **Add / update** in that section. - 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 diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 7b01d38f..330f3f6d 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -40,7 +40,6 @@ from .profiles import ( ProfileUpdate, get_profile, get_valid_access_token, - list_profiles, upsert_access_token_from_github_response, upsert_profile, ) @@ -76,7 +75,6 @@ from .thread_api import ( stream_dashboard_thread, ) from .user_mappings import ( - bulk_import, delete_mapping, list_mappings, upsert_mapping, @@ -285,37 +283,6 @@ async def put_my_profile( return await upsert_profile(session["sub"], session.get("email") or "", update) -@router.get("/admin/profiles") -async def admin_list_profiles( - _admin: dict[str, Any] = _ADMIN_DEP, -) -> list[dict[str, Any]]: - return await list_profiles() - - -class AdminProfileUpdate(ProfileUpdate): - email: str | None = None - - -@router.put("/admin/profiles/{login}") -async def admin_put_profile( - login: str, - update: AdminProfileUpdate, - _admin: dict[str, Any] = _ADMIN_DEP, -) -> dict[str, Any]: - update.validate_pairing() - existing = await get_profile(login) or {} - email = update.email or existing.get("email") or "" - # Overlay only fields that were explicitly sent so the admin form (which - # only sends model/effort/repo) can't reset other fields the target user - # configured via My Settings / Cloud Agents to ProfileUpdate's defaults. - incoming = update.model_dump(exclude={"email"}, exclude_unset=True) - merged = {**existing, **incoming} - base = ProfileUpdate( - **{k: v for k, v in merged.items() if k in ProfileUpdate.model_fields}, - ) - return await upsert_profile(login, email, base) - - @router.get("/team-settings") async def api_get_team_settings( session: dict[str, Any] = _SESSION_DEP, @@ -360,9 +327,22 @@ class UserMappingUpsert(BaseModel): @router.get("/admin/user-mappings") async def admin_list_user_mappings( + page: int = 1, + page_size: int = 20, _admin: dict[str, Any] = _ADMIN_DEP, -) -> list[dict[str, Any]]: - return await list_mappings() +) -> dict[str, Any]: + page = max(page, 1) + page_size = max(1, min(page_size, 100)) + records = await list_mappings() + total = len(records) + start = (page - 1) * page_size + items = records[start : start + page_size] + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + } @router.put("/admin/user-mappings") @@ -391,17 +371,6 @@ async def admin_delete_user_mapping( 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 diff --git a/agent/utils/github_user_email_map.py b/agent/utils/github_user_email_map.py deleted file mode 100644 index 70bb5e76..00000000 --- a/agent/utils/github_user_email_map.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Legacy GitHub-username → LangSmith-email mapping. - -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] = { - "aran-yogesh": "yogesh.mahendran@langchain.dev", - "AaryanPotdar": "aaryan.potdar@langchain.dev", - "agola11": "ankush@langchain.dev", - "akira": "alex@langchain.dev", - "amal-irgashev": "amal.irgashev@langchain.dev", - "andrew-langchain-gh": "andrew.selden@langchain.dev", - "andrewnguonly": "andrew@langchain.dev", - "andrewrreed": "andrew@langchain.dev", - "angus-langchain": "angus@langchain.dev", - "ArthurLangChain": "arthur@langchain.dev", - "asatish-langchain": "asatish@langchain.dev", - "ashwinamardeep-ashwin": "ashwin.amardeep@langchain.dev", - "asrira428": "siri.arun@langchain.dev", - "ayoung19": "andy@langchain.dev", - "baskaryan": "bagatur@langchain.dev", - "bastiangerstner": "bastian.gerstner@langchain.dev", - "bees": "arian@langchain.dev", - "bentanny": "ben.tannyhill@langchain.dev", - "bracesproul": "brace@langchain.dev", - "brianto-langchain": "brian.to@langchain.dev", - "bscott449": "brandon@langchain.dev", - "bvs-langchain": "brian@langchain.dev", - "bwhiting2356": "brendan.whiting@langchain.dev", - "carolinedivittorio": "caroline.divittorio@langchain.dev", - "casparb": "caspar@langchain.dev", - "catherine-langchain": "catherine@langchain.dev", - "ccurme": "chester@langchain.dev", - "christian-bromann": "christian@langchain.dev", - "christineastoria": "christine@langchain.dev", - "colifran": "colin.francis@langchain.dev", - "conradcorbett-crypto": "conrad.corbett@langchain.dev", - "cstanlee": "carlos.stanley@langchain.dev", - "cwaddingham": "chris.waddingham@langchain.dev", - "cwlbraa": "cwlbraa@langchain.dev", - "dahlke": "neil@langchain.dev", - "DanielKneipp": "daniel@langchain.dev", - "danielrlambert3": "daniel@langchain.dev", - "DavoCoder": "davidc@langchain.dev", - "ddzmitry": "dzmitry.dubarau@langchain.dev", - "denis-at-langchain": "denis@langchain.dev", - "dqbd": "david@langchain.dev", - "elibrosen": "eli@langchain.dev", - "emil-lc": "emil@langchain.dev", - "emily-langchain": "emily@langchain.dev", - "ericdong-langchain": "ericdong@langchain.dev", - "ericjohanson-langchain": "eric.johanson@langchain.dev", - "eyurtsev": "eugene@langchain.dev", - "gethin-langchain": "gethin.dibben@langchain.dev", - "gladwig2": "geoff@langchain.dev", - "GowriH-1": "gowri@langchain.dev", - "hanalodi": "hana@langchain.dev", - "hari-dhanushkodi": "hari@langchain.dev", - "hinthornw": "will@langchain.dev", - "hntrl": "hunter@langchain.dev", - "hwchase17": "harrison@langchain.dev", - "iakshay": "akshay@langchain.dev", - "sydney-runkle": "sydney@langchain.dev", - "tanushree-sharma": "tanushree@langchain.dev", - "victorm-lc": "victor@langchain.dev", - "vishnu-ssuresh": "vishnu.suresh@langchain.dev", - "vtrivedy": "vivek.trivedy@langchain.dev", - "will-langchain": "will.anderson@langchain.dev", - "xuro-langchain": "xuro@langchain.dev", - "yumuzi234": "zhen@langchain.dev", - "j-broekhuizen": "jb@langchain.dev", - "jacobalbert3": "jacob.albert@langchain.dev", - "jacoblee93": "jacob@langchain.dev", - "jdrogers940 ": "josh@langchain.dev", - "jeeyoonhyun": "jeeyoon@langchain.dev", - "jessieibarra": "jessie.ibarra@langchain.dev", - "jfglanc": "jan.glanc@langchain.dev", - "jkennedyvz": "john@langchain.dev", - "joaquin-borggio-lc": "joaquin@langchain.dev", - "joel-at-langchain": "joel.johnson@langchain.dev", - "johannes117": "johannes@langchain.dev", - "joshuatagoe": "joshua.tagoe@langchain.dev", - "katmayb": "kathryn@langchain.dev", - "kenvora": "kvora@langchain.dev", - "kevinbfrank": "kevin.frank@langchain.dev", - "KiewanVillatel": "kiewan@langchain.dev", - "l2and": "randall@langchain.dev", - "langchain-infra": "mukil@langchain.dev", - "langchain-karan": "karan@langchain.dev", - "lc-arjun": "arjun@langchain.dev", - "lc-chad": "chad@langchain.dev", - "lcochran400": "logan.cochran@langchain.dev", - "lnhsingh": "lauren@langchain.dev", - "longquanzheng": "long@langchain.dev", - "loralee90": "lora.lee@langchain.dev", - "lunevalex": "alunev@langchain.dev", - "maahir30": "maahir.sachdev@langchain.dev", - "madams0013": "maddy@langchain.dev", - "mdrxy": "mason@langchain.dev", - "mhk197": "katz@langchain.dev", - "mwalker5000": "mike.walker@langchain.dev", - "mlo20030": "morgan.lo@langchain.dev", - "natasha-langchain": "nwhitney@langchain.dev", - "nhuang-lc": "nick@langchain.dev", - "niilooy": "niloy@langchain.dev", - "nitboss": "nithin@langchain.dev", - "npentrel": "naomi@langchain.dev", - "nrc": "nick.cameron@langchain.dev", - "Palashio": "palash@langchain.dev", - "PeriniM": "marco@langchain.dev", - "pjrule": "parker@langchain.dev", - "QuentinBrosse": "quentin@langchain.dev", - "rahul-langchain": "rahul@langchain.dev", - "ramonpetgrave64": "ramon@langchain.dev", - "rx5ad": "rafid.saad@langchain.dev", - "saad-supports-langchain": "saad@langchain.dev", - "samecrowder": "scrowder@langchain.dev", - "samnoyes": "sam@langchain.dev", - "seanderoiste": "sean@langchain.dev", - "simon-langchain": "simon@langchain.dev", - "sriputhucode-ops": "sri.puthucode@langchain.dev", - "stephen-chu": "stephen.chu@langchain.dev", - "sthm": "steffen@langchain.dev", - "steve-langchain": "steve@langchain.dev", - "SumedhArani": "sumedh@langchain.dev", - "suraj-langchain": "suraj@langchain.dev", -} diff --git a/ui/src/components/ProfileForm.tsx b/ui/src/components/ProfileForm.tsx deleted file mode 100644 index 50320778..00000000 --- a/ui/src/components/ProfileForm.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { useEffect, useState } from "react"; - -import type { ModelOption, Profile, ProfileUpdate, Repository } from "@/lib/api"; -import { Button } from "@/components/ui/button"; -import { - Combobox, - ComboboxContent, - ComboboxEmpty, - ComboboxInput, - ComboboxItem, - ComboboxList, -} from "@/components/ui/combobox"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - -interface ProfileFormProps { - models: Array; - repos: Array; - initial: Profile; - onSubmit: (body: ProfileUpdate) => Promise; - saving: boolean; - error: string | null; -} - -export function ProfileForm({ models, repos, initial, onSubmit, saving, error }: ProfileFormProps) { - const first: ModelOption | undefined = models[0]; - const [modelId, setModelId] = useState(initial.default_model ?? first?.id ?? ""); - const currentModel: ModelOption | undefined = models.find((m) => m.id === modelId) ?? first; - const [effort, setEffort] = useState( - initial.reasoning_effort ?? currentModel?.default_effort ?? "", - ); - const [subagentModelId, setSubagentModelId] = useState( - initial.default_subagent_model ?? initial.default_model ?? first?.id ?? "", - ); - const currentSubagentModel: ModelOption | undefined = - models.find((m) => m.id === subagentModelId) ?? first; - const [subagentEffort, setSubagentEffort] = useState( - initial.subagent_reasoning_effort ?? - initial.reasoning_effort ?? - currentSubagentModel?.default_effort ?? - "", - ); - const [defaultRepo, setDefaultRepo] = useState(initial.default_repo ?? ""); - - useEffect(() => { - if (currentModel !== undefined && !currentModel.efforts.includes(effort)) { - setEffort(currentModel.default_effort); - } - }, [modelId, currentModel, effort]); - - useEffect(() => { - if ( - currentSubagentModel !== undefined && - !currentSubagentModel.efforts.includes(subagentEffort) - ) { - setSubagentEffort(currentSubagentModel.default_effort); - } - }, [subagentModelId, currentSubagentModel, subagentEffort]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - void onSubmit({ - default_model: modelId, - reasoning_effort: effort, - default_subagent_model: subagentModelId, - subagent_reasoning_effort: subagentEffort, - default_repo: defaultRepo || null, - }); - }; - - return ( -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - {repos.length > 0 ? ( - r.full_name)} - value={defaultRepo} - onValueChange={(v) => setDefaultRepo(typeof v === "string" ? v : "")} - > - - - - No repos match - {repos.map((r) => ( - - {r.full_name} - {r.private && ( - private - )} - - ))} - - - - ) : ( - setDefaultRepo(e.target.value)} - /> - )} -
- - {error &&

{error}

} - -
- -
-
- ); -} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 10511601..23160ab2 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -127,6 +127,13 @@ export interface UserMappingUpsert { slack_user_id?: string | null; } +export interface UserMappingsPage { + items: Array; + total: number; + page: number; + page_size: number; +} + export interface Repository { full_name: string; private: boolean; @@ -205,13 +212,10 @@ export const api = { method: "PUT", body: JSON.stringify({ full_name, enabled }), }), - adminListProfiles: () => request>("/admin/profiles"), - adminSaveProfile: (login: string, body: ProfileUpdate & { email?: string }) => - request(`/admin/profiles/${encodeURIComponent(login)}`, { - method: "PUT", - body: JSON.stringify(body), - }), - adminListUserMappings: () => request>("/admin/user-mappings"), + adminListUserMappings: (page = 1, pageSize = 20) => + request( + `/admin/user-mappings?page=${page}&page_size=${pageSize}`, + ), adminSaveUserMapping: (body: UserMappingUpsert) => request("/admin/user-mappings", { method: "PUT", @@ -222,8 +226,6 @@ export const api = { `/admin/user-mappings/${encodeURIComponent(github_login)}`, { method: "DELETE" }, ), - adminImportUserMappings: () => - request<{ created: number }>("/admin/user-mappings/import", { method: "POST" }), logout: () => request("/auth/logout", { method: "POST" }), }; diff --git a/ui/src/routes/admin.tsx b/ui/src/routes/admin.tsx index 0cdeb987..b4cc1d57 100644 --- a/ui/src/routes/admin.tsx +++ b/ui/src/routes/admin.tsx @@ -2,15 +2,8 @@ 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, - UserMapping, -} from "@/lib/api"; +import type { ModelOption, 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 { @@ -28,9 +21,6 @@ export const Route = createFileRoute("/admin")({ component: AdminPage }); function AdminPage() { const session = useSession(); - const qc = useQueryClient(); - const [selected, setSelected] = useState(null); - const [error, setError] = useState(null); const options = useQuery({ queryKey: ["options"], @@ -38,22 +28,6 @@ function AdminPage() { enabled: !!session.data?.is_admin, }); - const profiles = useQuery({ - queryKey: ["adminProfiles"], - queryFn: api.adminListProfiles, - enabled: !!session.data?.is_admin, - }); - - const save = useMutation({ - mutationFn: ({ login, body }: { login: string; body: ProfileUpdate }) => - api.adminSaveProfile(login, body), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["adminProfiles"] }); - setError(null); - }, - onError: (e: Error) => setError(e.message), - }); - if (session.isLoading) { return (
@@ -64,77 +38,44 @@ function AdminPage() { if (!session.data) return ; if (!session.data.is_admin) return ; - const activeProfile: Profile | null = - (selected && profiles.data?.find((p) => p.login === selected)) || null; - return ( - - -
-
- {profiles.isLoading ? ( - - ) : ( - profiles.data?.map((p) => ( - - )) - )} -
-
- {!activeProfile ? ( -

- Pick a user on the left to edit their profile. -

- ) : options.isLoading ? ( - - ) : ( - - save.mutateAsync({ login: activeProfile.login!, body }) - } - saving={save.isPending} - error={error} - /> - )} -
-
-
); } +const PAGE_SIZE = 20; + function UserMappingsSection({ enabled }: { enabled: boolean }) { const qc = useQueryClient(); const [login, setLogin] = useState(""); const [email, setEmail] = useState(""); const [slackId, setSlackId] = useState(""); const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); + const [page, setPage] = useState(1); const mappings = useQuery({ - queryKey: ["adminUserMappings"], - queryFn: api.adminListUserMappings, + queryKey: ["adminUserMappings", page], + queryFn: () => api.adminListUserMappings(page, PAGE_SIZE), enabled, }); + const total = mappings.data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + useEffect(() => { + if (!mappings.isFetching && page > pageCount) { + setPage(pageCount); + } + }, [mappings.isFetching, page, pageCount]); + const invalidate = () => void qc.invalidateQueries({ queryKey: ["adminUserMappings"] }); @@ -150,7 +91,6 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) { setEmail(""); setSlackId(""); setError(null); - setNotice(null); invalidate(); }, onError: (e: Error) => setError(e.message), @@ -162,15 +102,7 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) { 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), - }); + const items = mappings.data?.items ?? []; return ( -
- - {notice && {notice}} - {error && {error}} -
+ {error && {error}}
{mappings.isLoading ? ( - ) : !mappings.data?.length ? ( + ) : !items.length ? (

No mappings yet.

) : ( - mappings.data.map((m: UserMapping) => ( + items.map((m: UserMapping) => (
+ + {total > PAGE_SIZE && ( +
+ + {total} mapping{total === 1 ? "" : "s"} · page {page} of {pageCount} + +
+ + +
+
+ )}
);