Compare commits

..

120 Commits

Author SHA1 Message Date
openhands
1f7335fc15 feat: add notifications scope to GitHub OAuth defaultScope
Add the 'notifications' scope to the GitHub identity provider's
defaultScope in the Keycloak realm configuration. This enables
agents to read and manage GitHub notifications via the API
(list notifications, mark as read/done).

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 23:34:45 +00:00
aivong-openhands
e9067237f2 Fix CVE-2025-64340: Update fastmcp to 3.2.0 (#13685)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 20:08:57 +00:00
Tim O'Farrell
cae7d36522 Remove unused startConversation method and dead code (#13876)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 13:24:42 -06:00
Tim O'Farrell
27a2d59c23 Update getUser() to use V1 API endpoint /api/v1/users/git-info (#13875)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 12:23:00 -06:00
Tim O'Farrell
d3d916745a Update Suggestions Service API to use new V1 endpoint with pagination (#13872)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 11:36:15 -06:00
Tim O'Farrell
50f1d332cc Remove V1 enabled flag and agents from frontend (#13871)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 11:14:25 -06:00
Tim O'Farrell
de53245d1b refactor(frontend): Remove unused API methods from conversation-service.api.ts (#13870)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 09:43:33 -06:00
Vasco Schiavo
8c2661638e fix(slack): use markdown_text parameter for proper Markdown rendering in V1 (#13869)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 11:37:20 -04:00
Tim O'Farrell
bdbaba0c34 Remove unused searchEventsV0 method from EventService (#13865)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 09:20:58 -06:00
Tim O'Farrell
d866d735d9 refactor(frontend): Remove V0 conversation creation path (#13823)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 06:58:51 -06:00
Tim O'Farrell
39f3b293f5 Fix: Use container StartedAt for Docker sandbox status grace period calculation (#13841)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 06:58:26 -06:00
Rohit Malhotra
fa4afa9412 fix(enterprise): migrate device_code model to SQLAlchemy 2.0 [2/13] (#13848)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 05:13:31 +00:00
Rohit Malhotra
f274d5e90f fix(enterprise): migrate simple storage models to SQLAlchemy 2.0 [1/13] (#13847)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 01:04:05 -04:00
Rohit Malhotra
dd5eb69c65 fix(enterprise): enable SQLAlchemy 2.0 type checking foundation (#13846)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-10 00:42:17 -04:00
OpenHands Bot
21d86b6b5e fix: redact MCP server secrets from log output (#13840)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: simonrosenberg <157206163+simonrosenberg@users.noreply.github.com>
2026-04-09 19:02:28 -03:00
OpenHands Bot
2c2e37902f fix: redact session_api_key from uvicorn WebSocket access logs (#13839)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: simonrosenberg <157206163+simonrosenberg@users.noreply.github.com>
2026-04-09 19:02:23 -03:00
Tim O'Farrell
f7f029ec1a Removed the path for creating V0 conversations in the API. (#13837)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-09 15:10:27 -06:00
Graham Neubig
3e9017bb6e Remove CODEOWNERS file (#13833)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-09 16:55:58 -04:00
Tim O'Farrell
78e48ace2d Remove microagent management UI (#13835)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-09 13:18:24 -06:00
chuckbutkus
60ece6d7c2 feat: Add organization/authorization info to /api/v1/users/me endpoint (#13822)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-04-09 14:37:13 -04:00
Vasco Schiavo
738e7a9834 feat(frontend): render GFM tables with visible borders in chat messages (#13825) 2026-04-09 16:16:13 +07:00
aivong-openhands
8b4a1f9763 Fix CVE-2026-34591: Update poetry to 2.3.3 (#13711)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-09 00:07:42 +02:00
Tim O'Farrell
0804abec80 Remove V0-only feedback functionality (#13821)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 13:48:41 -06:00
Tim O'Farrell
06c3d9c17b Remove microagent functionality from frontend code (#13820)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-08 12:19:44 -06:00
Tim O'Farrell
754a96e7f3 chore(frontend): remove unused hooks and code (#13810)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 13:10:19 -06:00
Tim O'Farrell
211b73a088 Refactor conversation list to use V1 API (#13803)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 12:35:11 -06:00
Hiep Le
54041dd093 feat: remove ENABLE_ORG_CLAIMS_RESOLVER_ROUTING feature flag (#13809) 2026-04-08 00:55:36 +07:00
Hiep Le
f271346724 feat(backend): route Jira resolver conversations to claimed org workspaces (#13805) 2026-04-07 23:58:52 +07:00
Hiep Le
d6a0dd7fe4 feat(backend): route Linear resolver conversations to claimed org workspaces (#13804) 2026-04-07 23:22:48 +07:00
Tim O'Farrell
e46bcfa82f Add V1 API endpoints for git search and branches (#13794)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 06:52:56 -06:00
Tim O'Farrell
2eefa5edfd Deprecate /api/options/models, add /api/v1/config/models/search endpoint (#13799)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-07 06:51:49 -06:00
Ray Myers
54858c0fc0 ci: retire Blacksmith from all GitHub Actions workflows (#13795)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 16:51:09 -05:00
Rohit Malhotra
384c324652 fix(slack): immediately display 'No Repository' option (#13791)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 14:21:59 -04:00
Tim O'Farrell
4e68f57807 Add V1 git routes with pagination for installations and repositories (#13790)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 12:01:22 -06:00
Jamie Chicago
649ebc4078 Succinct pr template (#13779)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 19:05:24 +02:00
Tim O'Farrell
e3246c27d4 Added new v1 endpoint for user git info and deprecated old endpoint (#13787)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 09:54:24 -06:00
Ray Myers
72194f19db chore: Add sdk to mypy checking and fix the resulting errors (#13637)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2026-04-06 11:43:31 -04:00
gpothier
0c5e30ab33 Add KVM device passthrough support for hardware virtualization (#13618)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-04-06 14:57:58 +00:00
simonrosenberg
b8f2932b02 fix(security): redact credentials from MCP config logging (#13720)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-06 08:46:42 -06:00
dependabot[bot]
62673c028a chore(deps): bump the version-all group across 1 directory with 7 updates (#13774)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-04-06 08:39:09 -06:00
Hiep Le
7af2285fe6 fix(backend): custom API key overwritten when using non-OpenHands provider in basic view (#13785) 2026-04-06 21:14:14 +07:00
Hiep Le
69d281c6be fix(frontend): prevent budget/credit error banner from disappearing immediately (#13786) 2026-04-06 21:13:30 +07:00
Jamie Chicago
8ce3089a68 Add contributors section to README (#13696)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-04 01:27:45 +02:00
Tim O'Farrell
b9b10ebf5e APP-1197 Mark conversation endpoints as deprecated with updated docs (#13775)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 14:45:32 -06:00
Tim O'Farrell
ce6d5b77c4 Add more endpoints as deprecated (microagent repository endpoints) (#13776)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 20:45:14 +00:00
simonrosenberg
a458c9b785 Fix credential leak in callback event logging (#13718)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:29:26 +00:00
Hiep Le
a65ddc3db6 feat(backend): route Slack resolver conversations to claimed org workspaces (#13758) 2026-04-04 03:09:21 +07:00
Tim O'Farrell
732a1c1991 APP-1197 Migrate secrets endpoints to V1 API (#13770)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 14:06:51 -06:00
Hiep Le
d058323a87 feat(backend): route gitlab resolver conversations to claimed org workspaces (#13755) 2026-04-04 02:27:46 +07:00
aivong-openhands
7d04cffe4e Fix CVE-2026-25645: Update requests to 2.33.1 (#13692)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-03 13:55:31 -05:00
Hiep Le
6ad27b77bb feat(backend): route resolver conversations to claimed org workspaces (#13713) 2026-04-04 01:32:43 +07:00
aivong-openhands
2739fc8fbe Fix CVE-2026-22815: Update aiohttp to 3.13.5 (#13705)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-03 13:21:05 -05:00
dependabot[bot]
38b7e10252 chore(deps): bump the security-all group across 1 directory with 2 updates (#13764)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 11:46:46 -05:00
mamoodi
7b7d1c0c55 Update CODEOWNERS (#13762) 2026-04-03 12:01:58 -04:00
Tim O'Farrell
e38eda4ac9 APP-1197 Migrate settings endpoints to V1 API (/api/v1/settings) (#13759)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 09:38:24 -06:00
aivong-openhands
99c19b6ef0 enterprise lock update openhands aci to version already in openhands (#13704)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 09:57:14 -04:00
Jathin Sreenivas
0731e8c68a feat(frontend): Display LLM model on conversation cards and header (#13616)
Co-authored-by: Jathin Sreenivas <sjathin@amazon.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-03 17:57:37 +07:00
Tim O'Farrell
0a9570eea2 APP-1197 Consolidate health routes to app_server package (#13724)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-04-02 21:34:40 -06:00
Rohit Malhotra
c00f90bf86 feat: add tags storage for conversation metadata (#13680)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 00:54:27 +00:00
aivong-openhands
1bbf699498 Add Laminar redirect URI to Keycloak allhands client (#13666)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-02 14:15:59 -05:00
Rohit Malhotra
f76517732d Add git to app container runtime dependencies (#13715)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-02 14:43:23 -04:00
Hiep Le
7bb567734d feat(frontend): replace mocked git conversation routing with real API integration (#13698) 2026-04-03 01:05:28 +07:00
aivong-openhands
45f0c77f36 Fix CVE-2026-33699: Update pypdf to 6.9.2 (#13689)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-02 11:14:39 -05:00
dependabot[bot]
fe3d33f222 chore(deps): bump the security-all group across 1 directory with 2 updates (#13706)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 10:57:05 -05:00
dependabot[bot]
2b53d44c2a chore(deps): bump the security-all group across 1 directory with 1 update (#13607)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-04-02 10:32:36 -04:00
dependabot[bot]
0541cb58b2 chore(deps): bump dawidd6/action-download-artifact from 6 to 15 (#13001)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-04-02 09:55:12 -04:00
Hiep Le
5d593ca6e4 feat(backend): add API endpoints to claim and disconnect git organizations (#13683) 2026-04-02 12:35:30 +07:00
Jamie Chicago
2158e30e87 Fix README intro link formatting (#13695)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-02 02:32:01 +02:00
aivong-openhands
7b4ae66e5a fix: upgrade pip to fix CVE-2025-8869 (#13640)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-04-01 16:53:11 -05:00
Graham Neubig
3e1e8f00f7 refactor: single source of truth for verified models (#13421)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Juan Michelini <juan@juan.com.uy>
2026-04-01 18:00:29 -03:00
Joe Laverty
74a69b2dcc ci: add cloud-semver tag support for enterprise image (#13687) 2026-04-01 14:50:15 -04:00
mamoodi
fc36913518 ci: skip PyPI release for cloud- tags (#13686)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-01 13:18:51 -04:00
Engel Nyst
c788674b41 fix: remove resolver summary language hint (#13684)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-01 16:35:28 +02:00
dependabot[bot]
849548a132 chore(deps): bump actions/stale from 9 to 10 (#12261)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2026-03-31 16:34:21 -04:00
dependabot[bot]
c73e22d7cd chore(deps): bump actions/download-artifact from 6 to 7 (#12260)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-31 16:25:58 -04:00
dependabot[bot]
6304f9f4c5 chore(deps): bump actions/checkout from 4 to 6 (#12259)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-31 16:25:24 -04:00
dependabot[bot]
93be4d9d0b chore(deps): bump peter-evans/find-comment from 3 to 4 (#12190)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-31 16:23:51 -04:00
Hiep Le
ec66250e74 feat(backend): develop api to retrieve git organizations for the current organization (#13676) 2026-04-01 01:31:14 +07:00
Engel Nyst
dbd199e77c Validate selected branch names before checkout (#13667)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-31 18:21:21 +02:00
Jamie Chicago
f0c454caf1 Improve README trusted-by logos across light and dark themes (#13659)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-31 12:18:24 -04:00
Hiep Le
df3360005c feat(frontend): add Git Conversation Routing section for org claims UI (#13668) 2026-03-31 22:14:45 +07:00
Jamie Chicago
df4fea6aca Revert "[fix] maintainer doc" (#13673) 2026-03-31 11:09:58 -04:00
Hiep Le
2b3868ddc3 feat(frontend): add feature flag for organization claims resolver routing (#13669) 2026-03-31 21:39:36 +07:00
Joe Laverty
e3c9fa9d05 Remove unused KEYCLOAK_PROVIDER_NAME constant (#13663)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-31 09:46:25 -04:00
Hiep Le
2fec71320a fix(frontend): pin axios version to mitigate supply chain attack (#13670) 2026-03-31 19:29:02 +07:00
Hiep Le
9c0f5d785e fix(backend): persist disabled_skills in SaaS settings store (#13658) 2026-03-31 02:23:08 +07:00
Tim O'Farrell
73ba66faea Handling the new server error event (#13643)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 10:56:37 -06:00
aivong-openhands
a198599d91 docs(AGENTS.md): add guidance to preserve tool versions when regenerating lockfiles (#13561)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 12:23:39 -04:00
mamoodi
7e20bd51f9 Release 1.6.0 (#13604)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 12:01:16 -04:00
Hiep Le
b75c83d92a fix(frontend): prevent duplicate payment successful toast after Stripe checkout (#13649) 2026-03-30 22:36:35 +07:00
Hiep Le
5528b01c18 refactor(frontend): replace loading spinner with static icon for task tracking (#13625) 2026-03-30 20:32:11 +07:00
Hiep Le
ed5ab11fcc fix: planning agent auth error due to missing base_url (#13638) 2026-03-30 20:32:02 +07:00
Hiep Le
e1afc95b6c fix(frontend): hide right panel when active tab is unpinned (#13648) 2026-03-30 20:31:48 +07:00
Tim O'Farrell
6dd9046ba2 Fix issue where git setup fails on remote sandboxed when grouping. (#13646)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 12:58:42 +00:00
Xingyao Wang
9ad47bf43f fix: prevent V0 conversation creation due to settings race condition (#13628)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-30 13:11:25 +01:00
Jathin Sreenivas
b0d8244ad5 fix(frontend): prevent "Unknown event" shown for actions with empty d… (#13639)
Co-authored-by: Jathin Sreenivas <sjathin@amazon.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-30 16:49:25 +07:00
Karanja
c210d5294f feat: add /new to slash command menu for V1 conversations (#13599) 2026-03-30 15:39:35 +07:00
Tim O'Farrell
c7190ddb30 APP-1153 Fix for issue where popup menu does not display (#13635)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-28 07:47:13 -06:00
Hiep Le
df64ce9668 fix(frontend): reduce padding and gap for chat status indicator (#13624) 2026-03-28 01:39:02 +07:00
Jamie Chicago
f72a9622f6 [fix] maintainer doc (#13632)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 14:33:48 -04:00
Tim O'Farrell
193eb34dc7 fix(migration): serialize dict to JSON string in migration 103 (#13634)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 18:22:31 +00:00
Hiep Le
87f582db6a fix(frontend): tab icon overflow on mobile devices (#13627) 2026-03-28 00:25:39 +07:00
Hiep Le
4b69370c73 fix(frontend): set max width for toast messages (#13623) 2026-03-28 00:25:26 +07:00
Hiep Le
74ac6e06a1 refactor(frontend): add white background color on learn more button hover (user journey project) (#13621) 2026-03-28 00:25:12 +07:00
Hiep Le
a91dceacfb fix(frontend): add missing border radius to diff view (#13620) 2026-03-28 00:25:01 +07:00
Joe Laverty
98c61e1ee4 feat(enterprise): acquire pg_advisory_lock before running database migrations (#13608) 2026-03-27 23:24:49 +07:00
Tim O'Farrell
3268c29945 APP-1152 Add legacy fallback variable when finding persistence directory (#13629) 2026-03-27 10:18:13 -06:00
Engel Nyst
239e40da75 Fix: restore conversation link in PR bodies created via MCP (#13092)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 11:25:34 -04:00
Jamie Chicago
d190d8ee50 Add trusted-by logos to top of README (#13613)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 15:32:39 +01:00
aivong-openhands
5f064fa88b PLTF-330: log module funcName and lineno in enterprise (#13612)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-27 09:18:02 -05:00
Vasco Schiavo
8f87ef59c7 feat(frontend): Add view mode toggle (old/diff/new) to file changes viewer (#13519)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-27 19:16:20 +07:00
Vasco Schiavo
fdc6ba82c9 feat(frontend): Display skill ready events as expandable skill list in chat (#13511)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-27 18:57:47 +07:00
Hiep Le
a75038bee0 fix: user does not immediately appear in org after accepting invite in openhands cloud (#13562) 2026-03-27 14:37:38 +07:00
Hiep Le
fbe6eb30cb feat(backend): add organization members financial data endpoint (#13595) 2026-03-27 12:18:46 +07:00
Hiep Le
aeda0ea762 feat(frontend): display toast notification when switching organizations (#13598) 2026-03-27 12:18:17 +07:00
Hiep Le
30b7af31b9 feat(frontend): add contextual info messages on LLM settings page (org project) (#13601) 2026-03-27 12:17:58 +07:00
Hiep Le
05a3916c98 feat(frontend): use LoginCTA in device verify with source-specific Learn more behavior (#13606) 2026-03-27 12:17:38 +07:00
Tim O'Farrell
eba1f60c1d Reduced thrash on sandbox service (#13610)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-03-26 15:29:59 -06:00
OpenHands Bot
024f4d3326 Bump SDK packages to v1.15.0 (#13602)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-03-26 14:34:17 -06:00
Ray Myers
3e38f13d12 perf: speed up Docker builds — amd64-only PRs, eliminate cross-layer chmod/chown bloat (#13590)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-03-26 11:57:31 -06:00
486 changed files with 22523 additions and 19393 deletions

8
.github/CODEOWNERS vendored
View File

@@ -1,8 +0,0 @@
# CODEOWNERS file for OpenHands repository
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
/frontend/ @amanape @hieptl
/openhands-ui/ @amanape @hieptl
/openhands/ @tofarr @malhotra5 @hieptl
/enterprise/ @chuckbutkus @tofarr @malhotra5
/evaluation/ @xingyaoww @neubig

View File

@@ -1,38 +1,46 @@
<!-- If you are still working on the PR, please mark it as draft. Maintainers will review PRs marked ready for review, which leads to lost time if your PR is actually not ready yet. Keep the PR marked as draft until it is finally ready for review -->
<!-- Keep this PR as draft until it is ready for review. -->
## Summary of PR
<!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
<!-- Summarize what the PR does -->
- [ ] A human has tested these changes.
## Demo Screenshots/Videos
---
<!-- AI/LLM AGENTS: This section is intended for a human author to add screenshots or videos demonstrating the PR in action (optional). While many pull requests may be generated by AI/LLM agents, we are fine with this as long as a human author has reviewed and tested the changes to ensure accuracy and functionality. -->
## Why
## Change Type
<!-- Describe problem, motivation, etc.-->
<!-- Choose the types that apply to your PR -->
## Summary
<!-- 1-3 bullets describing what changed. -->
-
## Issue Number
<!-- Required if there is a relevant issue to this PR. -->
## How to Test
<!--
Required. Share the steps for the reviewer to be able to test your PR. e.g. You can test by running `npm install` then `npm build dev`.
If you could not test this, say why.
-->
## Video/Screenshots
<!--
Provide a video or screenshots of testing your PR. e.g. you added a new feature to the gui, show us the video of you testing it successfully.
-->
## Type
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Feature
- [ ] Refactor
- [ ] Other (dependency update, docs, typo fixes, etc.)
- [ ] Breaking change
- [ ] Docs / chore
## Checklist
<!-- AI/LLM AGENTS: This checklist is for a human author to complete. Do NOT check either of the two boxes below. Leave them unchecked until a human has personally reviewed and tested the changes. -->
## Notes
- [ ] I have read and reviewed the code and I understand what the code is doing.
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
## Fixes
<!-- If this resolves an issue, link it here so it will close automatically upon merge. -->
Resolves #(issue)
## Release Notes
<!-- Check the box if this change is worth adding to the release notes. If checked, you must provide an
end-user friendly description for your change below the checkbox. -->
- [ ] Include this change in the Release Notes.
<!-- Optional: migrations, config changes, rollout concerns, follow-ups, or anything reviewers should know. -->

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install poetry via pipx
uses: abatilo/actions-poetry@v4

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
@@ -34,7 +34,7 @@ jobs:
fi
- name: Find Comment
uses: peter-evans/find-comment@v3
uses: peter-evans/find-comment@v4
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}

View File

@@ -17,18 +17,20 @@ concurrency:
jobs:
fe-e2e-test:
name: FE E2E Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [22]
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci

View File

@@ -21,18 +21,20 @@ jobs:
# Run frontend unit tests
fe-test:
name: FE Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [22]
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci

View File

@@ -10,7 +10,6 @@ on:
branches:
- main
- "saas-rel-*"
- "jl/debug-webhook-auth"
tags:
- "*"
pull_request:
@@ -31,37 +30,42 @@ env:
jobs:
define-matrix:
runs-on: blacksmith
runs-on: ubuntu-latest
outputs:
base_image: ${{ steps.define-base-images.outputs.base_image }}
platforms: ${{ steps.define-base-images.outputs.platforms }}
steps:
- name: Define base images
shell: bash
id: define-base-images
run: |
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" }
platforms="linux/amd64"
json=$(jq -n -c --arg platforms "$platforms" '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms }
]')
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
platforms="linux/amd64,linux/arm64"
json=$(jq -n -c --arg platforms "$platforms" '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms },
{ image: "ubuntu:24.04", tag: "ubuntu", platforms: $platforms }
]')
fi
echo "base_image=$json" >> "$GITHUB_OUTPUT"
echo "platforms=$platforms" >> "$GITHUB_OUTPUT"
# Builds the OpenHands Docker images
ghcr_build_app:
name: Build App Image
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
needs: define-matrix
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -83,12 +87,12 @@ jobs:
- name: Build and push app image
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push -p ${{ needs.define-matrix.outputs.platforms }}
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Runtime Image
runs-on: blacksmith-8vcpu-ubuntu-2204
runs-on: ubuntu-22.04
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
@@ -99,7 +103,7 @@ jobs:
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
@@ -118,7 +122,7 @@ jobs:
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: poetry
@@ -137,7 +141,7 @@ jobs:
shell: bash
run: |
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry -p ${{ matrix.base_image.platforms }}
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
@@ -145,7 +149,7 @@ jobs:
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
- name: Build and push runtime image ${{ matrix.base_image.image }}
if: github.event.pull_request.head.repo.fork != true
uses: useblacksmith/build-push-action@v1
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ env.DOCKER_TAGS }}
@@ -159,7 +163,7 @@ jobs:
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
- name: Build runtime image ${{ matrix.base_image.image }} for fork
if: github.event.pull_request.head.repo.fork
uses: useblacksmith/build-push-action@v1
uses: docker/build-push-action@v6
with:
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
@@ -172,7 +176,7 @@ jobs:
ghcr_build_enterprise:
name: Push Enterprise Image
runs-on: blacksmith-8vcpu-ubuntu-2204
runs-on: ubuntu-22.04
permissions:
contents: read
packages: write
@@ -181,7 +185,7 @@ jobs:
if: github.event.pull_request.head.repo.fork != true
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
@@ -211,6 +215,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=match,pattern=cloud-\d+\.\d+\.\d+
flavor: |
latest=auto
prefix=
@@ -224,7 +229,7 @@ jobs:
# rather than a mutable branch tag like "main" which can serve stale cached layers.
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
- name: Build and push Docker image
uses: useblacksmith/build-push-action@v1
uses: docker/build-push-action@v6
with:
context: .
file: enterprise/Dockerfile
@@ -243,7 +248,7 @@ jobs:
# We can remove this once the config changes
runtime_tests_check_success:
name: All Runtime Tests Passed
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
@@ -252,10 +257,10 @@ jobs:
name: Update PR Description
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
needs: [ghcr_build_runtime]
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Get short SHA
id: short_sha

View File

@@ -9,12 +9,12 @@ jobs:
lint-fix-frontend:
if: github.event.label.name == 'lint-fix'
name: Fix frontend linting issues
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -22,13 +22,14 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: |
cd frontend
npm install --frozen-lockfile
working-directory: ./frontend
run: npm ci
- name: Generate i18n and route types
run: |
cd frontend
@@ -58,12 +59,12 @@ jobs:
lint-fix-python:
if: github.event.label.name == 'lint-fix'
name: Fix Python linting issues
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -71,7 +72,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: 3.12
cache: "pip"

View File

@@ -19,34 +19,35 @@ jobs:
# Run lint on the frontend code
lint-frontend:
name: Lint frontend
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: |
cd frontend
npm install --frozen-lockfile
working-directory: ./frontend
run: npm ci
- name: Lint, TypeScript compilation, and translation checks
run: |
cd frontend
npm run lint
npm run make-i18n && tsc
npm run make-i18n && npx tsc
npm run check-translation-completeness
# Run lint on the python code
lint-python:
name: Lint python
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: 3.12
cache: "pip"
@@ -57,13 +58,13 @@ jobs:
lint-enterprise-python:
name: Lint enterprise python
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: 3.12
cache: "pip"

View File

@@ -18,7 +18,7 @@ concurrency:
jobs:
check-version:
name: Check if version has changed
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
defaults:
run:
shell: bash
@@ -27,7 +27,7 @@ jobs:
current-version: ${{ steps.version-check.outputs.current-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 2 # Need previous commit to compare
@@ -55,7 +55,7 @@ jobs:
publish:
name: Publish to npm
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
defaults:
@@ -63,7 +63,7 @@ jobs:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -86,7 +86,7 @@ jobs:
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -28,7 +28,7 @@ jobs:
steps:
- name: Download review trace artifact
id: download-trace
uses: dawidd6/action-download-artifact@v6
uses: dawidd6/action-download-artifact@v15
continue-on-error: true
with:
workflow: pr-review-by-openhands.yml

View File

@@ -19,7 +19,7 @@ jobs:
# Run python tests on Linux
test-on-linux:
name: Python Tests on Linux
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-24.04
env:
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
strategy:
@@ -30,20 +30,22 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install tmux
run: sudo apt-get update && sudo apt-get install -y tmux
- name: Setup Node.js
uses: useblacksmith/setup-node@v5
uses: actions/setup-node@v4
with:
node-version: "22.x"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -73,16 +75,16 @@ jobs:
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: ["3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
@@ -111,9 +113,9 @@ jobs:
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
id: download
with:
pattern: coverage-*

View File

@@ -17,14 +17,14 @@ on:
jobs:
release:
runs-on: blacksmith-4vcpu-ubuntu-2204
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli'
runs-on: ubuntu-22.04
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli' and don't start with 'cloud-'
if: |
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli'))
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
steps:
- uses: actions/checkout@v4
- uses: useblacksmith/setup-python@v6
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Install Poetry

View File

@@ -8,10 +8,10 @@ on:
jobs:
stale:
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
if: github.repository == 'OpenHands/OpenHands'
steps:
- uses: actions/stale@v9
- uses: actions/stale@v10
with:
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'

View File

@@ -19,10 +19,10 @@ concurrency:
jobs:
ui-build:
name: Build openhands-ui
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"

View File

@@ -36,6 +36,42 @@ then re-run the command to ensure it passes. Common issues include:
- Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files
- When remote has new changes, use `git fetch upstream && git rebase upstream/<branch>` on the same branch
## Lockfile Regeneration (Preserve Original Tool Versions)
When regenerating lockfiles (poetry.lock, uv.lock, etc.), you MUST use the same tool version that originally generated the lockfile to avoid unnecessary diff noise. Each lockfile contains a version header indicating which tool version was used.
### Poetry (poetry.lock)
1. Extract the version from the lockfile header:
```bash
POETRY_VERSION=$(grep -m1 "^# This file is automatically @generated by Poetry" poetry.lock | sed 's/.*Poetry \([0-9.]*\).*/\1/')
```
2. If a version is found, install that specific version:
```bash
pipx install poetry==$POETRY_VERSION --force
```
3. Then regenerate the lockfile:
```bash
poetry lock --no-update
```
### uv (uv.lock)
1. Extract the version from the lockfile header:
```bash
UV_VERSION=$(grep -m1 "^# This file was autogenerated by uv" uv.lock | sed 's/.*uv version \([0-9.]*\).*/\1/')
```
2. If a version is found, install that specific version:
```bash
pipx install uv==$UV_VERSION --force
```
3. Then regenerate the lockfile:
```bash
uv lock
```
This ensures that lockfile updates only contain actual dependency changes, not tool version migration artifacts.
## PR-Specific Artifacts (`.pr/` directory)
When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root.

View File

@@ -23,7 +23,6 @@
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=pt">Português</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=ru">Русский</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=zh">中文</a>
</div>
<hr>
@@ -84,3 +83,71 @@ All our work is available under the MIT license, except for the `enterprise/` di
The core `openhands` and `agent-server` Docker images are fully MIT-licensed as well.
If you need help with anything, or just want to chat, [come find us on Slack](https://dub.sh/openhands).
<hr>
### Thank You to Our Contributors
<div align="center">
[![OpenHands Contributors](https://assets.openhands.dev/readme/openhands-openhands-contributors.svg)](https://github.com/OpenHands/OpenHands/graphs/contributors)
</div>
<hr>
### Trusted by Engineers at
<div align="center">
<br/><br/>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
<img src="https://assets.openhands.dev/logos/external/black/tiktok.svg" alt="TikTok" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/vmware.svg">
<img src="https://assets.openhands.dev/logos/external/black/vmware.svg" alt="VMware" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/roche.svg">
<img src="https://assets.openhands.dev/logos/external/black/roche.svg" alt="Roche" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/amazon.svg">
<img src="https://assets.openhands.dev/logos/external/black/amazon.svg" alt="Amazon" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/c3-ai.svg">
<img src="https://assets.openhands.dev/logos/external/black/c3-ai.svg" alt="C3 AI" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/netflix.svg">
<img src="https://assets.openhands.dev/logos/external/black/netflix.svg" alt="Netflix" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mastercard.svg">
<img src="https://assets.openhands.dev/logos/external/black/mastercard.svg" alt="Mastercard" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/red-hat.svg">
<img src="https://assets.openhands.dev/logos/external/black/red-hat.svg" alt="Red Hat" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/mongodb.svg">
<img src="https://assets.openhands.dev/logos/external/black/mongodb.svg" alt="MongoDB" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/apple.svg">
<img src="https://assets.openhands.dev/logos/external/black/apple.svg" alt="Apple" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/nvidia.svg">
<img src="https://assets.openhands.dev/logos/external/black/nvidia.svg" alt="NVIDIA" height="17" hspace="5">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/google.svg">
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
</picture>
</div>
</div>

View File

@@ -20,9 +20,11 @@ ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
# Pin Poetry version to match the version used to generate poetry.lock
ARG POETRY_VERSION=2.3.3
RUN apt-get update -y \
&& apt-get install -y curl make git build-essential jq gettext \
&& python3 -m pip install poetry --break-system-packages
&& python3 -m pip install "poetry==${POETRY_VERSION}" --break-system-packages
COPY pyproject.toml poetry.lock ./
RUN touch README.md
@@ -50,7 +52,7 @@ RUN mkdir -p $FILE_STORE_PATH
RUN mkdir -p $WORKSPACE_BASE
RUN apt-get update -y \
&& apt-get install -y curl ssh sudo \
&& apt-get install -y curl git ssh sudo \
&& rm -rf /var/lib/apt/lists/*
# Default is 1000, but OSX is often 501
@@ -73,6 +75,17 @@ ENV VIRTUAL_ENV=/app/.venv \
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
# Pin pip to a known-good version (reproducible builds) and fix CVE-2025-8869
# Pin both venv pip and system pip (Trivy scans both)
# - `python -m pip` uses the venv because `PATH` is prefixed with `${VIRTUAL_ENV}/bin`
# - `/usr/local/bin/python3 -m pip` uses the system interpreter regardless of `PATH`
ARG PIP_VERSION=26.0.1
RUN python -m pip install --no-cache-dir "pip==${PIP_VERSION}"
USER root
RUN /usr/local/bin/python3 -m pip install --no-cache-dir "pip==${PIP_VERSION}" --break-system-packages
USER openhands
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins

View File

@@ -8,15 +8,17 @@ push=0
load=0
tag_suffix=""
dry_run=0
platform_override=""
# Function to display usage information
usage() {
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry]"
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [-p <platform>] [--dry]"
echo " -i: Image name (required)"
echo " -o: Organization name"
echo " --push: Push the image"
echo " --load: Load the image"
echo " -t: Tag suffix"
echo " -p: Platform(s) to build for (e.g. linux/amd64 or linux/amd64,linux/arm64)"
echo " --dry: Don't build, only create build-args.json"
exit 1
}
@@ -29,6 +31,7 @@ while [[ $# -gt 0 ]]; do
--push) push=1; shift ;;
--load) load=1; shift ;;
-t) tag_suffix="$2"; shift 2 ;;
-p) platform_override="$2"; shift 2 ;;
--dry) dry_run=1; shift ;;
*) usage ;;
esac
@@ -134,8 +137,10 @@ fi
echo "Args: $args"
# Modify the platform selection based on --load flag
if [[ $load -eq 1 ]]; then
# Determine the platform(s) to build for
if [[ -n "$platform_override" ]]; then
platform="$platform_override"
elif [[ $load -eq 1 ]]; then
# When loading, build only for the current platform
platform=$(docker version -f '{{.Server.Os}}/{{.Server.Arch}}')
else

View File

@@ -13,7 +13,7 @@ services:
- DOCKER_HOST_ADDR=host.docker.internal
#
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -58,6 +58,8 @@ repos:
types-Markdown,
pydantic,
lxml,
"openhands-sdk==1.14",
"openhands-tools==1.14",
]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/

View File

@@ -14,3 +14,11 @@ exclude = (third_party/|enterprise/)
[mypy-openhands.memory.condenser.impl.*]
disable_error_code = override
[mypy-openai.*]
follow_imports = skip
ignore_missing_imports = True
[mypy-litellm.*]
follow_imports = skip
ignore_missing_imports = True

View File

@@ -8,7 +8,7 @@ services:
container_name: openhands-app-${DATE:-}
environment:
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.12.0-python}
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.15.0-python}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -723,11 +723,13 @@
"https://$WEB_HOST/slack/keycloak-callback",
"https://$WEB_HOST/oauth/device/keycloak-callback",
"https://$WEB_HOST/api/email/verified",
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*"
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*",
"https://laminar.$WEB_HOST/api/auth/callback/keycloak"
],
"webOrigins": [
"https://$WEB_HOST",
"https://$AUTH_WEB_HOST"
"https://$AUTH_WEB_HOST",
"https://laminar.$WEB_HOST"
],
"notBefore": 0,
"bearerOnly": false,
@@ -1727,7 +1729,7 @@
"syncMode": "IMPORT",
"clientSecret": "$GITHUB_APP_CLIENT_SECRET",
"caseSensitiveOriginalUsername": "false",
"defaultScope": "openid email profile",
"defaultScope": "openid email profile notifications",
"baseUrl": "$GITHUB_BASE_URL"
}
},

View File

@@ -10,6 +10,7 @@ from integrations.github.github_types import (
)
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
@@ -26,6 +27,7 @@ from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.org_store import OrgStore
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_conversation_store import SaasConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from openhands.agent_server.models import SendMessageRequest
@@ -41,16 +43,14 @@ from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.services.conversation_service import start_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.conversation_summary import get_default_conversation_title
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
@@ -154,12 +154,17 @@ class GithubIssue(ResolverViewInterface):
return user_secrets.custom_secrets if user_secrets else None
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
self.v1_enabled = await is_v1_enabled_for_github_resolver(
self.user_info.keycloak_user_id
)
# Resolve target org based on claimed git organizations
self.resolved_org_id = await resolve_org_for_repo(
provider='github',
full_repo_name=self.full_repo_name,
keycloak_user_id=self.user_info.keycloak_user_id,
)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
@@ -173,16 +178,28 @@ class GithubIssue(ResolverViewInterface):
selected_repository=self.full_repo_name,
)
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
self.user_info.keycloak_user_id,
self.resolved_org_id,
)
self.conversation_id = conversation_metadata.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=self.user_info.keycloak_user_id,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
git_provider=ProviderType.GITHUB,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -294,7 +311,10 @@ class GithubIssue(ResolverViewInterface):
)
# Set up the GitHub user context for the V1 system
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
github_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
async with get_app_conversation_service(
@@ -322,7 +342,7 @@ class GithubIssue(ResolverViewInterface):
'full_repo_name': self.full_repo_name,
'installation_id': self.installation_id,
},
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)
@@ -476,7 +496,7 @@ class GithubInlinePRComment(GithubPRComment):
'comment_id': self.comment_id,
},
inline_pr_comment=True,
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)

View File

@@ -3,6 +3,7 @@ from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_V1_GITLAB_RESOLVER,
@@ -14,6 +15,7 @@ from integrations.utils import (
from jinja2 import Environment
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.saas_conversation_store import SaasConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from openhands.agent_server.models import SendMessageRequest
@@ -29,15 +31,13 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.services.conversation_service import start_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
CONFIDENTIAL_NOTE = 'confidential_note'
@@ -118,6 +118,14 @@ class GitlabIssue(ResolverViewInterface):
async def initialize_new_conversation(self) -> ConversationMetadata:
# v1_enabled is already set at construction time in the factory method
# This is the source of truth for the conversation type
# Resolve target org based on claimed git organizations
self.resolved_org_id = await resolve_org_for_repo(
provider='gitlab',
full_repo_name=self.full_repo_name,
keycloak_user_id=self.user_info.keycloak_user_id,
)
if self.v1_enabled:
# Create dummy conversation metadata
# Don't save to conversation store
@@ -128,16 +136,28 @@ class GitlabIssue(ResolverViewInterface):
selected_repository=self.full_repo_name,
)
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITLAB,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
self.user_info.keycloak_user_id,
self.resolved_org_id,
)
self.conversation_id = conversation_metadata.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=self.user_info.keycloak_user_id,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
git_provider=ProviderType.GITLAB,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -228,7 +248,10 @@ class GitlabIssue(ResolverViewInterface):
)
# Set up the GitLab user context for the V1 system
gitlab_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
gitlab_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, gitlab_user_context)
async with get_app_conversation_service(
@@ -260,7 +283,7 @@ class GitlabIssue(ResolverViewInterface):
'is_mr': self.is_mr,
'discussion_id': getattr(self, 'discussion_id', None),
},
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)

View File

@@ -7,6 +7,7 @@ Views are responsible for:
"""
from dataclasses import dataclass, field
from uuid import uuid4
import httpx
from integrations.jira.jira_payload import JiraWebhookPayload
@@ -15,18 +16,25 @@ from integrations.jira.jira_types import (
RepositoryNotFoundError,
StartingConvoException,
)
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
from jinja2 import Environment
from server.config import get_config
from storage.jira_conversation import JiraConversation
from storage.jira_integration_store import JiraIntegrationStore
from storage.jira_user import JiraUser
from storage.jira_workspace import JiraWorkspace
from storage.saas_conversation_store import SaasConversationStore
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import create_new_conversation
from openhands.server.services.conversation_service import start_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
@@ -166,20 +174,68 @@ class JiraNewConversationView(JiraViewInterface):
instructions, user_msg = await self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
user_id=self.jira_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.JIRA,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
user_id = self.jira_user.keycloak_user_id
# Resolve git provider from repository
resolved_git_provider = None
if provider_tokens:
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(
self.selected_repo
)
resolved_git_provider = repository.git_provider
except Exception as e:
logger.warning(
f'[Jira] Failed to resolve git provider for {self.selected_repo}: {e}'
)
# Resolve target org based on claimed git organizations
resolved_org_id = None
if resolved_git_provider and self.selected_repo:
try:
resolved_org_id = await resolve_org_for_repo(
provider=resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=user_id,
)
except Exception as e:
logger.warning(
f'[Jira] Failed to resolve org for {self.selected_repo}: {e}'
)
# Create the conversation store with resolver org routing
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
resolved_org_id,
)
self.conversation_id = agent_loop_info.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.JIRA,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_msg,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=instructions,
)
self.conversation_id = conversation_id
logger.info(
'[Jira] Created conversation',
@@ -187,6 +243,9 @@ class JiraNewConversationView(JiraViewInterface):
'conversation_id': self.conversation_id,
'issue_key': self.payload.issue_key,
'selected_repo': self.selected_repo,
'resolved_org_id': str(resolved_org_id)
if resolved_org_id
else None,
},
)

View File

@@ -1,25 +1,34 @@
from dataclasses import dataclass
from uuid import uuid4
from integrations.linear.linear_types import LinearViewInterface, StartingConvoException
from integrations.models import JobContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
from jinja2 import Environment
from server.config import get_config
from storage.linear_conversation import LinearConversation
from storage.linear_integration_store import LinearIntegrationStore
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from storage.saas_conversation_store import SaasConversationStore
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
start_conversation,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
integration_store = LinearIntegrationStore.get_instance()
@@ -61,20 +70,70 @@ class LinearNewConversationView(LinearViewInterface):
instructions, user_msg = await self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
user_id=self.linear_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_msg,
conversation_instructions=instructions,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.LINEAR,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
user_id = self.linear_user.keycloak_user_id
# Resolve git provider from repository
resolved_git_provider = None
if provider_tokens:
try:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(
self.selected_repo
)
resolved_git_provider = repository.git_provider
except Exception as e:
logger.warning(
f'[Linear] Failed to resolve git provider for {self.selected_repo}: {e}'
)
# Resolve target org based on claimed git organizations
resolved_org_id = None
if resolved_git_provider and self.selected_repo:
try:
resolved_org_id = await resolve_org_for_repo(
provider=resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=user_id,
)
except Exception as e:
logger.warning(
f'[Linear] Failed to resolve org for {self.selected_repo}: {e}'
)
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
resolved_org_id,
)
self.conversation_id = agent_loop_info.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.LINEAR,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_msg,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=instructions,
)
self.conversation_id = conversation_id
logger.info(f'[Linear] Created conversation {self.conversation_id}')

View File

@@ -1,7 +1,9 @@
from uuid import UUID
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.service_types import ProviderType
from openhands.integrations.service_types import ProviderType, UserGitInfo
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
@@ -12,8 +14,10 @@ class ResolverUserContext(UserContext):
def __init__(
self,
saas_user_auth: UserAuth,
resolver_org_id: UUID | None = None,
):
self.saas_user_auth = saas_user_auth
self.resolver_org_id = resolver_org_id
self._provider_handler: ProviderHandler | None = None
async def get_user_id(self) -> str | None:
@@ -81,3 +85,6 @@ class ResolverUserContext(UserContext):
async def get_mcp_api_key(self) -> str | None:
return await self.saas_user_auth.get_mcp_api_key()
async def get_user_git_info(self) -> UserGitInfo | None:
return await self.saas_user_auth.get_user_git_info()

View File

@@ -0,0 +1,68 @@
"""Resolve which OpenHands organization workspace a resolver conversation should be created in.
This module provides a reusable utility for routing resolver conversations
(GitHub, GitLab, Bitbucket, Slack, etc.) to the correct OpenHands organization
workspace based on claimed Git organizations.
"""
from uuid import UUID
from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_member_store import OrgMemberStore
from openhands.core.logger import openhands_logger as logger
async def resolve_org_for_repo(
provider: str,
full_repo_name: str,
keycloak_user_id: str,
) -> UUID | None:
"""Determine the OpenHands org_id for a resolver conversation.
If the repo's git organization is claimed by an OpenHands org AND the user
is a member of that org, returns the claiming org's ID. Otherwise returns
None (caller should fall back to user.current_org_id / personal workspace).
Args:
provider: Git provider name ("github", "gitlab", "bitbucket")
full_repo_name: Full repository name (e.g., "OpenHands/foo")
keycloak_user_id: The user's Keycloak UUID string
Returns:
The org_id if the repo's org is claimed and user is a member, else None
"""
git_org = full_repo_name.split('/')[0].lower()
try:
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider, git_org
)
if not claim:
logger.debug(
f'[OrgResolver] No claim found for {provider}/{git_org}',
)
return None
member = await OrgMemberStore.get_org_member(
claim.org_id, UUID(keycloak_user_id)
)
if not member:
logger.debug(
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
f'{claim.org_id} (claimed {provider}/{git_org}). '
f'Falling back to personal workspace.',
)
return None
logger.info(
f'[OrgResolver] Routing conversation to org {claim.org_id} '
f'for {provider}/{git_org} (user {keycloak_user_id})',
)
return claim.org_id
except Exception as e:
logger.error(
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
exc_info=True,
)
return None

View File

@@ -239,12 +239,14 @@ class SlackManager(Manager[SlackViewInterface]):
def _generate_repo_selection_form(
self, message_ts: str, thread_ts: str | None
) -> list[dict[str, Any]]:
"""Generate a repo selection form using external_select for dynamic loading.
"""Generate a repo selection form with immediate "No Repository" button and search dropdown.
This uses Slack's external_select element which allows:
- Type-ahead search for repositories
- Dynamic loading of options from an external endpoint
- Support for users with many repositories (no 100 option limit)
This form provides two options side-by-side:
1. A "No Repository" button - immediately clickable without any loading
2. An external_select dropdown - for searching repositories dynamically
This design ensures "No Repository" is always immediately available while
still providing full dynamic search capability for repositories.
Args:
message_ts: The message timestamp for tracking
@@ -266,12 +268,22 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'section',
'text': {
'type': 'mrkdwn',
'text': 'Type to search your repositories:',
'text': 'Select a repository or continue without one:',
},
},
{
'type': 'actions',
'elements': [
{
'type': 'button',
'action_id': f'no_repository:{message_ts}:{thread_ts}',
'text': {
'type': 'plain_text',
'text': 'No Repository',
'emoji': True,
},
'value': '-',
},
{
'type': 'external_select',
'action_id': f'repository_select:{message_ts}:{thread_ts}',
@@ -279,8 +291,8 @@ class SlackManager(Manager[SlackViewInterface]):
'type': 'plain_text',
'text': 'Search repositories...',
},
'min_query_length': 0, # Load initial options immediately
}
'min_query_length': 0,
},
],
},
]
@@ -288,8 +300,11 @@ class SlackManager(Manager[SlackViewInterface]):
def _build_repo_options(self, repos: list[Repository]) -> list[dict[str, Any]]:
"""Build Slack options list from repositories.
Always includes a "No Repository" option at the top, followed by up to 99
repositories (Slack has a 100 option limit for external_select).
Returns up to 100 repositories formatted as Slack options
(Slack has a 100 option limit for external_select).
Note: "No Repository" is handled by a separate button in the form,
so it's not included in the dropdown options.
Args:
repos: List of Repository objects
@@ -297,13 +312,7 @@ class SlackManager(Manager[SlackViewInterface]):
Returns:
List of Slack option objects
"""
options: list[dict[str, Any]] = [
{
'text': {'type': 'plain_text', 'text': 'No Repository'},
'value': '-',
}
]
options.extend(
return [
{
'text': {
'type': 'plain_text',
@@ -311,9 +320,8 @@ class SlackManager(Manager[SlackViewInterface]):
},
'value': repo.full_name,
}
for repo in repos[:99] # Leave room for "No Repository" option
)
return options
for repo in repos[:100]
]
async def search_repos_for_slack(
self, user_auth: UserAuth, query: str, per_page: int = 20
@@ -363,33 +371,69 @@ class SlackManager(Manager[SlackViewInterface]):
SlackError(SlackErrorCode.UNEXPECTED_ERROR),
)
async def receive_form_interaction(self, slack_payload: dict):
"""Process a Slack form interaction (repository selection).
def _parse_form_action(self, action: dict) -> tuple[str, str | None, str] | None:
"""Parse action payload and extract message_ts, thread_ts, and selected value.
This handles the block_actions payload when a user selects a repository
from the dropdown form. It retrieves the original user message from Redis
and delegates to receive_message for processing.
This handles the different payload structures for button clicks vs dropdown
selections in the repository selection form.
Args:
action: The action object from the Slack payload
Returns:
Tuple of (message_ts, thread_ts, selected_value) if action is recognized,
None if the action_id is unknown.
"""
action_id = action['action_id']
if action_id.startswith('no_repository:'):
# Button click - value is in 'value' field
attribs = action_id.split('no_repository:')[-1]
selected_value = action.get('value', '-')
elif action_id.startswith('repository_select:'):
# Dropdown selection - value is in 'selected_option'
attribs = action_id.split('repository_select:')[-1]
selected_value = action['selected_option']['value']
else:
return None
message_ts, thread_ts = attribs.split(':')
thread_ts = None if thread_ts == 'None' else thread_ts
return message_ts, thread_ts, selected_value
async def receive_form_interaction(self, slack_payload: dict):
"""Process a Slack form interaction (repository selection or button click).
This handles the block_actions payload when a user interacts with the
repository selection form. It can handle:
- "No Repository" button click: proceeds with conversation without a repo
- Repository selection from dropdown: proceeds with the selected repo
Args:
slack_payload: The raw Slack interaction payload
"""
# Extract fields from the Slack interaction payload
selected_repository = slack_payload['actions'][0]['selected_option']['value']
if selected_repository == '-':
selected_repository = None
action = slack_payload['actions'][0]
slack_user_id = slack_payload['user']['id']
channel_id = slack_payload['container']['channel_id']
team_id = slack_payload['team']['id']
# Get original message_ts and thread_ts from action_id
attribs = slack_payload['actions'][0]['action_id'].split('repository_select:')[
-1
]
message_ts, thread_ts = attribs.split(':')
thread_ts = None if thread_ts == 'None' else thread_ts
# Parse the action to extract message_ts, thread_ts, and selected value
parsed = self._parse_form_action(action)
if parsed is None:
logger.warning(
'slack_unknown_action_id',
extra={
'action_id': action['action_id'],
'slack_user_id': slack_user_id,
},
)
return
# Build partial payload for error handling during Redis retrieval
message_ts, thread_ts, selected_value = parsed
# Build partial payload for error handling
payload = {
'team_id': team_id,
'channel_id': channel_id,
@@ -398,6 +442,9 @@ class SlackManager(Manager[SlackViewInterface]):
'thread_ts': thread_ts,
}
# Convert "-" (No Repository) to None
selected_repository = None if selected_value == '-' else selected_value
# Retrieve the original user message from Redis
try:
user_msg = await self._retrieve_user_msg_for_form(message_ts, thread_ts)

View File

@@ -111,9 +111,11 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
try:
# Post the summary as a threaded reply
# Use markdown_text instead of text to properly render standard Markdown
# (e.g., **bold**, [link](url)) which is used throughout the codebase
response = client.chat_postMessage(
channel=channel_id,
text=summary,
markdown_text=summary,
thread_ts=thread_ts,
unfurl_links=False,
unfurl_media=False,

View File

@@ -4,6 +4,7 @@ from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.slack.slack_types import (
SlackMessageView,
SlackViewInterface,
@@ -17,7 +18,9 @@ from integrations.utils import (
get_user_v1_enabled_setting,
)
from jinja2 import Environment
from server.config import get_config
from slack_sdk import WebClient
from storage.saas_conversation_store import SaasConversationStore
from storage.slack_conversation import SlackConversation
from storage.slack_conversation_store import SlackConversationStore
from storage.slack_team_store import SlackTeamStore
@@ -36,18 +39,20 @@ from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.integrations.provider import ProviderHandler
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
start_conversation,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.async_utils import GENERAL_TIMEOUT
from openhands.utils.conversation_summary import get_default_conversation_title
# =================================================
# SECTION: Slack view types
@@ -202,6 +207,22 @@ class SlackNewConversationView(SlackViewInterface):
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
# Determine git provider from repository (needed for both org routing and conversation creation)
self._resolved_git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
self._resolved_git_provider = repository.git_provider
# Resolve target org based on claimed git organizations
self.resolved_org_id = None
if self._resolved_git_provider and self.selected_repo:
self.resolved_org_id = await resolve_org_for_repo(
provider=self._resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
)
# Check if V1 conversations are enabled for this user
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
self.slack_to_openhands_user.keycloak_user_id
@@ -224,30 +245,44 @@ class SlackNewConversationView(SlackViewInterface):
jinja
)
# Determine git provider from repository
git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = repository.git_provider
user_id = self.slack_to_openhands_user.keycloak_user_id
agent_loop_info = await create_new_conversation(
user_id=self.slack_to_openhands_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
self.resolved_org_id,
)
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.SLACK,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=self._resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=(
conversation_instructions if conversation_instructions else None
),
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.SLACK,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
git_provider=git_provider,
)
self.conversation_id = agent_loop_info.conversation_id
self.conversation_id = conversation_id
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=False)
@@ -265,13 +300,8 @@ class SlackNewConversationView(SlackViewInterface):
# Create the Slack V1 callback processor
slack_callback_processor = self._create_slack_v1_callback_processor()
# Determine git provider from repository
git_provider = None
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = ProviderType(repository.git_provider.value)
# Use git provider resolved in create_or_update_conversation
git_provider = self._resolved_git_provider
# Get the app conversation service and start the conversation
injector_state = InjectorState()
@@ -292,7 +322,10 @@ class SlackNewConversationView(SlackViewInterface):
)
# Set up the Slack user context for the V1 system
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
slack_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
async with get_app_conversation_service(

View File

@@ -6,6 +6,7 @@ Create Date: 2026-03-26
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
@@ -33,7 +34,7 @@ def upgrade() -> None:
sa.text(
'UPDATE org_member SET mcp_config = :config WHERE org_id = :org_id'
),
{'config': mcp_config, 'org_id': str(org_id)},
{'config': json.dumps(mcp_config), 'org_id': str(org_id)},
)

View File

@@ -0,0 +1,29 @@
"""Add disabled_skills column to user table.
Migration 102 added disabled_skills to the legacy user_settings table,
but the active SaaS flow (SaasSettingsStore) reads from/writes to the
user table. This migration adds the column where it is actually needed.
Revision ID: 104
Revises: 103
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '104'
down_revision: Union[str, None] = '103'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('user', sa.Column('disabled_skills', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('user', 'disabled_skills')

View File

@@ -0,0 +1,37 @@
"""Create org_git_claim table for tracking Git organization claims.
Revision ID: 105
Revises: 104
Create Date: 2026-04-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '105'
down_revision: Union[str, None] = '104'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'org_git_claim',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('org_id', sa.UUID(), nullable=False),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('git_organization', sa.String(), nullable=False),
sa.Column('claimed_by', sa.UUID(), nullable=False),
sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['org_id'], ['org.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['claimed_by'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('provider', 'git_organization', name='uq_provider_git_org'),
)
def downgrade() -> None:
op.drop_table('org_git_claim')

View File

@@ -0,0 +1,32 @@
"""Add tags column to conversation_metadata table.
Tags store key-value pairs for automation context (trigger type, automation_id),
skills used, and other metadata. This enables querying conversations by
automation source and associating SDK-provided context with conversations.
Revision ID: 106
Revises: 105
Create Date: 2026-03-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '106'
down_revision: Union[str, None] = '105'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'conversation_metadata',
sa.Column('tags', sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column('conversation_metadata', 'tags')

4807
enterprise/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,9 @@ from server.routes.readiness import readiness_router # noqa: E402
from server.routes.service import service_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
from server.routes.users_v1 import ( # noqa: E402
override_users_me_endpoint,
)
from server.sharing.shared_conversation_router import ( # noqa: E402
router as shared_conversation_router,
)
@@ -123,6 +126,10 @@ base_app.include_router(
# This must happen after all routers are included
override_llm_models_dependency(base_app)
# Override the /api/v1/users/me endpoint to include organization info
# This replaces the OSS endpoint with a SAAS version that adds org_id, org_name, role, permissions
override_users_me_endpoint(base_app)
base_app.include_router(invitation_router) # Add routes for org invitation management
base_app.include_router(invitation_accept_router) # Add route for accepting invitations
add_github_proxy_routes(base_app)

View File

@@ -41,7 +41,7 @@ from storage.role import Role
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth import get_user_auth, get_user_id
class Permission(str, Enum):
@@ -84,6 +84,9 @@ class Permission(str, Enum):
# Temporary permissions until we finish the API updates.
EDIT_ORG_SETTINGS = 'edit_org_settings'
# Git organization claims
MANAGE_ORG_CLAIMS = 'manage_org_claims'
class RoleName(str, Enum):
"""Role names used in the system."""
@@ -118,6 +121,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Organization Management (Owner only)
Permission.CHANGE_ORGANIZATION_NAME,
Permission.DELETE_ORGANIZATION,
# Git organization claims
Permission.MANAGE_ORG_CLAIMS,
]
),
RoleName.ADMIN: frozenset(
@@ -139,6 +144,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
# Organization Management
Permission.VIEW_ORG_SETTINGS,
Permission.EDIT_ORG_SETTINGS,
# Git organization claims
Permission.MANAGE_ORG_CLAIMS,
]
),
RoleName.MEMBER: frozenset(
@@ -311,3 +318,96 @@ def require_permission(permission: Permission):
return user_id
return permission_checker
async def require_financial_data_access(
request: Request,
org_id: UUID,
user_id: str | None = Depends(get_user_id),
) -> str:
"""
Authorization dependency for accessing organization financial data.
Allows access if ANY of these conditions are met:
1. User has Admin or Owner role in the organization
2. User has @openhands.dev email domain
This is used for the organization members financial data endpoint.
Args:
request: FastAPI request object
org_id: Organization UUID from path parameter
user_id: User ID from authentication
Returns:
str: User ID if authorized
Raises:
HTTPException: 401 if not authenticated, 403 if not authorized
"""
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
# Validate API key organization binding
api_key_org_id = await get_api_key_org_id_from_request(request)
if api_key_org_id is not None:
if api_key_org_id != org_id:
logger.warning(
'API key organization mismatch for financial data access',
extra={
'user_id': user_id,
'api_key_org_id': str(api_key_org_id),
'target_org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='API key is not authorized for this organization',
)
# Check if user has @openhands.dev email
user_auth = await get_user_auth(request)
user_email = await user_auth.get_user_email()
if user_email and user_email.endswith('@openhands.dev'):
logger.debug(
'Financial data access granted via @openhands.dev email',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
return user_id
# Check if user has Admin or Owner role in the organization
user_role = await get_user_org_role(user_id, org_id)
if not user_role:
logger.warning(
'Financial data access denied - user not a member of organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User is not a member of this organization',
)
if user_role.name not in (RoleName.OWNER.value, RoleName.ADMIN.value):
logger.warning(
'Financial data access denied - insufficient role',
extra={
'user_id': user_id,
'org_id': str(org_id),
'user_role': user_role.name,
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Access restricted to organization admins, owners, or OpenHands members',
)
logger.debug(
'Financial data access granted via admin/owner role',
extra={'user_id': user_id, 'org_id': str(org_id), 'role': user_role.name},
)
return user_id

View File

@@ -6,7 +6,6 @@ GITHUB_APP_WEBHOOK_SECRET = os.getenv('GITHUB_APP_WEBHOOK_SECRET', '')
GITHUB_APP_PRIVATE_KEY = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace('\\n', '\n')
KEYCLOAK_SERVER_URL = os.getenv('KEYCLOAK_SERVER_URL', '').rstrip('/')
KEYCLOAK_REALM_NAME = os.getenv('KEYCLOAK_REALM_NAME', '')
KEYCLOAK_PROVIDER_NAME = os.getenv('KEYCLOAK_PROVIDER_NAME', '')
KEYCLOAK_CLIENT_ID = os.getenv('KEYCLOAK_CLIENT_ID', '')
KEYCLOAK_CLIENT_SECRET = os.getenv('KEYCLOAK_CLIENT_SECRET', '')
KEYCLOAK_SERVER_URL_EXT = os.getenv(

View File

@@ -4,7 +4,6 @@ from server.auth.constants import (
KEYCLOAK_ADMIN_PASSWORD,
KEYCLOAK_CLIENT_ID,
KEYCLOAK_CLIENT_SECRET,
KEYCLOAK_PROVIDER_NAME,
KEYCLOAK_REALM_NAME,
KEYCLOAK_SERVER_URL,
KEYCLOAK_SERVER_URL_EXT,
@@ -12,7 +11,7 @@ from server.auth.constants import (
from server.logger import logger
logger.debug(
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_PROVIDER_NAME:{KEYCLOAK_PROVIDER_NAME}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
f'KEYCLOAK_SERVER_URL:{KEYCLOAK_SERVER_URL}, KEYCLOAK_SERVER_URL_EXT:{KEYCLOAK_SERVER_URL_EXT}, KEYCLOAK_CLIENT_ID:{KEYCLOAK_CLIENT_ID}'
)
_keycloak_instances = {}

View File

@@ -14,6 +14,10 @@ from server.auth.auth_error import (
ExpiredError,
NoCredentialsError,
)
from server.auth.authorization import (
get_role_permissions,
get_user_org_role,
)
from server.auth.constants import BITBUCKET_DATA_CENTER_HOST
from server.auth.token_manager import TokenManager
from server.config import get_config
@@ -23,10 +27,12 @@ from sqlalchemy import delete, select
from storage.api_key_store import ApiKeyStore
from storage.auth_tokens import AuthTokens
from storage.database import a_session_maker
from storage.org_store import OrgStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.saas_settings_store import SaasSettingsStore
from storage.user_authorization import UserAuthorizationType
from storage.user_authorization_store import UserAuthorizationStore
from storage.user_store import UserStore
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
from openhands.integrations.provider import (
@@ -64,6 +70,12 @@ class SaasUserAuth(UserAuth):
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
api_key_id: int | None = None
api_key_name: str | None = None
# Organization context fields - populated lazily via get_org_info()
_org_id: str | None = None
_org_name: str | None = None
_role: str | None = None
_permissions: list[str] | None = None
_org_info_loaded: bool = False
def get_api_key_org_id(self) -> UUID | None:
"""Get the organization ID bound to the API key used for authentication.
@@ -242,6 +254,72 @@ class SaasUserAuth(UserAuth):
)
return mcp_api_key
async def get_org_info(self) -> dict | None:
"""Get organization info for the current user.
Lazily loads and caches organization data including:
- org_id: Current organization ID
- org_name: Current organization name
- role: User's role in the organization
- permissions: List of permission names for the role
Returns:
dict with org_id, org_name, role, permissions or None if not available
"""
if self._org_info_loaded:
if self._org_id is None:
return None
return {
'org_id': self._org_id,
'org_name': self._org_name,
'role': self._role,
'permissions': self._permissions,
}
# Mark as loaded to avoid repeated attempts on failure
self._org_info_loaded = True
try:
# Get user and their current org
user = await UserStore.get_user_by_id(self.user_id)
if not user:
logger.warning(f'User {self.user_id} not found for org info')
return None
# Get the current org
org = await OrgStore.get_org_by_id(user.current_org_id)
if not org:
logger.warning(
f'Organization {user.current_org_id} not found for user {self.user_id}'
)
return None
# Get user's role in the current org
role = await get_user_org_role(self.user_id, user.current_org_id)
role_name = role.name if role else None
# Get permissions for the role
permissions: list[str] = []
if role_name:
role_permissions = get_role_permissions(role_name)
permissions = [p.value for p in role_permissions]
# Cache the results
self._org_id = str(user.current_org_id)
self._org_name = org.name
self._role = role_name
self._permissions = permissions
return {
'org_id': self._org_id,
'org_name': self._org_name,
'role': self._role,
'permissions': self._permissions,
}
except Exception as e:
logger.error(f'Error fetching org info for user {self.user_id}: {e}')
return None
@classmethod
async def get_instance(cls, request: Request) -> UserAuth:
logger.debug('saas_user_auth_get_instance')

View File

@@ -80,8 +80,7 @@ def setup_json_logger(
handler.setLevel(level)
formatter = JsonFormatter(
'{message}{levelname}',
style='{',
'%(message)s%(levelname)s%(module)s%(funcName)s%(lineno)d',
rename_fields={'levelname': 'severity'},
json_serializer=custom_json_serializer,
# Use 'ts' for consistency with LOG_JSON_FOR_CONSOLE mode (skip when console mode to avoid duplicates)

View File

@@ -0,0 +1 @@
# Enterprise server models

View File

@@ -0,0 +1,16 @@
"""SAAS-specific user models that extend OSS UserInfo with organization fields."""
from openhands.app_server.user.user_models import UserInfo
class SaasUserInfo(UserInfo):
"""User info model for SAAS mode with organization context.
Extends the base UserInfo with SAAS-specific fields for organization
membership, role, and permissions.
"""
org_id: str | None = None
org_name: str | None = None
role: str | None = None
permissions: list[str] | None = None

View File

@@ -7,8 +7,8 @@ from storage.database import a_session_maker
from storage.feedback import ConversationFeedback
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.events.event_store import EventStore
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import file_store
from openhands.server.user_auth import get_user_id

View File

@@ -335,6 +335,9 @@ async def on_options_load(request: Request, background_tasks: BackgroundTasks):
2. Searches for repositories matching the user's query
3. Returns up to 100 options for the dropdown
Note: "No Repository" is handled by a separate button in the form, so it's
not included in the dropdown options. Error cases return an empty list.
Configuration: Set the Options Load URL in Slack App settings to:
https://your-domain/slack/on-options-load
"""

View File

@@ -120,3 +120,18 @@ class BatchInvitationResponse(BaseModel):
successful: list[InvitationResponse]
failed: list[InvitationFailure]
class AcceptInvitationRequest(BaseModel):
"""Request model for accepting an invitation via POST."""
token: str
class AcceptInvitationResponse(BaseModel):
"""Response model for successful invitation acceptance."""
success: bool
org_id: str
org_name: str
role: str

View File

@@ -5,6 +5,8 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from server.routes.org_invitation_models import (
AcceptInvitationRequest,
AcceptInvitationResponse,
BatchInvitationResponse,
EmailMismatchError,
InsufficientPermissionError,
@@ -17,10 +19,11 @@ from server.routes.org_invitation_models import (
)
from server.services.org_invitation_service import OrgInvitationService
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from storage.org_store import OrgStore
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth.user_auth import get_user_auth
# Router for invitation operations on an organization (requires org_id)
invitation_router = APIRouter(prefix='/api/organizations/{org_id}/members')
@@ -123,70 +126,93 @@ async def create_invitation(
@accept_router.get('/accept')
async def accept_invitation(
async def accept_invitation_redirect(
token: str,
request: Request,
):
"""Accept an organization invitation via token.
"""Redirect invitation acceptance to frontend.
This endpoint is accessed via the link in the invitation email.
It always redirects to the home page with the invitation token,
allowing the frontend to handle the acceptance flow via a modal.
Flow:
1. If user is authenticated: Accept invitation directly and redirect to home
2. If user is not authenticated: Redirect to login page with invitation token
- Frontend stores token and includes it in OAuth state during login
- After authentication, keycloak_callback processes the invitation
This approach works with SameSite='strict' cookies because:
- Cross-site navigation (clicking email link) doesn't send cookies
- But same-origin POST requests (from frontend) DO send cookies
Args:
token: The invitation token from the email link
request: FastAPI request
Returns:
RedirectResponse: Redirect to home page on success, or login page if not authenticated,
or home page with error query params on failure
RedirectResponse: Redirect to home page with invitation_token query param
"""
base_url = str(request.base_url).rstrip('/')
# Try to get user_id from auth (may not be authenticated)
user_id = None
try:
user_auth = await get_user_auth(request)
if user_auth:
user_id = await user_auth.get_user_id()
except Exception:
pass
logger.info(
'Invitation accept: redirecting to frontend for acceptance',
extra={'token_prefix': token[:10] + '...'},
)
if not user_id:
# User not authenticated - redirect to login page with invitation token
# Frontend will store the token and include it in OAuth state during login
logger.info(
'Invitation accept: redirecting unauthenticated user to login',
extra={'token_prefix': token[:10] + '...'},
)
login_url = f'{base_url}/login?invitation_token={token}'
return RedirectResponse(login_url, status_code=302)
return RedirectResponse(f'{base_url}/?invitation_token={token}', status_code=302)
@accept_router.post('/accept', response_model=AcceptInvitationResponse)
async def accept_invitation(
request_data: AcceptInvitationRequest,
user_id: str = Depends(get_user_id),
):
"""Accept an organization invitation via authenticated POST request.
This endpoint is called by the frontend after displaying the acceptance modal.
Requires authentication - cookies are sent because this is a same-origin request.
Args:
request_data: Contains the invitation token
user_id: Authenticated user ID (from dependency)
Returns:
AcceptInvitationResponse: Success response with organization details
Raises:
HTTPException 400: Invalid or expired token
HTTPException 403: Email mismatch
HTTPException 409: User already a member
"""
token = request_data.token
# User is authenticated - process the invitation directly
try:
await OrgInvitationService.accept_invitation(token, UUID(user_id))
invitation = await OrgInvitationService.accept_invitation(token, UUID(user_id))
# Get organization and role details for response
org = await OrgStore.get_org_by_id(invitation.org_id)
role = await RoleStore.get_role_by_id(invitation.role_id)
logger.info(
'Invitation accepted successfully',
'Invitation accepted via API',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
'org_id': str(invitation.org_id),
},
)
# Redirect to home page on success
return RedirectResponse(f'{base_url}/', status_code=302)
return AcceptInvitationResponse(
success=True,
org_id=str(invitation.org_id),
org_name=org.name if org else '',
role=role.name if role else '',
)
except InvitationExpiredError:
logger.warning(
'Invitation accept failed: expired',
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
)
return RedirectResponse(f'{base_url}/?invitation_expired=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='invitation_expired',
)
except InvitationInvalidError as e:
logger.warning(
@@ -197,14 +223,20 @@ async def accept_invitation(
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?invitation_invalid=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='invitation_invalid',
)
except UserAlreadyMemberError:
logger.info(
'Invitation accept: user already member',
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
)
return RedirectResponse(f'{base_url}/?already_member=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail='already_member',
)
except EmailMismatchError as e:
logger.warning(
@@ -215,15 +247,21 @@ async def accept_invitation(
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?email_mismatch=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='email_mismatch',
)
except Exception as e:
logger.exception(
'Unexpected error accepting invitation',
'Unexpected error accepting invitation via API',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?invitation_error=true', status_code=302)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)

View File

@@ -483,3 +483,72 @@ class OrgAppSettingsUpdate(BaseModel):
if v is not None and v <= 0:
raise ValueError('max_budget_per_task must be greater than 0')
return v
VALID_GIT_PROVIDERS = {'github', 'gitlab', 'bitbucket'}
class GitOrgClaimRequest(BaseModel):
"""Request model for claiming a Git organization."""
provider: str
git_organization: str
@field_validator('provider')
@classmethod
def validate_provider(cls, v: str) -> str:
v = v.lower().strip()
if v not in VALID_GIT_PROVIDERS:
raise ValueError(
f'Invalid provider: "{v}". Must be one of: {", ".join(sorted(VALID_GIT_PROVIDERS))}'
)
return v
@field_validator('git_organization')
@classmethod
def validate_git_organization(cls, v: str) -> str:
v = v.strip().lower()
if not v:
raise ValueError('git_organization must not be empty')
return v
class GitOrgClaimResponse(BaseModel):
"""Response model for a Git organization claim."""
id: str
org_id: str
provider: str
git_organization: str
claimed_by: str
claimed_at: str
class GitOrgAlreadyClaimedError(Exception):
"""Raised when a Git organization is already claimed by another OpenHands org."""
def __init__(self, provider: str, git_organization: str):
self.provider = provider
self.git_organization = git_organization
super().__init__(
f'Git organization "{git_organization}" on {provider} is already claimed by another organization'
)
class OrgMemberFinancialResponse(BaseModel):
"""Financial data for a single organization member."""
user_id: str
email: str | None
lifetime_spend: float # Total amount spent (from LiteLLM)
current_budget: float # Remaining budget (max_budget - spend)
max_budget: float | None # Total allocated budget (None = unlimited)
class OrgMemberFinancialPage(BaseModel):
"""Paginated response for organization member financial data."""
items: list[OrgMemberFinancialResponse]
current_page: int = 1
per_page: int = 10
next_page_id: str | None = None

View File

@@ -4,11 +4,15 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from server.auth.authorization import (
Permission,
require_financial_data_access,
require_permission,
)
from server.email_validation import get_admin_user_id
from server.routes.org_models import (
CannotModifySelfError,
GitOrgAlreadyClaimedError,
GitOrgClaimRequest,
GitOrgClaimResponse,
InsufficientPermissionError,
InvalidRoleError,
LastOwnerError,
@@ -22,6 +26,7 @@ from server.routes.org_models import (
OrgDatabaseError,
OrgLLMSettingsResponse,
OrgLLMSettingsUpdate,
OrgMemberFinancialPage,
OrgMemberNotFoundError,
OrgMemberPage,
OrgMemberResponse,
@@ -42,7 +47,10 @@ from server.services.org_llm_settings_service import (
OrgLLMSettingsService,
OrgLLMSettingsServiceInjector,
)
from server.services.org_member_financial_service import OrgMemberFinancialService
from server.services.org_member_service import OrgMemberService
from sqlalchemy.exc import IntegrityError
from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_service import OrgService
from storage.user_store import UserStore
@@ -883,6 +891,104 @@ async def get_org_members_count(
)
@org_router.get(
'/{org_id}/members/financial',
response_model=OrgMemberFinancialPage,
)
async def get_org_members_financial(
org_id: UUID,
page_id: Annotated[
str | None,
Query(
title='Pagination offset encoded as string',
description='Offset for pagination (e.g., "0", "10", "20")',
),
] = None,
limit: Annotated[
int,
Query(
title='Maximum items per page',
gt=0,
le=100,
),
] = 10,
email: Annotated[
str | None,
Query(
title='Filter members by email (case-insensitive partial match)',
min_length=1,
max_length=255,
),
] = None,
user_id: str = Depends(require_financial_data_access),
) -> OrgMemberFinancialPage:
"""Get paginated financial data for organization members.
Returns financial information (lifetime spend, current budget) for all members
within the specified organization. Access is restricted to:
- Organization Admins
- Organization Owners
- OpenHands members (users with @openhands.dev emails)
Args:
org_id: Organization ID (UUID)
page_id: Optional pagination offset encoded as string
limit: Maximum items per page (1-100, default 10)
email: Optional email filter (case-insensitive partial match)
user_id: Authenticated user ID (injected by require_financial_data_access)
Returns:
OrgMemberFinancialPage: Paginated response with member financial data
- items: List of members with user_id, email, lifetime_spend,
current_budget, and max_budget
- current_page: Current page number (1-indexed)
- per_page: Items per page
- next_page_id: Offset for next page, or None if no more pages
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks access (not admin/owner and not @openhands.dev)
HTTPException: 400 if page_id is invalid
HTTPException: 500 if retrieval fails
"""
logger.info(
'Getting financial data for organization members',
extra={
'org_id': str(org_id),
'user_id': user_id,
'page_id': page_id,
'limit': limit,
'email_filter': email,
},
)
try:
return await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id=page_id,
limit=limit,
email_filter=email,
)
except ValueError as e:
logger.warning(
'Invalid page_id for financial data request',
extra={'org_id': str(org_id), 'page_id': page_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception:
logger.exception(
'Error retrieving organization member financial data',
extra={'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve member financial data',
)
@org_router.delete('/{org_id}/members/{user_id}')
async def remove_org_member(
org_id: UUID,
@@ -1111,3 +1217,181 @@ async def update_org_member(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update member',
)
@org_router.get(
'/{org_id}/git-claims',
response_model=list[GitOrgClaimResponse],
)
async def get_git_claims(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
) -> list[GitOrgClaimResponse]:
"""Get all Git organization claims for an OpenHands organization.
Only admin and owner roles can view Git organization claims.
Args:
org_id: OpenHands organization UUID
user_id: Authenticated user ID (injected by permission check)
Returns:
List of GitOrgClaimResponse with claim details
"""
try:
claims = await OrgGitClaimStore.get_claims_by_org_id(org_id=org_id)
return [
GitOrgClaimResponse(
id=str(claim.id),
org_id=str(claim.org_id),
provider=claim.provider,
git_organization=claim.git_organization,
claimed_by=str(claim.claimed_by),
claimed_at=claim.claimed_at.isoformat(),
)
for claim in claims
]
except Exception:
logger.exception('Error fetching Git organization claims')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to fetch Git organization claims',
)
@org_router.post(
'/{org_id}/git-claims',
response_model=GitOrgClaimResponse,
status_code=status.HTTP_201_CREATED,
)
async def claim_git_organization(
org_id: UUID,
request: GitOrgClaimRequest,
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
) -> GitOrgClaimResponse:
"""Claim a Git organization for an OpenHands organization.
Only admin and owner roles can claim Git organizations.
A Git organization can only be claimed by one OpenHands organization at a time.
Args:
org_id: OpenHands organization UUID
request: Claim request with provider and git_organization
user_id: Authenticated user ID (injected by permission check)
Returns:
GitOrgClaimResponse with the created claim details
Raises:
HTTPException 409: If the Git organization is already claimed
HTTPException 403: If user lacks permission
"""
try:
# Check if this Git org is already claimed (early feedback for the common case)
existing_claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider=request.provider,
git_organization=request.git_organization,
)
if existing_claim:
raise GitOrgAlreadyClaimedError(
provider=request.provider,
git_organization=request.git_organization,
)
# Create the claim — the DB unique constraint handles the race condition
# where two concurrent requests both pass the check above.
claim = await OrgGitClaimStore.create_claim(
org_id=org_id,
provider=request.provider,
git_organization=request.git_organization,
claimed_by=UUID(user_id),
)
return GitOrgClaimResponse(
id=str(claim.id),
org_id=str(claim.org_id),
provider=claim.provider,
git_organization=claim.git_organization,
claimed_by=str(claim.claimed_by),
claimed_at=claim.claimed_at.isoformat(),
)
except GitOrgAlreadyClaimedError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
except IntegrityError as e:
# Only treat the unique constraint violation as a duplicate claim.
# Other integrity errors (e.g. FK violations) should surface as 500s.
if 'uq_provider_git_org' in str(e.orig):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(
GitOrgAlreadyClaimedError(
provider=request.provider,
git_organization=request.git_organization,
)
),
)
logger.exception('Integrity error claiming Git organization')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to claim Git organization',
)
except Exception:
logger.exception('Error claiming Git organization')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to claim Git organization',
)
@org_router.delete(
'/{org_id}/git-claims/{claim_id}',
status_code=status.HTTP_200_OK,
)
async def disconnect_git_organization(
org_id: UUID,
claim_id: UUID,
user_id: str = Depends(require_permission(Permission.MANAGE_ORG_CLAIMS)),
) -> dict:
"""Remove a Git organization claim from an OpenHands organization.
Only admin and owner roles can disconnect Git organization claims.
Args:
org_id: OpenHands organization UUID
claim_id: Claim UUID to remove
user_id: Authenticated user ID (injected by permission check)
Returns:
dict: Confirmation message on successful deletion
Raises:
HTTPException 404: If the claim is not found for this organization
HTTPException 403: If user lacks permission
"""
try:
deleted = await OrgGitClaimStore.delete_claim(
claim_id=claim_id,
org_id=org_id,
)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Git organization claim not found',
)
return {'message': 'Git organization claim removed successfully'}
except HTTPException:
raise
except Exception:
logger.exception('Error disconnecting Git organization')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to disconnect Git organization',
)

View File

@@ -7,8 +7,10 @@ from server.auth.token_manager import TokenManager
from storage.user_store import UserStore
from utils.identity import resolve_display_name
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
)
from openhands.integrations.service_types import (
Branch,
@@ -22,7 +24,6 @@ from openhands.microagent.types import (
MicroagentContentResponse,
MicroagentResponse,
)
from openhands.server.dependencies import get_dependencies
from openhands.server.routes.git import (
get_repository_branches,
get_repository_microagent_content,
@@ -44,7 +45,12 @@ saas_user_router = APIRouter(prefix='/api/user', dependencies=get_dependencies()
token_manager = TokenManager()
@saas_user_router.get('/installations', response_model=list[str])
@saas_user_router.get(
'/installations',
response_model=list[str],
deprecated=True,
description='Deprecated: Use `/api/v1/git/installations` instead.',
)
async def saas_get_user_installations(
provider: ProviderType,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
@@ -67,7 +73,59 @@ async def saas_get_user_installations(
)
@saas_user_router.get('/repositories', response_model=list[Repository])
@saas_user_router.get('/git-organizations')
async def saas_get_user_git_organizations(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if not provider_tokens:
retval = await _check_idp(
access_token=access_token,
default_value={},
)
if retval is not None:
return retval
# _check_idp returned None (tokens refreshed on Keycloak side),
# but provider_tokens is still None for this request.
return JSONResponse(
content='Git provider token required.',
status_code=status.HTTP_401_UNAUTHORIZED,
)
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
# SaaS users sign in with one provider at a time
provider = next(iter(provider_tokens))
if provider == ProviderType.GITHUB:
orgs = await client.get_github_organizations()
elif provider == ProviderType.GITLAB:
orgs = await client.get_gitlab_groups()
elif provider == ProviderType.BITBUCKET:
orgs = await client.get_bitbucket_workspaces()
else:
return JSONResponse(
content=f"Provider {provider.value} doesn't support git organizations",
status_code=status.HTTP_400_BAD_REQUEST,
)
return {
'provider': provider.value,
'organizations': orgs,
}
@saas_user_router.get(
'/repositories',
response_model=list[Repository],
deprecated=True,
description='Deprecated: Use `/api/v1/git/repositories` instead.',
)
async def saas_get_user_repositories(
sort: str = 'pushed',
selected_provider: ProviderType | None = None,
@@ -98,12 +156,13 @@ async def saas_get_user_repositories(
)
@saas_user_router.get('/info', response_model=User)
@saas_user_router.get('/info', response_model=User, deprecated=True)
async def saas_get_user(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
) -> User | JSONResponse:
"""Get the current user git info. Use GET /api/v1/users/git-info instead"""
if not provider_tokens:
if not access_token:
return JSONResponse(

View File

@@ -0,0 +1,106 @@
"""SAAS-specific extensions for the /api/v1/users endpoints.
This module provides SAAS-specific implementations that extend the OSS
user endpoints with organization context (org_id, org_name, role, permissions).
"""
import logging
from fastapi import APIRouter, FastAPI, Header, HTTPException, Query, status
from fastapi.responses import JSONResponse
from server.auth.saas_user_auth import SaasUserAuth
from server.models.user_models import SaasUserInfo
from openhands.app_server.config import depends_user_context
from openhands.app_server.sandbox.session_auth import validate_session_key_ownership
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.dependencies import get_dependencies
_logger = logging.getLogger(__name__)
saas_users_v1_router = APIRouter(
prefix='/api/v1/users', tags=['User'], dependencies=get_dependencies()
)
user_dependency = depends_user_context()
@saas_users_v1_router.get('/me')
async def get_current_user_saas(
user_context: UserContext = user_dependency,
expose_secrets: bool = Query(
default=False,
description='If true, return unmasked secret values (e.g. llm_api_key). '
'Requires a valid X-Session-API-Key header for an active sandbox '
'owned by the authenticated user.',
),
x_session_api_key: str | None = Header(default=None),
) -> SaasUserInfo:
"""Get the current authenticated user with SAAS-specific org info.
Returns user settings along with organization context:
- org_id: Current organization ID
- org_name: Current organization name
- role: User's role in the organization
- permissions: List of permission strings for the role
"""
# Get base user info from the context
base_user_info = await user_context.get_user_info()
if base_user_info is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
# Build SAAS user info from base settings
user_info_data = base_user_info.model_dump(
mode='json', context={'expose_secrets': True}
)
# Add org info if available (from SaasUserAuth)
org_info = await _get_org_info_from_context(user_context)
if org_info:
user_info_data.update(org_info)
user_info = SaasUserInfo(**user_info_data)
if expose_secrets:
await validate_session_key_ownership(user_context, x_session_api_key)
return JSONResponse( # type: ignore[return-value]
content=user_info.model_dump(mode='json', context={'expose_secrets': True})
)
return user_info
async def _get_org_info_from_context(user_context: UserContext) -> dict | None:
"""Extract org info from the user context if available.
This works by checking if the underlying user_auth is a SaasUserAuth
instance that has the get_org_info method.
"""
# Check if this is an AuthUserContext with a SaasUserAuth
if isinstance(user_context, AuthUserContext):
user_auth = user_context.user_auth
if isinstance(user_auth, SaasUserAuth):
return await user_auth.get_org_info()
return None
def override_users_me_endpoint(app: FastAPI) -> None:
"""Override the OSS /api/v1/users/me endpoint with SAAS version.
This removes the base OSS endpoint and registers the SAAS version
which includes organization context (org_id, org_name, role, permissions).
Must be called after the app is created in saas_server.py.
"""
# Find and remove the OSS /api/v1/users/me route
routes_to_remove = []
for route in app.routes:
if hasattr(route, 'path') and route.path == '/api/v1/users/me':
routes_to_remove.append(route)
for route in routes_to_remove:
app.routes.remove(route)
_logger.debug('Removed OSS route: %s', route.path)
# Add the SAAS version
app.include_router(saas_users_v1_router)
_logger.debug('Added SAAS /api/v1/users/me endpoint')

View File

@@ -0,0 +1,171 @@
"""Service for managing organization member financial data."""
from uuid import UUID
import httpx
from server.routes.org_models import (
OrgMemberFinancialPage,
OrgMemberFinancialResponse,
)
from storage.lite_llm_manager import LiteLlmManager
from storage.org_member_store import OrgMemberStore
from openhands.core.logger import openhands_logger as logger
class OrgMemberFinancialService:
"""Service for organization member financial data operations."""
@staticmethod
async def get_org_members_financial_data(
org_id: UUID,
page_id: str | None = None,
limit: int = 10,
email_filter: str | None = None,
) -> OrgMemberFinancialPage:
"""Get paginated financial data for organization members.
Fetches member list from database and joins with financial data from LiteLLM.
Args:
org_id: Organization UUID
page_id: Offset encoded as string (e.g., "0", "10", "20")
limit: Maximum items per page (default 10)
email_filter: Optional case-insensitive partial email match
Returns:
OrgMemberFinancialPage: Paginated response with financial data
Raises:
ValueError: If page_id is invalid
"""
# Parse page_id to get offset
offset = 0
if page_id is not None:
try:
offset = int(page_id)
if offset < 0:
raise ValueError('page_id must be non-negative')
except ValueError as e:
raise ValueError(f'Invalid page_id: {page_id}') from e
# Fetch paginated members from database
members, total_count = await OrgMemberStore.get_org_members_paginated(
org_id=org_id,
offset=offset,
limit=limit,
email_filter=email_filter,
)
if not members:
return OrgMemberFinancialPage(
items=[],
current_page=(offset // limit) + 1,
per_page=limit,
next_page_id=None,
)
# Fetch financial data from LiteLLM for the entire team
# This is a single API call that returns all team members' data
try:
financial_data = await LiteLlmManager.get_team_members_financial_data(
str(org_id)
)
except httpx.HTTPStatusError as e:
# Re-raise auth errors - these indicate configuration issues that need fixing
if e.response.status_code in (401, 403):
logger.error(
'LiteLLM authentication/authorization failed',
extra={
'org_id': str(org_id),
'status_code': e.response.status_code,
'error': str(e),
},
)
raise
# For other HTTP errors (404, 500, etc.), use graceful degradation
logger.warning(
'Failed to fetch financial data from LiteLLM',
extra={
'org_id': str(org_id),
'status_code': e.response.status_code,
'error_type': type(e).__name__,
'error': str(e),
},
)
financial_data = {}
except Exception as e:
# For network errors, timeouts, etc., use graceful degradation
logger.warning(
'Failed to fetch financial data from LiteLLM',
extra={
'org_id': str(org_id),
'error_type': type(e).__name__,
'error': str(e),
},
)
financial_data = {}
# Extract team-level data for shared budget calculation
team_spend = financial_data.get('team_spend', 0) or 0
members_financial = financial_data.get('members', {})
# Build response items by joining DB members with LiteLLM financial data
items: list[OrgMemberFinancialResponse] = []
for member in members:
user = member.user
user_id_str = str(member.user_id)
# Get financial data for this user (or defaults if not found)
user_financial = members_financial.get(user_id_str, {})
individual_spend = user_financial.get('spend', 0) or 0
max_budget = user_financial.get('max_budget')
uses_shared_budget = user_financial.get('uses_shared_budget', False)
# Calculate current budget (remaining)
# For shared team budgets, use team_spend to calculate remaining budget
# This ensures all members see the same remaining budget
if max_budget is not None:
if uses_shared_budget:
# Shared budget - use team's total spend
current_budget = max(max_budget - team_spend, 0)
else:
# Individual budget - use individual spend
current_budget = max(max_budget - individual_spend, 0)
else:
# If no max_budget, current_budget is unlimited (represented as 0)
current_budget = 0
items.append(
OrgMemberFinancialResponse(
user_id=user_id_str,
email=user.email if user else None,
lifetime_spend=individual_spend,
current_budget=current_budget,
max_budget=max_budget,
)
)
# Calculate current page (1-indexed)
current_page = (offset // limit) + 1
# Calculate next_page_id
next_offset = offset + limit
next_page_id = str(next_offset) if next_offset < total_count else None
logger.debug(
'OrgMemberFinancialService:get_org_members_financial_data:success',
extra={
'org_id': str(org_id),
'items_count': len(items),
'current_page': current_page,
'total_count': total_count,
},
)
return OrgMemberFinancialPage(
items=items,
current_page=current_page,
per_page=limit,
next_page_id=next_page_id,
)

View File

@@ -363,6 +363,11 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
if api_key_org_id is not None:
org_id = api_key_org_id
# Override with resolver org_id if set (from git org claim resolution)
resolver_org_id = getattr(self.user_context, 'resolver_org_id', None)
if resolver_org_id is not None:
org_id = resolver_org_id
# Check if SAAS metadata already exists
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(info.id)

View File

@@ -29,7 +29,10 @@ def get_cookie_domain() -> str | None:
def get_cookie_samesite() -> Literal['lax', 'strict']:
# for localhost and feature/staging stacks we set it to 'lax' as the cookie domain won't allow 'strict'
# Use 'strict' in production for maximum CSRF protection
# Use 'lax' for local development and staging environments
# Note: For invitation links from emails, the frontend handles acceptance via
# an authenticated POST request (same-origin), which works with 'strict' cookies
web_url = get_global_config().web_url
return (
'strict'

View File

@@ -17,7 +17,7 @@ from server.verified_models.verified_model_service import (
from openhands.app_server.config import get_db_session
from openhands.server.routes import public
from openhands.utils.llm import get_supported_llm_models
from openhands.utils.llm import ModelsResponse, get_supported_llm_models
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
@@ -117,7 +117,7 @@ async def delete_verified_model(
)
async def get_saas_llm_models_dependency(request: Request) -> list[str]:
async def get_saas_llm_models_dependency(request: Request) -> ModelsResponse:
"""SaaS implementation for the LLM models endpoint."""
async with get_db_session(request.state, request) as db_session:
# Prevent circular import

View File

@@ -19,6 +19,7 @@ from storage.linear_workspace import LinearWorkspace
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
from storage.openhands_pr import OpenhandsPR
from storage.org import Org
from storage.org_git_claim import OrgGitClaim
from storage.org_invitation import OrgInvitation
from storage.org_member import OrgMember
from storage.proactive_convos import ProactiveConversation
@@ -65,6 +66,7 @@ __all__ = [
'MaintenanceTaskStatus',
'OpenhandsPR',
'Org',
'OrgGitClaim',
'OrgInvitation',
'OrgMember',
'ProactiveConversation',

View File

@@ -1,5 +1,13 @@
"""
Unified SQLAlchemy declarative base for all models.
Re-exports the core Base to ensure enterprise and core models share the same
metadata registry. This allows foreign key relationships between enterprise
models (e.g., ConversationCallback) and core models (e.g., StoredConversationMetadata).
The core Base now uses SQLAlchemy 2.0 DeclarativeBase for proper type inference
with Mapped types, while remaining backward compatible with existing Column()
definitions.
"""
from openhands.app_server.utils.sql_utils import Base

View File

@@ -1,22 +1,28 @@
from datetime import UTC, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import DECIMAL, Column, DateTime, Enum, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy import DECIMAL, DateTime, Enum, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from storage.base import Base
if TYPE_CHECKING:
from storage.org import Org
class BillingSession(Base): # type: ignore
class BillingSession(Base):
"""
Represents a Stripe billing session for credit purchases.
Tracks the status of payment transactions and associated user information.
"""
__tablename__ = 'billing_sessions'
id = Column(String, primary_key=True)
user_id = Column(String, nullable=False)
org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), nullable=True)
status = Column(
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(String, nullable=False)
org_id: Mapped[UUID | None] = mapped_column(ForeignKey('org.id'), nullable=True)
status: Mapped[str] = mapped_column(
Enum(
'in_progress',
'completed',
@@ -26,16 +32,16 @@ class BillingSession(Base): # type: ignore
),
default='in_progress',
)
price = Column(DECIMAL(19, 4), nullable=False)
price_code = Column(String, nullable=False)
created_at = Column(
price: Mapped[Decimal] = mapped_column(DECIMAL(19, 4), nullable=False)
price_code: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
default=lambda: datetime.now(UTC),
)
updated_at = Column(
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
default=lambda: datetime.now(UTC),
)
# Relationships
org = relationship('Org', back_populates='billing_sessions')
org: Mapped['Org | None'] = relationship('Org', back_populates='billing_sessions')

View File

@@ -3,7 +3,8 @@
from datetime import datetime, timezone
from enum import Enum
from sqlalchemy import Column, DateTime, Integer, String
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from storage.base import Base
@@ -25,21 +26,33 @@ class DeviceCode(Base):
__tablename__ = 'device_codes'
id = Column(Integer, primary_key=True, autoincrement=True)
device_code = Column(String(128), unique=True, nullable=False, index=True)
user_code = Column(String(16), unique=True, nullable=False, index=True)
status = Column(String(32), nullable=False, default=DeviceCodeStatus.PENDING.value)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
device_code: Mapped[str] = mapped_column(
String(128), unique=True, nullable=False, index=True
)
user_code: Mapped[str] = mapped_column(
String(16), unique=True, nullable=False, index=True
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=DeviceCodeStatus.PENDING.value
)
# Keycloak user ID who authorized the device (set during verification)
keycloak_user_id = Column(String(255), nullable=True)
keycloak_user_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Timestamps
expires_at = Column(DateTime(timezone=True), nullable=False)
authorized_at = Column(DateTime(timezone=True), nullable=True)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
authorized_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Rate limiting fields for RFC 8628 section 3.5 compliance
last_poll_time = Column(DateTime(timezone=True), nullable=True)
current_interval = Column(Integer, nullable=False, default=5)
last_poll_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
current_interval: Mapped[int] = mapped_column(nullable=False, default=5)
def __repr__(self) -> str:
return f"<DeviceCode(user_code='{self.user_code}', status='{self.status}')>"

View File

@@ -1,29 +1,34 @@
from sqlalchemy import JSON, Column, DateTime, Enum, Integer, String, Text
from sqlalchemy.sql import func
from datetime import datetime
from typing import Any
from sqlalchemy import JSON, Enum, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from storage.base import Base
class Feedback(Base): # type: ignore
class Feedback(Base):
__tablename__ = 'feedback'
id = Column(String, primary_key=True)
version = Column(String, nullable=False)
email = Column(String, nullable=False)
polarity = Column(
id: Mapped[str] = mapped_column(String, primary_key=True)
version: Mapped[str] = mapped_column(String, nullable=False)
email: Mapped[str] = mapped_column(String, nullable=False)
polarity: Mapped[str] = mapped_column(
Enum('positive', 'negative', name='polarity_enum'), nullable=False
)
permissions = Column(
permissions: Mapped[str] = mapped_column(
Enum('public', 'private', name='permissions_enum'), nullable=False
)
trajectory = Column(JSON, nullable=True)
trajectory: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
class ConversationFeedback(Base): # type: ignore
class ConversationFeedback(Base):
__tablename__ = 'conversation_feedback'
id = Column(Integer, primary_key=True, autoincrement=True)
conversation_id = Column(String, nullable=False, index=True)
event_id = Column(Integer, nullable=True)
rating = Column(Integer, nullable=False)
reason = Column(Text, nullable=True)
created_at = Column(DateTime, nullable=False, server_default=func.now())
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
conversation_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
event_id: Mapped[int | None] = mapped_column(nullable=True)
rating: Mapped[int] = mapped_column(nullable=False)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
nullable=False, server_default=func.now()
)

View File

@@ -1524,6 +1524,83 @@ class LiteLlmManager:
'LiteLlmManager:_delete_key:key_deleted',
)
@staticmethod
async def _get_team_members_financial_data(
client: httpx.AsyncClient,
team_id: str,
) -> dict:
"""
Get financial data for all members in a team.
Fetches team info from LiteLLM and extracts spending/budget data for each member.
Args:
client: HTTP client for LiteLLM API
team_id: The team/organization ID
Returns:
Dict with structure:
{
"team_max_budget": float | None, # Team's shared budget
"team_spend": float, # Team's total spend (for shared budget calc)
"members": {
user_id: {
"spend": float,
"max_budget": float | None,
"uses_shared_budget": bool # True if using team budget
},
...
}
}
Returns empty dict if team not found or LiteLLM is not configured.
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return {}
team_info = await LiteLlmManager._get_team(client, team_id)
if not team_info:
logger.warning(
'LiteLlmManager:_get_team_members_financial_data:team_not_found',
extra={'team_id': team_id},
)
return {}
members: dict[str, dict] = {}
team_memberships = team_info.get('team_memberships', [])
# Get team-level budget info (shared across all members in team orgs)
team_data = team_info.get('team_info', {})
team_max_budget = team_data.get('max_budget')
team_spend = team_data.get('spend', 0) or 0
for membership in team_memberships:
user_id = membership.get('user_id')
if not user_id:
continue
# Use individual max_budget_in_team if set, otherwise fall back to team budget
member_max_budget = membership.get('max_budget_in_team')
uses_shared_budget = member_max_budget is None
if uses_shared_budget:
member_max_budget = team_max_budget
members[user_id] = {
'spend': membership.get('spend', 0) or 0,
'max_budget': member_max_budget,
'uses_shared_budget': uses_shared_budget,
}
logger.debug(
'LiteLlmManager:_get_team_members_financial_data:success',
extra={'team_id': team_id, 'member_count': len(members)},
)
return {
'team_max_budget': team_max_budget,
'team_spend': team_spend,
'members': members,
}
@staticmethod
def with_http_client(
internal_fn: Callable[..., Awaitable[Any]],
@@ -1559,3 +1636,6 @@ class LiteLlmManager:
get_user_keys = staticmethod(with_http_client(_get_user_keys))
delete_key_by_alias = staticmethod(with_http_client(_delete_key_by_alias))
update_user_keys = staticmethod(with_http_client(_update_user_keys))
get_team_members_financial_data = staticmethod(
with_http_client(_get_team_members_financial_data)
)

View File

@@ -64,6 +64,7 @@ class Org(Base): # type: ignore
slack_conversations = relationship('SlackConversation', back_populates='org')
slack_users = relationship('SlackUser', back_populates='org')
stripe_customers = relationship('StripeCustomer', back_populates='org')
git_claims = relationship('OrgGitClaim', back_populates='org')
def __init__(self, **kwargs):
# Handle known SQLAlchemy columns directly

View File

@@ -0,0 +1,30 @@
"""
SQLAlchemy model for Git Organization Claims.
"""
from uuid import uuid4
from sqlalchemy import UUID, Column, DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import relationship
from storage.base import Base
class OrgGitClaim(Base): # type: ignore
"""Model for tracking which OpenHands org has claimed a Git organization."""
__tablename__ = 'org_git_claim'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
org_id = Column(
UUID(as_uuid=True), ForeignKey('org.id', ondelete='CASCADE'), nullable=False
)
provider = Column(String, nullable=False)
git_organization = Column(String, nullable=False)
claimed_by = Column(UUID(as_uuid=True), ForeignKey('user.id'), nullable=False)
claimed_at = Column(DateTime(timezone=True), nullable=False)
__table_args__ = (
UniqueConstraint('provider', 'git_organization', name='uq_provider_git_org'),
)
org = relationship('Org', back_populates='git_claims')

View File

@@ -0,0 +1,141 @@
"""
Store class for managing Git organization claims.
"""
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from sqlalchemy import and_, select
from storage.database import a_session_maker
from storage.org_git_claim import OrgGitClaim
from openhands.core.logger import openhands_logger as logger
class OrgGitClaimStore:
"""Store for managing Git organization claims."""
@staticmethod
async def create_claim(
org_id: UUID,
provider: str,
git_organization: str,
claimed_by: UUID,
) -> OrgGitClaim:
"""Create a new Git organization claim.
Args:
org_id: OpenHands organization UUID
provider: Git provider ('github', 'gitlab', 'bitbucket')
git_organization: Name of the Git organization being claimed
claimed_by: User UUID who is making the claim
Returns:
OrgGitClaim: The created claim record
"""
async with a_session_maker() as session:
claim = OrgGitClaim(
org_id=org_id,
provider=provider,
git_organization=git_organization,
claimed_by=claimed_by,
claimed_at=datetime.now(timezone.utc),
)
session.add(claim)
await session.commit()
await session.refresh(claim)
logger.info(
'Created Git organization claim',
extra={
'claim_id': str(claim.id),
'org_id': str(org_id),
'provider': provider,
'git_organization': git_organization,
'claimed_by': str(claimed_by),
},
)
return claim
@staticmethod
async def get_claim_by_provider_and_git_org(
provider: str,
git_organization: str,
) -> Optional[OrgGitClaim]:
"""Check if a Git organization is already claimed.
Args:
provider: Git provider name
git_organization: Name of the Git organization
Returns:
OrgGitClaim or None if not claimed
"""
async with a_session_maker() as session:
result = await session.execute(
select(OrgGitClaim).filter(
and_(
OrgGitClaim.provider == provider,
OrgGitClaim.git_organization == git_organization,
)
)
)
return result.scalars().first()
@staticmethod
async def get_claims_by_org_id(org_id: UUID) -> list[OrgGitClaim]:
"""Get all Git organization claims for an OpenHands organization.
Args:
org_id: OpenHands organization UUID
Returns:
List of OrgGitClaim records
"""
async with a_session_maker() as session:
result = await session.execute(
select(OrgGitClaim).filter(OrgGitClaim.org_id == org_id)
)
return list(result.scalars().all())
@staticmethod
async def delete_claim(claim_id: UUID, org_id: UUID) -> bool:
"""Delete a Git organization claim.
Args:
claim_id: Claim UUID to delete
org_id: OpenHands organization UUID (for ownership verification)
Returns:
True if deleted, False if not found
"""
async with a_session_maker() as session:
result = await session.execute(
select(OrgGitClaim).filter(
and_(
OrgGitClaim.id == claim_id,
OrgGitClaim.org_id == org_id,
)
)
)
claim = result.scalars().first()
if not claim:
return False
await session.delete(claim)
await session.commit()
logger.info(
'Deleted Git organization claim',
extra={
'claim_id': str(claim_id),
'org_id': str(org_id),
'provider': claim.provider,
'git_organization': claim.git_organization,
},
)
return True

View File

@@ -34,10 +34,17 @@ class SaasConversationStore(ConversationStore):
session_maker: sessionmaker
org_id: UUID | None = None # will be fetched automatically
def __init__(self, user_id: str, org_id: UUID, session_maker: sessionmaker):
def __init__(
self,
user_id: str,
org_id: UUID,
session_maker: sessionmaker,
resolver_org_id: UUID | None = None,
):
self.user_id = user_id
self.org_id = org_id
self.session_maker = session_maker
self.resolver_org_id = resolver_org_id
def _select_by_id(self, session, conversation_id: str):
# Join StoredConversationMetadata with ConversationMetadataSaas to filter by user/org
@@ -103,6 +110,13 @@ class SaasConversationStore(ConversationStore):
stored_metadata = StoredConversationMetadata(**kwargs)
# Override with resolver org_id if set (from git org claim resolution),
# same pattern as V1's save_app_conversation_info in
# saas_app_conversation_info_injector.py
org_id = self.org_id
if self.resolver_org_id is not None:
org_id = self.resolver_org_id
def _save_metadata():
with self.session_maker() as session:
# Save the main conversation metadata
@@ -122,13 +136,13 @@ class SaasConversationStore(ConversationStore):
saas_metadata = StoredConversationMetadataSaas(
conversation_id=stored_metadata.conversation_id,
user_id=UUID(self.user_id),
org_id=self.org_id,
org_id=org_id,
)
session.add(saas_metadata)
else:
# Validate
expected_user_id = UUID(self.user_id)
expected_org_id = self.org_id
expected_org_id = org_id
if saas_metadata.user_id != expected_user_id:
raise ValueError(
@@ -240,3 +254,19 @@ class SaasConversationStore(ConversationStore):
user = await UserStore.get_user_by_id(user_id)
org_id = user.current_org_id if user else None
return SaasConversationStore(user_id, org_id, session_maker)
@classmethod
async def get_resolver_instance(
cls,
config: OpenHandsConfig,
user_id: str,
resolver_org_id: UUID | None = None,
) -> 'SaasConversationStore':
"""Get a store for resolver conversations with explicit org routing.
Unlike get_instance, this accepts a resolver_org_id that overrides
the user's default org when saving conversation metadata.
"""
user = await UserStore.get_user_by_id(user_id)
org_id = user.current_org_id if user else None
return SaasConversationStore(user_id, org_id, session_maker, resolver_org_id)

View File

@@ -182,7 +182,13 @@ class SaasSettingsStore(SettingsStore):
return None
# Check if we need to generate an LLM key.
if not item.llm_base_url or item.llm_base_url == LITE_LLM_API_URL:
# Only generate/verify proxy keys when the base URL is explicitly the
# LiteLLM proxy, or when it's unset and the model is an OpenHands model
# (which always needs a proxy key). For non-OpenHands models with no
# base URL (e.g. basic view BYOR), preserve the user's own API key.
if item.llm_base_url == LITE_LLM_API_URL or (
not item.llm_base_url and is_openhands_model(item.llm_model)
):
await self._ensure_api_key(
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
)

View File

@@ -1,10 +1,12 @@
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy import DECIMAL, Column, DateTime, Enum, Integer, String
from sqlalchemy import DECIMAL, DateTime, Enum, String
from sqlalchemy.orm import Mapped, mapped_column
from storage.base import Base
class SubscriptionAccess(Base): # type: ignore
class SubscriptionAccess(Base):
"""
Represents a user's subscription access record.
Tracks subscription status, duration, payment information, and cancellation status.
@@ -12,8 +14,8 @@ class SubscriptionAccess(Base): # type: ignore
__tablename__ = 'subscription_access'
id = Column(Integer, primary_key=True, autoincrement=True)
status = Column(
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
status: Mapped[str] = mapped_column(
Enum(
'ACTIVE',
'DISABLED',
@@ -22,22 +24,30 @@ class SubscriptionAccess(Base): # type: ignore
nullable=False,
index=True,
)
user_id = Column(String, nullable=False, index=True)
start_at = Column(DateTime(timezone=True), nullable=True)
end_at = Column(DateTime(timezone=True), nullable=True)
amount_paid = Column(DECIMAL(19, 4), nullable=True)
stripe_invoice_payment_id = Column(String, nullable=False)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
stripe_subscription_id = Column(String, nullable=True, index=True)
created_at = Column(
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
start_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
end_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
amount_paid: Mapped[Decimal | None] = mapped_column(DECIMAL(19, 4), nullable=True)
stripe_invoice_payment_id: Mapped[str] = mapped_column(String, nullable=False)
cancelled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
stripe_subscription_id: Mapped[str | None] = mapped_column(
String, nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
default=lambda: datetime.now(UTC),
nullable=False,
)
updated_at = Column(
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
onupdate=lambda: datetime.now(UTC), # type: ignore[attr-defined]
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)

View File

@@ -5,6 +5,7 @@ SQLAlchemy model for User.
from uuid import uuid4
from sqlalchemy import (
JSON,
UUID,
Boolean,
Column,
@@ -34,6 +35,7 @@ class User(Base): # type: ignore
git_user_name = Column(String, nullable=True)
git_user_email = Column(String, nullable=True)
sandbox_grouping_strategy = Column(String, nullable=True)
disabled_skills = Column(JSON, nullable=True)
# Relationships
role = relationship('Role', back_populates='users')

View File

@@ -13,7 +13,6 @@ Required environment variables:
- RESEND_AUDIENCE_ID: ID of the Resend audience to add users to
Optional environment variables:
- KEYCLOAK_PROVIDER_NAME: Provider name for Keycloak
- KEYCLOAK_CLIENT_ID: Client ID for Keycloak
- KEYCLOAK_CLIENT_SECRET: Client secret for Keycloak
- RESEND_FROM_EMAIL: Email address to use as the sender (default: "OpenHands Team <no-reply@welcome.openhands.dev>")
@@ -49,7 +48,6 @@ from openhands.core.logger import openhands_logger as logger
# Get Keycloak configuration from environment variables
KEYCLOAK_SERVER_URL = os.environ.get('KEYCLOAK_SERVER_URL', '')
KEYCLOAK_REALM_NAME = os.environ.get('KEYCLOAK_REALM_NAME', '')
KEYCLOAK_PROVIDER_NAME = os.environ.get('KEYCLOAK_PROVIDER_NAME', '')
KEYCLOAK_CLIENT_ID = os.environ.get('KEYCLOAK_CLIENT_ID', '')
KEYCLOAK_CLIENT_SECRET = os.environ.get('KEYCLOAK_CLIENT_SECRET', '')
KEYCLOAK_ADMIN_PASSWORD = os.environ.get('KEYCLOAK_ADMIN_PASSWORD', '')

View File

@@ -25,6 +25,7 @@ from storage.device_code import DeviceCode # noqa: F401
from storage.feedback import Feedback
from storage.github_app_installation import GithubAppInstallation
from storage.org import Org
from storage.org_git_claim import OrgGitClaim # noqa: F401
from storage.org_invitation import OrgInvitation # noqa: F401
from storage.org_member import OrgMember
from storage.role import Role

View File

@@ -88,6 +88,7 @@ class TestGithubViewV1InitialUserMessage:
view.previous_comments = [MagicMock(author='alice', body='old comment 1')]
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_app_conversation_service.return_value = (
@@ -144,6 +145,7 @@ class TestGithubViewV1InitialUserMessage:
]
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_app_conversation_service.return_value = (
@@ -200,6 +202,7 @@ class TestGithubViewV1InitialUserMessage:
view.previous_comments = []
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_service.return_value = _fake_app_conversation_service_ctx(fake_service)

View File

@@ -3,6 +3,7 @@ Tests for Jira view classes and factory.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.jira.jira_payload import (
@@ -18,6 +19,9 @@ from integrations.jira.jira_view import (
JiraNewConversationView,
)
from openhands.integrations.service_types import ProviderType
from openhands.server.user_auth.user_auth import UserAuth
class TestJiraNewConversationView:
"""Tests for JiraNewConversationView"""
@@ -86,29 +90,49 @@ class TestJiraNewConversationView:
assert 'Test Issue' in user_msg
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.create_new_conversation')
@patch('integrations.jira.jira_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.ProviderHandler')
@patch(
'integrations.jira.jira_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.jira.jira_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.integration_store')
async def test_create_or_update_conversation_success(
self,
mock_store,
mock_create_conversation,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test successful conversation creation"""
new_conversation_view._issue_title = 'Test Issue'
new_conversation_view._issue_description = 'Test description'
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result == 'conv-123'
mock_create_conversation.assert_called_once()
mock_store.create_conversation.assert_called_once()
assert result is not None
assert isinstance(result, str)
assert len(result) == 32 # uuid4().hex format
mock_start_convo.assert_called_once()
mock_integration_store.create_conversation.assert_called_once()
@pytest.mark.asyncio
async def test_create_or_update_conversation_no_repo(
@@ -348,6 +372,125 @@ class TestJiraFactory:
)
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
class TestJiraV0ConversationRouting:
"""Test V0 conversation routing logic based on claimed git organizations."""
@pytest.fixture
def routing_view(
self,
sample_webhook_payload,
sample_jira_user,
sample_jira_workspace,
):
"""View with non-empty provider tokens for routing tests."""
user_auth = MagicMock(spec=UserAuth)
user_auth.get_provider_tokens = AsyncMock(
return_value={ProviderType.GITHUB: MagicMock()}
)
user_auth.get_secrets = AsyncMock(return_value=None)
return JiraNewConversationView(
payload=sample_webhook_payload,
saas_user_auth=user_auth,
jira_user=sample_jira_user,
jira_workspace=sample_jira_workspace,
selected_repo='test/repo1',
_issue_title='Test Issue',
_issue_description='Test description',
_decrypted_api_key='decrypted_key',
)
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.ProviderHandler')
@patch(
'integrations.jira.jira_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.jira.jira_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.integration_store')
async def test_routes_to_claimed_org_when_user_is_member(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
routing_view,
mock_jinja_env,
):
"""When repo belongs to a claimed org and user is a member, conversation is created in that org."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = CLAIMING_ORG_ID
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
# Act
await routing_view.create_or_update_conversation(mock_jinja_env)
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='test/repo1',
keycloak_user_id='test_keycloak_id',
)
call_args = mock_get_resolver_instance.call_args
assert call_args[0][1] == 'test_keycloak_id' # user_id
assert call_args[0][2] == CLAIMING_ORG_ID # resolver_org_id
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.git_provider == ProviderType.GITHUB
@pytest.mark.asyncio
@patch('integrations.jira.jira_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.ProviderHandler')
@patch(
'integrations.jira.jira_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.jira.jira_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.jira.jira_view.integration_store')
async def test_falls_back_to_personal_workspace_when_no_claim(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
routing_view,
mock_jinja_env,
):
"""When no org has claimed the git org, conversation goes to personal workspace."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
# Act
await routing_view.create_or_update_conversation(mock_jinja_env)
# Assert
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None # resolver_org_id is None
class TestJiraPayloadParser:
"""Tests for JiraPayloadParser"""

View File

@@ -73,6 +73,7 @@ def sample_user_auth():
"""Create a mock UserAuth for testing."""
user_auth = MagicMock(spec=UserAuth)
user_auth.get_provider_tokens = AsyncMock(return_value={})
user_auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
user_auth.get_access_token = AsyncMock(return_value='test_token')
user_auth.get_user_id = AsyncMock(return_value='test_user_id')
return user_auth

View File

@@ -29,27 +29,33 @@ class TestLinearNewConversationView:
assert 'Test Issue' in user_msg
assert 'Fix this bug @openhands' in user_msg
@patch('integrations.linear.linear_view.create_new_conversation')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.linear.linear_view.integration_store')
async def test_create_or_update_conversation_success(
self,
mock_store,
mock_create_conversation,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test successful conversation creation"""
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result == 'conv-123'
mock_create_conversation.assert_called_once()
mock_store.create_conversation.assert_called_once()
assert result is not None
mock_start_convo.assert_called_once()
mock_integration_store.create_conversation.assert_called_once()
async def test_create_or_update_conversation_no_repo(
self, new_conversation_view, mock_jinja_env
@@ -60,12 +66,23 @@ class TestLinearNewConversationView:
with pytest.raises(StartingConvoException, match='No repository selected'):
await new_conversation_view.create_or_update_conversation(mock_jinja_env)
@patch('integrations.linear.linear_view.create_new_conversation')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
async def test_create_or_update_conversation_failure(
self, mock_create_conversation, new_conversation_view, mock_jinja_env
self,
mock_start_convo,
mock_get_resolver_instance,
new_conversation_view,
mock_jinja_env,
):
"""Test conversation creation failure"""
mock_create_conversation.side_effect = Exception('Creation failed')
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.side_effect = Exception('Creation failed')
with pytest.raises(
StartingConvoException, match='Failed to create conversation'
@@ -300,43 +317,57 @@ class TestLinearFactory:
class TestLinearViewEdgeCases:
"""Tests for edge cases and error scenarios"""
@patch('integrations.linear.linear_view.create_new_conversation')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.linear.linear_view.integration_store')
async def test_conversation_creation_with_no_user_secrets(
self,
mock_store,
mock_create_conversation,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test conversation creation when user has no secrets"""
new_conversation_view.saas_user_auth.get_secrets.return_value = None
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock()
new_conversation_view.saas_user_auth.get_secrets = AsyncMock(return_value=None)
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
result = await new_conversation_view.create_or_update_conversation(
mock_jinja_env
)
assert result == 'conv-123'
# Verify create_new_conversation was called with custom_secrets=None
call_kwargs = mock_create_conversation.call_args[1]
assert result is not None
# Verify start_conversation was called with custom_secrets=None
call_kwargs = mock_start_convo.call_args[1]
assert call_kwargs['custom_secrets'] is None
@patch('integrations.linear.linear_view.create_new_conversation')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch('integrations.linear.linear_view.integration_store')
async def test_conversation_creation_store_failure(
self,
mock_store,
mock_create_conversation,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
new_conversation_view,
mock_jinja_env,
mock_agent_loop_info,
):
"""Test conversation creation when store creation fails"""
mock_create_conversation.return_value = mock_agent_loop_info
mock_store.create_conversation = AsyncMock(side_effect=Exception('Store error'))
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock(
side_effect=Exception('Store error')
)
with pytest.raises(
StartingConvoException, match='Failed to create conversation'

View File

@@ -257,7 +257,7 @@ class TestSlackV1CallbackProcessor:
# Verify Slack posting
mock_slack_client.chat_postMessage.assert_called_once_with(
channel='C1234567890',
text='Test summary from agent',
markdown_text='Test summary from agent',
thread_ts='1234567890.123456',
unfurl_links=False,
unfurl_media=False,
@@ -509,7 +509,7 @@ class TestSlackV1CallbackProcessor:
# Verify user-friendly message was posted to Slack
mock_slack_client.chat_postMessage.assert_called_once()
call_kwargs = mock_slack_client.chat_postMessage.call_args[1]
posted_message = call_kwargs.get('text', '')
posted_message = call_kwargs.get('markdown_text', '')
assert 'OpenHands encountered an error' in posted_message
assert 'LLM budget has been exceeded' in posted_message
assert 'please re-fill' in posted_message

View File

@@ -32,6 +32,28 @@ def resolver_context(mock_saas_user_auth):
return ResolverUserContext(saas_user_auth=mock_saas_user_auth)
# ---------------------------------------------------------------------------
# Tests for resolver_org_id - org routing for resolver conversations
# ---------------------------------------------------------------------------
def test_resolver_org_id_defaults_to_none(mock_saas_user_auth):
"""Test that resolver_org_id defaults to None when not provided."""
ctx = ResolverUserContext(saas_user_auth=mock_saas_user_auth)
assert ctx.resolver_org_id is None
def test_resolver_org_id_can_be_set_via_constructor(mock_saas_user_auth):
"""Test that resolver_org_id can be set via constructor for org routing."""
from uuid import UUID
org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
ctx = ResolverUserContext(
saas_user_auth=mock_saas_user_auth, resolver_org_id=org_id
)
assert ctx.resolver_org_id == org_id
def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret:
"""Helper to create CustomSecret instances."""
return CustomSecret(secret=SecretStr(value), description=description)

View File

@@ -0,0 +1,111 @@
"""Tests for resolver org routing logic.
Tests the resolve_org_for_repo function which determines which OpenHands
organization workspace a resolver conversation should be created in.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
# Patch at module level where the names are looked up
_CLAIM_STORE = 'enterprise.integrations.resolver_org_router.OrgGitClaimStore'
_MEMBER_STORE = 'enterprise.integrations.resolver_org_router.OrgMemberStore'
@pytest.fixture(autouse=True)
def mock_stores():
"""Mock OrgGitClaimStore and OrgMemberStore for all tests."""
with (
patch(_CLAIM_STORE) as mock_claim_store,
patch(_MEMBER_STORE) as mock_member_store,
):
mock_claim_store.get_claim_by_provider_and_git_org = AsyncMock(
return_value=None
)
mock_member_store.get_org_member = AsyncMock(return_value=None)
yield mock_claim_store, mock_member_store
@pytest.mark.asyncio
async def test_returns_org_id_when_claimed_and_user_is_member(mock_stores):
"""When the git org is claimed and the user is a member, return the claiming org's ID."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, mock_member_store = mock_stores
# Arrange
claim = MagicMock()
claim.org_id = CLAIMING_ORG_ID
mock_claim_store.get_claim_by_provider_and_git_org.return_value = claim
mock_member_store.get_org_member.return_value = MagicMock() # member exists
# Act
result = await resolve_org_for_repo('github', 'OpenHands/foo', USER_ID)
# Assert
assert result == CLAIMING_ORG_ID
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
'github', 'openhands'
)
mock_member_store.get_org_member.assert_called_once_with(
CLAIMING_ORG_ID, UUID(USER_ID)
)
@pytest.mark.asyncio
async def test_returns_none_when_claimed_but_user_not_member(mock_stores):
"""When the git org is claimed but user is not a member, return None."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, mock_member_store = mock_stores
# Arrange
claim = MagicMock()
claim.org_id = CLAIMING_ORG_ID
mock_claim_store.get_claim_by_provider_and_git_org.return_value = claim
mock_member_store.get_org_member.return_value = None
# Act
result = await resolve_org_for_repo('github', 'OpenHands/foo', USER_ID)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_no_claim_exists(mock_stores):
"""When no org has claimed the git organization, return None."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, _ = mock_stores
mock_claim_store.get_claim_by_provider_and_git_org.return_value = None
# Act
result = await resolve_org_for_repo('github', 'UnclaimedOrg/repo', USER_ID)
# Assert
assert result is None
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
'github', 'unclaimedorg'
)
@pytest.mark.asyncio
async def test_extracts_git_org_lowercase_from_repo_name(mock_stores):
"""The git org is extracted from repo name and lowercased for claim lookup."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, _ = mock_stores
# Act
await resolve_org_for_repo('github', 'MyOrg/some-repo', USER_ID)
# Assert
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
'github', 'myorg'
)

View File

@@ -0,0 +1,351 @@
"""Unit tests for SaasUserAuth.get_org_info() using SQLite in-memory database.
These tests exercise the real `get_org_info()` implementation with actual DB queries
to catch regressions in the SAAS org lookup logic.
"""
import uuid
from unittest.mock import patch
import pytest
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from storage.base import Base
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
from storage.user import User
@pytest.fixture
async def async_engine():
"""Create an async SQLite engine for testing."""
engine = create_async_engine(
'sqlite+aiosqlite:///:memory:',
poolclass=StaticPool,
connect_args={'check_same_thread': False},
)
return engine
@pytest.fixture
async def async_session_maker(async_engine):
"""Create an async session maker bound to the async engine."""
session_maker = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False,
)
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return session_maker
@pytest.fixture
def user_id():
"""Generate a unique user ID for tests."""
return str(uuid.uuid4())
@pytest.fixture
def org_id():
"""Generate a unique org ID for tests."""
return uuid.uuid4()
async def create_role(session_maker, name: str, rank: int) -> Role:
"""Helper to create a role in the test database."""
async with session_maker() as session:
role = Role(name=name, rank=rank)
session.add(role)
await session.commit()
await session.refresh(role)
return role
async def create_org(session_maker, org_id: uuid.UUID, name: str) -> Org:
"""Helper to create an org in the test database."""
async with session_maker() as session:
org = Org(
id=org_id,
name=name,
org_version=1,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
session.add(org)
await session.commit()
await session.refresh(org)
return org
async def create_user(session_maker, user_id: str, current_org_id: uuid.UUID) -> User:
"""Helper to create a user in the test database."""
async with session_maker() as session:
user = User(
id=uuid.UUID(user_id),
current_org_id=current_org_id,
user_consents_to_analytics=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def create_org_member(
session_maker,
org_id: uuid.UUID,
user_id: str,
role_id: int,
status: str = 'active',
llm_api_key: str = 'test-api-key',
) -> OrgMember:
"""Helper to create an org member in the test database."""
async with session_maker() as session:
org_member = OrgMember(
org_id=org_id,
user_id=uuid.UUID(user_id),
role_id=role_id,
status=status,
llm_api_key=llm_api_key,
)
session.add(org_member)
await session.commit()
await session.refresh(org_member)
return org_member
class TestGetOrgInfoWithRealDB:
"""Tests for get_org_info() using in-memory SQLite database."""
@pytest.mark.asyncio
async def test_get_org_info_returns_correct_data_for_owner(
self, async_session_maker, user_id, org_id
):
"""Test that get_org_info returns correct data for an owner role."""
# Set up test data
owner_role = await create_role(async_session_maker, 'owner', 1)
await create_org(async_session_maker, org_id, 'Test Organization')
await create_user(async_session_maker, user_id, org_id)
await create_org_member(async_session_maker, org_id, user_id, owner_role.id)
# Create SaasUserAuth instance
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
# Patch the global a_session_maker in all stores that use it
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is not None
assert org_info['org_id'] == str(org_id)
assert org_info['org_name'] == 'Test Organization'
assert org_info['role'] == 'owner'
assert isinstance(org_info['permissions'], list)
# Owner should have many permissions
assert len(org_info['permissions']) > 0
assert 'manage_secrets' in org_info['permissions']
@pytest.mark.asyncio
async def test_get_org_info_returns_correct_data_for_member(
self, async_session_maker, user_id, org_id
):
"""Test that get_org_info returns correct data for a member role."""
# Set up test data
member_role = await create_role(async_session_maker, 'member', 3)
await create_org(async_session_maker, org_id, 'Member Org')
await create_user(async_session_maker, user_id, org_id)
await create_org_member(async_session_maker, org_id, user_id, member_role.id)
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is not None
assert org_info['org_id'] == str(org_id)
assert org_info['org_name'] == 'Member Org'
assert org_info['role'] == 'member'
# Member should have limited permissions
assert isinstance(org_info['permissions'], list)
@pytest.mark.asyncio
async def test_get_org_info_returns_correct_data_for_admin(
self, async_session_maker, user_id, org_id
):
"""Test that get_org_info returns correct data for an admin role."""
# Set up test data
admin_role = await create_role(async_session_maker, 'admin', 2)
await create_org(async_session_maker, org_id, 'Admin Org')
await create_user(async_session_maker, user_id, org_id)
await create_org_member(async_session_maker, org_id, user_id, admin_role.id)
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is not None
assert org_info['org_id'] == str(org_id)
assert org_info['org_name'] == 'Admin Org'
assert org_info['role'] == 'admin'
assert isinstance(org_info['permissions'], list)
@pytest.mark.asyncio
async def test_get_org_info_returns_none_when_user_not_found(
self, async_session_maker
):
"""Test that get_org_info returns None when user doesn't exist."""
nonexistent_user_id = str(uuid.uuid4())
user_auth = SaasUserAuth(
user_id=nonexistent_user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is None
@pytest.mark.asyncio
async def test_get_org_info_returns_none_when_org_not_found(
self, async_session_maker, user_id
):
"""Test that get_org_info returns None when user's org doesn't exist."""
nonexistent_org_id = uuid.uuid4()
# Create user pointing to nonexistent org
async with async_session_maker() as session:
user = User(
id=uuid.UUID(user_id),
current_org_id=nonexistent_org_id,
user_consents_to_analytics=True,
)
session.add(user)
await session.commit()
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is None
@pytest.mark.asyncio
async def test_get_org_info_caches_result(
self, async_session_maker, user_id, org_id
):
"""Test that get_org_info caches the result and doesn't hit DB twice."""
# Set up test data
owner_role = await create_role(async_session_maker, 'owner', 1)
await create_org(async_session_maker, org_id, 'Cached Org')
await create_user(async_session_maker, user_id, org_id)
await create_org_member(async_session_maker, org_id, user_id, owner_role.id)
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
# First call
org_info1 = await user_auth.get_org_info()
assert org_info1 is not None
assert user_auth._org_info_loaded is True
# Second call should return cached result
org_info2 = await user_auth.get_org_info()
assert org_info2 is not None
assert org_info1 == org_info2
@pytest.mark.asyncio
async def test_get_org_info_caches_none_result(self, async_session_maker):
"""Test that get_org_info caches None result for nonexistent user."""
nonexistent_user_id = str(uuid.uuid4())
user_auth = SaasUserAuth(
user_id=nonexistent_user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
# First call
org_info1 = await user_auth.get_org_info()
assert org_info1 is None
assert user_auth._org_info_loaded is True
# Second call should return cached None without hitting DB
org_info2 = await user_auth.get_org_info()
assert org_info2 is None
@pytest.mark.asyncio
async def test_get_org_info_with_unknown_role_returns_empty_permissions(
self, async_session_maker, user_id, org_id
):
"""Test that get_org_info returns empty permissions for unknown role."""
# Create a custom role that isn't in the ROLE_PERMISSIONS mapping
custom_role = await create_role(async_session_maker, 'custom_role', 99)
await create_org(async_session_maker, org_id, 'Custom Org')
await create_user(async_session_maker, user_id, org_id)
await create_org_member(async_session_maker, org_id, user_id, custom_role.id)
user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr('mock_refresh_token'),
)
with patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
), patch(
'storage.org_member_store.a_session_maker', async_session_maker
), patch('storage.role_store.a_session_maker', async_session_maker):
org_info = await user_auth.get_org_info()
assert org_info is not None
assert org_info['org_id'] == str(org_id)
assert org_info['role'] == 'custom_role'
# Unknown roles should have empty permissions
assert org_info['permissions'] == []

View File

@@ -0,0 +1,603 @@
"""Tests for Git organization claim API endpoints.
Tests the following endpoints:
- GET /api/organizations/{org_id}/git-claims (list claims)
- POST /api/organizations/{org_id}/git-claims (claim)
- DELETE /api/organizations/{org_id}/git-claims/{claim_id} (disconnect)
"""
import uuid
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI, status
from fastapi.testclient import TestClient
from server.routes.orgs import (
claim_git_organization,
disconnect_git_organization,
get_git_claims,
org_router,
)
from sqlalchemy.exc import IntegrityError
from storage.org_git_claim import OrgGitClaim
from openhands.server.user_auth import get_user_id
TEST_USER_ID = str(uuid.uuid4())
@pytest.fixture
def org_id():
return uuid.uuid4()
@pytest.fixture
def user_id():
return str(uuid.uuid4())
@pytest.fixture
def make_claim():
"""Factory to create mock OrgGitClaim objects."""
def _make(org_id, provider='github', git_organization='OpenHands', claimed_by=None):
claim = MagicMock(spec=OrgGitClaim)
claim.id = uuid.uuid4()
claim.org_id = org_id
claim.provider = provider
claim.git_organization = git_organization
claim.claimed_by = claimed_by or uuid.uuid4()
claim.claimed_at = datetime(2026, 4, 1, 12, 0, 0)
return claim
return _make
# =============================================================================
# GET /api/organizations/{org_id}/git-claims
# =============================================================================
class TestGetGitClaims:
"""Tests for the get Git organization claims endpoint."""
@pytest.mark.asyncio
async def test_returns_empty_list_when_no_claims(self, org_id, user_id):
"""
GIVEN: An organization with no Git claims
WHEN: GET /api/organizations/{org_id}/git-claims is called
THEN: An empty list is returned
"""
with patch(
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
AsyncMock(return_value=[]),
) as mock_get:
result = await get_git_claims(org_id=org_id, user_id=user_id)
assert result == []
mock_get.assert_called_once_with(org_id=org_id)
@pytest.mark.asyncio
async def test_returns_claims_for_organization(self, org_id, user_id, make_claim):
"""
GIVEN: An organization with multiple Git claims
WHEN: GET /api/organizations/{org_id}/git-claims is called
THEN: All claims are returned with correct details
"""
claim1 = make_claim(org_id, provider='github', git_organization='OpenHands')
claim2 = make_claim(org_id, provider='gitlab', git_organization='AcmeCo')
with patch(
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
AsyncMock(return_value=[claim1, claim2]),
):
result = await get_git_claims(org_id=org_id, user_id=user_id)
assert len(result) == 2
assert result[0].id == str(claim1.id)
assert result[0].org_id == str(org_id)
assert result[0].provider == 'github'
assert result[0].git_organization == 'OpenHands'
assert result[0].claimed_by == str(claim1.claimed_by)
assert result[0].claimed_at == '2026-04-01T12:00:00'
assert result[1].id == str(claim2.id)
assert result[1].provider == 'gitlab'
assert result[1].git_organization == 'AcmeCo'
@pytest.mark.asyncio
async def test_returns_500_on_unexpected_error(self, org_id, user_id):
"""
GIVEN: An unexpected error occurs when fetching claims
WHEN: GET /api/organizations/{org_id}/git-claims is called
THEN: A 500 Internal Server Error is returned
"""
with patch(
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
AsyncMock(side_effect=RuntimeError('db connection failed')),
):
with pytest.raises(Exception) as exc_info:
await get_git_claims(org_id=org_id, user_id=user_id)
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
# =============================================================================
# POST /api/organizations/{org_id}/git-claims
# =============================================================================
class TestClaimGitOrganization:
"""Tests for the claim Git organization endpoint."""
@pytest.mark.asyncio
async def test_claim_succeeds_for_unclaimed_org(self, org_id, user_id, make_claim):
"""
GIVEN: A Git organization that has not been claimed
WHEN: POST /api/organizations/{org_id}/git-claims is called
THEN: The claim is created and returned with correct details
"""
# Arrange
mock_claim = make_claim(org_id, claimed_by=uuid.UUID(user_id))
request = MagicMock()
request.provider = 'github'
request.git_organization = 'OpenHands'
with (
patch(
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
AsyncMock(return_value=None),
),
patch(
'server.routes.orgs.OrgGitClaimStore.create_claim',
AsyncMock(return_value=mock_claim),
) as mock_create,
):
# Act
response = await claim_git_organization(
org_id=org_id, request=request, user_id=user_id
)
# Assert
assert response.id == str(mock_claim.id)
assert response.org_id == str(org_id)
assert response.provider == 'github'
assert response.git_organization == 'OpenHands'
assert response.claimed_by == user_id
mock_create.assert_called_once_with(
org_id=org_id,
provider='github',
git_organization='OpenHands',
claimed_by=uuid.UUID(user_id),
)
@pytest.mark.asyncio
async def test_claim_fails_when_already_claimed(self, org_id, user_id, make_claim):
"""
GIVEN: A Git organization already claimed by another OpenHands org
WHEN: POST /api/organizations/{org_id}/git-claims is called
THEN: A 409 Conflict error is returned
"""
# Arrange
other_org_id = uuid.uuid4()
existing_claim = make_claim(
other_org_id, provider='github', git_organization='AlreadyClaimed'
)
request = MagicMock()
request.provider = 'github'
request.git_organization = 'AlreadyClaimed'
with patch(
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
AsyncMock(return_value=existing_claim),
):
# Act & Assert
with pytest.raises(Exception) as exc_info:
await claim_git_organization(
org_id=org_id, request=request, user_id=user_id
)
assert exc_info.value.status_code == status.HTTP_409_CONFLICT
@pytest.mark.asyncio
async def test_claim_returns_500_on_unexpected_error(self, org_id, user_id):
"""
GIVEN: An unexpected error occurs during claim creation
WHEN: POST /api/organizations/{org_id}/git-claims is called
THEN: A 500 Internal Server Error is returned
"""
# Arrange
request = MagicMock()
request.provider = 'github'
request.git_organization = 'OpenHands'
with patch(
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
AsyncMock(side_effect=RuntimeError('db connection failed')),
):
# Act & Assert
with pytest.raises(Exception) as exc_info:
await claim_git_organization(
org_id=org_id, request=request, user_id=user_id
)
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
@pytest.mark.asyncio
async def test_claim_race_condition_returns_409(self, org_id, user_id):
"""
GIVEN: Pre-check passes but a concurrent request claims the org first
WHEN: create_claim raises IntegrityError (DB unique constraint)
THEN: A 409 Conflict error is returned instead of 500
"""
# Arrange
request = MagicMock()
request.provider = 'github'
request.git_organization = 'RaceOrg'
with (
patch(
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
AsyncMock(return_value=None),
),
patch(
'server.routes.orgs.OrgGitClaimStore.create_claim',
AsyncMock(
side_effect=IntegrityError(
'duplicate',
'',
Exception('uq_provider_git_org'),
)
),
),
):
# Act & Assert
with pytest.raises(Exception) as exc_info:
await claim_git_organization(
org_id=org_id, request=request, user_id=user_id
)
assert exc_info.value.status_code == status.HTTP_409_CONFLICT
# =============================================================================
# DELETE /api/organizations/{org_id}/git-claims/{claim_id}
# =============================================================================
class TestDisconnectGitOrganization:
"""Tests for the disconnect Git organization endpoint."""
@pytest.mark.asyncio
async def test_disconnect_succeeds_for_existing_claim(self, org_id, user_id):
"""
GIVEN: A valid claim belonging to the organization
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} is called
THEN: The claim is deleted and a success message is returned
"""
# Arrange
claim_id = uuid.uuid4()
with patch(
'server.routes.orgs.OrgGitClaimStore.delete_claim',
AsyncMock(return_value=True),
) as mock_delete:
# Act
result = await disconnect_git_organization(
org_id=org_id, claim_id=claim_id, user_id=user_id
)
# Assert
assert result == {'message': 'Git organization claim removed successfully'}
mock_delete.assert_called_once_with(claim_id=claim_id, org_id=org_id)
@pytest.mark.asyncio
async def test_disconnect_fails_when_claim_not_found(self, org_id, user_id):
"""
GIVEN: A claim_id that does not exist for this organization
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} is called
THEN: A 404 Not Found error is returned
"""
# Arrange
claim_id = uuid.uuid4()
with patch(
'server.routes.orgs.OrgGitClaimStore.delete_claim',
AsyncMock(return_value=False),
):
# Act & Assert
with pytest.raises(Exception) as exc_info:
await disconnect_git_organization(
org_id=org_id, claim_id=claim_id, user_id=user_id
)
assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
async def test_disconnect_returns_500_on_unexpected_error(self, org_id, user_id):
"""
GIVEN: An unexpected error occurs during claim deletion
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} is called
THEN: A 500 Internal Server Error is returned
"""
# Arrange
claim_id = uuid.uuid4()
with patch(
'server.routes.orgs.OrgGitClaimStore.delete_claim',
AsyncMock(side_effect=RuntimeError('db connection failed')),
):
# Act & Assert
with pytest.raises(Exception) as exc_info:
await disconnect_git_organization(
org_id=org_id, claim_id=claim_id, user_id=user_id
)
assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
# =============================================================================
# Validation tests for GitOrgClaimRequest
# =============================================================================
class TestGitOrgClaimRequestValidation:
"""Tests for request model validation."""
def test_valid_providers_are_accepted(self):
"""Each supported provider is accepted and normalized to lowercase."""
from server.routes.org_models import GitOrgClaimRequest
for provider in ['github', 'GitLab', 'BITBUCKET']:
req = GitOrgClaimRequest(provider=provider, git_organization='test-org')
assert req.provider == provider.lower().strip()
def test_invalid_provider_is_rejected(self):
"""An unsupported provider raises a validation error."""
from pydantic import ValidationError
from server.routes.org_models import GitOrgClaimRequest
with pytest.raises(ValidationError, match='Invalid provider'):
GitOrgClaimRequest(provider='azure_devops', git_organization='test-org')
def test_empty_git_organization_is_rejected(self):
"""An empty git_organization raises a validation error."""
from pydantic import ValidationError
from server.routes.org_models import GitOrgClaimRequest
with pytest.raises(ValidationError, match='git_organization must not be empty'):
GitOrgClaimRequest(provider='github', git_organization=' ')
def test_git_organization_is_normalized_to_lowercase(self):
"""git_organization is lowercased to prevent case-sensitive duplicates."""
from server.routes.org_models import GitOrgClaimRequest
req = GitOrgClaimRequest(provider='github', git_organization='OpenHands')
assert req.git_organization == 'openhands'
# =============================================================================
# Integration tests — TestClient with real HTTP, auth, and Pydantic validation
# =============================================================================
@pytest.fixture
def mock_app():
"""FastAPI app with org routes and mocked user authentication."""
app = FastAPI()
app.include_router(org_router)
app.dependency_overrides[get_user_id] = lambda: TEST_USER_ID
return app
@pytest.fixture
def mock_owner_role():
role = MagicMock()
role.name = 'owner'
return role
@pytest.fixture
def mock_member_role():
role = MagicMock()
role.name = 'member'
return role
class TestGitClaimsAuthorization:
"""Integration tests verifying authorization through the real HTTP cycle."""
def test_non_member_gets_403_on_get(self, mock_app):
"""
GIVEN: A user who is not a member of the target organization
WHEN: GET /api/organizations/{org_id}/git-claims via HTTP
THEN: 403 is returned by require_permission
"""
org_id = uuid.uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=None),
):
client = TestClient(mock_app)
response = client.get(f'/api/organizations/{org_id}/git-claims')
assert response.status_code == status.HTTP_403_FORBIDDEN
assert 'not a member' in response.json()['detail']
def test_member_without_permission_gets_403_on_post(
self, mock_app, mock_member_role
):
"""
GIVEN: A user with member role (lacks MANAGE_ORG_CLAIMS)
WHEN: POST /api/organizations/{org_id}/git-claims via HTTP
THEN: 403 is returned by require_permission
"""
org_id = uuid.uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_member_role),
):
client = TestClient(mock_app)
response = client.post(
f'/api/organizations/{org_id}/git-claims',
json={'provider': 'github', 'git_organization': 'SomeOrg'},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert 'manage_org_claims' in response.json()['detail']
def test_member_without_permission_gets_403_on_delete(
self, mock_app, mock_member_role
):
"""
GIVEN: A user with member role (lacks MANAGE_ORG_CLAIMS)
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} via HTTP
THEN: 403 is returned by require_permission
"""
org_id = uuid.uuid4()
claim_id = uuid.uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_member_role),
):
client = TestClient(mock_app)
response = client.delete(
f'/api/organizations/{org_id}/git-claims/{claim_id}'
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert 'manage_org_claims' in response.json()['detail']
class TestGitClaimsHTTPIntegration:
"""Integration tests for the full request/response cycle via TestClient."""
def test_post_claim_with_invalid_provider_returns_422(
self, mock_app, mock_owner_role
):
"""
GIVEN: A request with an unsupported provider
WHEN: POST /api/organizations/{org_id}/git-claims via HTTP
THEN: 422 is returned by Pydantic validation
"""
org_id = uuid.uuid4()
with patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_owner_role),
):
client = TestClient(mock_app)
response = client.post(
f'/api/organizations/{org_id}/git-claims',
json={'provider': 'azure_devops', 'git_organization': 'test'},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_post_claim_success_returns_201(self, mock_app, mock_owner_role):
"""
GIVEN: A valid claim request by an authorized admin/owner
WHEN: POST /api/organizations/{org_id}/git-claims via HTTP
THEN: 201 is returned with the claim details
"""
org_id = uuid.uuid4()
mock_claim = MagicMock(spec=OrgGitClaim)
mock_claim.id = uuid.uuid4()
mock_claim.org_id = org_id
mock_claim.provider = 'github'
mock_claim.git_organization = 'openhands'
mock_claim.claimed_by = uuid.UUID(TEST_USER_ID)
mock_claim.claimed_at = datetime(2026, 4, 1, 12, 0, 0)
with (
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_owner_role),
),
patch(
'server.routes.orgs.OrgGitClaimStore.get_claim_by_provider_and_git_org',
AsyncMock(return_value=None),
),
patch(
'server.routes.orgs.OrgGitClaimStore.create_claim',
AsyncMock(return_value=mock_claim),
),
):
client = TestClient(mock_app)
response = client.post(
f'/api/organizations/{org_id}/git-claims',
json={'provider': 'github', 'git_organization': 'OpenHands'},
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data['org_id'] == str(org_id)
assert data['provider'] == 'github'
assert data['git_organization'] == 'openhands'
def test_delete_claim_success_returns_200(self, mock_app, mock_owner_role):
"""
GIVEN: A valid disconnect request by an authorized admin/owner
WHEN: DELETE /api/organizations/{org_id}/git-claims/{claim_id} via HTTP
THEN: 200 is returned with a success message
"""
org_id = uuid.uuid4()
claim_id = uuid.uuid4()
with (
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_owner_role),
),
patch(
'server.routes.orgs.OrgGitClaimStore.delete_claim',
AsyncMock(return_value=True),
),
):
client = TestClient(mock_app)
response = client.delete(
f'/api/organizations/{org_id}/git-claims/{claim_id}'
)
assert response.status_code == status.HTTP_200_OK
assert (
response.json()['message'] == 'Git organization claim removed successfully'
)
def test_get_claims_success_returns_200(self, mock_app, mock_owner_role):
"""
GIVEN: An authorized user requests claims for their organization
WHEN: GET /api/organizations/{org_id}/git-claims via HTTP
THEN: 200 is returned with the list of claims
"""
org_id = uuid.uuid4()
mock_claim = MagicMock(spec=OrgGitClaim)
mock_claim.id = uuid.uuid4()
mock_claim.org_id = org_id
mock_claim.provider = 'github'
mock_claim.git_organization = 'openhands'
mock_claim.claimed_by = uuid.UUID(TEST_USER_ID)
mock_claim.claimed_at = datetime(2026, 4, 1, 12, 0, 0)
with (
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_owner_role),
),
patch(
'server.routes.orgs.OrgGitClaimStore.get_claims_by_org_id',
AsyncMock(return_value=[mock_claim]),
),
):
client = TestClient(mock_app)
response = client.get(f'/api/organizations/{org_id}/git-claims')
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 1
assert data[0]['provider'] == 'github'
assert data[0]['git_organization'] == 'openhands'

View File

@@ -0,0 +1,283 @@
"""Unit tests for SAAS-specific /api/v1/users endpoints.
Tests:
- SaasUserInfo model with org fields
- get_current_user_saas endpoint with org info
- _get_org_info_from_context helper function
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
class TestSaasUserInfoModel:
"""Test suite for SaasUserInfo model."""
def test_saas_user_info_with_all_org_fields(self):
"""SaasUserInfo should accept all org-related fields."""
from server.models.user_models import SaasUserInfo
user_info = SaasUserInfo(
id='user-123',
org_id='org-456',
org_name='Test Organization',
role='admin',
permissions=['read', 'write', 'delete'],
)
assert user_info.id == 'user-123'
assert user_info.org_id == 'org-456'
assert user_info.org_name == 'Test Organization'
assert user_info.role == 'admin'
assert user_info.permissions == ['read', 'write', 'delete']
def test_saas_user_info_without_org_fields(self):
"""SaasUserInfo should work without org fields (fallback mode)."""
from server.models.user_models import SaasUserInfo
user_info = SaasUserInfo(id='user-123')
assert user_info.id == 'user-123'
assert user_info.org_id is None
assert user_info.org_name is None
assert user_info.role is None
assert user_info.permissions is None
def test_saas_user_info_with_partial_org_fields(self):
"""SaasUserInfo should handle partial org fields (e.g., role is None)."""
from server.models.user_models import SaasUserInfo
user_info = SaasUserInfo(
id='user-123',
org_id='org-456',
org_name='Test Organization',
role=None,
permissions=[],
)
assert user_info.org_id == 'org-456'
assert user_info.org_name == 'Test Organization'
assert user_info.role is None
assert user_info.permissions == []
def test_saas_user_info_model_dump_includes_org_fields(self):
"""SaasUserInfo model_dump should include org fields."""
from server.models.user_models import SaasUserInfo
user_info = SaasUserInfo(
id='user-123',
org_id='org-456',
org_name='Test Organization',
role='member',
permissions=['read'],
)
data = user_info.model_dump()
assert data['org_id'] == 'org-456'
assert data['org_name'] == 'Test Organization'
assert data['role'] == 'member'
assert data['permissions'] == ['read']
def test_saas_user_info_extends_base_user_info(self):
"""SaasUserInfo should inherit from UserInfo base class."""
from server.models.user_models import SaasUserInfo
from openhands.app_server.user.user_models import UserInfo
assert issubclass(SaasUserInfo, UserInfo)
class TestGetOrgInfoFromContext:
"""Test suite for _get_org_info_from_context helper function."""
@pytest.mark.asyncio
async def test_returns_org_info_from_saas_user_auth(self):
"""Should return org info when context has SaasUserAuth."""
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.users_v1 import _get_org_info_from_context
from openhands.app_server.user.auth_user_context import AuthUserContext
# Create a SaasUserAuth with mocked get_org_info
mock_user_auth = MagicMock(spec=SaasUserAuth)
mock_user_auth.get_org_info = AsyncMock(
return_value={
'org_id': 'org-456',
'org_name': 'Test Organization',
'role': 'admin',
'permissions': ['read', 'write'],
}
)
# Create AuthUserContext with the mock
context = MagicMock(spec=AuthUserContext)
context.user_auth = mock_user_auth
org_info = await _get_org_info_from_context(context)
assert org_info is not None
assert org_info['org_id'] == 'org-456'
assert org_info['org_name'] == 'Test Organization'
assert org_info['role'] == 'admin'
mock_user_auth.get_org_info.assert_called_once()
@pytest.mark.asyncio
async def test_returns_none_for_non_auth_user_context(self):
"""Should return None when context is not AuthUserContext."""
from server.routes.users_v1 import _get_org_info_from_context
from openhands.app_server.user.user_context import UserContext
# Create a non-AuthUserContext
mock_context = MagicMock(spec=UserContext)
org_info = await _get_org_info_from_context(mock_context)
assert org_info is None
@pytest.mark.asyncio
async def test_returns_none_for_non_saas_user_auth(self):
"""Should return None when user_auth is not SaasUserAuth."""
from server.routes.users_v1 import _get_org_info_from_context
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.server.user_auth.user_auth import UserAuth
# Create AuthUserContext with a non-SaasUserAuth
mock_user_auth = MagicMock(spec=UserAuth)
mock_context = MagicMock(spec=AuthUserContext)
mock_context.user_auth = mock_user_auth
org_info = await _get_org_info_from_context(mock_context)
assert org_info is None
class TestGetCurrentUserSaasEndpoint:
"""Test suite for get_current_user_saas endpoint."""
@pytest.fixture
def mock_user_context(self):
"""Create a mock user context."""
return AsyncMock()
@pytest.mark.asyncio
async def test_endpoint_returns_saas_user_info_with_org_fields(
self, mock_user_context
):
"""Endpoint should return SaasUserInfo with org fields."""
from unittest.mock import patch
from server.models.user_models import SaasUserInfo
from server.routes.users_v1 import get_current_user_saas
from openhands.app_server.user.user_models import UserInfo
# Mock base user info
base_user_info = UserInfo(id='user-123', llm_model='test-model')
mock_user_context.get_user_info = AsyncMock(return_value=base_user_info)
# Mock _get_org_info_from_context to return org info
org_info = {
'org_id': 'org-456',
'org_name': 'Test Organization',
'role': 'member',
'permissions': ['read', 'write'],
}
with patch(
'server.routes.users_v1._get_org_info_from_context',
return_value=org_info,
):
result = await get_current_user_saas(
user_context=mock_user_context, expose_secrets=False
)
assert isinstance(result, SaasUserInfo)
assert result.id == 'user-123'
assert result.org_id == 'org-456'
assert result.org_name == 'Test Organization'
assert result.role == 'member'
assert result.permissions == ['read', 'write']
@pytest.mark.asyncio
async def test_endpoint_returns_saas_user_info_without_org_fields(
self, mock_user_context
):
"""Endpoint should work when org info is not available."""
from unittest.mock import patch
from server.models.user_models import SaasUserInfo
from server.routes.users_v1 import get_current_user_saas
from openhands.app_server.user.user_models import UserInfo
# Mock base user info
base_user_info = UserInfo(id='user-123', llm_model='test-model')
mock_user_context.get_user_info = AsyncMock(return_value=base_user_info)
# Mock _get_org_info_from_context to return None
with patch(
'server.routes.users_v1._get_org_info_from_context',
return_value=None,
):
result = await get_current_user_saas(
user_context=mock_user_context, expose_secrets=False
)
assert isinstance(result, SaasUserInfo)
assert result.id == 'user-123'
assert result.org_id is None
assert result.org_name is None
assert result.role is None
assert result.permissions is None
@pytest.mark.asyncio
async def test_endpoint_raises_401_when_user_info_is_none(self, mock_user_context):
"""Endpoint should raise 401 when user info is None."""
from fastapi import HTTPException
from server.routes.users_v1 import get_current_user_saas
mock_user_context.get_user_info = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await get_current_user_saas(
user_context=mock_user_context, expose_secrets=False
)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == 'Not authenticated'
class TestOverrideUsersEndpoint:
"""Test suite for override_users_me_endpoint function."""
def test_override_removes_oss_route_and_adds_saas_route(self):
"""override_users_me_endpoint should remove OSS route and add SAAS route."""
from fastapi import FastAPI
from server.routes.users_v1 import override_users_me_endpoint
# Create a minimal app with a mock OSS route
app = FastAPI()
@app.get('/api/v1/users/me')
def mock_oss_endpoint():
return {'source': 'oss'}
# Verify OSS route exists
oss_routes = [
r for r in app.routes if hasattr(r, 'path') and r.path == '/api/v1/users/me'
]
assert len(oss_routes) == 1
assert oss_routes[0].endpoint.__name__ == 'mock_oss_endpoint'
# Apply the override
override_users_me_endpoint(app)
# Verify SAAS route exists and OSS route is gone
saas_routes = [
r for r in app.routes if hasattr(r, 'path') and r.path == '/api/v1/users/me'
]
assert len(saas_routes) == 1
assert saas_routes[0].endpoint.__name__ == 'get_current_user_saas'

View File

@@ -0,0 +1,420 @@
"""Tests for OrgMemberFinancialService."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from server.routes.org_models import OrgMemberFinancialPage
from server.services.org_member_financial_service import OrgMemberFinancialService
from storage.org_member import OrgMember
@pytest.fixture
def org_id():
"""Create a test organization ID."""
return uuid.uuid4()
@pytest.fixture
def mock_user():
"""Create a mock user."""
user = MagicMock()
user.email = 'test@example.com'
return user
@pytest.fixture
def mock_role():
"""Create a mock role."""
role = MagicMock()
role.id = 1
role.name = 'member'
role.rank = 1000
return role
@pytest.fixture
def mock_org_member(org_id, mock_user, mock_role):
"""Create a mock org member with user and role."""
member = MagicMock(spec=OrgMember)
member.org_id = org_id
member.user_id = uuid.uuid4()
member.role_id = mock_role.id
member.status = 'active'
member.user = mock_user
member.role = mock_role
return member
class TestOrgMemberFinancialServiceGetFinancialData:
"""Test cases for OrgMemberFinancialService.get_org_members_financial_data."""
@pytest.mark.asyncio
async def test_returns_paginated_financial_data_with_individual_budget(
self, org_id, mock_org_member
):
"""
GIVEN: Organization with members having individual budget limits
WHEN: get_org_members_financial_data is called
THEN: Returns financial data using individual spend for current_budget calc
"""
# Arrange
user_id_str = str(mock_org_member.user_id)
litellm_data = {
'team_max_budget': 1000.0,
'team_spend': 200.0,
'members': {
user_id_str: {'spend': 125.50, 'max_budget': 500.0} # Individual budget
},
}
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 1)
mock_get_financial.return_value = litellm_data
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id=None,
limit=10,
)
# Assert
assert isinstance(result, OrgMemberFinancialPage)
assert len(result.items) == 1
assert result.items[0].user_id == user_id_str
assert result.items[0].email == 'test@example.com'
assert result.items[0].lifetime_spend == 125.50
assert result.items[0].max_budget == 500.0
# Individual budget: 500 - 125.50 = 374.50
assert result.items[0].current_budget == 374.50
assert result.current_page == 1
assert result.per_page == 10
@pytest.mark.asyncio
async def test_returns_shared_budget_using_team_spend(
self, org_id, mock_org_member
):
"""
GIVEN: Organization with shared team budget
WHEN: get_org_members_financial_data is called
THEN: Uses team_spend (not individual spend) for current_budget calculation
"""
# Arrange
user_id_str = str(mock_org_member.user_id)
litellm_data = {
'team_max_budget': 500.0,
'team_spend': 150.0, # Total team spend
'members': {
user_id_str: {
'spend': 50.0,
'max_budget': 500.0,
'uses_shared_budget': True, # Explicitly using shared budget
}
},
}
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 1)
mock_get_financial.return_value = litellm_data
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
)
# Assert
assert len(result.items) == 1
assert result.items[0].lifetime_spend == 50.0 # Individual spend
assert result.items[0].max_budget == 500.0
# Shared budget: 500 - 150 (team_spend) = 350
assert result.items[0].current_budget == 350.0
@pytest.mark.asyncio
async def test_returns_defaults_when_litellm_data_missing(
self, org_id, mock_org_member
):
"""
GIVEN: Organization with members but no LiteLLM data for them
WHEN: get_org_members_financial_data is called
THEN: Returns financial data with default values (spend=0, budget=None)
"""
# Arrange
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 1)
mock_get_financial.return_value = {
'team_max_budget': None,
'team_spend': 0,
'members': {},
}
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
)
# Assert
assert len(result.items) == 1
assert result.items[0].lifetime_spend == 0
assert result.items[0].max_budget is None
assert result.items[0].current_budget == 0
@pytest.mark.asyncio
async def test_handles_litellm_failure_gracefully(self, org_id, mock_org_member):
"""
GIVEN: LiteLLM service throws an exception
WHEN: get_org_members_financial_data is called
THEN: Returns financial data with default values (doesn't fail)
"""
# Arrange
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 1)
mock_get_financial.side_effect = Exception('LiteLLM unavailable')
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
)
# Assert - should not raise, returns defaults
assert len(result.items) == 1
assert result.items[0].lifetime_spend == 0
assert result.items[0].max_budget is None
@pytest.mark.asyncio
async def test_pagination_returns_next_page_id(self, org_id, mock_org_member):
"""
GIVEN: Organization with more members than limit
WHEN: get_org_members_financial_data is called
THEN: Returns next_page_id for pagination
"""
# Arrange
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 25) # 25 total
mock_get_financial.return_value = {
'team_max_budget': None,
'team_spend': 0,
'members': {},
}
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id='0',
limit=10,
)
# Assert
assert result.current_page == 1
assert result.next_page_id == '10'
@pytest.mark.asyncio
async def test_pagination_no_next_page_on_last_page(self, org_id, mock_org_member):
"""
GIVEN: Organization on last page of results
WHEN: get_org_members_financial_data is called
THEN: Returns next_page_id as None
"""
# Arrange
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 5) # 5 total
mock_get_financial.return_value = {
'team_max_budget': None,
'team_spend': 0,
'members': {},
}
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id='0',
limit=10,
)
# Assert
assert result.next_page_id is None
@pytest.mark.asyncio
async def test_empty_organization_returns_empty_items(self, org_id):
"""
GIVEN: Organization with no members
WHEN: get_org_members_financial_data is called
THEN: Returns empty items list
"""
# Arrange
with patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated:
mock_get_paginated.return_value = ([], 0)
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
)
# Assert
assert len(result.items) == 0
assert result.next_page_id is None
@pytest.mark.asyncio
async def test_invalid_page_id_raises_value_error(self, org_id):
"""
GIVEN: Invalid page_id format
WHEN: get_org_members_financial_data is called
THEN: Raises ValueError
"""
# Act & Assert
with pytest.raises(ValueError) as exc_info:
await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id='invalid',
)
assert 'Invalid page_id' in str(exc_info.value)
@pytest.mark.asyncio
async def test_negative_page_id_raises_value_error(self, org_id):
"""
GIVEN: Negative page_id
WHEN: get_org_members_financial_data is called
THEN: Raises ValueError
"""
# Act & Assert
with pytest.raises(ValueError) as exc_info:
await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
page_id='-5',
)
assert 'Invalid page_id' in str(exc_info.value)
@pytest.mark.asyncio
async def test_passes_email_filter_to_store(self, org_id, mock_org_member):
"""
GIVEN: Email filter parameter
WHEN: get_org_members_financial_data is called
THEN: Passes email filter to the store
"""
# Arrange
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([mock_org_member], 1)
mock_get_financial.return_value = {
'team_max_budget': None,
'team_spend': 0,
'members': {},
}
# Act
await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
email_filter='alice',
)
# Assert
mock_get_paginated.assert_called_once_with(
org_id=org_id, offset=0, limit=10, email_filter='alice'
)
@pytest.mark.asyncio
async def test_handles_missing_user_relationship(self, org_id, mock_role):
"""
GIVEN: Member with no user relationship loaded
WHEN: get_org_members_financial_data is called
THEN: Returns None for email
"""
# Arrange
member_no_user = MagicMock(spec=OrgMember)
member_no_user.org_id = org_id
member_no_user.user_id = uuid.uuid4()
member_no_user.role_id = mock_role.id
member_no_user.user = None # No user relationship
with (
patch(
'server.services.org_member_financial_service.OrgMemberStore.get_org_members_paginated',
new_callable=AsyncMock,
) as mock_get_paginated,
patch(
'server.services.org_member_financial_service.LiteLlmManager.get_team_members_financial_data',
new_callable=AsyncMock,
) as mock_get_financial,
):
mock_get_paginated.return_value = ([member_no_user], 1)
mock_get_financial.return_value = {
'team_max_budget': None,
'team_spend': 0,
'members': {},
}
# Act
result = await OrgMemberFinancialService.get_org_members_financial_data(
org_id=org_id,
)
# Assert
assert len(result.items) == 1
assert result.items[0].email is None

View File

@@ -0,0 +1,210 @@
"""Tests for OrgGitClaimStore with real in-memory SQLite database.
Covers CRUD operations and unique constraint enforcement.
"""
import uuid
from unittest.mock import patch
import pytest
from sqlalchemy.exc import IntegrityError
from storage.org import Org
from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_member import OrgMember
from storage.role import Role
from storage.user import User
@pytest.fixture
async def seed_org_and_user(async_session_maker):
"""Create a minimal org, role, user, and org_member for FK satisfaction."""
org_id = uuid.uuid4()
user_id = uuid.uuid4()
role_id = 1
async with async_session_maker() as session:
session.add(Role(id=role_id, name='owner', rank=10))
session.add(Org(id=org_id, name='test-org'))
session.add(User(id=user_id, current_org_id=org_id, role_id=role_id))
session.add(
OrgMember(
org_id=org_id,
user_id=user_id,
role_id=role_id,
status='active',
llm_api_key='test-key',
)
)
await session.commit()
return org_id, user_id
class TestOrgGitClaimStoreCreate:
"""Tests for OrgGitClaimStore.create_claim."""
@pytest.mark.asyncio
async def test_create_claim_persists_and_returns(
self, async_session_maker, seed_org_and_user
):
"""A new claim is persisted with correct fields and returned."""
org_id, user_id = seed_org_and_user
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
claim = await OrgGitClaimStore.create_claim(
org_id=org_id,
provider='github',
git_organization='OpenHands',
claimed_by=user_id,
)
assert claim.org_id == org_id
assert claim.provider == 'github'
assert claim.git_organization == 'OpenHands'
assert claim.claimed_by == user_id
assert claim.claimed_at is not None
@pytest.mark.asyncio
async def test_create_duplicate_raises_integrity_error(
self, async_session_maker, seed_org_and_user
):
"""Creating a duplicate (provider, git_organization) violates the unique constraint."""
org_id, user_id = seed_org_and_user
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
await OrgGitClaimStore.create_claim(
org_id=org_id,
provider='github',
git_organization='DuplicateOrg',
claimed_by=user_id,
)
with pytest.raises(IntegrityError):
await OrgGitClaimStore.create_claim(
org_id=org_id,
provider='github',
git_organization='DuplicateOrg',
claimed_by=user_id,
)
class TestOrgGitClaimStoreLookup:
"""Tests for OrgGitClaimStore lookup methods."""
@pytest.mark.asyncio
async def test_get_claim_by_provider_and_git_org_found(
self, async_session_maker, seed_org_and_user
):
"""Returns the claim when provider+git_organization exists."""
org_id, user_id = seed_org_and_user
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
await OrgGitClaimStore.create_claim(
org_id=org_id,
provider='gitlab',
git_organization='MyGroup',
claimed_by=user_id,
)
result = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider='gitlab', git_organization='MyGroup'
)
assert result is not None
assert result.provider == 'gitlab'
assert result.git_organization == 'MyGroup'
@pytest.mark.asyncio
async def test_get_claim_by_provider_and_git_org_not_found(
self, async_session_maker
):
"""Returns None when no matching claim exists."""
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
result = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider='github', git_organization='NonExistent'
)
assert result is None
@pytest.mark.asyncio
async def test_get_claims_by_org_id(self, async_session_maker, seed_org_and_user):
"""Returns all claims belonging to the given org."""
org_id, user_id = seed_org_and_user
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
await OrgGitClaimStore.create_claim(
org_id=org_id,
provider='github',
git_organization='Org1',
claimed_by=user_id,
)
await OrgGitClaimStore.create_claim(
org_id=org_id,
provider='gitlab',
git_organization='Org2',
claimed_by=user_id,
)
claims = await OrgGitClaimStore.get_claims_by_org_id(org_id)
assert len(claims) == 2
class TestOrgGitClaimStoreDelete:
"""Tests for OrgGitClaimStore.delete_claim."""
@pytest.mark.asyncio
async def test_delete_existing_claim_returns_true(
self, async_session_maker, seed_org_and_user
):
"""Deleting an existing claim returns True and removes it from the DB."""
org_id, user_id = seed_org_and_user
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
claim = await OrgGitClaimStore.create_claim(
org_id=org_id,
provider='github',
git_organization='ToDelete',
claimed_by=user_id,
)
result = await OrgGitClaimStore.delete_claim(
claim_id=claim.id, org_id=org_id
)
assert result is True
@pytest.mark.asyncio
async def test_delete_nonexistent_claim_returns_false(
self, async_session_maker, seed_org_and_user
):
"""Deleting a claim that doesn't exist returns False."""
org_id, _ = seed_org_and_user
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
result = await OrgGitClaimStore.delete_claim(
claim_id=uuid.uuid4(), org_id=org_id
)
assert result is False
@pytest.mark.asyncio
async def test_delete_claim_wrong_org_returns_false(
self, async_session_maker, seed_org_and_user
):
"""Deleting a claim with a mismatched org_id returns False."""
org_id, user_id = seed_org_and_user
with patch('storage.org_git_claim_store.a_session_maker', async_session_maker):
claim = await OrgGitClaimStore.create_claim(
org_id=org_id,
provider='github',
git_organization='WrongOrg',
claimed_by=user_id,
)
result = await OrgGitClaimStore.delete_claim(
claim_id=claim.id, org_id=uuid.uuid4()
)
assert result is False

View File

@@ -280,6 +280,8 @@ class TestSaasSQLAppConversationInfoService:
stored_metadata.reasoning_tokens = 0
stored_metadata.context_window = 0
stored_metadata.per_turn_token = 0
stored_metadata.public = None
stored_metadata.tags = {}
saas_metadata = MagicMock(spec=StoredConversationMetadataSaas)
saas_metadata.user_id = UUID('a1111111-1111-1111-1111-111111111111')
@@ -1304,3 +1306,100 @@ class TestApiKeyOrgIdHandling:
conv_from_org1 = await user_service_org1.get_app_conversation_info(conv_id)
assert conv_from_org1 is not None
assert conv_from_org1.id == conv_id
class TestResolverOrgIdRouting:
"""Test that resolver_org_id on user_context overrides the default org_id."""
@pytest.mark.asyncio
async def test_save_uses_resolver_org_id_when_set_on_context(
self,
async_session_with_users: AsyncSession,
):
"""When user_context has resolver_org_id, conversation is saved in that org."""
from unittest.mock import AsyncMock
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from enterprise.integrations.resolver_context import ResolverUserContext
# Arrange: user1 is in ORG1, but resolver routes to ORG2
# Use spec to prevent MagicMock from auto-creating undefined attributes
mock_context = MagicMock(spec=ResolverUserContext)
mock_context.get_user_id = AsyncMock(return_value=str(USER1_ID))
mock_context.resolver_org_id = ORG2_ID
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_resolver',
title='Resolver Routed Conversation',
)
# Act
await service.save_app_conversation_info(conv_info)
# Assert: conversation is stored in ORG2, not user's default ORG1
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None
assert saas_metadata.org_id == ORG2_ID
assert saas_metadata.user_id == USER1_ID
@pytest.mark.asyncio
async def test_save_uses_default_org_when_resolver_org_id_is_none(
self,
async_session_with_users: AsyncSession,
):
"""When resolver_org_id is None, conversation uses user's default org."""
from unittest.mock import AsyncMock
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from enterprise.integrations.resolver_context import ResolverUserContext
# Arrange: user1 in ORG1 with no resolver override
# Use spec to prevent MagicMock from auto-creating undefined attributes
mock_context = MagicMock(spec=ResolverUserContext)
mock_context.get_user_id = AsyncMock(return_value=str(USER1_ID))
mock_context.resolver_org_id = None
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_default',
title='Default Org Conversation',
)
# Act
await service.save_app_conversation_info(conv_info)
# Assert: conversation stored in user's default ORG1
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None
assert saas_metadata.org_id == ORG1_ID

View File

@@ -1008,3 +1008,234 @@ class TestGetApiKeyOrgIdFromRequest:
# Assert
assert result is None
# =============================================================================
# Tests for require_financial_data_access dependency
# =============================================================================
def _create_mock_request_with_email(api_key_org_id=None, user_email='user@example.com'):
"""Helper to create a mock request with optional api_key_org_id and email."""
mock_request = MagicMock()
mock_user_auth = MagicMock()
# get_api_key_org_id is sync, not async
mock_user_auth.get_api_key_org_id.return_value = api_key_org_id
# get_user_email is async
mock_user_auth.get_user_email = AsyncMock(return_value=user_email)
mock_request.state.user_auth = mock_user_auth
return mock_request
class TestRequireFinancialDataAccess:
"""Tests for require_financial_data_access compound authorization dependency."""
@pytest.mark.asyncio
async def test_grants_access_for_openhands_email(self):
"""
GIVEN: User with @openhands.dev email
WHEN: require_financial_data_access is called
THEN: Returns user_id (access granted)
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request_with_email(user_email='admin@openhands.dev')
with patch(
'server.auth.authorization.get_user_auth',
AsyncMock(return_value=mock_request.state.user_auth),
):
# Act
result = await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=user_id
)
# Assert
assert result == user_id
@pytest.mark.asyncio
async def test_grants_access_for_owner_role(self):
"""
GIVEN: User with owner role in organization (non-@openhands.dev email)
WHEN: require_financial_data_access is called
THEN: Returns user_id (access granted)
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request_with_email(user_email='user@company.com')
mock_role = MagicMock()
mock_role.name = 'owner'
with (
patch(
'server.auth.authorization.get_user_auth',
AsyncMock(return_value=mock_request.state.user_auth),
),
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
),
):
# Act
result = await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=user_id
)
# Assert
assert result == user_id
@pytest.mark.asyncio
async def test_grants_access_for_admin_role(self):
"""
GIVEN: User with admin role in organization (non-@openhands.dev email)
WHEN: require_financial_data_access is called
THEN: Returns user_id (access granted)
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request_with_email(user_email='user@company.com')
mock_role = MagicMock()
mock_role.name = 'admin'
with (
patch(
'server.auth.authorization.get_user_auth',
AsyncMock(return_value=mock_request.state.user_auth),
),
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
),
):
# Act
result = await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=user_id
)
# Assert
assert result == user_id
@pytest.mark.asyncio
async def test_denies_access_for_member_role_without_openhands_email(self):
"""
GIVEN: User with member role (not admin/owner) and non-@openhands.dev email
WHEN: require_financial_data_access is called
THEN: Raises 403 Forbidden
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request_with_email(user_email='user@company.com')
mock_role = MagicMock()
mock_role.name = 'member'
with (
patch(
'server.auth.authorization.get_user_auth',
AsyncMock(return_value=mock_request.state.user_auth),
),
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
),
):
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'admins, owners, or OpenHands' in exc_info.value.detail
@pytest.mark.asyncio
async def test_denies_access_for_non_member(self):
"""
GIVEN: User who is not a member of the organization
WHEN: require_financial_data_access is called
THEN: Raises 403 Forbidden
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request_with_email(user_email='user@company.com')
with (
patch(
'server.auth.authorization.get_user_auth',
AsyncMock(return_value=mock_request.state.user_auth),
),
patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=None),
),
):
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'not a member' in exc_info.value.detail
@pytest.mark.asyncio
async def test_denies_access_when_not_authenticated(self):
"""
GIVEN: No user_id (not authenticated)
WHEN: require_financial_data_access is called
THEN: Raises 401 Unauthorized
"""
from server.auth.authorization import require_financial_data_access
# Arrange
org_id = uuid4()
mock_request = _create_mock_request_with_email()
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await require_financial_data_access(
request=mock_request, org_id=org_id, user_id=None
)
assert exc_info.value.status_code == 401
assert 'not authenticated' in exc_info.value.detail
@pytest.mark.asyncio
async def test_denies_access_when_api_key_org_mismatch(self):
"""
GIVEN: API key created for Org A, but user tries to access Org B
WHEN: require_financial_data_access is called
THEN: Raises 403 Forbidden with org mismatch message
"""
from server.auth.authorization import require_financial_data_access
# Arrange
user_id = str(uuid4())
api_key_org_id = uuid4() # Org A
target_org_id = uuid4() # Org B
mock_request = _create_mock_request_with_email(
api_key_org_id=api_key_org_id, user_email='admin@openhands.dev'
)
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await require_financial_data_access(
request=mock_request, org_id=target_org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'API key is not authorized' in exc_info.value.detail

View File

@@ -1,5 +1,6 @@
from unittest import TestCase, mock
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.github.github_view import GithubFactory, GithubIssue, get_oh_labels
@@ -215,3 +216,119 @@ class TestGithubV1ConversationRouting(TestCase):
jinja_env, saas_user_auth, conversation_metadata
)
mock_create_v0.assert_not_called()
class TestGithubOrgRouting(TestCase):
"""Test org routing for GitHub resolver conversations."""
def setUp(self):
self.user_data = UserData(
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
)
self.raw_payload = Message(
source=SourceType.GITHUB,
message={
'payload': {
'action': 'opened',
'issue': {'number': 42},
}
},
)
self.resolved_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
def _create_github_issue(self):
return GithubIssue(
user_info=self.user_data,
full_repo_name='ClaimedOrg/repo',
issue_number=42,
installation_id=456,
conversation_id='',
should_extract=True,
send_summary_instruction=False,
is_public_repo=True,
raw_payload=self.raw_payload,
uuid='test-uuid',
title='',
description='',
previous_comments=[],
v1_enabled=False,
)
@pytest.mark.asyncio
@patch(
'integrations.github.github_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.github.github_view.resolve_org_for_repo')
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
self, mock_v1_setting, mock_resolve_org, mock_get_resolver
):
"""V0 path creates store via get_resolver_instance with resolver_org_id."""
# Arrange
mock_v1_setting.return_value = False
mock_resolve_org.return_value = self.resolved_org_id
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
github_issue = self._create_github_issue()
# Act
await github_issue.initialize_new_conversation()
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='ClaimedOrg/repo',
keycloak_user_id='test-keycloak-id',
)
# get_resolver_instance(config, user_id, resolver_org_id)
args, _ = mock_get_resolver.call_args
assert args[1] == 'test-keycloak-id'
assert args[2] == self.resolved_org_id
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_app_conversation_service')
@patch('integrations.github.github_view.resolve_org_for_repo')
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
async def test_v1_passes_resolver_org_id_to_resolver_user_context(
self, mock_v1_setting, mock_resolve_org, mock_get_service
):
"""V1 path passes resolved org_id to ResolverUserContext."""
# Arrange
mock_v1_setting.return_value = True
mock_resolve_org.return_value = self.resolved_org_id
github_issue = self._create_github_issue()
# Initialize to set resolved_org_id and v1_enabled
await github_issue.initialize_new_conversation()
# Assert
assert github_issue.resolved_org_id == self.resolved_org_id
@pytest.mark.asyncio
@patch(
'integrations.github.github_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.github.github_view.resolve_org_for_repo')
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
async def test_no_claim_passes_none_resolver_org_id(
self, mock_v1_setting, mock_resolve_org, mock_get_resolver
):
"""When no claim exists, resolver_org_id is None (falls back to personal workspace)."""
# Arrange
mock_v1_setting.return_value = False
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
github_issue = self._create_github_issue()
# Act
await github_issue.initialize_new_conversation()
# Assert
args, _ = mock_get_resolver.call_args
assert args[2] is None

View File

@@ -0,0 +1,126 @@
"""Tests for GitLab resolver org routing logic.
Tests that the GitLab resolver correctly resolves the target organization
and passes resolver_org_id through V0 and V1 conversation paths.
"""
from unittest import TestCase
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.gitlab.gitlab_view import GitlabIssue
from integrations.models import Message, SourceType
from integrations.types import UserData
class TestGitlabOrgRouting(TestCase):
"""Test org routing for GitLab resolver conversations."""
def setUp(self):
self.user_data = UserData(
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
)
self.raw_payload = Message(
source=SourceType.GITLAB,
message={
'payload': {
'object_kind': 'issue',
'object_attributes': {'action': 'open', 'iid': 42},
}
},
)
self.resolved_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
def _create_gitlab_issue(self):
return GitlabIssue(
user_info=self.user_data,
full_repo_name='ClaimedOrg/repo',
issue_number=42,
project_id=100,
installation_id='install-123',
conversation_id='',
should_extract=True,
send_summary_instruction=False,
is_public_repo=True,
raw_payload=self.raw_payload,
title='',
description='',
previous_comments=[],
is_mr=False,
v1_enabled=False,
)
@pytest.mark.asyncio
@patch(
'integrations.gitlab.gitlab_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
self, mock_resolve_org, mock_get_resolver
):
"""V0 path creates store via get_resolver_instance with resolver_org_id."""
# Arrange
mock_resolve_org.return_value = self.resolved_org_id
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
gitlab_issue = self._create_gitlab_issue()
# Act
await gitlab_issue.initialize_new_conversation()
# Assert
mock_resolve_org.assert_called_once_with(
provider='gitlab',
full_repo_name='ClaimedOrg/repo',
keycloak_user_id='test-keycloak-id',
)
# get_resolver_instance(config, user_id, resolver_org_id)
args, _ = mock_get_resolver.call_args
assert args[1] == 'test-keycloak-id'
assert args[2] == self.resolved_org_id
@pytest.mark.asyncio
@patch('integrations.gitlab.gitlab_view.get_app_conversation_service')
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
async def test_v1_passes_resolver_org_id_to_resolver_user_context(
self, mock_resolve_org, mock_get_service
):
"""V1 path passes resolved org_id to ResolverUserContext."""
# Arrange
mock_resolve_org.return_value = self.resolved_org_id
gitlab_issue = self._create_gitlab_issue()
gitlab_issue.v1_enabled = True
# Initialize to set resolved_org_id
await gitlab_issue.initialize_new_conversation()
# Assert
assert gitlab_issue.resolved_org_id == self.resolved_org_id
@pytest.mark.asyncio
@patch(
'integrations.gitlab.gitlab_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
async def test_no_claim_passes_none_resolver_org_id(
self, mock_resolve_org, mock_get_resolver
):
"""When no claim exists, resolver_org_id is None (falls back to personal workspace)."""
# Arrange
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
gitlab_issue = self._create_gitlab_issue()
# Act
await gitlab_issue.initialize_new_conversation()
# Assert
args, _ = mock_get_resolver.call_args
assert args[2] is None

View File

@@ -0,0 +1,347 @@
"""Tests for Linear resolver org routing logic.
Tests that the LinearNewConversationView correctly resolves the target
organization and passes resolver_org_id through the V0 conversation path.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.linear.linear_view import LinearNewConversationView
from integrations.models import JobContext
from storage.linear_user import LinearUser
from storage.linear_workspace import LinearWorkspace
from openhands.integrations.service_types import ProviderType
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
KEYCLOAK_USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
@pytest.fixture
def mock_linear_user():
user = LinearUser()
user.id = 1
user.keycloak_user_id = KEYCLOAK_USER_ID
user.linear_user_id = 'linear-user-123'
user.linear_workspace_id = 1
user.status = 'active'
return user
@pytest.fixture
def mock_linear_workspace():
workspace = LinearWorkspace()
workspace.id = 1
workspace.name = 'test-workspace'
workspace.linear_org_id = 'linear-org-123'
workspace.admin_user_id = 'admin-123'
workspace.webhook_secret = 'secret'
workspace.svc_acc_email = 'svc@test.com'
workspace.svc_acc_api_key = 'api-key'
workspace.status = 'active'
return workspace
@pytest.fixture
def mock_user_auth():
auth = MagicMock(spec=UserAuth)
auth.get_provider_tokens = AsyncMock(
return_value={ProviderType.GITHUB: MagicMock()}
)
auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
return auth
@pytest.fixture
def job_context():
return JobContext(
issue_id='issue-123',
issue_key='PROJ-42',
issue_title='Test issue',
issue_description='Test description',
user_msg='@openhands fix this',
user_email='user@test.com',
platform_user_id='linear-user-123',
workspace_name='test-workspace',
display_name='Test User',
)
@pytest.fixture
def linear_view(job_context, mock_user_auth, mock_linear_user, mock_linear_workspace):
return LinearNewConversationView(
job_context=job_context,
saas_user_auth=mock_user_auth,
linear_user=mock_linear_user,
linear_workspace=mock_linear_workspace,
selected_repo='OpenHands/foo',
conversation_id='',
)
class TestLinearV0OrgRouting:
"""Test V0 conversation routing logic for Linear resolver."""
@pytest.mark.asyncio
@patch(
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
)
@patch('integrations.linear.linear_view.ProviderHandler')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch(
'integrations.linear.linear_view.integration_store',
)
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
linear_view,
):
"""V0 path should resolve org and pass resolver_org_id to get_resolver_instance."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = CLAIMING_ORG_ID
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_jinja = MagicMock()
# Act
with patch.object(
linear_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('instructions', 'user_msg'),
):
await linear_view.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='OpenHands/foo',
keycloak_user_id=KEYCLOAK_USER_ID,
)
call_args = mock_get_resolver_instance.call_args
assert call_args[0][1] == KEYCLOAK_USER_ID
assert call_args[0][2] == CLAIMING_ORG_ID
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.trigger == ConversationTrigger.LINEAR
assert saved_metadata.git_provider == ProviderType.GITHUB
@pytest.mark.asyncio
@patch(
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
)
@patch('integrations.linear.linear_view.ProviderHandler')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch(
'integrations.linear.linear_view.integration_store',
)
async def test_v0_passes_none_when_no_claim(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
linear_view,
):
"""When no claim exists, resolver_org_id should be None (personal workspace)."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_jinja = MagicMock()
# Act
with patch.object(
linear_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('instructions', 'user_msg'),
):
await linear_view.create_or_update_conversation(mock_jinja)
# Assert
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None
@pytest.mark.asyncio
@patch(
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
)
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch(
'integrations.linear.linear_view.integration_store',
)
async def test_no_provider_tokens_skips_org_resolution(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_resolve_org,
linear_view,
mock_user_auth,
):
"""When provider tokens are None, org resolution should be skipped."""
# Arrange
mock_user_auth.get_provider_tokens = AsyncMock(return_value=None)
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_jinja = MagicMock()
# Act
with patch.object(
linear_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('instructions', 'user_msg'),
):
await linear_view.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_not_called()
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.git_provider is None
@pytest.mark.asyncio
@patch(
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
)
@patch('integrations.linear.linear_view.ProviderHandler')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch(
'integrations.linear.linear_view.integration_store',
)
async def test_verify_repo_provider_failure_falls_back_to_personal_workspace(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
linear_view,
):
"""When verify_repo_provider fails, should fall back to personal workspace."""
# Arrange
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(
side_effect=Exception('Repository not found')
)
mock_provider_handler_cls.return_value = mock_handler
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_jinja = MagicMock()
# Act
with patch.object(
linear_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('instructions', 'user_msg'),
):
await linear_view.create_or_update_conversation(mock_jinja)
# Assert - org resolution should be skipped, conversation created in personal workspace
mock_resolve_org.assert_not_called()
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None
@pytest.mark.asyncio
@patch(
'integrations.linear.linear_view.resolve_org_for_repo', new_callable=AsyncMock
)
@patch('integrations.linear.linear_view.ProviderHandler')
@patch(
'integrations.linear.linear_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.linear.linear_view.start_conversation', new_callable=AsyncMock)
@patch(
'integrations.linear.linear_view.integration_store',
)
async def test_resolve_org_failure_falls_back_to_personal_workspace(
self,
mock_integration_store,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
linear_view,
):
"""When resolve_org_for_repo fails, should fall back to personal workspace."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.side_effect = Exception('Database connection failed')
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_integration_store.create_conversation = AsyncMock()
mock_jinja = MagicMock()
# Act
with patch.object(
linear_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('instructions', 'user_msg'),
):
await linear_view.create_or_update_conversation(mock_jinja)
# Assert - conversation should be created with resolver_org_id=None
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None

View File

@@ -2576,3 +2576,304 @@ class TestBudgetPayloadHandling:
'max_budget_in_team' in json_payload
), 'max_budget_in_team should be in payload when set to a value'
assert json_payload['max_budget_in_team'] == 75.0
class TestGetTeamMembersFinancialData:
"""Test cases for _get_team_members_financial_data method."""
@pytest.fixture
def mock_http_client(self):
"""Create a mock HTTP client."""
return AsyncMock(spec=httpx.AsyncClient)
@pytest.mark.asyncio
async def test_returns_financial_data_for_all_team_members(self, mock_http_client):
"""
GIVEN: Team with multiple members having financial data
WHEN: _get_team_members_financial_data is called
THEN: Returns dict with team info and member data
"""
# Arrange
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_response.json.return_value = {
'team_info': {'team_id': 'test-team', 'max_budget': 500.0, 'spend': 125.5},
'team_memberships': [
{
'user_id': 'user-1',
'spend': 50.0,
'max_budget_in_team': 200.0,
},
{
'user_id': 'user-2',
'spend': 75.5,
'max_budget_in_team': 150.0,
},
],
}
mock_response.raise_for_status = MagicMock()
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'test-team'
)
# Assert
assert result['team_max_budget'] == 500.0
assert result['team_spend'] == 125.5
assert len(result['members']) == 2
# Both users have individual budgets (max_budget_in_team is set)
assert result['members']['user-1'] == {
'spend': 50.0,
'max_budget': 200.0,
'uses_shared_budget': False,
}
assert result['members']['user-2'] == {
'spend': 75.5,
'max_budget': 150.0,
'uses_shared_budget': False,
}
@pytest.mark.asyncio
async def test_returns_empty_dict_when_litellm_not_configured(
self, mock_http_client
):
"""
GIVEN: LiteLLM API key or URL not configured
WHEN: _get_team_members_financial_data is called
THEN: Returns empty dict
"""
# Arrange - no patching, so LITE_LLM_API_KEY/URL are None
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', None):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', None):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'test-team'
)
# Assert
assert result == {}
mock_http_client.get.assert_not_called()
@pytest.mark.asyncio
async def test_returns_empty_dict_when_team_not_found(self, mock_http_client):
"""
GIVEN: Team does not exist in LiteLLM
WHEN: _get_team_members_financial_data is called
THEN: Returns empty dict
"""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
'Not found', request=MagicMock(), response=mock_response
)
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act & Assert
with pytest.raises(httpx.HTTPStatusError):
await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'nonexistent-team'
)
@pytest.mark.asyncio
async def test_returns_empty_members_when_team_has_no_members(
self, mock_http_client
):
"""
GIVEN: Team exists but has no members
WHEN: _get_team_members_financial_data is called
THEN: Returns structure with empty members dict
"""
# Arrange
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_response.json.return_value = {
'team_info': {'team_id': 'empty-team', 'max_budget': 100.0, 'spend': 0},
'team_memberships': [],
}
mock_response.raise_for_status = MagicMock()
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'empty-team'
)
# Assert
assert result['team_max_budget'] == 100.0
assert result['team_spend'] == 0
assert result['members'] == {}
@pytest.mark.asyncio
async def test_falls_back_to_team_budget_when_member_budget_missing(
self, mock_http_client
):
"""
GIVEN: Team with shared budget, members without individual max_budget_in_team
WHEN: _get_team_members_financial_data is called
THEN: Falls back to team_info.max_budget for members without individual budget
"""
# Arrange
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_response.json.return_value = {
'team_info': {'team_id': 'test-team', 'max_budget': 500.0, 'spend': 150.0},
'team_memberships': [
{
'user_id': 'user-no-individual-budget',
'spend': 50.0,
# No max_budget_in_team - should fall back to team budget
},
{
'user_id': 'user-with-individual-budget',
'spend': 75.0,
'max_budget_in_team': 200.0, # Individual budget set
},
{
'user_id': 'user-null-budget',
'spend': 25.0,
'max_budget_in_team': None, # Explicit null - fall back to team
},
],
}
mock_response.raise_for_status = MagicMock()
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'test-team'
)
# Assert
assert result['team_max_budget'] == 500.0
assert result['team_spend'] == 150.0
members = result['members']
assert members['user-no-individual-budget'] == {
'spend': 50.0,
'max_budget': 500.0,
'uses_shared_budget': True,
}
assert members['user-with-individual-budget'] == {
'spend': 75.0,
'max_budget': 200.0,
'uses_shared_budget': False,
}
assert members['user-null-budget'] == {
'spend': 25.0,
'max_budget': 500.0,
'uses_shared_budget': True,
}
@pytest.mark.asyncio
async def test_uses_defaults_when_no_budget_data_available(self, mock_http_client):
"""
GIVEN: Team without budget and members without individual budgets
WHEN: _get_team_members_financial_data is called
THEN: Returns default values (spend=0, max_budget=None)
"""
# Arrange
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_response.json.return_value = {
'team_info': {'team_id': 'test-team'}, # No max_budget at team level
'team_memberships': [
{
'user_id': 'user-no-data',
# No spend or max_budget_in_team
},
{
'user_id': 'user-null-spend',
'spend': None, # Explicit null
},
],
}
mock_response.raise_for_status = MagicMock()
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'test-team'
)
# Assert
assert result['team_max_budget'] is None
assert result['team_spend'] == 0
members = result['members']
# Both users fall back to team budget (which is None)
assert members['user-no-data'] == {
'spend': 0,
'max_budget': None,
'uses_shared_budget': True,
}
assert members['user-null-spend'] == {
'spend': 0,
'max_budget': None,
'uses_shared_budget': True,
}
@pytest.mark.asyncio
async def test_skips_members_without_user_id(self, mock_http_client):
"""
GIVEN: Team with members, some missing user_id
WHEN: _get_team_members_financial_data is called
THEN: Skips members without user_id
"""
# Arrange
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_response.json.return_value = {
'team_info': {'team_id': 'test-team', 'max_budget': 300.0, 'spend': 105.0},
'team_memberships': [
{
'user_id': 'valid-user',
'spend': 25.0,
'max_budget_in_team': 100.0,
},
{
# Missing user_id
'spend': 50.0,
'max_budget_in_team': 200.0,
},
{
'user_id': None, # Explicit null
'spend': 30.0,
},
],
}
mock_response.raise_for_status = MagicMock()
mock_http_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
# Act
result = await LiteLlmManager._get_team_members_financial_data(
mock_http_client, 'test-team'
)
# Assert - only valid user should be included
assert result['team_max_budget'] == 300.0
assert result['team_spend'] == 105.0
assert len(result['members']) == 1
assert 'valid-user' in result['members']
assert result['members']['valid-user'] == {
'spend': 25.0,
'max_budget': 100.0,
'uses_shared_budget': False,
}

View File

@@ -32,11 +32,12 @@ class TestLogOutput:
logger.info('Test message')
output = json.loads(string_io.getvalue())
assert output == {
'message': 'Test message',
'severity': 'INFO',
'ts': FROZEN_TIMESTAMP,
}
assert output['message'] == 'Test message'
assert output['severity'] == 'INFO'
assert output['ts'] == FROZEN_TIMESTAMP
assert output['module'] == 'test_logger'
assert output['funcName'] == 'test_info'
assert 'lineno' in output
@freeze_time(FROZEN_TIMESTAMP)
def test_error(self, log_output):
@@ -44,11 +45,12 @@ class TestLogOutput:
logger.error('Test message')
output = json.loads(string_io.getvalue())
assert output == {
'message': 'Test message',
'severity': 'ERROR',
'ts': FROZEN_TIMESTAMP,
}
assert output['message'] == 'Test message'
assert output['severity'] == 'ERROR'
assert output['ts'] == FROZEN_TIMESTAMP
assert output['module'] == 'test_logger'
assert output['funcName'] == 'test_error'
assert 'lineno' in output
@freeze_time(FROZEN_TIMESTAMP)
def test_extra_fields(self, log_output):
@@ -56,12 +58,13 @@ class TestLogOutput:
logger.info('Test message', extra={'key': '..val..'})
output = json.loads(string_io.getvalue())
assert output == {
'key': '..val..',
'message': 'Test message',
'severity': 'INFO',
'ts': FROZEN_TIMESTAMP,
}
assert output['key'] == '..val..'
assert output['message'] == 'Test message'
assert output['severity'] == 'INFO'
assert output['ts'] == FROZEN_TIMESTAMP
assert output['module'] == 'test_logger'
assert output['funcName'] == 'test_extra_fields'
assert 'lineno' in output
def test_format_stack(self):
stack = (
@@ -284,11 +287,12 @@ class TestLogOutput:
):
openhands_logger.info('The secret key was supersecretvalue')
output = json.loads(string_io.getvalue())
assert output == {
'message': 'The secret key was ******',
'severity': 'INFO',
'ts': FROZEN_TIMESTAMP,
}
assert output['message'] == 'The secret key was ******'
assert output['severity'] == 'INFO'
assert output['ts'] == FROZEN_TIMESTAMP
assert 'module' in output
assert 'funcName' in output
assert 'lineno' in output
@freeze_time(FROZEN_TIMESTAMP)
def test_console_serializer_uses_ts_not_timestamp(self):

View File

@@ -41,191 +41,157 @@ class TestRouterPrefixes:
assert accept_router.prefix == '/api/organizations/members/invite'
class TestAcceptInvitationEndpoint:
"""Test cases for the accept invitation endpoint."""
class TestAcceptInvitationGetEndpoint:
"""Test cases for the GET accept invitation endpoint (redirect flow)."""
def test_get_accept_redirects_to_home_with_token(self, client):
"""Test that GET request always redirects to home with invitation_token.
The GET endpoint is accessed via the link in invitation emails.
It always redirects to the home page with the token, allowing the
frontend to handle acceptance via a modal with authenticated POST.
"""
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
)
assert response.status_code == 302
location = response.headers.get('location', '')
assert '/?invitation_token=inv-test-token-123' in location
class TestAcceptInvitationPostEndpoint:
"""Test cases for the POST accept invitation endpoint (authenticated flow)."""
@pytest.fixture
def mock_user_auth(self):
"""Create a mock user auth."""
user_auth = MagicMock()
user_auth.get_user_id = AsyncMock(
return_value='87654321-4321-8765-4321-876543218765'
def auth_app(self):
"""Create a FastAPI app with dependency overrides for authenticated tests."""
from openhands.server.user_auth import get_user_id
app = FastAPI()
app.include_router(accept_router)
# Override the get_user_id dependency
app.dependency_overrides[get_user_id] = (
lambda: '87654321-4321-8765-4321-876543218765'
)
return user_auth
return app
@pytest.fixture
def auth_client(self, auth_app):
"""Create a test client with authentication dependency overrides."""
return TestClient(auth_app)
@pytest.mark.asyncio
async def test_accept_unauthenticated_redirects_to_login(self, client):
"""Test that unauthenticated users are redirected to login with invitation token."""
with patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=None,
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
)
async def test_post_accept_success_returns_org_details(self, auth_client):
"""Test that successful POST acceptance returns organization details."""
from uuid import UUID
assert response.status_code == 302
assert '/login?invitation_token=inv-test-token-123' in response.headers.get(
'location', ''
)
@pytest.mark.asyncio
async def test_accept_authenticated_success_redirects_home(
self, client, mock_user_auth
):
"""Test that successful acceptance redirects to home page."""
mock_invitation = MagicMock()
mock_invitation.org_id = UUID('12345678-1234-5678-1234-567812345678')
mock_invitation.role_id = 3
mock_org = MagicMock()
mock_org.name = 'Test Organization'
mock_role = MagicMock()
mock_role.name = 'member'
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
return_value=mock_invitation,
),
patch(
'server.routes.org_invitations.OrgStore.get_org_by_id',
new_callable=AsyncMock,
return_value=mock_org,
),
patch(
'server.routes.org_invitations.RoleStore.get_role_by_id',
new_callable=AsyncMock,
return_value=mock_role,
),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
response = auth_client.post(
'/api/organizations/members/invite/accept',
json={'token': 'inv-test-token-123'},
)
assert response.status_code == 302
location = response.headers.get('location', '')
assert location.endswith('/')
assert 'invitation_expired' not in location
assert 'invitation_invalid' not in location
assert 'email_mismatch' not in location
assert response.status_code == 200
data = response.json()
assert data['success'] is True
assert data['org_id'] == '12345678-1234-5678-1234-567812345678'
assert data['org_name'] == 'Test Organization'
assert data['role'] == 'member'
@pytest.mark.asyncio
async def test_accept_expired_invitation_redirects_with_flag(
self, client, mock_user_auth
):
"""Test that expired invitation redirects with invitation_expired=true."""
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=InvitationExpiredError(),
),
async def test_post_accept_expired_returns_400(self, auth_client):
"""Test that expired invitation returns 400 with detail."""
with patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=InvitationExpiredError(),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
response = auth_client.post(
'/api/organizations/members/invite/accept',
json={'token': 'inv-test-token-123'},
)
assert response.status_code == 302
assert 'invitation_expired=true' in response.headers.get('location', '')
assert response.status_code == 400
assert response.json()['detail'] == 'invitation_expired'
@pytest.mark.asyncio
async def test_accept_invalid_invitation_redirects_with_flag(
self, client, mock_user_auth
):
"""Test that invalid invitation redirects with invitation_invalid=true."""
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=InvitationInvalidError(),
),
async def test_post_accept_invalid_returns_400(self, auth_client):
"""Test that invalid invitation returns 400 with detail."""
with patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=InvitationInvalidError(),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
response = auth_client.post(
'/api/organizations/members/invite/accept',
json={'token': 'inv-test-token-123'},
)
assert response.status_code == 302
assert 'invitation_invalid=true' in response.headers.get('location', '')
assert response.status_code == 400
assert response.json()['detail'] == 'invitation_invalid'
@pytest.mark.asyncio
async def test_accept_already_member_redirects_with_flag(
self, client, mock_user_auth
):
"""Test that already member error redirects with already_member=true."""
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=UserAlreadyMemberError(),
),
async def test_post_accept_already_member_returns_409(self, auth_client):
"""Test that already member error returns 409 with detail."""
with patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=UserAlreadyMemberError(),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
response = auth_client.post(
'/api/organizations/members/invite/accept',
json={'token': 'inv-test-token-123'},
)
assert response.status_code == 302
assert 'already_member=true' in response.headers.get('location', '')
assert response.status_code == 409
assert response.json()['detail'] == 'already_member'
@pytest.mark.asyncio
async def test_accept_email_mismatch_redirects_with_flag(
self, client, mock_user_auth
):
"""Test that email mismatch error redirects with email_mismatch=true."""
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=EmailMismatchError(),
),
async def test_post_accept_email_mismatch_returns_403(self, auth_client):
"""Test that email mismatch error returns 403 with detail."""
with patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=EmailMismatchError(),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
response = auth_client.post(
'/api/organizations/members/invite/accept',
json={'token': 'inv-test-token-123'},
)
assert response.status_code == 302
assert 'email_mismatch=true' in response.headers.get('location', '')
@pytest.mark.asyncio
async def test_accept_unexpected_error_redirects_with_flag(
self, client, mock_user_auth
):
"""Test that unexpected errors redirect with invitation_error=true."""
with (
patch(
'server.routes.org_invitations.get_user_auth',
new_callable=AsyncMock,
return_value=mock_user_auth,
),
patch(
'server.routes.org_invitations.OrgInvitationService.accept_invitation',
new_callable=AsyncMock,
side_effect=Exception('Unexpected error'),
),
):
response = client.get(
'/api/organizations/members/invite/accept?token=inv-test-token-123',
follow_redirects=False,
)
assert response.status_code == 302
assert 'invitation_error=true' in response.headers.get('location', '')
assert response.status_code == 403
assert response.json()['detail'] == 'email_mismatch'
class TestCreateInvitationBatchEndpoint:

View File

@@ -214,3 +214,125 @@ class TestGetInstance:
# Assert
assert store.user_id == user_id
assert store.org_id is None
@pytest.mark.asyncio
async def test_get_resolver_instance_passes_resolver_org_id(self):
"""Verify get_resolver_instance forwards resolver_org_id to the store."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
resolver_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
mock_user = MagicMock(spec=User)
mock_user.current_org_id = UUID(user_id)
mock_config = MagicMock(spec=OpenHandsConfig)
with patch(
'storage.saas_conversation_store.UserStore.get_user_by_id',
AsyncMock(return_value=mock_user),
), patch('storage.saas_conversation_store.session_maker'):
# Act
store = await SaasConversationStore.get_resolver_instance(
mock_config, user_id, resolver_org_id=resolver_org_id
)
# Assert
assert store.resolver_org_id == resolver_org_id
@pytest.mark.asyncio
async def test_get_instance_does_not_have_resolver_org_id(self):
"""Verify get_instance does not set resolver_org_id (it's not a resolver path)."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
mock_user = MagicMock(spec=User)
mock_user.current_org_id = UUID(user_id)
mock_config = MagicMock(spec=OpenHandsConfig)
with patch(
'storage.saas_conversation_store.UserStore.get_user_by_id',
AsyncMock(return_value=mock_user),
), patch('storage.saas_conversation_store.session_maker'):
# Act
store = await SaasConversationStore.get_instance(mock_config, user_id)
# Assert
assert store.resolver_org_id is None
class TestResolverOrgIdRouting:
"""Tests for resolver_org_id overriding org_id in save_metadata."""
@pytest.mark.asyncio
async def test_save_metadata_uses_resolver_org_id_over_default(self, session_maker):
"""When resolver_org_id is set, save_metadata stores it instead of the default org_id."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
default_org_id = UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
resolver_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
store = SaasConversationStore(
user_id, default_org_id, session_maker, resolver_org_id=resolver_org_id
)
metadata = ConversationMetadata(
conversation_id='resolver-routed-conv',
user_id=user_id,
selected_repository='ClaimedOrg/repo',
selected_branch=None,
created_at=datetime.now(UTC),
last_updated_at=datetime.now(UTC),
)
# Act
await store.save_metadata(metadata)
# Assert - verify the SaaS metadata record has the resolver org, not the default
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
with session_maker() as session:
saas_record = (
session.query(StoredConversationMetadataSaas)
.filter(
StoredConversationMetadataSaas.conversation_id
== 'resolver-routed-conv'
)
.first()
)
assert saas_record is not None
assert saas_record.org_id == resolver_org_id
assert saas_record.org_id != default_org_id
@pytest.mark.asyncio
async def test_save_metadata_uses_default_org_when_no_resolver_org(
self, session_maker
):
"""When resolver_org_id is None, save_metadata uses the default org_id."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
default_org_id = UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
store = SaasConversationStore(user_id, default_org_id, session_maker)
metadata = ConversationMetadata(
conversation_id='default-org-conv',
user_id=user_id,
selected_repository='PersonalOrg/repo',
selected_branch=None,
created_at=datetime.now(UTC),
last_updated_at=datetime.now(UTC),
)
# Act
await store.save_metadata(metadata)
# Assert
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
with session_maker() as session:
saas_record = (
session.query(StoredConversationMetadataSaas)
.filter(
StoredConversationMetadataSaas.conversation_id == 'default-org-conv'
)
.first()
)
assert saas_record is not None
assert saas_record.org_id == default_org_id

View File

@@ -535,6 +535,99 @@ async def test_store_does_not_update_org_mcp_config(
assert org.mcp_config is None
@pytest.mark.asyncio
async def test_store_skips_ensure_api_key_for_non_openhands_model_without_base_url(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When saving a non-OpenHands model with no base URL (basic view BYOR),
_ensure_api_key should NOT be called, preserving the user's custom API key.
This is the primary bug fix: users selecting e.g. OpenAI in basic view and
providing their own API key should not have it overwritten by a proxy key.
"""
# Arrange
fixture = org_with_multiple_members_fixture
admin_user_id = str(fixture['admin_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
custom_api_key = 'sk-user-custom-openai-key'
settings = DataSettings(
llm_model='openai/gpt-5.2',
llm_base_url=None, # Basic view: no base URL provided
llm_api_key=SecretStr(custom_api_key),
)
# Act
with (
patch('storage.saas_settings_store.a_session_maker', async_session_maker),
patch.object(store, '_ensure_api_key', new_callable=AsyncMock) as mock_ensure,
):
await store.store(settings)
# Assert
mock_ensure.assert_not_called()
@pytest.mark.asyncio
async def test_store_calls_ensure_api_key_for_openhands_model_without_base_url(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When saving an OpenHands model with no base URL, _ensure_api_key should
still be called to generate/verify the proxy key.
This guards the edge case of switching from a non-OpenHands provider to
OpenHands in basic view, where a stale BYOR key needs to be replaced.
"""
# Arrange
fixture = org_with_multiple_members_fixture
admin_user_id = str(fixture['admin_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
settings = DataSettings(
llm_model='openhands/claude-opus-4-5-20251101',
llm_base_url=None,
llm_api_key=SecretStr('sk-stale-openai-key'),
)
# Act
with (
patch('storage.saas_settings_store.a_session_maker', async_session_maker),
patch.object(store, '_ensure_api_key', new_callable=AsyncMock) as mock_ensure,
):
await store.store(settings)
# Assert
mock_ensure.assert_called_once()
@pytest.mark.asyncio
async def test_store_calls_ensure_api_key_when_base_url_is_litellm_proxy(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When the base URL is explicitly the LiteLLM proxy, _ensure_api_key should
be called regardless of the model type."""
# Arrange
fixture = org_with_multiple_members_fixture
admin_user_id = str(fixture['admin_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
settings = DataSettings(
llm_model='openai/gpt-5.2',
llm_base_url=LITE_LLM_API_URL,
llm_api_key=SecretStr('sk-some-key'),
)
# Act
with (
patch('storage.saas_settings_store.a_session_maker', async_session_maker),
patch.object(store, '_ensure_api_key', new_callable=AsyncMock) as mock_ensure,
):
await store.store(settings)
# Assert
mock_ensure.assert_called_once()
@pytest.mark.asyncio
async def test_load_returns_user_specific_mcp_config(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture

View File

@@ -135,14 +135,19 @@ class TestRepoVerificationHandling:
@patch('integrations.slack.slack_manager.sio')
@patch.object(SlackManager, 'send_message', new_callable=AsyncMock)
async def test_no_repo_mentioned_shows_external_selector(
async def test_no_repo_mentioned_shows_button_and_dropdown(
self,
mock_send_message,
mock_sio,
slack_manager,
slack_new_conversation_view,
):
"""Test that when no repo is mentioned, external_select repo selector is shown."""
"""Test that when no repo is mentioned, a button and dropdown are shown.
The form shows:
1. A "No Repository" button - immediately clickable without loading
2. An external_select dropdown - for searching repositories dynamically
"""
# Setup Redis mock
mock_redis = AsyncMock()
mock_sio.manager.redis = mock_redis
@@ -162,17 +167,75 @@ class TestRepoVerificationHandling:
mock_send_message.assert_called_once()
call_args = mock_send_message.call_args
# Should be the repo selection form with external_select
# Should be the repo selection form with button + external_select
message = call_args[0][0]
assert isinstance(message, dict)
assert message.get('text') == 'Choose a Repository:'
# Verify it's using external_select
blocks = message.get('blocks', [])
actions_block = next((b for b in blocks if b.get('type') == 'actions'), None)
assert actions_block is not None
elements = actions_block.get('elements', [])
assert len(elements) > 0
assert elements[0].get('type') == 'external_select'
# Should have 2 elements: button and external_select
assert len(elements) == 2
# First element: "No Repository" button (immediately available)
assert elements[0].get('type') == 'button'
assert elements[0].get('action_id').startswith('no_repository:')
assert elements[0].get('value') == '-'
# Second element: external_select for searching repos
assert elements[1].get('type') == 'external_select'
assert elements[1].get('action_id').startswith('repository_select:')
@pytest.mark.asyncio
@patch('integrations.slack.slack_manager.sio')
async def test_no_repository_button_click_processes_correctly(
self,
mock_sio,
slack_manager,
):
"""Test that clicking 'No Repository' button correctly processes the interaction.
This verifies the button click path through receive_form_interaction, ensuring
the no_repository: action_id is correctly parsed and processed.
"""
# Setup: Mock Redis to return a stored user message
mock_redis = AsyncMock()
mock_sio.manager.redis = mock_redis
stored_msg = json.dumps({'text': 'Hello, help me with code', 'user': 'U123'})
mock_redis.get = AsyncMock(return_value=stored_msg)
# Simulate button click payload (what Slack sends when button is clicked)
button_payload = {
'type': 'block_actions',
'actions': [
{
'action_id': 'no_repository:1234567890.123456:None',
'type': 'button',
'value': '-',
}
],
'user': {'id': 'U123'},
'container': {'channel_id': 'C123'},
'team': {'id': 'T123'},
}
# Mock receive_message to capture what's passed to it
with patch.object(
slack_manager, 'receive_message', new_callable=AsyncMock
) as mock_receive:
await slack_manager.receive_form_interaction(button_payload)
# Verify receive_message was called
mock_receive.assert_called_once()
# Verify the message payload has selected_repo as None
call_args = mock_receive.call_args[0][0]
assert call_args.message['selected_repo'] is None
assert call_args.message['message_ts'] == '1234567890.123456'
assert call_args.message['thread_ts'] is None
@patch('integrations.slack.slack_manager.sio')
@patch('integrations.slack.slack_manager.ProviderHandler')
@@ -223,8 +286,8 @@ class TestRepoVerificationHandling:
class TestBuildRepoOptions:
"""Test the _build_repo_options helper method.
Note: _build_repo_options always includes the "No Repository" option at the top.
This is by design for the external_select dropdown.
Note: _build_repo_options returns only actual repositories. The "No Repository"
option is now handled by a separate button in the form, not the dropdown.
"""
def test_build_options_with_repos(self, slack_manager):
@@ -247,21 +310,20 @@ class TestBuildRepoOptions:
options = slack_manager._build_repo_options(repos)
# Should have 3 options: "No Repository" + 2 repos
assert len(options) == 3
assert options[0]['value'] == '-'
assert options[0]['text']['text'] == 'No Repository'
assert options[1]['value'] == 'owner/repo1'
assert options[2]['value'] == 'owner/repo2'
# Should have 2 options (repos only - "No Repository" is now a button)
assert len(options) == 2
assert options[0]['value'] == 'owner/repo1'
assert options[1]['value'] == 'owner/repo2'
def test_build_options_empty_repos(self, slack_manager):
"""Test building options with empty repo list still includes No Repository."""
"""Test building options with empty repo list returns empty list.
Note: "No Repository" is now handled by a separate button in the form.
"""
options = slack_manager._build_repo_options([])
# Should have 1 option: just "No Repository"
assert len(options) == 1
assert options[0]['value'] == '-'
assert options[0]['text']['text'] == 'No Repository'
# Should have 0 options (empty list)
assert len(options) == 0
def test_build_options_truncates_long_names(self, slack_manager):
"""Test that repo names longer than 75 chars are truncated."""
@@ -278,12 +340,12 @@ class TestBuildRepoOptions:
options = slack_manager._build_repo_options(repos)
# First option is "No Repository", second is the repo
assert len(options) == 2
# Should have 1 option (the repo only - "No Repository" is a button)
assert len(options) == 1
# Text should be truncated to 75 chars
assert len(options[1]['text']['text']) == 75
assert len(options[0]['text']['text']) == 75
# But value should have full name
assert options[1]['value'] == long_name
assert options[0]['value'] == long_name
class TestSearchRepositories:
@@ -413,23 +475,23 @@ class TestSearchRepositories:
options = slack_manager._build_repo_options(search_results)
# Verify: Options are correctly built from search results
assert len(options) == 4 # "No Repository" + 3 repos
# Note: "No Repository" is now a button, not in the dropdown
assert len(options) == 3 # 3 repos only
# First option should be "No Repository"
assert options[0]['value'] == '-'
assert options[0]['text']['text'] == 'No Repository'
# Remaining options should be the repos in order
assert options[1]['value'] == 'myorg/react-dashboard'
assert options[1]['text']['text'] == 'myorg/react-dashboard'
assert options[2]['value'] == 'myorg/python-api'
assert options[3]['value'] == 'myorg/docs-site'
# Options should be the repos in order
assert options[0]['value'] == 'myorg/react-dashboard'
assert options[0]['text']['text'] == 'myorg/react-dashboard'
assert options[1]['value'] == 'myorg/python-api'
assert options[2]['value'] == 'myorg/docs-site'
@patch('integrations.slack.slack_manager.ProviderHandler')
async def test_search_with_empty_results_builds_no_repo_only_option(
async def test_search_with_empty_results_builds_empty_options(
self, mock_provider_handler_class, slack_manager, mock_user_auth
):
"""Test that when search returns no results, only 'No Repository' option is shown."""
"""Test that when search returns no results, empty options list is returned.
Note: "No Repository" is now handled by a separate button in the form.
"""
# Setup: No matching repos
mock_provider_handler = MagicMock()
mock_provider_handler.search_repositories = AsyncMock(return_value=[])
@@ -447,10 +509,8 @@ class TestSearchRepositories:
)
options = slack_manager._build_repo_options(search_results)
# Verify: Only "No Repository" option
assert len(options) == 1
assert options[0]['value'] == '-'
assert options[0]['text']['text'] == 'No Repository'
# Verify: Empty options list (button handles "No Repository")
assert len(options) == 0
class TestUserMsgStorage:
@@ -669,7 +729,10 @@ class TestOnOptionsLoadEndpoint:
async def test_on_options_load_disabled_returns_empty_options(
self, mock_request, background_tasks
):
"""Test that when webhooks are disabled, empty options are returned."""
"""Test that when webhooks are disabled, empty options are returned.
Note: 'No Repository' is handled by a separate button in the form.
"""
from server.routes.integration.slack import on_options_load
response = await on_options_load(mock_request, background_tasks)
@@ -683,7 +746,10 @@ class TestOnOptionsLoadEndpoint:
async def test_on_options_load_no_payload_returns_empty_options(
self, mock_request, background_tasks
):
"""Test that when no payload is in request, empty options are returned."""
"""Test that when no payload is in request, empty options are returned.
Note: 'No Repository' is handled by a separate button in the form.
"""
from server.routes.integration.slack import on_options_load
mock_request.body = AsyncMock(return_value=b'')
@@ -731,7 +797,10 @@ class TestOnOptionsLoadEndpoint:
async def test_on_options_load_wrong_payload_type_returns_empty_options(
self, mock_signature_verifier, mock_request, background_tasks
):
"""Test that non-block_suggestion payload returns empty options."""
"""Test that non-block_suggestion payload returns empty options.
Note: 'No Repository' is handled by a separate button in the form.
"""
from server.routes.integration.slack import on_options_load
payload = {
@@ -764,7 +833,10 @@ class TestOnOptionsLoadEndpoint:
background_tasks,
valid_block_suggestion_payload,
):
"""Test that unauthenticated users get empty options and linking message is queued."""
"""Test that unauthenticated users get empty options and linking message is queued.
Note: 'No Repository' is handled by a separate button in the form.
"""
from server.routes.integration.slack import on_options_load
payload_str = json.dumps(valid_block_suggestion_payload)
@@ -817,9 +889,8 @@ class TestOnOptionsLoadEndpoint:
return_value=(mock_slack_user, mock_user_auth)
)
# Expected options from search_repos_for_slack
# Expected options from search_repos_for_slack (no "No Repository" - that's a button)
expected_options = [
{'text': {'type': 'plain_text', 'text': 'No Repository'}, 'value': '-'},
{
'text': {'type': 'plain_text', 'text': 'owner/repo1'},
'value': 'owner/repo1',
@@ -878,11 +949,8 @@ class TestOnOptionsLoadEndpoint:
mock_slack_manager.authenticate_user = AsyncMock(
return_value=(mock_slack_user, mock_user_auth)
)
mock_slack_manager.search_repos_for_slack = AsyncMock(
return_value=[
{'text': {'type': 'plain_text', 'text': 'No Repository'}, 'value': '-'}
]
)
# Empty search returns empty list (no repos found, and "No Repository" is a button)
mock_slack_manager.search_repos_for_slack = AsyncMock(return_value=[])
response = await on_options_load(mock_request, background_tasks)
@@ -907,7 +975,10 @@ class TestOnOptionsLoadEndpoint:
mock_slack_user,
mock_user_auth,
):
"""Test that when search raises an exception, empty options are returned gracefully."""
"""Test that when search raises an exception, empty options are returned gracefully.
Note: 'No Repository' is handled by a separate button in the form.
"""
from server.routes.integration.slack import on_options_load
payload_str = json.dumps(valid_block_suggestion_payload)

View File

@@ -0,0 +1,331 @@
"""Tests for Slack view org routing logic.
Tests that the SlackNewConversationView correctly resolves the target org
based on claimed git organizations and passes it through V0/V1 paths.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.slack.slack_view import SlackNewConversationView
from storage.slack_user import SlackUser
from openhands.integrations.service_types import ProviderType
from openhands.server.user_auth.user_auth import UserAuth
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
KEYCLOAK_USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
@pytest.fixture
def mock_slack_user():
"""Create a mock SlackUser."""
user = SlackUser()
user.slack_user_id = 'U1234567890'
user.keycloak_user_id = KEYCLOAK_USER_ID
user.slack_display_name = 'Test User'
user.org_id = UUID('cccccccc-cccc-cccc-cccc-cccccccccccc')
return user
@pytest.fixture
def mock_user_auth():
"""Create a mock UserAuth."""
auth = MagicMock(spec=UserAuth)
auth.get_provider_tokens = AsyncMock(
return_value={ProviderType.GITHUB: MagicMock()}
)
auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
auth.get_access_token = AsyncMock(return_value='access-token')
auth.get_user_id = AsyncMock(return_value=KEYCLOAK_USER_ID)
return auth
@pytest.fixture
def slack_view(mock_slack_user, mock_user_auth):
"""Create a SlackNewConversationView instance for testing."""
return SlackNewConversationView(
bot_access_token='xoxb-test-token',
user_msg='Hello OpenHands!',
slack_user_id='U1234567890',
slack_to_openhands_user=mock_slack_user,
saas_user_auth=mock_user_auth,
channel_id='C1234567890',
message_ts='1234567890.123456',
thread_ts=None,
selected_repo='OpenHands/foo',
should_extract=True,
send_summary_instruction=True,
conversation_id='',
team_id='T1234567890',
v1_enabled=False,
)
@pytest.fixture
def slack_view_no_repo(mock_slack_user, mock_user_auth):
"""Create a SlackNewConversationView with no selected repo."""
return SlackNewConversationView(
bot_access_token='xoxb-test-token',
user_msg='Hello OpenHands!',
slack_user_id='U1234567890',
slack_to_openhands_user=mock_slack_user,
saas_user_auth=mock_user_auth,
channel_id='C1234567890',
message_ts='1234567890.123456',
thread_ts=None,
selected_repo=None,
should_extract=True,
send_summary_instruction=True,
conversation_id='',
team_id='T1234567890',
v1_enabled=False,
)
class TestSlackV0ConversationRouting:
"""Test V0 conversation routing logic in Slack integration."""
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=False,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.slack.slack_view.ProviderHandler')
@patch(
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
async def test_v0_passes_resolver_org_id(
self,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
mock_v1_enabled,
slack_view,
):
"""V0 path should pass resolver_org_id to SaasConversationStore.get_resolver_instance."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = CLAIMING_ORG_ID
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
mock_jinja = MagicMock()
# Act
with (
patch.object(
slack_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
),
patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock),
):
await slack_view.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='OpenHands/foo',
keycloak_user_id=KEYCLOAK_USER_ID,
)
mock_get_resolver_instance.assert_called_once()
call_args = mock_get_resolver_instance.call_args
assert call_args[0][1] == KEYCLOAK_USER_ID # user_id
assert call_args[0][2] == CLAIMING_ORG_ID # resolver_org_id
mock_store.save_metadata.assert_called_once()
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.git_provider == ProviderType.GITHUB
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=False,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.slack.slack_view.ProviderHandler')
@patch(
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
async def test_v0_passes_none_when_no_claim(
self,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
mock_v1_enabled,
slack_view,
):
"""V0 path should pass resolver_org_id=None when no claim exists."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
mock_jinja = MagicMock()
# Act
with (
patch.object(
slack_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
),
patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock),
):
await slack_view.create_or_update_conversation(mock_jinja)
# Assert
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None # resolver_org_id is None
class TestSlackV1ConversationRouting:
"""Test V1 conversation routing logic in Slack integration."""
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=True,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.slack.slack_view.ProviderHandler')
@patch('integrations.slack.slack_view.get_app_conversation_service')
@patch('integrations.slack.slack_view.ResolverUserContext')
async def test_v1_passes_resolver_org_id_to_context(
self,
mock_resolver_ctx_cls,
mock_get_service,
mock_provider_handler_cls,
mock_resolve_org,
mock_v1_enabled,
slack_view,
):
"""V1 path should pass resolver_org_id to ResolverUserContext."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = CLAIMING_ORG_ID
mock_resolver_ctx_cls.return_value = MagicMock()
# Mock the async context manager for app_conversation_service
mock_service = MagicMock()
mock_service.start_app_conversation = MagicMock(return_value=aiter_empty())
mock_ctx = MagicMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_service)
mock_ctx.__aexit__ = AsyncMock(return_value=None)
mock_get_service.return_value = mock_ctx
mock_jinja = MagicMock()
# Act
with patch.object(
slack_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
):
with patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock):
await slack_view.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='OpenHands/foo',
keycloak_user_id=KEYCLOAK_USER_ID,
)
mock_resolver_ctx_cls.assert_called_once_with(
saas_user_auth=slack_view.saas_user_auth,
resolver_org_id=CLAIMING_ORG_ID,
)
class TestSlackNoRepoRouting:
"""Test routing when no repository is selected."""
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=False,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch(
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
async def test_no_repo_skips_org_resolution(
self,
mock_start_convo,
mock_get_resolver_instance,
mock_resolve_org,
mock_v1_enabled,
slack_view_no_repo,
):
"""When selected_repo is None, org resolution should be skipped."""
# Arrange
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
mock_jinja = MagicMock()
# Act
with (
patch.object(
slack_view_no_repo,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
),
patch.object(
slack_view_no_repo, 'save_slack_convo', new_callable=AsyncMock
),
patch.object(slack_view_no_repo, '_verify_necessary_values_are_set'),
):
await slack_view_no_repo.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_not_called()
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None # resolver_org_id is None
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.git_provider is None
async def aiter_empty():
"""Helper: empty async iterator."""
return
yield # noqa: unreachable - makes this an async generator

Some files were not shown because too many files have changed in this diff Show More