mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
23 Commits
tos-db-sto
...
rm-github-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d31927230 | ||
|
|
9a24df14f1 | ||
|
|
956d278138 | ||
|
|
fa186fc433 | ||
|
|
4ef9c72da1 | ||
|
|
c5245a622d | ||
|
|
9b1aaa53fe | ||
|
|
4deffa3907 | ||
|
|
a47c6f3ed1 | ||
|
|
90ece3f8e1 | ||
|
|
a948b0fef3 | ||
|
|
52848cd3db | ||
|
|
62f015370a | ||
|
|
7109b057b6 | ||
|
|
e694fc2d58 | ||
|
|
50baf3fd18 | ||
|
|
dc37f039bf | ||
|
|
91fff77ffe | ||
|
|
db01cd39d5 | ||
|
|
00865fbb8a | ||
|
|
356cd9ff9f | ||
|
|
78d82408b7 | ||
|
|
3c4ebc3d8a |
13
Makefile
13
Makefile
@@ -39,6 +39,7 @@ ifeq ($(INSTALL_DOCKER),)
|
||||
@$(MAKE) -s check-docker
|
||||
endif
|
||||
@$(MAKE) -s check-poetry
|
||||
@$(MAKE) -s check-tmux
|
||||
@echo "$(GREEN)Dependencies checked successfully.$(RESET)"
|
||||
|
||||
check-system:
|
||||
@@ -101,6 +102,18 @@ check-docker:
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
check-tmux:
|
||||
@echo "$(YELLOW)Checking tmux installation...$(RESET)"
|
||||
@if command -v tmux > /dev/null; then \
|
||||
echo "$(BLUE)$(shell tmux -V) is already installed.$(RESET)"; \
|
||||
else \
|
||||
echo "$(YELLOW)╔════════════════════════════════════════════════════════════════════════════╗$(RESET)"; \
|
||||
echo "$(YELLOW)║ OPTIONAL: tmux is not installed. ║$(RESET)"; \
|
||||
echo "$(YELLOW)║ Some advanced terminal features may not work without tmux. ║$(RESET)"; \
|
||||
echo "$(YELLOW)║ You can install it if needed, but it's not required for development. ║$(RESET)"; \
|
||||
echo "$(YELLOW)╚════════════════════════════════════════════════════════════════════════════╝$(RESET)"; \
|
||||
fi
|
||||
|
||||
check-poetry:
|
||||
@echo "$(YELLOW)Checking Poetry installation...$(RESET)"
|
||||
@if command -v poetry > /dev/null; then \
|
||||
|
||||
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
|
||||
# Production
|
||||
/build
|
||||
/static/swagger-ui
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
|
||||
@@ -36,6 +36,7 @@ const config: Config = {
|
||||
mermaid: true,
|
||||
},
|
||||
themes: ['@docusaurus/theme-mermaid'],
|
||||
plugins: [],
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
@@ -75,6 +76,11 @@ const config: Config = {
|
||||
position: 'left',
|
||||
label: 'User Guides',
|
||||
},
|
||||
{
|
||||
href: 'https://docs.all-hands.dev/swagger-ui/', // FIXME: this should be a relative path, but docusarus steals the click
|
||||
label: 'API',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
type: 'localeDropdown',
|
||||
position: 'left',
|
||||
|
||||
102
docs/generate-swagger-ui.js
Normal file
102
docs/generate-swagger-ui.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const swaggerUiDist = require('swagger-ui-dist');
|
||||
|
||||
/**
|
||||
* This script manually sets up Swagger UI for the Docusaurus documentation.
|
||||
*
|
||||
* Why we need this approach:
|
||||
* 1. Docusaurus doesn't have a built-in way to integrate Swagger UI
|
||||
* 2. We need to copy the necessary files from swagger-ui-dist to our static directory
|
||||
* 3. We need to create a custom index.html file that points to our OpenAPI spec
|
||||
* 4. This approach allows us to customize the Swagger UI to match our documentation style
|
||||
*/
|
||||
|
||||
// Get the absolute path to the swagger-ui-dist package
|
||||
const swaggerUiDistPath = swaggerUiDist.getAbsoluteFSPath();
|
||||
|
||||
// Create the target directory if it doesn't exist
|
||||
const targetDir = path.join(__dirname, 'static', 'swagger-ui');
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy all files from swagger-ui-dist to our target directory
|
||||
const files = fs.readdirSync(swaggerUiDistPath);
|
||||
files.forEach(file => {
|
||||
const sourcePath = path.join(swaggerUiDistPath, file);
|
||||
const targetPath = path.join(targetDir, file);
|
||||
|
||||
// Skip directories and non-essential files
|
||||
if (fs.statSync(sourcePath).isDirectory() ||
|
||||
file === 'package.json' ||
|
||||
file === 'README.md' ||
|
||||
file.endsWith('.map')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
});
|
||||
|
||||
// Create a custom index.html file that points to our OpenAPI spec
|
||||
const indexHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>OpenHands API Documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
|
||||
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
|
||||
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
// Begin Swagger UI call region
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
// End Swagger UI call region
|
||||
window.ui = ui;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(targetDir, 'index.html'), indexHtml);
|
||||
|
||||
console.log('Swagger UI files generated successfully in static/swagger-ui/');
|
||||
@@ -8,18 +8,22 @@ OpenHands Cloud can be accessed at https://app.all-hands.dev/.
|
||||
|
||||
## Getting Started
|
||||
|
||||
After visiting OpenHands Cloud, you will be asked to connect with your GitHub account:
|
||||
1. After reading and accepting the terms of service, click `Connect to GitHub`.
|
||||
After visiting OpenHands Cloud, you will be asked to connect with your GitHub or GitLab account:
|
||||
|
||||
1. After reading and accepting the terms of service, click `Log in with GitHub` or `Log in with GitLab`.
|
||||
2. Review the permissions requested by OpenHands and then click `Authorize OpenHands AI`.
|
||||
- OpenHands will require some permissions from your GitHub account. To read more about these permissions,
|
||||
you can click the `Learn more` link on the GitHub authorize page.
|
||||
- OpenHands will require some permissions from your GitHub or GitLab account. To read more about these permissions:
|
||||
- GitHub: You can click the `Learn more` link on the GitHub authorize page.
|
||||
- GitLab: You can expand each permission request on the GitLab authorize page.
|
||||
|
||||
## Repository Access
|
||||
|
||||
### Adding Repository Access
|
||||
### GitHub
|
||||
|
||||
#### Adding Repository Access
|
||||
|
||||
You can grant OpenHands specific repository access:
|
||||
1. Click the `Select a Git project` dropdown, select `Add more repositories...`.
|
||||
1. Click `Add GitHub repos` on the Home page.
|
||||
2. Select the organization, then choose the specific repositories to grant OpenHands access to.
|
||||
<details>
|
||||
<summary>Permission Details for Repository Access</summary>
|
||||
@@ -42,11 +46,15 @@ You can grant OpenHands specific repository access:
|
||||
|
||||
3. Click on `Install & Authorize`.
|
||||
|
||||
### Modifying Repository Access
|
||||
#### Modifying Repository Access
|
||||
|
||||
You can modify repository access at any time by:
|
||||
* Using the same `Select a Git project > Add more repositories` workflow, or
|
||||
* Visiting the Settings page and selecting `Configure GitHub Repositories` under the `GitHub Settings` section.
|
||||
You can modify GitHub repository access at any time by:
|
||||
* Using the same `Add GitHub repos` workflow, or
|
||||
* Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git Settings` section.
|
||||
|
||||
### GitLab
|
||||
|
||||
When using your GitLab account, OpenHands will automatically have access to your repositories.
|
||||
|
||||
## Conversation Persistence
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
- Displays the conversation between the user and OpenHands.
|
||||
- OpenHands explains its actions in this panel.
|
||||
|
||||
### Changes
|
||||
- Shows the file changes performed by OpenHands.
|
||||
|
||||
### Workspace
|
||||
- Browse project files and directories.
|
||||
- Use the `Open in VS Code` option to:
|
||||
@@ -20,7 +23,7 @@
|
||||
- Particularly handy when using OpenHands to perform data visualization tasks.
|
||||
|
||||
### App
|
||||
- Shows the web server when OpenHands runs an application.
|
||||
- Displays the web server when OpenHands runs an application.
|
||||
- Users can interact with the running application.
|
||||
|
||||
### Browser
|
||||
|
||||
433
docs/package-lock.json
generated
433
docs/package-lock.json
generated
@@ -24,6 +24,8 @@
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"swagger-cli": "^4.0.4",
|
||||
"swagger-ui-dist": "^5.21.0",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -273,6 +275,273 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/openapi-schemas": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
|
||||
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-cli/-/swagger-cli-4.0.4.tgz",
|
||||
"integrity": "sha512-hdDT3B6GLVovCsRZYDi3+wMcB1HfetTU20l2DC8zD3iFRNMC6QNAZG5fo/6PYeHWBEv7ri4MvnlKodhNB0nt7g==",
|
||||
"deprecated": "This package has been abandoned. Please switch to using the actively maintained @redocly/cli",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^10.0.1",
|
||||
"chalk": "^4.1.0",
|
||||
"js-yaml": "^3.14.0",
|
||||
"yargs": "^15.4.1"
|
||||
},
|
||||
"bin": {
|
||||
"swagger-cli": "bin/swagger-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-cli/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-methods": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
|
||||
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-parser": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.1.1.tgz",
|
||||
"integrity": "sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apidevtools/json-schema-ref-parser": "11.7.2",
|
||||
"@apidevtools/openapi-schemas": "^2.1.0",
|
||||
"@apidevtools/swagger-methods": "^3.0.2",
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-draft-04": "^1.0.0",
|
||||
"call-me-maybe": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openapi-types": ">=7"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-parser/node_modules/@apidevtools/json-schema-ref-parser": {
|
||||
"version": "11.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz",
|
||||
"integrity": "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"js-yaml": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/philsturgeon"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||
@@ -3835,6 +4104,13 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsdevtools/ono": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@leichtgewicht/ip-codec": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||
@@ -3970,6 +4246,14 @@
|
||||
"integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@sideway/address": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
|
||||
@@ -4967,6 +5251,21 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-draft-04": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
|
||||
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
@@ -5549,6 +5848,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-me-maybe": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
|
||||
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -7192,6 +7498,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
|
||||
@@ -8600,6 +8916,16 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
|
||||
@@ -13110,15 +13436,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -13427,6 +13754,14 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-types": {
|
||||
"version": "12.1.3",
|
||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/opener": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
|
||||
@@ -13809,9 +14144,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.38",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
|
||||
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
|
||||
"version": "8.4.49",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -13826,10 +14161,11 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.2.0"
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -15367,6 +15703,15 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pupa": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz",
|
||||
@@ -16205,6 +16550,16 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -16222,6 +16577,13 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -16702,6 +17064,13 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -16980,9 +17349,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
|
||||
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -17347,6 +17717,32 @@
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
|
||||
},
|
||||
"node_modules/swagger-cli": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/swagger-cli/-/swagger-cli-4.0.4.tgz",
|
||||
"integrity": "sha512-Cp8YYuLny3RJFQ4CvOBTaqmOOgYsem52dPx1xM5S4EUWFblIh2Q8atppMZvXKUr1e9xH5RwipYpmdUzdPcxWcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-cli": "4.0.4"
|
||||
},
|
||||
"bin": {
|
||||
"swagger-cli": "swagger-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "5.21.0",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.21.0.tgz",
|
||||
"integrity": "sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@scarf/scarf": "=1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||
@@ -17949,14 +18345,6 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js/node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/url-loader": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
|
||||
@@ -18610,6 +18998,13 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/widest-line": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
|
||||
|
||||
@@ -4,16 +4,18 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"start": "node generate-swagger-ui.js && docusaurus start",
|
||||
"build": "node generate-swagger-ui.js && docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
"typecheck": "tsc",
|
||||
"generate-swagger-ui": "node generate-swagger-ui.js"
|
||||
},
|
||||
"// Note": "The OpenAPI spec is stored in docs/static/openapi.json so it's accessible at /openapi.json in the deployed site",
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.7.0",
|
||||
"@docusaurus/plugin-content-pages": "^3.7.0",
|
||||
@@ -31,6 +33,8 @@
|
||||
"@docusaurus/module-type-aliases": "^3.5.1",
|
||||
"@docusaurus/tsconfig": "^3.7.0",
|
||||
"@docusaurus/types": "^3.5.1",
|
||||
"swagger-cli": "^4.0.4",
|
||||
"swagger-ui-dist": "^5.21.0",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"browserslist": {
|
||||
@@ -47,5 +51,6 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -268,4 +268,4 @@ const sidebars: SidebarsConfig = {
|
||||
],
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
export default sidebars;
|
||||
15
docs/static/README.md
vendored
Normal file
15
docs/static/README.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Static Files for OpenHands Documentation
|
||||
|
||||
This directory contains static files that are copied directly to the build output of the Docusaurus documentation.
|
||||
|
||||
## OpenAPI Specification
|
||||
|
||||
The `openapi.json` file in this directory is the OpenAPI specification for the OpenHands API. It is copied to the build output and is accessible at `/openapi.json` in the deployed site.
|
||||
|
||||
This file is used by the Swagger UI interface, which is accessible at `/swagger-ui/` in the deployed site.
|
||||
|
||||
## Why is the OpenAPI spec in the static directory?
|
||||
|
||||
The OpenAPI specification is placed in the static directory so that it's accessible at a predictable URL in the deployed site. This allows the Swagger UI to reference it directly.
|
||||
|
||||
We only need one copy of the OpenAPI spec file, which is this one in the static directory.
|
||||
BIN
docs/static/img/oh-features.png
vendored
BIN
docs/static/img/oh-features.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 120 KiB |
2085
docs/static/openapi.json
vendored
Normal file
2085
docs/static/openapi.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
628
docs/yarn.lock
628
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,10 @@
|
||||
# Run frontend checks
|
||||
echo "Running frontend checks..."
|
||||
cd frontend
|
||||
npm run check-unlocalized-strings
|
||||
npx lint-staged
|
||||
|
||||
# Run backend pre-commit
|
||||
echo "Running backend pre-commit..."
|
||||
cd ..
|
||||
pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("RepoConnector", () => {
|
||||
expect(createConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// select a repository from the dropdown
|
||||
const dropdown = await waitFor(() =>
|
||||
const dropdown = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("repo-dropdown")
|
||||
);
|
||||
await userEvent.click(dropdown);
|
||||
|
||||
@@ -23,6 +23,7 @@ const MOCK_TASK_1: SuggestedTask = {
|
||||
repo: "repo1",
|
||||
title: "Task 1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
};
|
||||
|
||||
const MOCK_TASK_2: SuggestedTask = {
|
||||
@@ -30,6 +31,7 @@ const MOCK_TASK_2: SuggestedTask = {
|
||||
repo: "repo2",
|
||||
title: "Task 2",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
};
|
||||
|
||||
const MOCK_TASK_3: SuggestedTask = {
|
||||
@@ -37,6 +39,7 @@ const MOCK_TASK_3: SuggestedTask = {
|
||||
repo: "repo3",
|
||||
title: "Task 3",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
git_provider: "gitlab",
|
||||
};
|
||||
|
||||
const MOCK_TASK_4: SuggestedTask = {
|
||||
@@ -44,6 +47,7 @@ const MOCK_TASK_4: SuggestedTask = {
|
||||
repo: "repo4",
|
||||
title: "Task 4",
|
||||
task_type: "OPEN_ISSUE",
|
||||
git_provider: "gitlab",
|
||||
};
|
||||
|
||||
const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
@@ -119,7 +123,11 @@ describe("TaskCard", () => {
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
MOCK_RESPOSITORIES[0],
|
||||
getMergeConflictPrompt(MOCK_TASK_1.issue_number, MOCK_TASK_1.repo),
|
||||
getMergeConflictPrompt(
|
||||
MOCK_TASK_1.git_provider,
|
||||
MOCK_TASK_1.issue_number,
|
||||
MOCK_TASK_1.repo,
|
||||
),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
@@ -135,7 +143,11 @@ describe("TaskCard", () => {
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
MOCK_RESPOSITORIES[1],
|
||||
getFailingChecksPrompt(MOCK_TASK_2.issue_number, MOCK_TASK_2.repo),
|
||||
getFailingChecksPrompt(
|
||||
MOCK_TASK_2.git_provider,
|
||||
MOCK_TASK_2.issue_number,
|
||||
MOCK_TASK_2.repo,
|
||||
),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
@@ -151,7 +163,11 @@ describe("TaskCard", () => {
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
MOCK_RESPOSITORIES[2],
|
||||
getUnresolvedCommentsPrompt(MOCK_TASK_3.issue_number, MOCK_TASK_3.repo),
|
||||
getUnresolvedCommentsPrompt(
|
||||
MOCK_TASK_3.git_provider,
|
||||
MOCK_TASK_3.issue_number,
|
||||
MOCK_TASK_3.repo,
|
||||
),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
@@ -167,7 +183,11 @@ describe("TaskCard", () => {
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
MOCK_RESPOSITORIES[3],
|
||||
getOpenIssuePrompt(MOCK_TASK_4.issue_number, MOCK_TASK_4.repo),
|
||||
getOpenIssuePrompt(
|
||||
MOCK_TASK_4.git_provider,
|
||||
MOCK_TASK_4.issue_number,
|
||||
MOCK_TASK_4.repo,
|
||||
),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -25,7 +25,6 @@ const mock_provider_tokens_are_set: Record<Provider, boolean> = {
|
||||
describe("Settings Screen", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const resetSettingsSpy = vi.spyOn(OpenHands, "resetSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
|
||||
const { handleLogoutMock } = vi.hoisted(() => ({
|
||||
@@ -67,7 +66,6 @@ describe("Settings Screen", () => {
|
||||
// Use queryAllByText to handle multiple elements with the same text
|
||||
expect(screen.queryAllByText("SETTINGS$LLM_SETTINGS")).not.toHaveLength(0);
|
||||
screen.getByText("ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS");
|
||||
screen.getByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
screen.getByText("BUTTON$SAVE");
|
||||
});
|
||||
});
|
||||
@@ -542,54 +540,6 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("resetting settings with no changes but having advanced enabled should hide the advanced items", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const resetButton = screen.getByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
// show modal
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// Mock the settings that will be returned after reset
|
||||
// This should be the default settings with no advanced settings enabled
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_base_url: "",
|
||||
confirmation_mode: false,
|
||||
security_analyzer: "",
|
||||
});
|
||||
|
||||
// confirm reset
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("llm-custom-model-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("base-url-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should save if only confirmation mode is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
@@ -762,81 +712,6 @@ describe("Settings Screen", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should reset the settings when the 'Reset to defaults' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const languageInput = await screen.findByTestId("language-input");
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = await screen.findByText("Norsk");
|
||||
await user.click(norskOption);
|
||||
|
||||
expect(languageInput).toHaveValue("Norsk");
|
||||
|
||||
const resetButton = screen.getByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
|
||||
// show modal
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// confirm reset
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetSettingsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Mock the settings response after reset
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_base_url: "",
|
||||
confirmation_mode: false,
|
||||
security_analyzer: "",
|
||||
});
|
||||
|
||||
// Wait for the mutation to complete and the modal to be removed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("llm-custom-model-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should cancel the reset when the 'Cancel' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const resetButton = await screen.findByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
const cancelButton = within(modal).getByText("Cancel");
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsent with true if the save is successful", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
@@ -1044,18 +919,5 @@ describe("Settings Screen", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should not submit the unwanted fields when resetting", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
const resetButton = await screen.findByText("BUTTON$RESET_TO_DEFAULTS");
|
||||
await user.click(resetButton);
|
||||
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(resetSettingsSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,30 +11,35 @@ const rawTasks: SuggestedTask[] = [
|
||||
repo: "repo1",
|
||||
title: "Task 1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 2,
|
||||
repo: "repo1",
|
||||
title: "Task 2",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 3,
|
||||
repo: "repo2",
|
||||
title: "Task 3",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 4,
|
||||
repo: "repo2",
|
||||
title: "Task 4",
|
||||
task_type: "OPEN_ISSUE",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 5,
|
||||
repo: "repo3",
|
||||
title: "Task 5",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -47,12 +52,14 @@ const groupedTasks: SuggestedTaskGroup[] = [
|
||||
repo: "repo1",
|
||||
title: "Task 1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 2,
|
||||
repo: "repo1",
|
||||
title: "Task 2",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -64,12 +71,14 @@ const groupedTasks: SuggestedTaskGroup[] = [
|
||||
repo: "repo2",
|
||||
title: "Task 3",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 4,
|
||||
repo: "repo2",
|
||||
title: "Task 4",
|
||||
task_type: "OPEN_ISSUE",
|
||||
git_provider: "github",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -81,6 +90,7 @@ const groupedTasks: SuggestedTaskGroup[] = [
|
||||
repo: "repo3",
|
||||
title: "Task 5",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
582
frontend/package-lock.json
generated
582
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,12 @@
|
||||
"@heroui/react": "2.7.6",
|
||||
"@microlink/react-json-view": "^1.26.1",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.5.1",
|
||||
"@react-router/serve": "^7.5.1",
|
||||
"@react-router/node": "^7.5.2",
|
||||
"@react-router/serve": "^7.5.2",
|
||||
"@react-types/shared": "^3.29.0",
|
||||
"@reduxjs/toolkit": "^2.7.0",
|
||||
"@stripe/react-stripe-js": "^3.6.0",
|
||||
"@stripe/stripe-js": "^7.1.0",
|
||||
"@stripe/stripe-js": "^7.2.0",
|
||||
"@tanstack/react-query": "^5.74.4",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -23,31 +23,31 @@
|
||||
"axios": "^1.8.4",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.7.4",
|
||||
"i18next": "^25.0.0",
|
||||
"framer-motion": "^12.9.1",
|
||||
"i18next": "^25.0.1",
|
||||
"i18next-browser-languagedetector": "^8.0.5",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.25",
|
||||
"jose": "^6.0.10",
|
||||
"lucide-react": "^0.501.0",
|
||||
"lucide-react": "^0.503.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.236.2",
|
||||
"posthog-js": "^1.236.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.5.1",
|
||||
"react-router": "^7.5.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"vite": "^6.3.2",
|
||||
"vite": "^6.3.3",
|
||||
"web-vitals": "^3.5.2",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
@@ -82,7 +82,7 @@
|
||||
"@babel/types": "^7.27.0",
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@react-router/dev": "^7.5.1",
|
||||
"@react-router/dev": "^7.5.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.73.3",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
@@ -97,7 +97,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/coverage-v8": "^3.1.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
|
||||
@@ -199,14 +199,6 @@ class OpenHands {
|
||||
return data.status === 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user settings in server
|
||||
*/
|
||||
static async resetSettings(): Promise<boolean> {
|
||||
const response = await openHands.post("/api/reset-settings");
|
||||
return response.status === 200;
|
||||
}
|
||||
|
||||
static async createCheckoutSession(amount: number): Promise<string> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/create-checkout-session",
|
||||
|
||||
@@ -1,48 +1,94 @@
|
||||
import { Provider } from "#/types/settings";
|
||||
import { SuggestedTaskType } from "./task.types";
|
||||
|
||||
// Helper function to get provider-specific terminology
|
||||
const getProviderTerms = (git_provider: Provider) => {
|
||||
if (git_provider === "gitlab") {
|
||||
return {
|
||||
requestType: "Merge Request",
|
||||
requestTypeShort: "MR",
|
||||
apiName: "GitLab API",
|
||||
tokenEnvVar: "GITLAB_TOKEN",
|
||||
ciSystem: "CI pipelines",
|
||||
ciProvider: "GitLab",
|
||||
requestVerb: "merge request",
|
||||
};
|
||||
}
|
||||
return {
|
||||
requestType: "Pull Request",
|
||||
requestTypeShort: "PR",
|
||||
apiName: "GitHub API",
|
||||
tokenEnvVar: "GITHUB_TOKEN",
|
||||
ciSystem: "GitHub Actions",
|
||||
ciProvider: "GitHub",
|
||||
requestVerb: "pull request",
|
||||
};
|
||||
};
|
||||
|
||||
export const getMergeConflictPrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the merge conflicts.
|
||||
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to fix the merge conflicts.
|
||||
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
|
||||
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.`;
|
||||
};
|
||||
|
||||
export const getFailingChecksPrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the failing CI checks.
|
||||
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
|
||||
Then use the GitHub API to look at the GitHub Actions that are failing on the most recent commit. Try and reproduce the failure locally.
|
||||
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the GitHub actions have run again. If they are still failing, repeat the process.`;
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to fix the failing CI checks.
|
||||
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
|
||||
Then use the ${terms.apiName} to look at the ${terms.ciSystem} that are failing on the most recent commit. Try and reproduce the failure locally.
|
||||
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the ${terms.ciProvider} ${terms.ciSystem.toLowerCase()} have run again. If they are still failing, repeat the process.`;
|
||||
};
|
||||
|
||||
export const getUnresolvedCommentsPrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers.
|
||||
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
|
||||
Then use the GitHub API to retrieve all the feedback on the PR so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`;
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers.
|
||||
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
|
||||
Then use the ${terms.apiName} to retrieve all the feedback on the ${terms.requestTypeShort} so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`;
|
||||
};
|
||||
|
||||
export const getOpenIssuePrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue
|
||||
Use the GitHub API to retrieve the issue details and any comments on the issue. Then check out a new branch and investigate what changes will need to be made
|
||||
Finally, make the required changes and open up a pull request. Be sure to reference the issue in the PR description`;
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue.
|
||||
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the issue details and any comments on the issue. Then check out a new branch and investigate what changes will need to be made.
|
||||
Finally, make the required changes and open up a ${terms.requestVerb}. Be sure to reference the issue in the ${terms.requestTypeShort} description.`;
|
||||
};
|
||||
|
||||
export const getPromptForQuery = (
|
||||
git_provider: Provider,
|
||||
type: SuggestedTaskType,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => {
|
||||
switch (type) {
|
||||
case "MERGE_CONFLICTS":
|
||||
return getMergeConflictPrompt(issueNumber, repo);
|
||||
return getMergeConflictPrompt(git_provider, issueNumber, repo);
|
||||
case "FAILING_CHECKS":
|
||||
return getFailingChecksPrompt(issueNumber, repo);
|
||||
return getFailingChecksPrompt(git_provider, issueNumber, repo);
|
||||
case "UNRESOLVED_COMMENTS":
|
||||
return getUnresolvedCommentsPrompt(issueNumber, repo);
|
||||
return getUnresolvedCommentsPrompt(git_provider, issueNumber, repo);
|
||||
case "OPEN_ISSUE":
|
||||
return getOpenIssuePrompt(issueNumber, repo);
|
||||
return getOpenIssuePrompt(git_provider, issueNumber, repo);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from "#/utils/utils";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { getPromptForQuery } from "./get-prompt-for-query";
|
||||
import { TaskIssueNumber } from "./task-issue-number";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
const getTaskTypeMap = (
|
||||
t: (key: string) => string,
|
||||
@@ -26,18 +27,21 @@ export function TaskCard({ task }: TaskCardProps) {
|
||||
const isCreatingConversation = useIsCreatingConversation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getRepo = (repo: string) => {
|
||||
const getRepo = (repo: string, git_provider: Provider) => {
|
||||
const repositoriesList = repositories?.pages.flatMap((page) => page.data);
|
||||
const selectedRepo = repositoriesList?.find(
|
||||
(repository) => repository.full_name === repo,
|
||||
(repository) =>
|
||||
repository.full_name === repo &&
|
||||
repository.git_provider === git_provider,
|
||||
);
|
||||
|
||||
return selectedRepo;
|
||||
};
|
||||
|
||||
const handleLaunchConversation = () => {
|
||||
const repo = getRepo(task.repo);
|
||||
const repo = getRepo(task.repo, task.git_provider);
|
||||
const query = getPromptForQuery(
|
||||
task.git_provider,
|
||||
task.task_type,
|
||||
task.issue_number,
|
||||
task.repo,
|
||||
@@ -49,8 +53,16 @@ export function TaskCard({ task }: TaskCardProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
|
||||
const href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
|
||||
// Determine the correct URL format based on git provider
|
||||
let href: string;
|
||||
if (task.git_provider === "gitlab") {
|
||||
const issueType =
|
||||
task.task_type === "OPEN_ISSUE" ? "issues" : "merge_requests";
|
||||
href = `https://gitlab.com/${task.repo}/-/${issueType}/${task.issue_number}`;
|
||||
} else {
|
||||
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
|
||||
href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export type SuggestedTaskType =
|
||||
| "MERGE_CONFLICTS"
|
||||
| "FAILING_CHECKS"
|
||||
@@ -5,6 +7,7 @@ export type SuggestedTaskType =
|
||||
| "OPEN_ISSUE"; // This is a task type identifier, not a UI string
|
||||
|
||||
export interface SuggestedTask {
|
||||
git_provider: Provider;
|
||||
issue_number: number;
|
||||
repo: string;
|
||||
title: string;
|
||||
|
||||
@@ -4,15 +4,7 @@ import OpenHands from "#/api/open-hands";
|
||||
import { PostSettings, PostApiSettings } from "#/types/settings";
|
||||
import { useSettings } from "../query/use-settings";
|
||||
|
||||
const saveSettingsMutationFn = async (
|
||||
settings: Partial<PostSettings> | null,
|
||||
) => {
|
||||
// If settings is null, we're resetting
|
||||
if (settings === null) {
|
||||
await OpenHands.resetSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
const apiSettings: Partial<PostApiSettings> = {
|
||||
llm_model: settings.LLM_MODEL,
|
||||
llm_base_url: settings.LLM_BASE_URL,
|
||||
@@ -39,12 +31,7 @@ export const useSaveSettings = () => {
|
||||
const { data: currentSettings } = useSettings();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (settings: Partial<PostSettings> | null) => {
|
||||
if (settings === null) {
|
||||
await saveSettingsMutationFn(null);
|
||||
return;
|
||||
}
|
||||
|
||||
mutationFn: async (settings: Partial<PostSettings>) => {
|
||||
const newSettings = { ...currentSettings, ...settings };
|
||||
await saveSettingsMutationFn(newSettings);
|
||||
},
|
||||
|
||||
@@ -82,7 +82,6 @@ export enum I18nKey {
|
||||
API$DONT_KNOW_KEY = "API$DONT_KNOW_KEY",
|
||||
BUTTON$SAVE = "BUTTON$SAVE",
|
||||
BUTTON$CLOSE = "BUTTON$CLOSE",
|
||||
BUTTON$RESET_TO_DEFAULTS = "BUTTON$RESET_TO_DEFAULTS",
|
||||
MODAL$CONFIRM_RESET_TITLE = "MODAL$CONFIRM_RESET_TITLE",
|
||||
MODAL$CONFIRM_RESET_MESSAGE = "MODAL$CONFIRM_RESET_MESSAGE",
|
||||
MODAL$END_SESSION_TITLE = "MODAL$END_SESSION_TITLE",
|
||||
@@ -321,7 +320,6 @@ export enum I18nKey {
|
||||
SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL = "SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL",
|
||||
SETTINGS_FORM$SAVE_LABEL = "SETTINGS_FORM$SAVE_LABEL",
|
||||
SETTINGS_FORM$CLOSE_LABEL = "SETTINGS_FORM$CLOSE_LABEL",
|
||||
SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL = "SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL",
|
||||
SETTINGS_FORM$CANCEL_LABEL = "SETTINGS_FORM$CANCEL_LABEL",
|
||||
SETTINGS_FORM$END_SESSION_LABEL = "SETTINGS_FORM$END_SESSION_LABEL",
|
||||
SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE = "SETTINGS_FORM$CHANGING_WORKSPACE_WARNING_MESSAGE",
|
||||
@@ -340,6 +338,7 @@ export enum I18nKey {
|
||||
STATUS$LLM_RETRY = "STATUS$LLM_RETRY",
|
||||
AGENT_ERROR$BAD_ACTION = "AGENT_ERROR$BAD_ACTION",
|
||||
AGENT_ERROR$ACTION_TIMEOUT = "AGENT_ERROR$ACTION_TIMEOUT",
|
||||
AGENT_ERROR$TOO_MANY_CONVERSATIONS = "AGENT_ERROR$TOO_MANY_CONVERSATIONS",
|
||||
PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL",
|
||||
PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$PUSH_TO_GITHUB_LABEL",
|
||||
PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL",
|
||||
|
||||
@@ -1231,21 +1231,6 @@
|
||||
"tr": "Kapat",
|
||||
"de": "Schließen"
|
||||
},
|
||||
"BUTTON$RESET_TO_DEFAULTS": {
|
||||
"en": "Reset to defaults",
|
||||
"ja": "デフォルトにリセット",
|
||||
"zh-CN": "重置为默认值",
|
||||
"zh-TW": "還原為預設值",
|
||||
"ko-KR": "기본값으로 재설정",
|
||||
"no": "Tilbakestill til standard",
|
||||
"it": "Ripristina valori predefiniti",
|
||||
"pt": "Restaurar padrões",
|
||||
"es": "Restablecer valores predeterminados",
|
||||
"ar": "إعادة التعيين إلى الإعدادات الافتراضية",
|
||||
"fr": "Réinitialiser aux valeurs par défaut",
|
||||
"tr": "Varsayılanlara sıfırla",
|
||||
"de": "Auf Standardwerte zurücksetzen"
|
||||
},
|
||||
"MODAL$CONFIRM_RESET_TITLE": {
|
||||
"en": "Are you sure?",
|
||||
"ja": "本当によろしいですか?",
|
||||
@@ -4541,21 +4526,6 @@
|
||||
"pt": "Fechar",
|
||||
"tr": "Kapat"
|
||||
},
|
||||
"SETTINGS_FORM$RESET_TO_DEFAULTS_LABEL": {
|
||||
"en": "Reset to defaults",
|
||||
"es": "Reiniciar valores por defect",
|
||||
"zh-CN": "重置为默认值",
|
||||
"zh-TW": "還原為預設值",
|
||||
"ko-KR": "기본값으로 재설정",
|
||||
"ja": "デフォルトに戻す",
|
||||
"no": "Tilbakestill til standardverdier",
|
||||
"ar": "إعادة التعيين إلى الإعدادات الافتراضية",
|
||||
"de": "Auf Standardwerte zurücksetzen",
|
||||
"fr": "Réinitialiser aux valeurs par défaut",
|
||||
"it": "Ripristina valori predefiniti",
|
||||
"pt": "Restaurar padrões",
|
||||
"tr": "Varsayılanlara sıfırla"
|
||||
},
|
||||
"SETTINGS_FORM$CANCEL_LABEL": {
|
||||
"en": "Cancel",
|
||||
"es": "Cancelar",
|
||||
@@ -4826,6 +4796,9 @@
|
||||
"es": "La acción expiró",
|
||||
"tr": "İşlem zaman aşımına uğradı"
|
||||
},
|
||||
"AGENT_ERROR$TOO_MANY_CONVERSATIONS": {
|
||||
"en": "Too many conversations at once."
|
||||
},
|
||||
"PROJECT_MENU_CARD_CONTEXT_MENU$CONNECT_TO_GITHUB_LABEL": {
|
||||
"en": "Connect to GitHub",
|
||||
"es": "Conectar a GitHub",
|
||||
|
||||
@@ -7,6 +7,7 @@ const TASKS_1: SuggestedTask[] = [
|
||||
title: "Fix merge conflicts",
|
||||
repo: "octocat/hello-world",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,54 +17,63 @@ const TASKS_2: SuggestedTask[] = [
|
||||
title: "Fix broken CI checks",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 281,
|
||||
title: "Fix issue",
|
||||
repo: "octocat/earth",
|
||||
task_type: "UNRESOLVED_COMMENTS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 293,
|
||||
title: "Update documentation",
|
||||
repo: "octocat/earth",
|
||||
task_type: "OPEN_ISSUE",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 305,
|
||||
title: "Refactor user service",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 312,
|
||||
title: "Fix styling bug",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 327,
|
||||
title: "Add unit tests",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 331,
|
||||
title: "Implement dark mode",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 345,
|
||||
title: "Optimize build process",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
issue_number: 352,
|
||||
title: "Update dependencies",
|
||||
repo: "octocat/earth",
|
||||
task_type: "FAILING_CHECKS",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { SettingsDropdownInput } from "#/components/features/settings/settings-d
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
@@ -95,8 +94,6 @@ function AccountSettings() {
|
||||
>(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] =
|
||||
React.useState(!!settings?.SECURITY_ANALYZER);
|
||||
const [resetSettingsModalIsOpen, setResetSettingsModalIsOpen] =
|
||||
React.useState(false);
|
||||
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
@@ -180,16 +177,6 @@ function AccountSettings() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
saveSettings(null, {
|
||||
onSuccess: () => {
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$RESET));
|
||||
setResetSettingsModalIsOpen(false);
|
||||
setLlmConfigMode("basic");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
// If settings is still loading by the time the state is set, it will always
|
||||
// default to basic settings. This is a workaround to ensure the correct
|
||||
@@ -527,13 +514,6 @@ function AccountSettings() {
|
||||
</form>
|
||||
|
||||
<footer className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setResetSettingsModalIsOpen(true)}
|
||||
>
|
||||
{t(I18nKey.BUTTON$RESET_TO_DEFAULTS)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
@@ -544,40 +524,6 @@ function AccountSettings() {
|
||||
{t(I18nKey.BUTTON$SAVE)}
|
||||
</BrandButton>
|
||||
</footer>
|
||||
|
||||
{resetSettingsModalIsOpen && (
|
||||
<ModalBackdrop>
|
||||
<div
|
||||
data-testid="reset-modal"
|
||||
className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary"
|
||||
>
|
||||
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
|
||||
<div className="w-full flex gap-2">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
handleReset();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
setResetSettingsModalIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,14 +14,16 @@ export function groupSuggestedTasks(
|
||||
const groupsMap: Record<string, SuggestedTaskGroup> = {};
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!groupsMap[task.repo]) {
|
||||
groupsMap[task.repo] = {
|
||||
title: task.repo,
|
||||
const groupKey = `${task.repo}`;
|
||||
|
||||
if (!groupsMap[groupKey]) {
|
||||
groupsMap[groupKey] = {
|
||||
title: groupKey,
|
||||
tasks: [],
|
||||
};
|
||||
}
|
||||
|
||||
groupsMap[task.repo].tasks.push(task);
|
||||
groupsMap[groupKey].tasks.push(task);
|
||||
}
|
||||
|
||||
return Object.values(groupsMap);
|
||||
|
||||
@@ -414,7 +414,7 @@ class AgentController:
|
||||
should_step = self.should_step(event)
|
||||
if should_step:
|
||||
self.log(
|
||||
'info',
|
||||
'debug',
|
||||
f'Stepping agent after event: {type(event).__name__}',
|
||||
extra={'msg_type': 'STEPPING_AGENT'},
|
||||
)
|
||||
@@ -779,7 +779,7 @@ class AgentController:
|
||||
return
|
||||
|
||||
self.log(
|
||||
'info',
|
||||
'debug',
|
||||
f'LEVEL {self.state.delegate_level} LOCAL STEP {self.state.local_iteration} GLOBAL STEP {self.state.iteration}',
|
||||
extra={'msg_type': 'STEP'},
|
||||
)
|
||||
@@ -968,7 +968,7 @@ class AgentController:
|
||||
action_type = type(prev_action).__name__
|
||||
elapsed_time = time.time() - timestamp
|
||||
self.log(
|
||||
'info',
|
||||
'debug',
|
||||
f'Cleared pending action after {elapsed_time:.2f}s: {action_type} (id={action_id})',
|
||||
extra={'msg_type': 'PENDING_ACTION_CLEARED'},
|
||||
)
|
||||
@@ -977,7 +977,7 @@ class AgentController:
|
||||
action_id = getattr(action, 'id', 'unknown')
|
||||
action_type = type(action).__name__
|
||||
self.log(
|
||||
'info',
|
||||
'debug',
|
||||
f'Set pending action: {action_type} (id={action_id})',
|
||||
extra={'msg_type': 'PENDING_ACTION_SET'},
|
||||
)
|
||||
|
||||
@@ -135,7 +135,6 @@ def event_to_dict(event: 'Event') -> dict:
|
||||
k: (v.value if isinstance(v, Enum) else _convert_pydantic_to_dict(v))
|
||||
for k, v in props.items()
|
||||
}
|
||||
logger.debug(f'extras data in event_to_dict: {d["extras"]}')
|
||||
# Include success field for CmdOutputObservation
|
||||
if hasattr(event, 'success'):
|
||||
d['success'] = event.success
|
||||
|
||||
@@ -351,6 +351,7 @@ class GitHubService(BaseGitService, GitService):
|
||||
if task_type != TaskType.OPEN_PR:
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITHUB,
|
||||
task_type=task_type,
|
||||
repo=repo_name,
|
||||
issue_number=pr['number'],
|
||||
@@ -363,6 +364,7 @@ class GitHubService(BaseGitService, GitService):
|
||||
repo_name = issue['repository']['nameWithOwner']
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITHUB,
|
||||
task_type=TaskType.OPEN_ISSUE,
|
||||
repo=repo_name,
|
||||
issue_number=issue['number'],
|
||||
|
||||
@@ -16,6 +16,6 @@ A comment on the issue has been addressed to you.
|
||||
When you're done, make sure to
|
||||
|
||||
1. Use the `GITHUB_TOKEN` environment variable and GitHub API to open a new PR
|
||||
2. Name the branch using the format: `openhands/{branch-name}`
|
||||
2. Name the branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
|
||||
3. The PR description should mention that it "fixes" or "closes" the issue number
|
||||
4. Make sure to leave the following sentence at the end of the PR description: `@{{ username }} can click here to [continue refining the PR]({{ conversation_url }})`
|
||||
|
||||
@@ -10,6 +10,8 @@ from openhands.integrations.service_types import (
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
SuggestedTask,
|
||||
TaskType,
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
@@ -106,7 +108,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
except httpx.HTTPError as e:
|
||||
raise self.handle_http_error(e)
|
||||
|
||||
async def execute_graphql_query(self, query: str, variables: dict[str, Any]) -> Any:
|
||||
async def execute_graphql_query(self, query: str, variables: dict[str, Any] = {}) -> Any:
|
||||
"""
|
||||
Execute a GraphQL query against the GitLab GraphQL API
|
||||
|
||||
@@ -244,6 +246,138 @@ class GitLabService(BaseGitService, GitService):
|
||||
for repo in all_repos
|
||||
]
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories.
|
||||
|
||||
Returns:
|
||||
- Merge requests authored by the user.
|
||||
- Issues assigned to the user.
|
||||
"""
|
||||
# Get user info to use in queries
|
||||
user = await self.get_user()
|
||||
username = user.login
|
||||
|
||||
# GraphQL query to get merge requests
|
||||
query = """
|
||||
query GetUserTasks {
|
||||
currentUser {
|
||||
authoredMergeRequests(state: opened, sort: UPDATED_DESC, first: 100) {
|
||||
nodes {
|
||||
id
|
||||
iid
|
||||
title
|
||||
project {
|
||||
fullPath
|
||||
}
|
||||
conflicts
|
||||
mergeStatus
|
||||
pipelines(first: 1) {
|
||||
nodes {
|
||||
status
|
||||
}
|
||||
}
|
||||
discussions(first: 100) {
|
||||
nodes {
|
||||
notes {
|
||||
nodes {
|
||||
resolvable
|
||||
resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
tasks: list[SuggestedTask] = []
|
||||
|
||||
# Get merge requests using GraphQL
|
||||
response = await self.execute_graphql_query(query)
|
||||
data = response.get('currentUser', {})
|
||||
|
||||
# Process merge requests
|
||||
merge_requests = data.get('authoredMergeRequests', {}).get('nodes', [])
|
||||
for mr in merge_requests:
|
||||
repo_name = mr.get('project', {}).get('fullPath', '')
|
||||
mr_number = mr.get('iid')
|
||||
title = mr.get('title', '')
|
||||
|
||||
# Start with default task type
|
||||
task_type = TaskType.OPEN_PR
|
||||
|
||||
# Check for specific states
|
||||
if mr.get('conflicts'):
|
||||
task_type = TaskType.MERGE_CONFLICTS
|
||||
elif (
|
||||
mr.get('pipelines', {}).get('nodes', [])
|
||||
and mr.get('pipelines', {}).get('nodes', [])[0].get('status')
|
||||
== 'FAILED'
|
||||
):
|
||||
task_type = TaskType.FAILING_CHECKS
|
||||
else:
|
||||
# Check for unresolved comments
|
||||
has_unresolved_comments = False
|
||||
for discussion in mr.get('discussions', {}).get('nodes', []):
|
||||
for note in discussion.get('notes', {}).get('nodes', []):
|
||||
if note.get('resolvable') and not note.get('resolved'):
|
||||
has_unresolved_comments = True
|
||||
break
|
||||
if has_unresolved_comments:
|
||||
break
|
||||
|
||||
if has_unresolved_comments:
|
||||
task_type = TaskType.UNRESOLVED_COMMENTS
|
||||
|
||||
# Only add the task if it's not OPEN_PR
|
||||
if task_type != TaskType.OPEN_PR:
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITLAB,
|
||||
task_type=task_type,
|
||||
repo=repo_name,
|
||||
issue_number=mr_number,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
# Get assigned issues using REST API
|
||||
url = f"{self.BASE_URL}/issues"
|
||||
params = {
|
||||
"assignee_username": username,
|
||||
"state": "opened",
|
||||
"scope": "assigned_to_me"
|
||||
}
|
||||
|
||||
issues_response, _ = await self._make_request(
|
||||
method=RequestMethod.GET,
|
||||
url=url,
|
||||
params=params
|
||||
)
|
||||
|
||||
# Process issues
|
||||
for issue in issues_response:
|
||||
repo_name = issue.get('references', {}).get('full', '').split('#')[0].strip()
|
||||
issue_number = issue.get('iid')
|
||||
title = issue.get('title', '')
|
||||
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITLAB,
|
||||
task_type=TaskType.OPEN_ISSUE,
|
||||
repo=repo_name,
|
||||
issue_number=issue_number,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
return tasks
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
gitlab_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITLAB_SERVICE_CLS',
|
||||
|
||||
@@ -25,6 +25,7 @@ from openhands.integrations.service_types import (
|
||||
GitService,
|
||||
ProviderType,
|
||||
Repository,
|
||||
SuggestedTask,
|
||||
User,
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
@@ -227,7 +228,7 @@ class ProviderHandler:
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""
|
||||
Get repositories from a selected providers with pagination support
|
||||
Get repositories from providers
|
||||
"""
|
||||
|
||||
all_repos: list[Repository] = []
|
||||
@@ -241,6 +242,21 @@ class ProviderHandler:
|
||||
|
||||
return all_repos
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""
|
||||
Get suggested tasks from providers
|
||||
"""
|
||||
tasks: list[SuggestedTask] = []
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
service_repos = await service.get_suggested_tasks()
|
||||
tasks.extend(service_repos)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching repos from {provider}: {e}')
|
||||
|
||||
return tasks
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
query: str,
|
||||
|
||||
@@ -23,6 +23,7 @@ class TaskType(str, Enum):
|
||||
|
||||
|
||||
class SuggestedTask(BaseModel):
|
||||
git_provider: ProviderType
|
||||
task_type: TaskType
|
||||
repo: str
|
||||
issue_number: int
|
||||
@@ -110,7 +111,7 @@ class BaseGitService(ABC):
|
||||
return UnknownException('Unknown error')
|
||||
|
||||
def handle_http_error(self, e: HTTPError) -> UnknownException:
|
||||
logger.warning(f'HTTP error on {self.provider} API: {e}')
|
||||
logger.warning(f'HTTP error on {self.provider} API: {type(e).__name__} : {e}')
|
||||
return UnknownException('Unknown error')
|
||||
|
||||
|
||||
@@ -149,3 +150,7 @@ class GitService(Protocol):
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user"""
|
||||
...
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories"""
|
||||
...
|
||||
|
||||
@@ -713,7 +713,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
completion_response=response, **extra_kwargs
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting cost from litellm: {e}')
|
||||
logger.debug(f'Error getting cost from litellm: {e}')
|
||||
|
||||
if cost is None:
|
||||
_model_name = '/'.join(self.config.model.split('/')[1:])
|
||||
|
||||
@@ -555,7 +555,7 @@ if __name__ == '__main__':
|
||||
# Start the file viewer server in a separate thread
|
||||
logger.info('Starting file viewer server')
|
||||
_file_viewer_port = find_available_tcp_port(
|
||||
min_port=args.port + 1, max_port=args.port + 10000
|
||||
min_port=args.port + 1, max_port=min(args.port + 1024, 65535)
|
||||
)
|
||||
server_url, _ = start_file_viewer_server(port=_file_viewer_port)
|
||||
logger.info(f'File viewer server started at {server_url}')
|
||||
|
||||
@@ -68,7 +68,10 @@ def check_dependencies(code_repo_path: str, poetry_venvs_path: str):
|
||||
import libtmux
|
||||
|
||||
server = libtmux.Server()
|
||||
session = server.new_session(session_name='test-session')
|
||||
try:
|
||||
session = server.new_session(session_name='test-session')
|
||||
except Exception:
|
||||
raise ValueError('tmux is not properly installed or available on the path.')
|
||||
pane = session.attached_pane
|
||||
pane.send_keys('echo "test"')
|
||||
pane_output = '\n'.join(pane.cmd('capture-pane', '-p').stdout)
|
||||
|
||||
@@ -7,6 +7,7 @@ import re
|
||||
from uuid import uuid4
|
||||
|
||||
import tornado
|
||||
import tornado.websocket
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
||||
from tornado.escape import json_decode, json_encode, url_escape
|
||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||
@@ -139,7 +140,7 @@ class JupyterKernel:
|
||||
wait=wait_fixed(2),
|
||||
)
|
||||
async def execute(self, code: str, timeout: int = 120) -> str:
|
||||
if not self.ws:
|
||||
if not self.ws or self.ws.stream.closed():
|
||||
await self._connect()
|
||||
|
||||
msg_id = uuid4().hex
|
||||
|
||||
@@ -215,13 +215,13 @@ class BashSession:
|
||||
self.session.set_option('history-limit', str(self.HISTORY_LIMIT), _global=True)
|
||||
self.session.history_limit = self.HISTORY_LIMIT
|
||||
# We need to create a new pane because the initial pane's history limit is (default) 2000
|
||||
_initial_window = self.session.attached_window
|
||||
_initial_window = self.session.active_window
|
||||
self.window = self.session.new_window(
|
||||
window_name='bash',
|
||||
window_shell=window_command,
|
||||
start_directory=self.work_dir, # This parameter is supported by libtmux
|
||||
)
|
||||
self.pane = self.window.attached_pane
|
||||
self.pane = self.window.active_pane
|
||||
logger.debug(f'pane: {self.pane}; history_limit: {self.session.history_limit}')
|
||||
_initial_window.kill_window()
|
||||
|
||||
|
||||
@@ -82,11 +82,20 @@ class GitHandler:
|
||||
Returns:
|
||||
str | None: A valid Git reference or None if no valid reference is found.
|
||||
"""
|
||||
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{self._get_current_branch()})")'
|
||||
ref_default_branch = 'origin/' + self._get_current_branch()
|
||||
current_branch = self._get_current_branch()
|
||||
default_branch = self._get_default_branch()
|
||||
|
||||
ref_current_branch = f'origin/{current_branch}'
|
||||
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
ref_default_branch = 'origin/' + default_branch
|
||||
ref_new_repo = '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
|
||||
|
||||
refs = [ref_non_default_branch, ref_default_branch, ref_new_repo]
|
||||
refs = [
|
||||
ref_current_branch,
|
||||
ref_non_default_branch,
|
||||
ref_default_branch,
|
||||
ref_new_repo,
|
||||
]
|
||||
for ref in refs:
|
||||
if self._verify_ref_exists(ref):
|
||||
return ref
|
||||
@@ -111,7 +120,7 @@ class GitHandler:
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content if output.exit_code == 0 else ''
|
||||
|
||||
def _get_current_branch(self) -> str:
|
||||
def _get_default_branch(self) -> str:
|
||||
"""
|
||||
Retrieves the primary Git branch name of the repository.
|
||||
|
||||
@@ -122,6 +131,17 @@ class GitHandler:
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.split()[-1].strip()
|
||||
|
||||
def _get_current_branch(self) -> str:
|
||||
"""
|
||||
Retrieves the currently selected Git branch.
|
||||
|
||||
Returns:
|
||||
str: The name of the current branch.
|
||||
"""
|
||||
cmd = 'git rev-parse --abbrev-ref HEAD'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.strip()
|
||||
|
||||
def _get_changed_files(self) -> list[str]:
|
||||
"""
|
||||
Retrieves a list of changed files compared to a valid Git reference.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
from fastapi import Request
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
|
||||
|
||||
def get_provider_tokens(request: Request) -> PROVIDER_TOKEN_TYPE | None:
|
||||
"""Get GitHub token from request state. For backward compatibility."""
|
||||
return getattr(request.state, 'provider_tokens', None)
|
||||
|
||||
|
||||
def get_access_token(request: Request) -> SecretStr | None:
|
||||
return getattr(request.state, 'access_token', None)
|
||||
|
||||
|
||||
def get_user_id(request: Request) -> str | None:
|
||||
return getattr(request.state, 'user_id', None)
|
||||
|
||||
|
||||
def get_github_token(request: Request) -> SecretStr | None:
|
||||
provider_tokens = get_provider_tokens(request)
|
||||
|
||||
if provider_tokens and ProviderType.GITHUB in provider_tokens:
|
||||
return provider_tokens[ProviderType.GITHUB].token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_github_user_id(request: Request) -> str | None:
|
||||
provider_tokens = get_provider_tokens(request)
|
||||
if provider_tokens and ProviderType.GITHUB in provider_tokens:
|
||||
return provider_tokens[ProviderType.GITHUB].user_id
|
||||
|
||||
return None
|
||||
@@ -20,6 +20,7 @@ class ServerConfig(ServerConfigInterface):
|
||||
)
|
||||
conversation_manager_class: str = 'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager'
|
||||
monitoring_listener_class: str = 'openhands.server.monitoring.MonitoringListener'
|
||||
user_auth_class: str = 'openhands.server.user_auth.default_user_auth.DefaultUserAuth'
|
||||
|
||||
def verify_config(self):
|
||||
if self.config_cls:
|
||||
|
||||
@@ -53,7 +53,6 @@ class ConversationManager(ABC):
|
||||
connection_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
github_user_id: str | None,
|
||||
) -> EventStore | None:
|
||||
"""Join a conversation and return its event stream."""
|
||||
|
||||
@@ -82,7 +81,6 @@ class ConversationManager(ABC):
|
||||
user_id: str | None,
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
replay_json: str | None = None,
|
||||
github_user_id: str | None = None,
|
||||
) -> EventStore:
|
||||
"""Start an event loop if one is not already running"""
|
||||
|
||||
|
||||
@@ -115,7 +115,6 @@ class StandaloneConversationManager(ConversationManager):
|
||||
connection_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
github_user_id: str | None,
|
||||
) -> EventStore:
|
||||
logger.info(
|
||||
f'join_conversation:{sid}:{connection_id}',
|
||||
@@ -123,9 +122,7 @@ class StandaloneConversationManager(ConversationManager):
|
||||
)
|
||||
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
|
||||
self._local_connection_id_to_session_id[connection_id] = sid
|
||||
event_stream = await self.maybe_start_agent_loop(
|
||||
sid, settings, user_id, github_user_id=github_user_id
|
||||
)
|
||||
event_stream = await self.maybe_start_agent_loop(sid, settings, user_id)
|
||||
if not event_stream:
|
||||
logger.error(
|
||||
f'No event stream after joining conversation: {sid}',
|
||||
@@ -193,18 +190,14 @@ class StandaloneConversationManager(ConversationManager):
|
||||
logger.error('error_cleaning_stale')
|
||||
await asyncio.sleep(_CLEANUP_INTERVAL)
|
||||
|
||||
async def _get_conversation_store(
|
||||
self, user_id: str | None, github_user_id: str | None
|
||||
) -> ConversationStore:
|
||||
async def _get_conversation_store(self, user_id: str | None) -> ConversationStore:
|
||||
conversation_store_class = self._conversation_store_class
|
||||
if not conversation_store_class:
|
||||
self._conversation_store_class = conversation_store_class = get_impl(
|
||||
ConversationStore, # type: ignore
|
||||
self.server_config.conversation_store_class,
|
||||
)
|
||||
store = await conversation_store_class.get_instance(
|
||||
self.config, user_id, github_user_id
|
||||
)
|
||||
store = await conversation_store_class.get_instance(self.config, user_id)
|
||||
return store
|
||||
|
||||
async def get_running_agent_loops(
|
||||
@@ -254,12 +247,11 @@ class StandaloneConversationManager(ConversationManager):
|
||||
user_id: str | None,
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
replay_json: str | None = None,
|
||||
github_user_id: str | None = None,
|
||||
) -> EventStore:
|
||||
logger.info(f'maybe_start_agent_loop:{sid}', extra={'session_id': sid})
|
||||
if not await self.is_agent_loop_running(sid):
|
||||
await self._start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json, github_user_id
|
||||
sid, settings, user_id, initial_user_msg, replay_json
|
||||
)
|
||||
|
||||
event_store = await self._get_event_store(sid, user_id)
|
||||
@@ -278,25 +270,38 @@ class StandaloneConversationManager(ConversationManager):
|
||||
user_id: str | None,
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
replay_json: str | None = None,
|
||||
github_user_id: str | None = None,
|
||||
) -> Session:
|
||||
logger.info(f'starting_agent_loop:{sid}', extra={'session_id': sid})
|
||||
|
||||
response_ids = await self.get_running_agent_loops(user_id)
|
||||
if len(response_ids) >= self.config.max_concurrent_conversations:
|
||||
logger.info(
|
||||
'too_many_sessions_for:{user_id}',
|
||||
f'too_many_sessions_for:{user_id or ''}',
|
||||
extra={'session_id': sid, 'user_id': user_id},
|
||||
)
|
||||
# Get the conversations sorted (oldest first)
|
||||
conversation_store = await self._get_conversation_store(
|
||||
user_id, github_user_id
|
||||
)
|
||||
conversation_store = await self._get_conversation_store(user_id)
|
||||
conversations = await conversation_store.get_all_metadata(response_ids)
|
||||
conversations.sort(key=_last_updated_at_key, reverse=True)
|
||||
|
||||
while len(conversations) >= self.config.max_concurrent_conversations:
|
||||
oldest_conversation_id = conversations.pop().conversation_id
|
||||
logger.debug(
|
||||
f'closing_from_too_many_sessions:{user_id or ''}:{oldest_conversation_id}',
|
||||
extra={'session_id': oldest_conversation_id, 'user_id': user_id},
|
||||
)
|
||||
# Send status message to client and close session.
|
||||
status_update_dict = {
|
||||
'status_update': True,
|
||||
'type': 'error',
|
||||
'id': 'AGENT_ERROR$TOO_MANY_CONVERSATIONS',
|
||||
'message': 'Too many conversations at once. If you are still using this one, try reactivating it by prompting the agent to continue',
|
||||
}
|
||||
await self.sio.emit(
|
||||
'oh_event',
|
||||
status_update_dict,
|
||||
to=ROOM_KEY.format(sid=oldest_conversation_id),
|
||||
)
|
||||
await self.close_session(oldest_conversation_id)
|
||||
|
||||
session = Session(
|
||||
@@ -314,7 +319,7 @@ class StandaloneConversationManager(ConversationManager):
|
||||
try:
|
||||
session.agent_session.event_stream.subscribe(
|
||||
EventStreamSubscriber.SERVER,
|
||||
self._create_conversation_update_callback(user_id, github_user_id, sid),
|
||||
self._create_conversation_update_callback(user_id, sid),
|
||||
UPDATED_AT_CALLBACK_ID,
|
||||
)
|
||||
except ValueError:
|
||||
@@ -381,8 +386,8 @@ class StandaloneConversationManager(ConversationManager):
|
||||
f'removing connections: {connection_ids_to_remove}',
|
||||
extra={'session_id': sid},
|
||||
)
|
||||
for connnnection_id in connection_ids_to_remove:
|
||||
self._local_connection_id_to_session_id.pop(connnnection_id, None)
|
||||
for connection_id in connection_ids_to_remove:
|
||||
self._local_connection_id_to_session_id.pop(connection_id, None)
|
||||
|
||||
session = self._local_agent_loops_by_sid.pop(sid, None)
|
||||
if not session:
|
||||
@@ -411,14 +416,13 @@ class StandaloneConversationManager(ConversationManager):
|
||||
)
|
||||
|
||||
def _create_conversation_update_callback(
|
||||
self, user_id: str | None, github_user_id: str | None, conversation_id: str
|
||||
self, user_id: str | None, conversation_id: str
|
||||
) -> Callable:
|
||||
def callback(event, *args, **kwargs):
|
||||
call_async_from_sync(
|
||||
self._update_conversation_for_event,
|
||||
GENERAL_TIMEOUT,
|
||||
user_id,
|
||||
github_user_id,
|
||||
conversation_id,
|
||||
event,
|
||||
)
|
||||
@@ -426,9 +430,9 @@ class StandaloneConversationManager(ConversationManager):
|
||||
return callback
|
||||
|
||||
async def _update_conversation_for_event(
|
||||
self, user_id: str, github_user_id: str, conversation_id: str, event=None
|
||||
self, user_id: str, conversation_id: str, event=None
|
||||
):
|
||||
conversation_store = await self._get_conversation_store(user_id, github_user_id)
|
||||
conversation_store = await self._get_conversation_store(user_id)
|
||||
conversation = await conversation_store.get_metadata(conversation_id)
|
||||
conversation.last_updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from openhands.server.middleware import (
|
||||
CacheControlMiddleware,
|
||||
InMemoryRateLimiter,
|
||||
LocalhostCORSMiddleware,
|
||||
ProviderTokenMiddleware,
|
||||
RateLimitMiddleware,
|
||||
)
|
||||
from openhands.server.static import SPAStaticFiles
|
||||
@@ -32,6 +31,5 @@ base_app.add_middleware(
|
||||
rate_limiter=InMemoryRateLimiter(requests=10, seconds=1),
|
||||
)
|
||||
base_app.middleware('http')(AttachConversationMiddleware(base_app))
|
||||
base_app.middleware('http')(ProviderTokenMiddleware(base_app))
|
||||
|
||||
app = socketio.ASGIApp(sio, other_asgi_app=base_app)
|
||||
|
||||
@@ -60,9 +60,7 @@ async def connect(connection_id: str, environ):
|
||||
|
||||
cookies_str = environ.get('HTTP_COOKIE', '')
|
||||
conversation_validator = create_conversation_validator()
|
||||
user_id, github_user_id = await conversation_validator.validate(
|
||||
conversation_id, cookies_str
|
||||
)
|
||||
user_id = await conversation_validator.validate(conversation_id, cookies_str)
|
||||
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
@@ -81,7 +79,7 @@ async def connect(connection_id: str, environ):
|
||||
conversation_init_data = ConversationInitData(**session_init_args)
|
||||
|
||||
event_stream = await conversation_manager.join_conversation(
|
||||
conversation_id, connection_id, conversation_init_data, user_id, github_user_id
|
||||
conversation_id, connection_id, conversation_init_data, user_id
|
||||
)
|
||||
logger.info(
|
||||
f'Connected to conversation {conversation_id} with connection_id {connection_id}. Replaying event stream...'
|
||||
|
||||
@@ -12,8 +12,8 @@ from starlette.requests import Request as StarletteRequest
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from openhands.server import shared
|
||||
from openhands.server.auth import get_user_id
|
||||
from openhands.server.types import SessionMiddlewareInterface
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
|
||||
class LocalhostCORSMiddleware(CORSMiddleware):
|
||||
@@ -147,9 +147,10 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
|
||||
"""
|
||||
Attach the user's session based on the provided authentication token.
|
||||
"""
|
||||
user_id = await get_user_id(request)
|
||||
request.state.conversation = (
|
||||
await shared.conversation_manager.attach_to_conversation(
|
||||
request.state.sid, get_user_id(request)
|
||||
request.state.sid, user_id
|
||||
)
|
||||
)
|
||||
if not request.state.conversation:
|
||||
@@ -183,27 +184,3 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
|
||||
await self._detach_session(request)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class ProviderTokenMiddleware(SessionMiddlewareInterface):
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable):
|
||||
settings_store = await shared.SettingsStoreImpl.get_instance(
|
||||
shared.config, get_user_id(request)
|
||||
)
|
||||
settings = await settings_store.load()
|
||||
|
||||
# TODO: To avoid checks like this we should re-add the abilty to have completely different middleware in SAAS as in OSS
|
||||
if getattr(request.state, 'provider_tokens', None) is None:
|
||||
if (
|
||||
settings
|
||||
and settings.secrets_store
|
||||
and settings.secrets_store.provider_tokens
|
||||
):
|
||||
request.state.provider_tokens = settings.secrets_store.provider_tokens
|
||||
else:
|
||||
request.state.provider_tokens = None
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
status,
|
||||
@@ -21,7 +22,6 @@ from openhands.events.observation import (
|
||||
FileReadObservation,
|
||||
)
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.auth import get_github_user_id, get_user_id
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.file_config import (
|
||||
FILES_TO_IGNORE,
|
||||
@@ -31,6 +31,8 @@ from openhands.server.shared import (
|
||||
config,
|
||||
conversation_manager,
|
||||
)
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.utils import get_conversation_store
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
@@ -187,10 +189,15 @@ def zip_current_workspace(request: Request):
|
||||
|
||||
|
||||
@app.get('/git/changes')
|
||||
async def git_changes(request: Request, conversation_id: str):
|
||||
async def git_changes(
|
||||
request: Request,
|
||||
conversation_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
):
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
config,
|
||||
user_id,
|
||||
)
|
||||
|
||||
cwd = await get_cwd(
|
||||
@@ -204,7 +211,7 @@ async def git_changes(request: Request, conversation_id: str):
|
||||
changes = await call_sync_from_async(runtime.get_git_changes, cwd)
|
||||
if changes is None:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
status_code=404,
|
||||
content={'error': 'Not a git repository'},
|
||||
)
|
||||
return changes
|
||||
@@ -223,11 +230,13 @@ async def git_changes(request: Request, conversation_id: str):
|
||||
|
||||
|
||||
@app.get('/git/diff')
|
||||
async def git_diff(request: Request, path: str, conversation_id: str):
|
||||
async def git_diff(
|
||||
request: Request,
|
||||
path: str,
|
||||
conversation_id: str,
|
||||
conversation_store = Depends(get_conversation_store),
|
||||
):
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
|
||||
cwd = await get_cwd(
|
||||
conversation_store,
|
||||
|
||||
@@ -15,8 +15,12 @@ from openhands.integrations.service_types import (
|
||||
UnknownException,
|
||||
User,
|
||||
)
|
||||
from openhands.server.auth import get_access_token, get_provider_tokens, get_user_id
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.user_auth import (
|
||||
get_access_token,
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
)
|
||||
|
||||
app = APIRouter(prefix='/api/user')
|
||||
|
||||
@@ -139,12 +143,9 @@ async def get_suggested_tasks(
|
||||
- PRs owned by the user
|
||||
- Issues assigned to the user.
|
||||
"""
|
||||
|
||||
if provider_tokens and ProviderType.GITHUB in provider_tokens:
|
||||
token = provider_tokens[ProviderType.GITHUB]
|
||||
|
||||
client = GithubServiceImpl(
|
||||
user_id=token.user_id, external_auth_token=access_token, token=token.token
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens, external_auth_token=access_token
|
||||
)
|
||||
try:
|
||||
tasks: list[SuggestedTask] = await client.get_suggested_tasks()
|
||||
@@ -153,16 +154,16 @@ async def get_suggested_tasks(
|
||||
except AuthenticationError as e:
|
||||
return JSONResponse(
|
||||
content=str(e),
|
||||
status_code=401,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
except UnknownException as e:
|
||||
return JSONResponse(
|
||||
content=str(e),
|
||||
status_code=500,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content='GitHub token required.',
|
||||
content='No providers set.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Body, Request, status
|
||||
from fastapi import APIRouter, Body, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -15,11 +15,6 @@ from openhands.integrations.provider import (
|
||||
)
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.runtime import get_runtime_cls
|
||||
from openhands.server.auth import (
|
||||
get_github_user_id,
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
)
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
@@ -33,6 +28,12 @@ from openhands.server.shared import (
|
||||
file_store,
|
||||
)
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
)
|
||||
from openhands.server.utils import get_conversation_store
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
@@ -95,7 +96,7 @@ async def _create_new_conversation(
|
||||
session_init_args['selected_branch'] = selected_branch
|
||||
conversation_init_data = ConversationInitData(**session_init_args)
|
||||
logger.info('Loading conversation store')
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id, None)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
logger.info('Conversation store loaded')
|
||||
|
||||
conversation_id = uuid.uuid4().hex
|
||||
@@ -152,14 +153,17 @@ async def _create_new_conversation(
|
||||
|
||||
|
||||
@app.post('/conversations')
|
||||
async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
async def new_conversation(
|
||||
data: InitSessionRequest,
|
||||
user_id: str = Depends(get_user_id),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
|
||||
):
|
||||
"""Initialize a new session or join an existing one.
|
||||
|
||||
After successful initialization, the client should connect to the WebSocket
|
||||
using the returned conversation ID.
|
||||
"""
|
||||
logger.info('Initializing new conversation')
|
||||
provider_tokens = get_provider_tokens(request)
|
||||
selected_repository = data.selected_repository
|
||||
selected_branch = data.selected_branch
|
||||
initial_user_msg = data.initial_user_msg
|
||||
@@ -169,7 +173,7 @@ async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
try:
|
||||
# Create conversation with initial message
|
||||
conversation_id = await _create_new_conversation(
|
||||
get_user_id(request),
|
||||
user_id,
|
||||
provider_tokens,
|
||||
selected_repository,
|
||||
selected_branch,
|
||||
@@ -204,13 +208,11 @@ async def new_conversation(request: Request, data: InitSessionRequest):
|
||||
|
||||
@app.get('/conversations')
|
||||
async def search_conversations(
|
||||
request: Request,
|
||||
page_id: str | None = None,
|
||||
limit: int = 20,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
) -> ConversationInfoResultSet:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
|
||||
|
||||
# Filter out conversations older than max_age
|
||||
@@ -228,7 +230,7 @@ async def search_conversations(
|
||||
conversation.conversation_id for conversation in filtered_results
|
||||
)
|
||||
running_conversations = await conversation_manager.get_running_agent_loops(
|
||||
get_user_id(request), set(conversation_ids)
|
||||
user_id, set(conversation_ids)
|
||||
)
|
||||
result = ConversationInfoResultSet(
|
||||
results=await wait_all(
|
||||
@@ -245,11 +247,9 @@ async def search_conversations(
|
||||
|
||||
@app.get('/conversations/{conversation_id}')
|
||||
async def get_conversation(
|
||||
conversation_id: str, request: Request
|
||||
conversation_id: str,
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
) -> ConversationInfo | None:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
)
|
||||
try:
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
is_running = await conversation_manager.is_agent_loop_running(conversation_id)
|
||||
@@ -340,11 +340,12 @@ async def auto_generate_title(conversation_id: str, user_id: str | None) -> str:
|
||||
|
||||
@app.patch('/conversations/{conversation_id}')
|
||||
async def update_conversation(
|
||||
request: Request, conversation_id: str, title: str = Body(embed=True)
|
||||
conversation_id: str,
|
||||
title: str = Body(embed=True),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> bool:
|
||||
user_id = get_user_id(request)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id, get_github_user_id(request)
|
||||
config, user_id
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
if not metadata:
|
||||
@@ -366,10 +367,10 @@ async def update_conversation(
|
||||
@app.delete('/conversations/{conversation_id}')
|
||||
async def delete_conversation(
|
||||
conversation_id: str,
|
||||
request: Request,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> bool:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, get_user_id(request), get_github_user_id(request)
|
||||
config, user_id
|
||||
)
|
||||
try:
|
||||
await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from fastapi import APIRouter, Request, status
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType, SecretStore
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderToken,
|
||||
ProviderType,
|
||||
SecretStore,
|
||||
)
|
||||
from openhands.integrations.utils import validate_provider_token
|
||||
from openhands.server.auth import get_provider_tokens, get_user_id
|
||||
from openhands.server.settings import (
|
||||
GETSettingsCustomSecrets,
|
||||
GETSettingsModel,
|
||||
@@ -15,16 +19,24 @@ from openhands.server.settings import (
|
||||
)
|
||||
from openhands.server.shared import SettingsStoreImpl, config, server_config
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
get_user_settings,
|
||||
get_user_settings_store,
|
||||
)
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
|
||||
|
||||
@app.get('/settings', response_model=GETSettingsModel)
|
||||
async def load_settings(request: Request) -> GETSettingsModel | JSONResponse:
|
||||
async def load_settings(
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
settings: Settings | None = Depends(get_user_settings),
|
||||
) -> GETSettingsModel | JSONResponse:
|
||||
try:
|
||||
user_id = get_user_id(request)
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
if not settings:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -36,7 +48,6 @@ async def load_settings(request: Request) -> GETSettingsModel | JSONResponse:
|
||||
if bool(user_id):
|
||||
provider_tokens_set[ProviderType.GITHUB.value] = True
|
||||
|
||||
provider_tokens = get_provider_tokens(request)
|
||||
if provider_tokens:
|
||||
all_provider_types = [provider.value for provider in ProviderType]
|
||||
provider_tokens_types = [provider.value for provider in provider_tokens]
|
||||
@@ -48,7 +59,7 @@ async def load_settings(request: Request) -> GETSettingsModel | JSONResponse:
|
||||
|
||||
settings_with_token_data = GETSettingsModel(
|
||||
**settings.model_dump(exclude='secrets_store'),
|
||||
llm_api_key_set=settings.llm_api_key is not None,
|
||||
llm_api_key_set=settings.llm_api_key is not None and bool(settings.llm_api_key),
|
||||
provider_tokens_set=provider_tokens_set,
|
||||
)
|
||||
settings_with_token_data.llm_api_key = None
|
||||
@@ -63,12 +74,9 @@ async def load_settings(request: Request) -> GETSettingsModel | JSONResponse:
|
||||
|
||||
@app.get('/secrets', response_model=GETSettingsCustomSecrets)
|
||||
async def load_custom_secrets_names(
|
||||
request: Request,
|
||||
settings: Settings | None = Depends(get_user_settings),
|
||||
) -> GETSettingsCustomSecrets | JSONResponse:
|
||||
try:
|
||||
user_id = get_user_id(request)
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
if not settings:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -93,13 +101,11 @@ async def load_custom_secrets_names(
|
||||
|
||||
@app.post('/secrets', response_model=dict[str, str])
|
||||
async def add_custom_secret(
|
||||
request: Request, incoming_secrets: POSTSettingsCustomSecrets
|
||||
incoming_secrets: POSTSettingsCustomSecrets,
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
existing_settings: Settings = await settings_store.load()
|
||||
existing_settings = await settings_store.load()
|
||||
if existing_settings:
|
||||
for (
|
||||
secret_name,
|
||||
@@ -121,7 +127,6 @@ async def add_custom_secret(
|
||||
update={'secrets_store': updated_secret_store}
|
||||
)
|
||||
|
||||
updated_settings = convert_to_settings(updated_settings)
|
||||
await settings_store.store(updated_settings)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -137,11 +142,11 @@ async def add_custom_secret(
|
||||
|
||||
|
||||
@app.delete('/secrets/{secret_id}')
|
||||
async def delete_custom_secret(request: Request, secret_id: str) -> JSONResponse:
|
||||
async def delete_custom_secret(
|
||||
secret_id: str,
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
existing_settings: Settings | None = await settings_store.load()
|
||||
custom_secrets = {}
|
||||
if existing_settings:
|
||||
@@ -162,7 +167,6 @@ async def delete_custom_secret(request: Request, secret_id: str) -> JSONResponse
|
||||
update={'secrets_store': updated_secret_store}
|
||||
)
|
||||
|
||||
updated_settings = convert_to_settings(updated_settings)
|
||||
await settings_store.store(updated_settings)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -178,12 +182,10 @@ async def delete_custom_secret(request: Request, secret_id: str) -> JSONResponse
|
||||
|
||||
|
||||
@app.post('/unset-settings-tokens', response_model=dict[str, str])
|
||||
async def unset_settings_tokens(request: Request) -> JSONResponse:
|
||||
async def unset_settings_tokens(
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
|
||||
existing_settings = await settings_store.load()
|
||||
if existing_settings:
|
||||
settings = existing_settings.model_copy(
|
||||
@@ -205,58 +207,20 @@ async def unset_settings_tokens(request: Request) -> JSONResponse:
|
||||
|
||||
|
||||
@app.post('/reset-settings', response_model=dict[str, str])
|
||||
async def reset_settings(request: Request) -> JSONResponse:
|
||||
async def reset_settings() -> JSONResponse:
|
||||
"""
|
||||
Resets user settings.
|
||||
Resets user settings. (Deprecated)
|
||||
"""
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
|
||||
existing_settings = await settings_store.load()
|
||||
settings = Settings(
|
||||
language='en',
|
||||
agent='CodeActAgent',
|
||||
security_analyzer='',
|
||||
confirmation_mode=False,
|
||||
llm_model='anthropic/claude-3-5-sonnet-20241022',
|
||||
llm_api_key='',
|
||||
llm_base_url='',
|
||||
remote_runtime_resource_factor=1,
|
||||
enable_default_condenser=True,
|
||||
enable_sound_notifications=False,
|
||||
user_consents_to_analytics=existing_settings.user_consents_to_analytics
|
||||
if existing_settings
|
||||
else False,
|
||||
)
|
||||
|
||||
server_config_values = server_config.get_config()
|
||||
is_hide_llm_settings_enabled = server_config_values.get(
|
||||
'FEATURE_FLAGS', {}
|
||||
).get('HIDE_LLM_SETTINGS', False)
|
||||
# We don't want the user to be able to modify these settings in SaaS
|
||||
if server_config.app_mode == AppMode.SAAS and is_hide_llm_settings_enabled:
|
||||
if existing_settings:
|
||||
settings.llm_api_key = existing_settings.llm_api_key
|
||||
settings.llm_base_url = existing_settings.llm_base_url
|
||||
settings.llm_model = existing_settings.llm_model
|
||||
|
||||
await settings_store.store(settings)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Settings stored'},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Something went wrong resetting settings: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': 'Something went wrong resetting settings'},
|
||||
)
|
||||
logger.warning(
|
||||
f"Deprecated endpoint /api/reset-settings called by user"
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
content={'error': 'Reset settings functionality has been removed.'},
|
||||
)
|
||||
|
||||
|
||||
async def check_provider_tokens(request: Request, settings: POSTSettingsModel) -> str:
|
||||
async def check_provider_tokens(settings: POSTSettingsModel) -> str:
|
||||
if settings.provider_tokens:
|
||||
# Remove extraneous token types
|
||||
provider_types = [provider.value for provider in ProviderType]
|
||||
@@ -276,8 +240,9 @@ async def check_provider_tokens(request: Request, settings: POSTSettingsModel) -
|
||||
return ''
|
||||
|
||||
|
||||
async def store_provider_tokens(request: Request, settings: POSTSettingsModel):
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, get_user_id(request))
|
||||
async def store_provider_tokens(
|
||||
settings: POSTSettingsModel, settings_store: SettingsStore
|
||||
):
|
||||
existing_settings = await settings_store.load()
|
||||
if existing_settings:
|
||||
if settings.provider_tokens:
|
||||
@@ -311,9 +276,8 @@ async def store_provider_tokens(request: Request, settings: POSTSettingsModel):
|
||||
|
||||
|
||||
async def store_llm_settings(
|
||||
request: Request, settings: POSTSettingsModel
|
||||
settings: POSTSettingsModel, settings_store: SettingsStore
|
||||
) -> POSTSettingsModel:
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, get_user_id(request))
|
||||
existing_settings = await settings_store.load()
|
||||
|
||||
# Convert to Settings model and merge with existing settings
|
||||
@@ -331,11 +295,11 @@ async def store_llm_settings(
|
||||
|
||||
@app.post('/settings', response_model=dict[str, str])
|
||||
async def store_settings(
|
||||
request: Request,
|
||||
settings: POSTSettingsModel,
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
) -> JSONResponse:
|
||||
# Check provider tokens are valid
|
||||
provider_err_msg = await check_provider_tokens(request, settings)
|
||||
provider_err_msg = await check_provider_tokens(settings)
|
||||
if provider_err_msg:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -343,14 +307,11 @@ async def store_settings(
|
||||
)
|
||||
|
||||
try:
|
||||
settings_store = await SettingsStoreImpl.get_instance(
|
||||
config, get_user_id(request)
|
||||
)
|
||||
existing_settings = await settings_store.load()
|
||||
|
||||
# Convert to Settings model and merge with existing settings
|
||||
if existing_settings:
|
||||
settings = await store_llm_settings(request, settings)
|
||||
settings = await store_llm_settings(settings, settings_store)
|
||||
|
||||
# Keep existing analytics consent if not provided
|
||||
if settings.user_consents_to_analytics is None:
|
||||
@@ -358,7 +319,7 @@ async def store_settings(
|
||||
existing_settings.user_consents_to_analytics
|
||||
)
|
||||
|
||||
settings = await store_provider_tokens(request, settings)
|
||||
settings = await store_provider_tokens(settings, settings_store)
|
||||
|
||||
# Update sandbox config with new settings
|
||||
if settings.remote_runtime_resource_factor is not None:
|
||||
|
||||
@@ -94,7 +94,10 @@ class Settings(BaseModel):
|
||||
return {
|
||||
'provider_tokens': secrets.provider_tokens_serializer(
|
||||
secrets.provider_tokens, info
|
||||
)
|
||||
),
|
||||
'custom_secrets': secrets.custom_secrets_serializer(
|
||||
secrets.custom_secrets, info
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
48
openhands/server/user_auth/__init__.py
Normal file
48
openhands/server/user_auth/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from fastapi import Request
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
|
||||
async def get_provider_tokens(request: Request) -> PROVIDER_TOKEN_TYPE | None:
|
||||
user_auth = await get_user_auth(request)
|
||||
provider_tokens = await user_auth.get_provider_tokens()
|
||||
return provider_tokens
|
||||
|
||||
|
||||
async def get_access_token(request: Request) -> SecretStr | None:
|
||||
user_auth = await get_user_auth(request)
|
||||
access_token = await user_auth.get_access_token()
|
||||
return access_token
|
||||
|
||||
|
||||
async def get_user_id(request: Request) -> str | None:
|
||||
user_auth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
return user_id
|
||||
|
||||
|
||||
async def get_github_user_id(request: Request) -> str | None:
|
||||
provider_tokens = await get_provider_tokens(request)
|
||||
if not provider_tokens:
|
||||
return None
|
||||
github_provider = provider_tokens.get(ProviderType.GITHUB)
|
||||
if github_provider:
|
||||
return github_provider.user_id
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_settings(request: Request) -> Settings | None:
|
||||
user_auth = await get_user_auth(request)
|
||||
user_settings = await user_auth.get_user_settings()
|
||||
return user_settings
|
||||
|
||||
|
||||
async def get_user_settings_store(request: Request) -> SettingsStore | None:
|
||||
user_auth = await get_user_auth(request)
|
||||
user_settings_store = await user_auth.get_user_settings_store()
|
||||
return user_settings_store
|
||||
57
openhands/server/user_auth/default_user_auth.py
Normal file
57
openhands/server/user_auth/default_user_auth.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.server import shared
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
|
||||
@dataclass
|
||||
class DefaultUserAuth(UserAuth):
|
||||
"""Default user authentication mechanism"""
|
||||
|
||||
_settings: Settings | None = None
|
||||
_settings_store: SettingsStore | None = None
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
"""The default implementation does not support multi tenancy, so user_id is always None"""
|
||||
return None
|
||||
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
"""The default implementation does not support multi tenancy, so access_token is always None"""
|
||||
return None
|
||||
|
||||
async def get_user_settings_store(self):
|
||||
settings_store = self._settings_store
|
||||
if settings_store:
|
||||
return settings_store
|
||||
user_id = await self.get_user_id()
|
||||
settings_store = await shared.SettingsStoreImpl.get_instance(
|
||||
shared.config, user_id
|
||||
)
|
||||
self._settings_store = settings_store
|
||||
return settings_store
|
||||
|
||||
async def get_user_settings(self) -> Settings | None:
|
||||
settings = self._settings
|
||||
if settings:
|
||||
return settings
|
||||
settings_store = await self.get_user_settings_store()
|
||||
settings = await settings_store.load()
|
||||
self._settings = settings
|
||||
return settings
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
settings = await self.get_user_settings()
|
||||
secrets_store = getattr(settings, 'secrets_store', None)
|
||||
provider_tokens = getattr(secrets_store, 'provider_tokens', None)
|
||||
return provider_tokens
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls, request: Request) -> UserAuth:
|
||||
user_auth = DefaultUserAuth()
|
||||
return user_auth
|
||||
63
openhands/server/user_auth/user_auth.py
Normal file
63
openhands/server/user_auth/user_auth.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class UserAuth(ABC):
|
||||
"""Extensible class encapsulating user Authentication"""
|
||||
|
||||
_settings: Settings | None
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_id(self) -> str | None:
|
||||
"""Get the unique identifier for the current user"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
"""Get the access token for the current user"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
"""Get the provider tokens for the current user."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_settings_store(self) -> SettingsStore | None:
|
||||
"""Get the settings store for the current user."""
|
||||
|
||||
async def get_user_settings(self) -> Settings | None:
|
||||
"""Get the user settings for the current user"""
|
||||
settings = self._settings
|
||||
if settings:
|
||||
return settings
|
||||
settings_store = await self.get_user_settings_store()
|
||||
if settings_store is None:
|
||||
return None
|
||||
settings = await settings_store.load()
|
||||
self._settings = settings
|
||||
return settings
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
async def get_instance(cls, request: Request) -> UserAuth:
|
||||
"""Get an instance of UserAuth from the request given"""
|
||||
|
||||
|
||||
async def get_user_auth(request: Request) -> UserAuth:
|
||||
user_auth = getattr(request.state, 'user_auth', None)
|
||||
if user_auth:
|
||||
return user_auth
|
||||
impl_name = server_config.user_auth_class
|
||||
impl = get_impl(UserAuth, impl_name)
|
||||
user_auth = await impl.get_instance(request)
|
||||
request.state.user_auth = user_auth
|
||||
return user_auth
|
||||
16
openhands/server/utils.py
Normal file
16
openhands/server/utils.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from fastapi import Request
|
||||
|
||||
from openhands.server.shared import ConversationStoreImpl, config
|
||||
from openhands.server.user_auth import get_user_auth
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
|
||||
|
||||
async def get_conversation_store(request: Request) -> ConversationStore | None:
|
||||
conversation_store = getattr(request.state, 'conversation_store', None)
|
||||
if conversation_store:
|
||||
return conversation_store
|
||||
user_auth = await get_user_auth(request)
|
||||
user_id = await user_auth.get_user_id()
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
request.state.conversation_store = conversation_store
|
||||
return conversation_store
|
||||
@@ -22,15 +22,10 @@ class ConversationStore(ABC):
|
||||
async def get_metadata(self, conversation_id: str) -> ConversationMetadata:
|
||||
"""Load conversation metadata."""
|
||||
|
||||
async def validate_metadata(
|
||||
self, conversation_id: str, user_id: str, github_user_id: str
|
||||
) -> bool:
|
||||
async def validate_metadata(self, conversation_id: str, user_id: str) -> bool:
|
||||
"""Validate that conversation belongs to the current user."""
|
||||
# TODO: remove github_user_id after transition to Keycloak is complete.
|
||||
metadata = await self.get_metadata(conversation_id)
|
||||
if (not metadata.user_id and not metadata.github_user_id) or (
|
||||
metadata.user_id != user_id and metadata.github_user_id != github_user_id
|
||||
):
|
||||
if not metadata.user_id or metadata.user_id != user_id:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
@@ -60,6 +55,6 @@ class ConversationStore(ABC):
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
async def get_instance(
|
||||
cls, config: AppConfig, user_id: str | None, github_user_id: str | None
|
||||
cls, config: AppConfig, user_id: str | None
|
||||
) -> ConversationStore:
|
||||
"""Get a store for the user represented by the token given."""
|
||||
|
||||
@@ -101,7 +101,7 @@ class FileConversationStore(ConversationStore):
|
||||
|
||||
@classmethod
|
||||
async def get_instance(
|
||||
cls, config: AppConfig, user_id: str | None, github_user_id: str | None
|
||||
cls, config: AppConfig, user_id: str | None
|
||||
) -> FileConversationStore:
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
return FileConversationStore(file_store)
|
||||
|
||||
1068
poetry.lock
generated
1068
poetry.lock
generated
File diff suppressed because one or more lines are too long
@@ -71,7 +71,7 @@ stripe = ">=11.5,<13.0"
|
||||
ipywidgets = "^8.1.5"
|
||||
qtconsole = "^5.6.1"
|
||||
memory-profiler = "^0.61.0"
|
||||
daytona-sdk = "0.14.0"
|
||||
daytona-sdk = "0.15.0"
|
||||
mcp = "1.6.0"
|
||||
python-json-logger = "^3.2.1"
|
||||
playwright = "^1.51.0"
|
||||
|
||||
@@ -13,12 +13,13 @@ from openhands.resolver.send_pull_request import (
|
||||
apply_patch,
|
||||
initialize_repo,
|
||||
load_single_resolver_output,
|
||||
main,
|
||||
make_commit,
|
||||
process_single_issue,
|
||||
send_pull_request,
|
||||
update_existing_pull_request,
|
||||
)
|
||||
from openhands.resolver.send_pull_request import main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_output_dir():
|
||||
@@ -1104,7 +1105,6 @@ def test_main(
|
||||
mock_process_single_issue,
|
||||
mock_parser,
|
||||
):
|
||||
|
||||
# Setup mock parser
|
||||
mock_args = MagicMock()
|
||||
mock_args.token = None
|
||||
@@ -1171,7 +1171,6 @@ def test_main(
|
||||
mock_path_exists.assert_called_with('/mock/output')
|
||||
mock_load_single_resolver_output.assert_called_with('/mock/output/output.jsonl', 42)
|
||||
|
||||
|
||||
# Test for invalid issue number
|
||||
mock_args.issue_number = 'invalid'
|
||||
with pytest.raises(ValueError):
|
||||
|
||||
@@ -14,12 +14,12 @@ from openhands.resolver.send_pull_request import (
|
||||
apply_patch,
|
||||
initialize_repo,
|
||||
load_single_resolver_output,
|
||||
main,
|
||||
make_commit,
|
||||
process_single_issue,
|
||||
send_pull_request,
|
||||
update_existing_pull_request,
|
||||
)
|
||||
from openhands.resolver.send_pull_request import main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -1007,7 +1007,6 @@ def test_main(
|
||||
mock_process_single_issue,
|
||||
mock_parser,
|
||||
):
|
||||
|
||||
# Setup mock parser
|
||||
mock_args = MagicMock()
|
||||
mock_args.token = None
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
@@ -16,6 +15,7 @@ from openhands.server.routes.manage_conversations import (
|
||||
search_conversations,
|
||||
update_conversation,
|
||||
)
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.storage.locations import get_conversation_metadata_filename
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
@@ -72,9 +72,36 @@ async def test_search_conversations():
|
||||
)
|
||||
mock_datetime.fromisoformat = datetime.fromisoformat
|
||||
mock_datetime.timezone = timezone
|
||||
result_set = await search_conversations(
|
||||
MagicMock(state=MagicMock(github_token=''))
|
||||
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.search = AsyncMock(
|
||||
return_value=ConversationInfoResultSet(
|
||||
results=[
|
||||
ConversationMetadata(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
),
|
||||
last_updated_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:01:00+00:00'
|
||||
),
|
||||
selected_repository='foobar',
|
||||
github_user_id='12345',
|
||||
user_id='12345',
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
result_set = await search_conversations(
|
||||
page_id=None,
|
||||
limit=20,
|
||||
user_id='12345',
|
||||
conversation_store=mock_store,
|
||||
)
|
||||
|
||||
expected = ConversationInfoResultSet(
|
||||
results=[
|
||||
ConversationInfo(
|
||||
@@ -97,26 +124,51 @@ async def test_search_conversations():
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_conversation():
|
||||
with _patch_store():
|
||||
conversation = await get_conversation(
|
||||
'some_conversation_id', MagicMock(state=MagicMock(github_token=''))
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_metadata = AsyncMock(
|
||||
return_value=ConversationMetadata(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
selected_repository='foobar',
|
||||
github_user_id='12345',
|
||||
user_id='12345',
|
||||
)
|
||||
)
|
||||
expected = ConversationInfo(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
status=ConversationStatus.STOPPED,
|
||||
selected_repository='foobar',
|
||||
)
|
||||
assert conversation == expected
|
||||
|
||||
# Mock the conversation manager
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
|
||||
|
||||
conversation = await get_conversation(
|
||||
'some_conversation_id', conversation_store=mock_store
|
||||
)
|
||||
|
||||
expected = ConversationInfo(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
status=ConversationStatus.STOPPED,
|
||||
selected_repository='foobar',
|
||||
)
|
||||
assert conversation == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_missing_conversation():
|
||||
with _patch_store():
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_metadata = AsyncMock(side_effect=FileNotFoundError)
|
||||
|
||||
assert (
|
||||
await get_conversation(
|
||||
'no_such_conversation', MagicMock(state=MagicMock(github_token=''))
|
||||
'no_such_conversation', conversation_store=mock_store
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -125,34 +177,102 @@ async def test_get_missing_conversation():
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_conversation():
|
||||
with _patch_store():
|
||||
await update_conversation(
|
||||
MagicMock(state=MagicMock(github_token='')),
|
||||
'some_conversation_id',
|
||||
'New Title',
|
||||
)
|
||||
conversation = await get_conversation(
|
||||
'some_conversation_id', MagicMock(state=MagicMock(github_token=''))
|
||||
)
|
||||
expected = ConversationInfo(
|
||||
conversation_id='some_conversation_id',
|
||||
title='New Title',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
status=ConversationStatus.STOPPED,
|
||||
selected_repository='foobar',
|
||||
)
|
||||
assert conversation == expected
|
||||
# Mock the ConversationStoreImpl.get_instance
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.ConversationStoreImpl.get_instance'
|
||||
) as mock_get_instance:
|
||||
# Create a mock conversation store
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Mock metadata
|
||||
metadata = ConversationMetadata(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
selected_repository='foobar',
|
||||
github_user_id='12345',
|
||||
user_id='12345',
|
||||
)
|
||||
|
||||
# Set up the mock to return metadata and then save it
|
||||
mock_store.get_metadata = AsyncMock(return_value=metadata)
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
|
||||
# Return the mock store from get_instance
|
||||
mock_get_instance.return_value = mock_store
|
||||
|
||||
# Call update_conversation
|
||||
result = await update_conversation(
|
||||
'some_conversation_id',
|
||||
'New Title',
|
||||
user_id='12345',
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is True
|
||||
|
||||
# Verify that save_metadata was called with updated metadata
|
||||
mock_store.save_metadata.assert_called_once()
|
||||
saved_metadata = mock_store.save_metadata.call_args[0][0]
|
||||
assert saved_metadata.title == 'New Title'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_conversation():
|
||||
with _patch_store():
|
||||
with patch.object(DockerRuntime, 'delete', return_value=None):
|
||||
await delete_conversation(
|
||||
'some_conversation_id',
|
||||
MagicMock(state=MagicMock(github_token='')),
|
||||
# Mock the ConversationStoreImpl.get_instance
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.ConversationStoreImpl.get_instance'
|
||||
) as mock_get_instance:
|
||||
# Create a mock conversation store
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Set up the mock to return metadata and then delete it
|
||||
mock_store.get_metadata = AsyncMock(
|
||||
return_value=ConversationMetadata(
|
||||
conversation_id='some_conversation_id',
|
||||
title='Some Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
selected_repository='foobar',
|
||||
github_user_id='12345',
|
||||
user_id='12345',
|
||||
)
|
||||
)
|
||||
conversation = await get_conversation(
|
||||
'some_conversation_id', MagicMock(state=MagicMock(github_token=''))
|
||||
)
|
||||
assert conversation is None
|
||||
mock_store.delete_metadata = AsyncMock()
|
||||
|
||||
# Return the mock store from get_instance
|
||||
mock_get_instance.return_value = mock_store
|
||||
|
||||
# Mock the conversation manager
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
|
||||
|
||||
# Mock the runtime class
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.get_runtime_cls'
|
||||
) as mock_get_runtime_cls:
|
||||
mock_runtime_cls = MagicMock()
|
||||
mock_runtime_cls.delete = AsyncMock()
|
||||
mock_get_runtime_cls.return_value = mock_runtime_cls
|
||||
|
||||
# Call delete_conversation
|
||||
result = await delete_conversation(
|
||||
'some_conversation_id', user_id='12345'
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is True
|
||||
|
||||
# Verify that delete_metadata was called
|
||||
mock_store.delete_metadata.assert_called_once_with(
|
||||
'some_conversation_id'
|
||||
)
|
||||
|
||||
# Verify that runtime.delete was called
|
||||
mock_runtime_cls.delete.assert_called_once_with(
|
||||
'some_conversation_id'
|
||||
)
|
||||
|
||||
363
tests/unit/test_git_handler.py
Normal file
363
tests/unit/test_git_handler.py
Normal file
@@ -0,0 +1,363 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
|
||||
|
||||
|
||||
class TestGitHandler(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Create temporary directories for our test repositories
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.origin_dir = os.path.join(self.test_dir, 'origin')
|
||||
self.local_dir = os.path.join(self.test_dir, 'local')
|
||||
|
||||
# Create the directories
|
||||
os.makedirs(self.origin_dir, exist_ok=True)
|
||||
os.makedirs(self.local_dir, exist_ok=True)
|
||||
|
||||
# Track executed commands for verification
|
||||
self.executed_commands = []
|
||||
|
||||
# Initialize the GitHandler with our real execute function
|
||||
self.git_handler = GitHandler(self._execute_command)
|
||||
self.git_handler.set_cwd(self.local_dir)
|
||||
|
||||
# Set up the git repositories
|
||||
self._setup_git_repos()
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up the temporary directories
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def _execute_command(self, cmd, cwd=None):
|
||||
"""Execute a shell command and return the result."""
|
||||
self.executed_commands.append((cmd, cwd))
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False
|
||||
)
|
||||
return CommandResult(result.stdout, result.returncode)
|
||||
except Exception as e:
|
||||
return CommandResult(str(e), 1)
|
||||
|
||||
def _setup_git_repos(self):
|
||||
"""Set up real git repositories for testing."""
|
||||
# Set up origin repository
|
||||
self._execute_command('git init --initial-branch=main', self.origin_dir)
|
||||
self._execute_command(
|
||||
"git config user.email 'test@example.com'", self.origin_dir
|
||||
)
|
||||
self._execute_command("git config user.name 'Test User'", self.origin_dir)
|
||||
|
||||
# Create a file and commit it
|
||||
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Original content')
|
||||
|
||||
self._execute_command('git add file1.txt', self.origin_dir)
|
||||
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
|
||||
|
||||
# Clone the origin repository to local
|
||||
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
|
||||
self._execute_command(
|
||||
"git config user.email 'test@example.com'", self.local_dir
|
||||
)
|
||||
self._execute_command("git config user.name 'Test User'", self.local_dir)
|
||||
|
||||
# Create a feature branch in the local repository
|
||||
self._execute_command('git checkout -b feature-branch', self.local_dir)
|
||||
|
||||
# Modify a file and create a new file
|
||||
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Modified content')
|
||||
|
||||
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
|
||||
f.write('New file content')
|
||||
|
||||
# Add and commit file1.txt changes to create a baseline
|
||||
self._execute_command('git add file1.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Update file1.txt'", self.local_dir)
|
||||
|
||||
# Add and commit file2.txt, then modify it
|
||||
self._execute_command('git add file2.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Add file2.txt'", self.local_dir)
|
||||
|
||||
# Modify file2.txt and stage it
|
||||
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
|
||||
f.write('Modified new file content')
|
||||
self._execute_command('git add file2.txt', self.local_dir)
|
||||
|
||||
# Create a file that will be deleted
|
||||
with open(os.path.join(self.local_dir, 'file3.txt'), 'w') as f:
|
||||
f.write('File to be deleted')
|
||||
|
||||
self._execute_command('git add file3.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Add file3.txt'", self.local_dir)
|
||||
self._execute_command('git rm file3.txt', self.local_dir)
|
||||
|
||||
# Modify file1.txt again but don't stage it (unstaged change)
|
||||
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Modified content again')
|
||||
|
||||
# Push the feature branch to origin
|
||||
self._execute_command('git push -u origin feature-branch', self.local_dir)
|
||||
|
||||
def test_is_git_repo(self):
|
||||
"""Test that _is_git_repo returns True for a git repository."""
|
||||
self.assertTrue(self.git_handler._is_git_repo())
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd == 'git rev-parse --is-inside-work-tree'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
def test_get_default_branch(self):
|
||||
"""Test that _get_default_branch returns the correct branch name."""
|
||||
branch = self.git_handler._get_default_branch()
|
||||
self.assertEqual(branch, 'main')
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd == 'git remote show origin | grep "HEAD branch"'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
def test_get_current_branch(self):
|
||||
"""Test that _get_current_branch returns the correct branch name."""
|
||||
branch = self.git_handler._get_current_branch()
|
||||
self.assertEqual(branch, 'feature-branch')
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd == 'git rev-parse --abbrev-ref HEAD'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
def test_get_valid_ref_with_origin_current_branch(self):
|
||||
"""Test that _get_valid_ref returns the current branch in origin when it exists."""
|
||||
# This test uses the setup from setUp where the current branch exists in origin
|
||||
ref = self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Check that the refs were checked in the correct order
|
||||
verify_commands = [
|
||||
cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
if cmd.startswith('git rev-parse --verify')
|
||||
]
|
||||
|
||||
# First should check origin/feature-branch (current branch)
|
||||
self.assertTrue(any('origin/feature-branch' in cmd for cmd in verify_commands))
|
||||
|
||||
# Should have found a valid ref (origin/feature-branch)
|
||||
self.assertEqual(ref, 'origin/feature-branch')
|
||||
|
||||
# Verify the ref exists
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
def test_get_valid_ref_without_origin_current_branch(self):
|
||||
"""Test that _get_valid_ref falls back to default branch when current branch doesn't exist in origin."""
|
||||
# Create a new branch that doesn't exist in origin
|
||||
self._execute_command('git checkout -b new-local-branch', self.local_dir)
|
||||
|
||||
# Clear the executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
|
||||
ref = self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Check that the refs were checked in the correct order
|
||||
verify_commands = [
|
||||
cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
if cmd.startswith('git rev-parse --verify')
|
||||
]
|
||||
|
||||
# Should have tried origin/new-local-branch first (which doesn't exist)
|
||||
self.assertTrue(
|
||||
any('origin/new-local-branch' in cmd for cmd in verify_commands)
|
||||
)
|
||||
|
||||
# Should have found a valid ref (origin/main or merge-base)
|
||||
self.assertNotEqual(ref, 'origin/new-local-branch')
|
||||
self.assertTrue(ref == 'origin/main' or 'merge-base' in ref)
|
||||
|
||||
# Verify the ref exists
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
def test_get_valid_ref_without_origin(self):
|
||||
"""Test that _get_valid_ref falls back to empty tree ref when there's no origin."""
|
||||
# Create a new directory with a git repo but no origin
|
||||
no_origin_dir = os.path.join(self.test_dir, 'no-origin')
|
||||
os.makedirs(no_origin_dir, exist_ok=True)
|
||||
|
||||
# Initialize git repo without origin
|
||||
self._execute_command('git init', no_origin_dir)
|
||||
self._execute_command("git config user.email 'test@example.com'", no_origin_dir)
|
||||
self._execute_command("git config user.name 'Test User'", no_origin_dir)
|
||||
|
||||
# Create a file and commit it
|
||||
with open(os.path.join(no_origin_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Content in repo without origin')
|
||||
self._execute_command('git add file1.txt', no_origin_dir)
|
||||
self._execute_command("git commit -m 'Initial commit'", no_origin_dir)
|
||||
|
||||
# Create a custom GitHandler with a modified _get_default_branch method for this test
|
||||
class TestGitHandler(GitHandler):
|
||||
def _get_default_branch(self) -> str:
|
||||
# Override to handle repos without origin
|
||||
try:
|
||||
return super()._get_default_branch()
|
||||
except IndexError:
|
||||
return 'main' # Default fallback
|
||||
|
||||
# Create a new GitHandler for this repo
|
||||
no_origin_handler = TestGitHandler(self._execute_command)
|
||||
no_origin_handler.set_cwd(no_origin_dir)
|
||||
|
||||
# Clear the executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
|
||||
ref = no_origin_handler._get_valid_ref()
|
||||
|
||||
# Verify that git commands were executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd.startswith('git rev-parse --verify')
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
# Should have fallen back to the empty tree ref
|
||||
self.assertEqual(
|
||||
ref, '$(git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
|
||||
)
|
||||
|
||||
# Verify the ref exists (the empty tree ref always exists)
|
||||
result = self._execute_command(
|
||||
'git rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904',
|
||||
no_origin_dir,
|
||||
)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
def test_get_ref_content(self):
|
||||
"""Test that _get_ref_content returns the content from a valid ref."""
|
||||
content = self.git_handler._get_ref_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Modified content')
|
||||
|
||||
# Should have called _get_valid_ref and then git show
|
||||
show_commands = [
|
||||
cmd for cmd, _ in self.executed_commands if cmd.startswith('git show')
|
||||
]
|
||||
self.assertTrue(any('file1.txt' in cmd for cmd in show_commands))
|
||||
|
||||
def test_get_current_file_content(self):
|
||||
"""Test that _get_current_file_content returns the current content of a file."""
|
||||
content = self.git_handler._get_current_file_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Modified content again')
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(cmd == 'cat file1.txt' for cmd, _ in self.executed_commands)
|
||||
)
|
||||
|
||||
def test_get_changed_files(self):
|
||||
"""Test that _get_changed_files returns the list of changed files."""
|
||||
# Let's create a new file to ensure it shows up in the diff
|
||||
with open(os.path.join(self.local_dir, 'new_file.txt'), 'w') as f:
|
||||
f.write('New file content')
|
||||
self._execute_command('git add new_file.txt', self.local_dir)
|
||||
|
||||
files = self.git_handler._get_changed_files()
|
||||
self.assertTrue(files)
|
||||
|
||||
# Should include file1.txt (modified) and file3.txt (deleted)
|
||||
file_paths = [line.split('\t')[-1] for line in files if '\t' in line]
|
||||
self.assertIn('file1.txt', file_paths)
|
||||
self.assertIn('file3.txt', file_paths)
|
||||
# Also check for the new file
|
||||
self.assertIn('new_file.txt', file_paths)
|
||||
|
||||
# Should have called _get_valid_ref and then git diff
|
||||
diff_commands = [
|
||||
cmd for cmd, _ in self.executed_commands if cmd.startswith('git diff')
|
||||
]
|
||||
self.assertTrue(diff_commands)
|
||||
|
||||
def test_get_untracked_files(self):
|
||||
"""Test that _get_untracked_files returns the list of untracked files."""
|
||||
# Create an untracked file
|
||||
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
|
||||
f.write('Untracked file content')
|
||||
|
||||
files = self.git_handler._get_untracked_files()
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertEqual(files[0]['path'], 'untracked.txt')
|
||||
self.assertEqual(files[0]['status'], 'A')
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
any(
|
||||
cmd == 'git ls-files --others --exclude-standard'
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
def test_get_git_changes(self):
|
||||
"""Test that get_git_changes returns the combined list of changed and untracked files."""
|
||||
# Create an untracked file
|
||||
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
|
||||
f.write('Untracked file content')
|
||||
|
||||
# Create a new file and stage it
|
||||
with open(os.path.join(self.local_dir, 'new_file2.txt'), 'w') as f:
|
||||
f.write('New file 2 content')
|
||||
self._execute_command('git add new_file2.txt', self.local_dir)
|
||||
|
||||
changes = self.git_handler.get_git_changes()
|
||||
self.assertIsNotNone(changes)
|
||||
|
||||
# Should include file1.txt (modified), file3.txt (deleted), new_file2.txt (added), and untracked.txt (untracked)
|
||||
paths = [change['path'] for change in changes]
|
||||
self.assertIn('file1.txt', paths)
|
||||
self.assertIn('file3.txt', paths)
|
||||
self.assertIn('new_file2.txt', paths)
|
||||
self.assertIn('untracked.txt', paths)
|
||||
|
||||
# Check that the changes include both changed and untracked files
|
||||
statuses = [change['status'] for change in changes]
|
||||
self.assertIn('M', statuses) # Modified
|
||||
self.assertIn('A', statuses) # Added
|
||||
self.assertIn('D', statuses) # Deleted
|
||||
|
||||
def test_get_git_diff(self):
|
||||
"""Test that get_git_diff returns the original and modified content of a file."""
|
||||
diff = self.git_handler.get_git_diff('file1.txt')
|
||||
self.assertEqual(diff['modified'].strip(), 'Modified content again')
|
||||
self.assertEqual(diff['original'].strip(), 'Modified content')
|
||||
|
||||
# Should have called _get_current_file_content and _get_ref_content
|
||||
self.assertTrue(
|
||||
any('cat file1.txt' in cmd for cmd, _ in self.executed_commands)
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
'git show' in cmd and 'file1.txt' in cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
167
tests/unit/test_git_handler_real.py
Normal file
167
tests/unit/test_git_handler_real.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
|
||||
|
||||
|
||||
class TestGitHandlerWithRealRepo(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Create temporary directories for our test repositories
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.origin_dir = os.path.join(self.test_dir, 'origin')
|
||||
self.local_dir = os.path.join(self.test_dir, 'local')
|
||||
|
||||
# Create the directories
|
||||
os.makedirs(self.origin_dir, exist_ok=True)
|
||||
os.makedirs(self.local_dir, exist_ok=True)
|
||||
|
||||
# Set up the git repositories
|
||||
self._setup_git_repos()
|
||||
|
||||
# Initialize the GitHandler with a real execute function
|
||||
self.git_handler = GitHandler(self._execute_command)
|
||||
self.git_handler.set_cwd(self.local_dir)
|
||||
|
||||
def tearDown(self):
|
||||
# Clean up the temporary directories
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def _execute_command(self, cmd, cwd=None):
|
||||
"""Execute a shell command and return the result."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, cwd=cwd, capture_output=True, text=True, check=False
|
||||
)
|
||||
return CommandResult(result.stdout, result.returncode)
|
||||
except Exception as e:
|
||||
return CommandResult(str(e), 1)
|
||||
|
||||
def _setup_git_repos(self):
|
||||
"""Set up real git repositories for testing."""
|
||||
# Set up origin repository
|
||||
self._execute_command('git init --initial-branch=main', self.origin_dir)
|
||||
self._execute_command(
|
||||
"git config user.email 'test@example.com'", self.origin_dir
|
||||
)
|
||||
self._execute_command("git config user.name 'Test User'", self.origin_dir)
|
||||
|
||||
# Create a file and commit it
|
||||
with open(os.path.join(self.origin_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Original content')
|
||||
|
||||
self._execute_command('git add file1.txt', self.origin_dir)
|
||||
self._execute_command("git commit -m 'Initial commit'", self.origin_dir)
|
||||
|
||||
# Clone the origin repository to local
|
||||
self._execute_command(f'git clone {self.origin_dir} {self.local_dir}')
|
||||
self._execute_command(
|
||||
"git config user.email 'test@example.com'", self.local_dir
|
||||
)
|
||||
self._execute_command("git config user.name 'Test User'", self.local_dir)
|
||||
|
||||
# Create a feature branch in the local repository
|
||||
self._execute_command('git checkout -b feature-branch', self.local_dir)
|
||||
|
||||
# Modify a file and create a new file
|
||||
with open(os.path.join(self.local_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Modified content')
|
||||
|
||||
with open(os.path.join(self.local_dir, 'file2.txt'), 'w') as f:
|
||||
f.write('New file content')
|
||||
|
||||
# Add the new file but don't commit anything yet
|
||||
self._execute_command('git add file2.txt', self.local_dir)
|
||||
|
||||
def test_is_git_repo(self):
|
||||
"""Test that _is_git_repo returns True for a git repository."""
|
||||
self.assertTrue(self.git_handler._is_git_repo())
|
||||
|
||||
def test_get_default_branch(self):
|
||||
"""Test that _get_default_branch returns the correct branch name."""
|
||||
branch = self.git_handler._get_default_branch()
|
||||
self.assertEqual(branch, 'main')
|
||||
|
||||
def test_get_current_branch(self):
|
||||
"""Test that _get_current_branch returns the correct branch name."""
|
||||
branch = self.git_handler._get_current_branch()
|
||||
self.assertEqual(branch, 'feature-branch')
|
||||
|
||||
def test_get_valid_ref(self):
|
||||
"""Test that _get_valid_ref returns a valid ref."""
|
||||
ref = self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Push the feature branch to origin to test the highest priority ref
|
||||
self._execute_command('git push -u origin feature-branch', self.local_dir)
|
||||
|
||||
# Get the valid ref again, should be origin/feature-branch now
|
||||
ref = self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Verify the ref exists
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
def test_get_ref_content(self):
|
||||
"""Test that _get_ref_content returns the content from a valid ref."""
|
||||
# First commit the changes to make sure we have a valid ref
|
||||
self._execute_command('git add file1.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Update file1.txt'", self.local_dir)
|
||||
|
||||
# Get the content of file1.txt from the main branch
|
||||
content = self.git_handler._get_ref_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Original content')
|
||||
|
||||
def test_get_current_file_content(self):
|
||||
"""Test that _get_current_file_content returns the current content of a file."""
|
||||
content = self.git_handler._get_current_file_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Modified content')
|
||||
|
||||
def test_get_changed_files(self):
|
||||
"""Test that _get_changed_files returns the list of changed files."""
|
||||
files = self.git_handler._get_changed_files()
|
||||
self.assertTrue(files)
|
||||
|
||||
# Should include file1.txt (modified) and file2.txt (added)
|
||||
file_paths = [line.split('\t')[-1] for line in files]
|
||||
self.assertIn('file1.txt', file_paths)
|
||||
self.assertIn('file2.txt', file_paths)
|
||||
|
||||
def test_get_untracked_files(self):
|
||||
"""Test that _get_untracked_files returns the list of untracked files."""
|
||||
# Create an untracked file
|
||||
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
|
||||
f.write('Untracked file content')
|
||||
|
||||
files = self.git_handler._get_untracked_files()
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertEqual(files[0]['path'], 'untracked.txt')
|
||||
self.assertEqual(files[0]['status'], 'A')
|
||||
|
||||
def test_get_git_changes(self):
|
||||
"""Test that get_git_changes returns the combined list of changed and untracked files."""
|
||||
# Create an untracked file
|
||||
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
|
||||
f.write('Untracked file content')
|
||||
|
||||
changes = self.git_handler.get_git_changes()
|
||||
self.assertIsNotNone(changes)
|
||||
|
||||
# Should include file1.txt (modified), file2.txt (added), and untracked.txt (untracked)
|
||||
paths = [change['path'] for change in changes]
|
||||
self.assertIn('file1.txt', paths)
|
||||
self.assertIn('file2.txt', paths)
|
||||
self.assertIn('untracked.txt', paths)
|
||||
|
||||
def test_get_git_diff(self):
|
||||
"""Test that get_git_diff returns the original and modified content of a file."""
|
||||
diff = self.git_handler.get_git_diff('file1.txt')
|
||||
self.assertEqual(diff['modified'].strip(), 'Modified content')
|
||||
self.assertEqual(diff['original'].strip(), 'Original content')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for the custom secrets API endpoints."""
|
||||
# flake8: noqa: E501
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
@@ -10,6 +12,8 @@ from pydantic import SecretStr
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType, SecretStore
|
||||
from openhands.server.routes.settings import app as settings_app
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
from openhands.storage.settings.file_settings_store import FileSettingsStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -20,235 +24,128 @@ def test_client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings_store():
|
||||
with patch('openhands.server.routes.settings.SettingsStoreImpl') as mock:
|
||||
store_instance = MagicMock()
|
||||
mock.get_instance = AsyncMock(return_value=store_instance)
|
||||
store_instance.load = AsyncMock()
|
||||
store_instance.store = AsyncMock()
|
||||
yield store_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_convert_to_settings():
|
||||
with patch('openhands.server.routes.settings.convert_to_settings') as mock:
|
||||
# Make the mock function pass through the input settings
|
||||
mock.side_effect = lambda settings: settings
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_user_id():
|
||||
with patch('openhands.server.routes.settings.get_user_id') as mock:
|
||||
mock.return_value = 'test-user'
|
||||
yield mock
|
||||
@contextmanager
|
||||
def patch_file_settings_store():
|
||||
store = FileSettingsStore(InMemoryFileStore())
|
||||
with patch(
|
||||
'openhands.storage.settings.file_settings_store.FileSettingsStore.get_instance',
|
||||
AsyncMock(return_value=store),
|
||||
):
|
||||
yield store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_custom_secrets_names(test_client, mock_settings_store):
|
||||
async def test_load_custom_secrets_names(test_client):
|
||||
"""Test loading custom secrets names."""
|
||||
# Create initial settings with custom secrets
|
||||
custom_secrets = {
|
||||
'API_KEY': SecretStr('api-key-value'),
|
||||
'DB_PASSWORD': SecretStr('db-password-value'),
|
||||
}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Make the GET request
|
||||
response = test_client.get('/api/secrets')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check the response
|
||||
data = response.json()
|
||||
assert 'custom_secrets' in data
|
||||
assert sorted(data['custom_secrets']) == ['API_KEY', 'DB_PASSWORD']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_custom_secrets_names_empty(test_client, mock_settings_store):
|
||||
"""Test loading custom secrets names when there are no custom secrets."""
|
||||
# Create initial settings with no custom secrets
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(provider_tokens=provider_tokens)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Make the GET request
|
||||
response = test_client.get('/api/secrets')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check the response
|
||||
data = response.json()
|
||||
assert 'custom_secrets' in data
|
||||
assert data['custom_secrets'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_custom_secret(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
"""Test adding a new custom secret."""
|
||||
# Create initial settings with provider tokens but no custom secrets
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(provider_tokens=provider_tokens)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Make the POST request to add a custom secret
|
||||
add_secret_data = {'custom_secrets': {'API_KEY': 'api-key-value'}}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored with the new secret
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
|
||||
# Check that the secret was added
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_existing_custom_secret(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
"""Test updating an existing custom secret."""
|
||||
# Create initial settings with a custom secret
|
||||
custom_secrets = {'API_KEY': SecretStr('old-api-key')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Make the POST request to update the custom secret
|
||||
update_secret_data = {'custom_secrets': {'API_KEY': 'new-api-key'}}
|
||||
response = test_client.post('/api/secrets', json=update_secret_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored with the updated secret
|
||||
stored_settings: Settings = mock_settings_store.store.call_args[0][0]
|
||||
|
||||
# Check that the secret was updated
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'new-api-key'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_multiple_custom_secrets(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
"""Test adding multiple custom secrets at once."""
|
||||
# Create initial settings with one custom secret
|
||||
custom_secrets = {'EXISTING_SECRET': SecretStr('existing-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Make the POST request to add multiple custom secrets
|
||||
add_secrets_data = {
|
||||
'custom_secrets': {
|
||||
'API_KEY': 'api-key-value',
|
||||
'DB_PASSWORD': 'db-password-value',
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with custom secrets
|
||||
custom_secrets = {
|
||||
'API_KEY': SecretStr('api-key-value'),
|
||||
'DB_PASSWORD': SecretStr('db-password-value'),
|
||||
}
|
||||
}
|
||||
response = test_client.post('/api/secrets', json=add_secrets_data)
|
||||
assert response.status_code == 200
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Verify that the settings were stored with the new secrets
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
|
||||
# Check that the new secrets were added
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
assert 'DB_PASSWORD' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['DB_PASSWORD'].get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
# Make the GET request
|
||||
response = test_client.get('/api/secrets')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check that existing secrets were preserved
|
||||
assert 'EXISTING_SECRET' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'EXISTING_SECRET'
|
||||
].get_secret_value()
|
||||
== 'existing-value'
|
||||
)
|
||||
# Check the response
|
||||
data = response.json()
|
||||
assert 'custom_secrets' in data
|
||||
assert sorted(data['custom_secrets']) == ['API_KEY', 'DB_PASSWORD']
|
||||
|
||||
# Verify that the original settings were not modified
|
||||
stored_settings = await file_settings_store.load()
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'DB_PASSWORD'
|
||||
].get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_custom_secrets_names_empty(test_client):
|
||||
"""Test loading custom secrets names when there are no custom secrets."""
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with no custom secrets
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(provider_tokens=provider_tokens)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
|
||||
# Make the GET request
|
||||
response = test_client.get('/api/secrets')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check the response
|
||||
data = response.json()
|
||||
assert 'custom_secrets' in data
|
||||
assert data['custom_secrets'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_custom_secret(test_client):
|
||||
"""Test adding a new custom secret."""
|
||||
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with provider tokens but no custom secrets
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(provider_tokens=provider_tokens)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
|
||||
# Make the POST request to add a custom secret
|
||||
add_secret_data = {'custom_secrets': {'API_KEY': 'api-key-value'}}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored with the new secret
|
||||
stored_settings = await file_settings_store.load()
|
||||
|
||||
# Check that the secret was added
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
@@ -257,150 +154,274 @@ async def test_add_multiple_custom_secrets(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_custom_secret(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
async def test_update_existing_custom_secret(test_client):
|
||||
"""Test updating an existing custom secret."""
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with a custom secret
|
||||
custom_secrets = {'API_KEY': SecretStr('old-api-key')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
|
||||
# Make the POST request to update the custom secret
|
||||
update_secret_data = {'custom_secrets': {'API_KEY': 'new-api-key'}}
|
||||
response = test_client.post('/api/secrets', json=update_secret_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored with the updated secret
|
||||
stored_settings = await file_settings_store.load()
|
||||
|
||||
# Check that the secret was updated
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'new-api-key'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_multiple_custom_secrets(test_client):
|
||||
"""Test adding multiple custom secrets at once."""
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with one custom secret
|
||||
custom_secrets = {'EXISTING_SECRET': SecretStr('existing-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
|
||||
# Make the POST request to add multiple custom secrets
|
||||
add_secrets_data = {
|
||||
'custom_secrets': {
|
||||
'API_KEY': 'api-key-value',
|
||||
'DB_PASSWORD': 'db-password-value',
|
||||
}
|
||||
}
|
||||
response = test_client.post('/api/secrets', json=add_secrets_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored with the new secrets
|
||||
stored_settings = await file_settings_store.load()
|
||||
|
||||
# Check that the new secrets were added
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
assert 'DB_PASSWORD' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'DB_PASSWORD'
|
||||
].get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
|
||||
# Check that existing secrets were preserved
|
||||
assert 'EXISTING_SECRET' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'EXISTING_SECRET'
|
||||
].get_secret_value()
|
||||
== 'existing-value'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_custom_secret(test_client):
|
||||
"""Test deleting a custom secret."""
|
||||
# Create initial settings with multiple custom secrets
|
||||
custom_secrets = {
|
||||
'API_KEY': SecretStr('api-key-value'),
|
||||
'DB_PASSWORD': SecretStr('db-password-value'),
|
||||
}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with multiple custom secrets
|
||||
custom_secrets = {
|
||||
'API_KEY': SecretStr('api-key-value'),
|
||||
'DB_PASSWORD': SecretStr('db-password-value'),
|
||||
}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
|
||||
# Make the DELETE request to delete a custom secret
|
||||
response = test_client.delete('/api/secrets/API_KEY')
|
||||
assert response.status_code == 200
|
||||
# Make the DELETE request to delete a custom secret
|
||||
response = test_client.delete('/api/secrets/API_KEY')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored without the deleted secret
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
# Verify that the settings were stored without the deleted secret
|
||||
stored_settings = await file_settings_store.load()
|
||||
|
||||
# Check that the specified secret was deleted
|
||||
assert 'API_KEY' not in stored_settings.secrets_store.custom_secrets
|
||||
# Check that the specified secret was deleted
|
||||
assert 'API_KEY' not in stored_settings.secrets_store.custom_secrets
|
||||
|
||||
# Check that other secrets were preserved
|
||||
assert 'DB_PASSWORD' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['DB_PASSWORD'].get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
# Check that other secrets were preserved
|
||||
assert 'DB_PASSWORD' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'DB_PASSWORD'
|
||||
].get_secret_value()
|
||||
== 'db-password-value'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_custom_secret(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
async def test_delete_nonexistent_custom_secret(test_client):
|
||||
"""Test deleting a custom secret that doesn't exist."""
|
||||
# Create initial settings with a custom secret
|
||||
custom_secrets = {'API_KEY': SecretStr('api-key-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with a custom secret
|
||||
custom_secrets = {'API_KEY': SecretStr('api-key-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token'))
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
|
||||
# Make the DELETE request to delete a nonexistent custom secret
|
||||
response = test_client.delete('/api/secrets/NONEXISTENT_KEY')
|
||||
assert response.status_code == 200
|
||||
# Make the DELETE request to delete a nonexistent custom secret
|
||||
response = test_client.delete('/api/secrets/NONEXISTENT_KEY')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored without changes to existing secrets
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
# Verify that the settings were stored without changes to existing secrets
|
||||
stored_settings = await file_settings_store.load()
|
||||
|
||||
# Check that the existing secret was preserved
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
# Check that the existing secret was preserved
|
||||
assert 'API_KEY' in stored_settings.secrets_store.custom_secrets
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets['API_KEY'].get_secret_value()
|
||||
== 'api-key-value'
|
||||
)
|
||||
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
# Check that other settings were preserved
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_secrets_operations_preserve_settings(
|
||||
test_client, mock_settings_store, mock_convert_to_settings
|
||||
):
|
||||
async def test_custom_secrets_operations_preserve_settings(test_client):
|
||||
"""Test that operations on custom secrets preserve all other settings."""
|
||||
# Create initial settings with comprehensive data
|
||||
custom_secrets = {'INITIAL_SECRET': SecretStr('initial-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token')),
|
||||
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab-token')),
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
max_iterations=100,
|
||||
security_analyzer='default',
|
||||
confirmation_mode=True,
|
||||
llm_model='test-model',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
llm_base_url='https://test.com',
|
||||
remote_runtime_resource_factor=2,
|
||||
enable_default_condenser=True,
|
||||
enable_sound_notifications=False,
|
||||
user_consents_to_analytics=True,
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
with patch_file_settings_store() as file_settings_store:
|
||||
# Create initial settings with comprehensive data
|
||||
custom_secrets = {'INITIAL_SECRET': SecretStr('initial-value')}
|
||||
provider_tokens = {
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr('github-token')),
|
||||
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab-token')),
|
||||
}
|
||||
secret_store = SecretStore(
|
||||
custom_secrets=custom_secrets, provider_tokens=provider_tokens
|
||||
)
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
max_iterations=100,
|
||||
security_analyzer='default',
|
||||
confirmation_mode=True,
|
||||
llm_model='test-model',
|
||||
llm_api_key=SecretStr('test-llm-key'),
|
||||
llm_base_url='https://test.com',
|
||||
remote_runtime_resource_factor=2,
|
||||
enable_default_condenser=True,
|
||||
enable_sound_notifications=False,
|
||||
user_consents_to_analytics=True,
|
||||
secrets_store=secret_store,
|
||||
)
|
||||
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
# Store the initial settings
|
||||
await file_settings_store.store(initial_settings)
|
||||
|
||||
# 1. Test adding a new custom secret
|
||||
add_secret_data = {'custom_secrets': {'NEW_SECRET': 'new-value'}}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data)
|
||||
assert response.status_code == 200
|
||||
# 1. Test adding a new custom secret
|
||||
add_secret_data = {'custom_secrets': {'NEW_SECRET': 'new-value'}}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify all settings are preserved
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.max_iterations == 100
|
||||
assert stored_settings.security_analyzer == 'default'
|
||||
assert stored_settings.confirmation_mode is True
|
||||
assert stored_settings.llm_model == 'test-model'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert stored_settings.llm_base_url == 'https://test.com'
|
||||
assert stored_settings.remote_runtime_resource_factor == 2
|
||||
assert stored_settings.enable_default_condenser is True
|
||||
assert stored_settings.enable_sound_notifications is False
|
||||
assert stored_settings.user_consents_to_analytics is True
|
||||
assert len(stored_settings.secrets_store.provider_tokens) == 2
|
||||
# Verify all settings are preserved
|
||||
stored_settings = await file_settings_store.load()
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.max_iterations == 100
|
||||
assert stored_settings.security_analyzer == 'default'
|
||||
assert stored_settings.confirmation_mode is True
|
||||
assert stored_settings.llm_model == 'test-model'
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-llm-key'
|
||||
assert stored_settings.llm_base_url == 'https://test.com'
|
||||
assert stored_settings.remote_runtime_resource_factor == 2
|
||||
assert stored_settings.enable_default_condenser is True
|
||||
assert stored_settings.enable_sound_notifications is False
|
||||
assert stored_settings.user_consents_to_analytics is True
|
||||
assert len(stored_settings.secrets_store.provider_tokens) == 2
|
||||
assert ProviderType.GITHUB in stored_settings.secrets_store.provider_tokens
|
||||
assert ProviderType.GITLAB in stored_settings.secrets_store.provider_tokens
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'INITIAL_SECRET'
|
||||
].get_secret_value()
|
||||
== 'initial-value'
|
||||
)
|
||||
assert (
|
||||
stored_settings.secrets_store.custom_secrets[
|
||||
'NEW_SECRET'
|
||||
].get_secret_value()
|
||||
== 'new-value'
|
||||
)
|
||||
|
||||
# 2. Test updating an existing custom secret
|
||||
update_secret_data = {'custom_secrets': {'INITIAL_SECRET': 'updated-value'}}
|
||||
@@ -408,7 +429,7 @@ async def test_custom_secrets_operations_preserve_settings(
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify all settings are still preserved
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
stored_settings = await file_settings_store.load()
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.max_iterations == 100
|
||||
@@ -428,7 +449,7 @@ async def test_custom_secrets_operations_preserve_settings(
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify all settings are still preserved
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
stored_settings = await file_settings_store.load()
|
||||
assert stored_settings.language == 'en'
|
||||
assert stored_settings.agent == 'test-agent'
|
||||
assert stored_settings.max_iterations == 100
|
||||
|
||||
@@ -1,82 +1,60 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.config.sandbox_config import SandboxConfig
|
||||
from openhands.integrations.provider import ProviderType, SecretStore
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.server.app import app
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
|
||||
class MockUserAuth(UserAuth):
|
||||
"""Mock implementation of UserAuth for testing"""
|
||||
|
||||
def __init__(self):
|
||||
self._settings = None
|
||||
self._settings_store = MagicMock()
|
||||
self._settings_store.load = AsyncMock(return_value=None)
|
||||
self._settings_store.store = AsyncMock()
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return 'test-user'
|
||||
|
||||
async def get_access_token(self) -> SecretStr | None:
|
||||
return SecretStr('test-token')
|
||||
|
||||
async def get_provider_tokens(self) -> dict[ProviderType, ProviderToken] | None: # noqa: E501
|
||||
return None
|
||||
|
||||
async def get_user_settings_store(self) -> SettingsStore | None:
|
||||
return self._settings_store
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls, request: Request) -> UserAuth:
|
||||
return MockUserAuth()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings_store():
|
||||
with patch('openhands.server.routes.settings.SettingsStoreImpl') as mock:
|
||||
store_instance = MagicMock()
|
||||
mock.get_instance = AsyncMock(return_value=store_instance)
|
||||
store_instance.load = AsyncMock()
|
||||
store_instance.store = AsyncMock()
|
||||
yield store_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_user_id():
|
||||
with patch('openhands.server.routes.settings.get_user_id') as mock:
|
||||
mock.return_value = 'test-user'
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_validate_provider_token():
|
||||
with patch('openhands.server.routes.settings.validate_provider_token') as mock:
|
||||
|
||||
async def mock_determine(*args, **kwargs):
|
||||
return ProviderType.GITHUB
|
||||
|
||||
mock.side_effect = mock_determine
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(mock_settings_store):
|
||||
# Mock the middleware that adds github_token
|
||||
class MockMiddleware:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
settings = mock_settings_store.load.return_value
|
||||
token = None
|
||||
if settings and settings.secrets_store.provider_tokens.get(
|
||||
ProviderType.GITHUB
|
||||
):
|
||||
token = settings.secrets_store.provider_tokens[
|
||||
ProviderType.GITHUB
|
||||
].token
|
||||
if scope['type'] == 'http':
|
||||
scope['state'] = {'token': token}
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
# Replace the middleware
|
||||
app.middleware_stack = None # Clear existing middleware
|
||||
app.add_middleware(MockMiddleware)
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_github_service():
|
||||
with patch('openhands.server.routes.settings.GitHubService') as mock:
|
||||
yield mock
|
||||
def test_client():
|
||||
# Create a test client
|
||||
with patch(
|
||||
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
|
||||
return_value=MockUserAuth(),
|
||||
):
|
||||
with patch(
|
||||
'openhands.server.routes.settings.validate_provider_token',
|
||||
return_value=ProviderType.GITHUB,
|
||||
):
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_api_runtime_factor(
|
||||
test_client, mock_settings_store, mock_get_user_id, mock_validate_provider_token
|
||||
):
|
||||
# Mock the settings store to return None initially (no existing settings)
|
||||
mock_settings_store.load.return_value = None
|
||||
async def test_settings_api_endpoints(test_client):
|
||||
"""Test that the settings API endpoints work with the new auth system"""
|
||||
|
||||
# Test data with remote_runtime_resource_factor
|
||||
settings_data = {
|
||||
@@ -92,176 +70,29 @@ async def test_settings_api_runtime_factor(
|
||||
'provider_tokens': {'github': 'test-token'},
|
||||
}
|
||||
|
||||
# The test_client fixture already handles authentication
|
||||
|
||||
# Make the POST request to store settings
|
||||
response = test_client.post('/api/settings', json=settings_data)
|
||||
|
||||
# We're not checking the exact response, just that it doesn't error
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the settings were stored with the correct runtime factor
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert stored_settings.remote_runtime_resource_factor == 2
|
||||
|
||||
# Mock settings store to return our settings for the GET request
|
||||
mock_settings_store.load.return_value = Settings(**settings_data)
|
||||
|
||||
# Make a GET request to retrieve settings
|
||||
# Test the GET settings endpoint
|
||||
response = test_client.get('/api/settings')
|
||||
assert response.status_code == 200
|
||||
assert response.json()['remote_runtime_resource_factor'] == 2
|
||||
|
||||
# Verify that the sandbox config gets updated when settings are loaded
|
||||
with patch('openhands.server.shared.config') as mock_config:
|
||||
mock_config.sandbox = SandboxConfig()
|
||||
response = test_client.get('/api/settings')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the sandbox config was updated with the new value
|
||||
mock_settings_store.store.assert_called()
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert stored_settings.remote_runtime_resource_factor == 2
|
||||
|
||||
assert isinstance(stored_settings.llm_api_key, SecretStr)
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_llm_api_key(
|
||||
test_client, mock_settings_store, mock_get_user_id, mock_validate_provider_token
|
||||
):
|
||||
# Mock the settings store to return None initially (no existing settings)
|
||||
mock_settings_store.load.return_value = None
|
||||
|
||||
# Test data with remote_runtime_resource_factor
|
||||
settings_data = {
|
||||
'llm_api_key': 'test-key',
|
||||
'provider_tokens': {'github': 'test-token'},
|
||||
# Test updating with partial settings
|
||||
partial_settings = {
|
||||
'language': 'fr',
|
||||
'llm_model': None, # Should preserve existing value
|
||||
'llm_api_key': None, # Should preserve existing value
|
||||
}
|
||||
|
||||
# The test_client fixture already handles authentication
|
||||
|
||||
# Make the POST request to store settings
|
||||
response = test_client.post('/api/settings', json=settings_data)
|
||||
response = test_client.post('/api/settings', json=partial_settings)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the settings were stored with the correct secret API key
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert isinstance(stored_settings.llm_api_key, SecretStr)
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'test-key'
|
||||
|
||||
# Mock settings store to return our settings for the GET request
|
||||
mock_settings_store.load.return_value = Settings(**settings_data)
|
||||
|
||||
# Make a GET request to retrieve settings
|
||||
response = test_client.get('/api/settings')
|
||||
# Test the unset-settings-tokens endpoint
|
||||
response = test_client.post('/api/unset-settings-tokens')
|
||||
assert response.status_code == 200
|
||||
|
||||
# We should never expose the API key in the response
|
||||
assert 'test-key' not in response.json()
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason='Mock middleware does not seem to properly set the github_token'
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_api_set_github_token(
|
||||
mock_github_service,
|
||||
test_client,
|
||||
mock_settings_store,
|
||||
mock_get_user_id,
|
||||
mock_validate_provider_token,
|
||||
):
|
||||
# Test data with provider token set
|
||||
settings_data = {
|
||||
'language': 'en',
|
||||
'agent': 'test-agent',
|
||||
'max_iterations': 100,
|
||||
'security_analyzer': 'default',
|
||||
'confirmation_mode': True,
|
||||
'llm_model': 'test-model',
|
||||
'llm_api_key': 'test-key',
|
||||
'llm_base_url': 'https://test.com',
|
||||
'provider_tokens': {'github': 'test-token'},
|
||||
}
|
||||
|
||||
# Make the POST request to store settings
|
||||
response = test_client.post('/api/settings', json=settings_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the settings were stored with the provider token
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
assert (
|
||||
stored_settings.secrets_store.provider_tokens[
|
||||
ProviderType.GITHUB
|
||||
].token.get_secret_value()
|
||||
== 'test-token'
|
||||
)
|
||||
|
||||
# Mock settings store to return our settings for the GET request
|
||||
mock_settings_store.load.return_value = Settings(**settings_data)
|
||||
|
||||
# Make a GET request to retrieve settings
|
||||
response = test_client.get('/api/settings')
|
||||
data = response.json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data.get('token') is None
|
||||
assert data['token_is_set'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_preserve_llm_fields_when_none(test_client, mock_settings_store):
|
||||
# Setup initial settings with LLM fields populated
|
||||
initial_settings = Settings(
|
||||
language='en',
|
||||
agent='test-agent',
|
||||
max_iterations=100,
|
||||
security_analyzer='default',
|
||||
confirmation_mode=True,
|
||||
llm_model='existing-model',
|
||||
llm_api_key=SecretStr('existing-key'),
|
||||
llm_base_url='https://existing.com',
|
||||
secrets_store=SecretStore(),
|
||||
)
|
||||
|
||||
# Mock the settings store to return our initial settings
|
||||
mock_settings_store.load.return_value = initial_settings
|
||||
|
||||
# Test data with None values for LLM fields
|
||||
settings_update = {
|
||||
'language': 'fr', # Change something else to verify the update happens
|
||||
'llm_model': None,
|
||||
'llm_api_key': None,
|
||||
'llm_base_url': None,
|
||||
}
|
||||
|
||||
# Make the POST request to update settings
|
||||
response = test_client.post('/api/settings', json=settings_update)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored with preserved LLM values
|
||||
stored_settings = mock_settings_store.store.call_args[0][0]
|
||||
|
||||
# Check that language was updated
|
||||
assert stored_settings.language == 'fr'
|
||||
|
||||
# Check that LLM fields were preserved and not cleared
|
||||
assert stored_settings.llm_model == 'existing-model'
|
||||
assert isinstance(stored_settings.llm_api_key, SecretStr)
|
||||
assert stored_settings.llm_api_key.get_secret_value() == 'existing-key'
|
||||
assert stored_settings.llm_base_url == 'https://existing.com'
|
||||
|
||||
# Update the mock to return our new settings for the GET request
|
||||
mock_settings_store.load.return_value = stored_settings
|
||||
|
||||
# Make a GET request to verify the updated settings
|
||||
response = test_client.get('/api/settings')
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify fields in the response
|
||||
assert data['language'] == 'fr'
|
||||
assert data['llm_model'] == 'existing-model'
|
||||
assert data['llm_base_url'] == 'https://existing.com'
|
||||
# We expect the API key not to be included in the response
|
||||
assert 'test-key' not in str(response.content)
|
||||
# We'll skip the secrets endpoints for now as they require more complex mocking # noqa: E501
|
||||
# and they're not directly related to the authentication refactoring
|
||||
|
||||
@@ -23,7 +23,6 @@ async def get_settings_store(request):
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_provider_tokens_valid():
|
||||
"""Test check_provider_tokens with valid tokens."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={'github': 'valid-token'})
|
||||
|
||||
# Mock the validate_provider_token function to return GITHUB for valid tokens
|
||||
@@ -32,7 +31,7 @@ async def test_check_provider_tokens_valid():
|
||||
) as mock_validate:
|
||||
mock_validate.return_value = ProviderType.GITHUB
|
||||
|
||||
result = await check_provider_tokens(mock_request, settings)
|
||||
result = await check_provider_tokens(settings)
|
||||
|
||||
# Should return empty string for valid token
|
||||
assert result == ''
|
||||
@@ -42,7 +41,6 @@ async def test_check_provider_tokens_valid():
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_provider_tokens_invalid():
|
||||
"""Test check_provider_tokens with invalid tokens."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={'github': 'invalid-token'})
|
||||
|
||||
# Mock the validate_provider_token function to return None for invalid tokens
|
||||
@@ -51,7 +49,7 @@ async def test_check_provider_tokens_invalid():
|
||||
) as mock_validate:
|
||||
mock_validate.return_value = None
|
||||
|
||||
result = await check_provider_tokens(mock_request, settings)
|
||||
result = await check_provider_tokens(settings)
|
||||
|
||||
# Should return error message for invalid token
|
||||
assert 'Invalid token' in result
|
||||
@@ -61,10 +59,9 @@ async def test_check_provider_tokens_invalid():
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_provider_tokens_wrong_type():
|
||||
"""Test check_provider_tokens with unsupported provider type."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={'unsupported': 'some-token'})
|
||||
|
||||
result = await check_provider_tokens(mock_request, settings)
|
||||
result = await check_provider_tokens(settings)
|
||||
|
||||
# Should return empty string for unsupported provider
|
||||
assert result == ''
|
||||
@@ -73,10 +70,9 @@ async def test_check_provider_tokens_wrong_type():
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_provider_tokens_no_tokens():
|
||||
"""Test check_provider_tokens with no tokens."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={})
|
||||
|
||||
result = await check_provider_tokens(mock_request, settings)
|
||||
result = await check_provider_tokens(settings)
|
||||
|
||||
# Should return empty string when no tokens provided
|
||||
assert result == ''
|
||||
@@ -86,7 +82,6 @@ async def test_check_provider_tokens_no_tokens():
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_llm_settings_new_settings():
|
||||
"""Test store_llm_settings with new settings."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(
|
||||
llm_model='gpt-4',
|
||||
llm_api_key='test-api-key',
|
||||
@@ -94,25 +89,20 @@ async def test_store_llm_settings_new_settings():
|
||||
)
|
||||
|
||||
# Mock the settings store
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
mock_store.load = AsyncMock(return_value=None) # No existing settings
|
||||
mock_get_store.return_value = mock_store
|
||||
mock_store = MagicMock()
|
||||
mock_store.load = AsyncMock(return_value=None) # No existing settings
|
||||
|
||||
result = await store_llm_settings(mock_request, settings)
|
||||
result = await store_llm_settings(settings, mock_store)
|
||||
|
||||
# Should return settings with the provided values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
assert result.llm_api_key.get_secret_value() == 'test-api-key'
|
||||
assert result.llm_base_url == 'https://api.example.com'
|
||||
# Should return settings with the provided values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
assert result.llm_api_key.get_secret_value() == 'test-api-key'
|
||||
assert result.llm_base_url == 'https://api.example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_llm_settings_update_existing():
|
||||
"""Test store_llm_settings updates existing settings."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(
|
||||
llm_model='gpt-4',
|
||||
llm_api_key='new-api-key',
|
||||
@@ -120,142 +110,118 @@ async def test_store_llm_settings_update_existing():
|
||||
)
|
||||
|
||||
# Mock the settings store
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Create existing settings
|
||||
existing_settings = Settings(
|
||||
llm_model='gpt-3.5',
|
||||
llm_api_key=SecretStr('old-api-key'),
|
||||
llm_base_url='https://old.example.com',
|
||||
)
|
||||
# Create existing settings
|
||||
existing_settings = Settings(
|
||||
llm_model='gpt-3.5',
|
||||
llm_api_key=SecretStr('old-api-key'),
|
||||
llm_base_url='https://old.example.com',
|
||||
)
|
||||
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_get_store.return_value = mock_store
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
|
||||
result = await store_llm_settings(mock_request, settings)
|
||||
result = await store_llm_settings(settings, mock_store)
|
||||
|
||||
# Should return settings with the updated values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
assert result.llm_api_key.get_secret_value() == 'new-api-key'
|
||||
assert result.llm_base_url == 'https://new.example.com'
|
||||
# Should return settings with the updated values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
assert result.llm_api_key.get_secret_value() == 'new-api-key'
|
||||
assert result.llm_base_url == 'https://new.example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_llm_settings_partial_update():
|
||||
"""Test store_llm_settings with partial update."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(
|
||||
llm_model='gpt-4' # Only updating model
|
||||
)
|
||||
|
||||
# Mock the settings store
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Create existing settings
|
||||
existing_settings = Settings(
|
||||
llm_model='gpt-3.5',
|
||||
llm_api_key=SecretStr('existing-api-key'),
|
||||
llm_base_url='https://existing.example.com',
|
||||
)
|
||||
# Create existing settings
|
||||
existing_settings = Settings(
|
||||
llm_model='gpt-3.5',
|
||||
llm_api_key=SecretStr('existing-api-key'),
|
||||
llm_base_url='https://existing.example.com',
|
||||
)
|
||||
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_get_store.return_value = mock_store
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
|
||||
result = await store_llm_settings(mock_request, settings)
|
||||
result = await store_llm_settings(settings, mock_store)
|
||||
|
||||
# Should return settings with updated model but keep other values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
# For SecretStr objects, we need to compare the secret value
|
||||
assert result.llm_api_key.get_secret_value() == 'existing-api-key'
|
||||
assert result.llm_base_url == 'https://existing.example.com'
|
||||
# Should return settings with updated model but keep other values
|
||||
assert result.llm_model == 'gpt-4'
|
||||
# For SecretStr objects, we need to compare the secret value
|
||||
assert result.llm_api_key.get_secret_value() == 'existing-api-key'
|
||||
assert result.llm_base_url == 'https://existing.example.com'
|
||||
|
||||
|
||||
# Tests for store_provider_tokens
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_provider_tokens_new_tokens():
|
||||
"""Test store_provider_tokens with new tokens."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={'github': 'new-token'})
|
||||
|
||||
# Mock the settings store
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
mock_store.load = AsyncMock(return_value=None) # No existing settings
|
||||
mock_get_store.return_value = mock_store
|
||||
mock_store = MagicMock()
|
||||
mock_store.load = AsyncMock(return_value=None) # No existing settings
|
||||
|
||||
result = await store_provider_tokens(mock_request, settings)
|
||||
result = await store_provider_tokens(settings, mock_store)
|
||||
|
||||
# Should return settings with the provided tokens
|
||||
assert result.provider_tokens == {'github': 'new-token'}
|
||||
# Should return settings with the provided tokens
|
||||
assert result.provider_tokens == {'github': 'new-token'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_provider_tokens_update_existing():
|
||||
"""Test store_provider_tokens updates existing tokens."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(provider_tokens={'github': 'updated-token'})
|
||||
|
||||
# Mock the settings store
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Create existing settings with a GitHub token
|
||||
github_token = ProviderToken(token=SecretStr('old-token'))
|
||||
provider_tokens = {ProviderType.GITHUB: github_token}
|
||||
# Create existing settings with a GitHub token
|
||||
github_token = ProviderToken(token=SecretStr('old-token'))
|
||||
provider_tokens = {ProviderType.GITHUB: github_token}
|
||||
|
||||
# Create a SecretStore with the provider tokens
|
||||
secrets_store = SecretStore(provider_tokens=provider_tokens)
|
||||
# Create a SecretStore with the provider tokens
|
||||
secrets_store = SecretStore(provider_tokens=provider_tokens)
|
||||
|
||||
# Create existing settings with the secrets store
|
||||
existing_settings = Settings(secrets_store=secrets_store)
|
||||
# Create existing settings with the secrets store
|
||||
existing_settings = Settings(secrets_store=secrets_store)
|
||||
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_get_store.return_value = mock_store
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
|
||||
result = await store_provider_tokens(mock_request, settings)
|
||||
result = await store_provider_tokens(settings, mock_store)
|
||||
|
||||
# Should return settings with the updated tokens
|
||||
assert result.provider_tokens == {'github': 'updated-token'}
|
||||
# Should return settings with the updated tokens
|
||||
assert result.provider_tokens == {'github': 'updated-token'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_provider_tokens_keep_existing():
|
||||
"""Test store_provider_tokens keeps existing tokens when empty string provided."""
|
||||
mock_request = MagicMock()
|
||||
settings = POSTSettingsModel(
|
||||
provider_tokens={'github': ''} # Empty string should keep existing token
|
||||
)
|
||||
|
||||
# Mock the settings store
|
||||
with patch(
|
||||
'openhands.server.routes.settings.SettingsStoreImpl.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_store = MagicMock()
|
||||
mock_store = MagicMock()
|
||||
|
||||
# Create existing settings with a GitHub token
|
||||
github_token = ProviderToken(token=SecretStr('existing-token'))
|
||||
provider_tokens = {ProviderType.GITHUB: github_token}
|
||||
# Create existing settings with a GitHub token
|
||||
github_token = ProviderToken(token=SecretStr('existing-token'))
|
||||
provider_tokens = {ProviderType.GITHUB: github_token}
|
||||
|
||||
# Create a SecretStore with the provider tokens
|
||||
secrets_store = SecretStore(provider_tokens=provider_tokens)
|
||||
# Create a SecretStore with the provider tokens
|
||||
secrets_store = SecretStore(provider_tokens=provider_tokens)
|
||||
|
||||
# Create existing settings with the secrets store
|
||||
existing_settings = Settings(secrets_store=secrets_store)
|
||||
# Create existing settings with the secrets store
|
||||
existing_settings = Settings(secrets_store=secrets_store)
|
||||
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
mock_get_store.return_value = mock_store
|
||||
mock_store.load = AsyncMock(return_value=existing_settings)
|
||||
|
||||
result = await store_provider_tokens(mock_request, settings)
|
||||
result = await store_provider_tokens(settings, mock_store)
|
||||
|
||||
# Should return settings with the existing token preserved
|
||||
assert result.provider_tokens == {'github': 'existing-token'}
|
||||
# Should return settings with the existing token preserved
|
||||
assert result.provider_tokens == {'github': 'existing-token'}
|
||||
|
||||
@@ -73,8 +73,7 @@ async def test_init_new_local_session():
|
||||
'new-session-id',
|
||||
'new-session-id',
|
||||
ConversationInitData(),
|
||||
1,
|
||||
'12345',
|
||||
'12345'
|
||||
)
|
||||
assert session_instance.initialize_agent.call_count == 1
|
||||
assert sio.enter_room.await_count == 1
|
||||
@@ -118,15 +117,13 @@ async def test_join_local_session():
|
||||
'new-session-id',
|
||||
'new-session-id',
|
||||
ConversationInitData(),
|
||||
None,
|
||||
'12345',
|
||||
None
|
||||
)
|
||||
await conversation_manager.join_conversation(
|
||||
'new-session-id',
|
||||
'new-session-id',
|
||||
ConversationInitData(),
|
||||
None,
|
||||
'12345',
|
||||
None
|
||||
)
|
||||
assert session_instance.initialize_agent.call_count == 1
|
||||
assert sio.enter_room.await_count == 2
|
||||
@@ -159,7 +156,7 @@ async def test_add_to_local_event_stream():
|
||||
'new-session-id', ConversationInitData(), 1
|
||||
)
|
||||
await conversation_manager.join_conversation(
|
||||
'new-session-id', 'connection-id', ConversationInitData(), 1, '12345'
|
||||
'new-session-id', 'connection-id', ConversationInitData(), 1
|
||||
)
|
||||
await conversation_manager.send_to_event_stream(
|
||||
'connection-id', {'event_type': 'some_event'}
|
||||
|
||||
Reference in New Issue
Block a user