mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
5 Commits
feat/mcp-s
...
refactor/b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adbfae2600 | ||
|
|
213d2dc056 | ||
|
|
da38890aaf | ||
|
|
edb373cea8 | ||
|
|
f8c5be917c |
@@ -6,11 +6,15 @@ To set up the entire repo, including frontend and backend, run `make build`.
|
||||
You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
|
||||
|
||||
## Running OpenHands with OpenHands:
|
||||
To run the full application to debug issues:
|
||||
To run the full application for development or self-improvement:
|
||||
```bash
|
||||
export INSTALL_DOCKER=0
|
||||
export RUNTIME=local
|
||||
make build && make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 &> /tmp/openhands-log.txt &
|
||||
make build && make run
|
||||
```
|
||||
For external access (cloud environments), use:
|
||||
```bash
|
||||
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
|
||||
```
|
||||
|
||||
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# MCP CLI Runtime Implementation Summary
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
✅ **Phase 1: HTTP/SSE Support** - Successfully implemented MCP action support in CLI Runtime with maximum code reuse from existing infrastructure.
|
||||
|
||||
### Key Features Implemented
|
||||
|
||||
1. **MCP Action Execution**: `call_tool_mcp()` method that handles MCP actions
|
||||
2. **Configuration Management**: `get_mcp_config()` method that loads MCP config from multiple sources
|
||||
3. **Error Handling**: Proper Windows platform checks and error reporting
|
||||
4. **Code Reuse**: ~80% code reuse from `action_execution_client.py` patterns
|
||||
|
||||
### Configuration Sources (in order of precedence)
|
||||
|
||||
1. **OpenHands Config**: If your OpenHands config already has MCP settings
|
||||
2. **Environment Variables**: For programmatic configuration
|
||||
3. **User Config File**: `~/.openhands/config.toml` (completely optional)
|
||||
4. **Default Empty Config**: If no configuration is found
|
||||
|
||||
### Technical Implementation
|
||||
|
||||
- **Reused Infrastructure**: Uses existing `MCPClient`, `create_mcp_clients`, `call_tool_mcp` from utils
|
||||
- **Consistent Patterns**: Same error handling, logging, and platform checks as other runtimes
|
||||
- **TOML Loading**: Uses OpenHands standard `toml` library and `MCPConfig.from_toml_section()`
|
||||
- **No Dependencies**: No new dependencies added
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### User Config File (`~/.openhands/config.toml`)
|
||||
```toml
|
||||
[mcp]
|
||||
# SSE Servers - External servers that communicate via Server-Sent Events
|
||||
sse_servers = [
|
||||
# Basic SSE server with just a URL
|
||||
"http://localhost:3000/mcp",
|
||||
|
||||
# SSE server with API key authentication
|
||||
{url="https://secure-example.com/mcp", api_key="your-api-key"}
|
||||
]
|
||||
|
||||
# Note: stdio_servers are not yet supported in CLI Runtime (Phase 2)
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
export OPENHANDS_MCP_SSE_SERVERS='[{"url":"http://localhost:3000/mcp"}]'
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from openhands.runtime.impl.cli import CLIRuntime
|
||||
from openhands.events.action import MCPAction
|
||||
|
||||
# Create runtime
|
||||
runtime = CLIRuntime(config=your_config)
|
||||
|
||||
# Execute MCP action
|
||||
action = MCPAction(server_name="your-server", tool_name="your-tool", arguments={})
|
||||
result = await runtime.call_tool_mcp(action)
|
||||
```
|
||||
|
||||
## What's Next (Phase 2)
|
||||
|
||||
- **Stdio MCP Client Implementation**: Support for local process-based MCP servers
|
||||
- **Process Management**: Handle stdio server lifecycle
|
||||
- **Enhanced Configuration**: Auto-discovery of localhost MCP servers
|
||||
|
||||
## Compatibility
|
||||
|
||||
- ✅ **Backward Compatible**: Existing CLI runtime functionality unchanged
|
||||
- ✅ **Cross-Platform**: Works on Windows, macOS, Linux (Windows has MCP disabled)
|
||||
- ✅ **Optional Config**: Works without any configuration files
|
||||
- ✅ **Docker Alternative**: Provides MCP support without Docker requirements
|
||||
|
||||
## Code Quality
|
||||
|
||||
- ✅ **High Code Reuse**: ~80% reuse from existing action_execution_client.py
|
||||
- ✅ **Consistent Error Handling**: Same patterns as other runtimes
|
||||
- ✅ **Proper Validation**: Uses existing MCPConfig validation
|
||||
- ✅ **Clean Implementation**: Minimal changes, focused functionality
|
||||
|
||||
## Testing
|
||||
|
||||
The implementation has been validated for:
|
||||
- ✅ Proper import structure
|
||||
- ✅ Code reuse patterns
|
||||
- ✅ Error handling
|
||||
- ✅ Configuration loading
|
||||
- ✅ Phase 1 requirements compliance
|
||||
@@ -35,7 +35,7 @@ You can grant OpenHands access to specific GitHub repositories:
|
||||
|
||||
You can modify GitHub repository access at any time by:
|
||||
- Selecting `Add GitHub repos` on the landing page or
|
||||
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Integrations` tab
|
||||
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git` tab
|
||||
|
||||
## Working With GitHub Repos in Openhands Cloud
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ OpenHands automatically exports a `GITHUB_TOKEN` to the shell environment if pro
|
||||
- All Repositories (You can select specific repositories, but this will impact what returns in repo search)
|
||||
- Minimal Permissions (Select `Meta Data = Read-only` read for search, `Pull Requests = Read and Write` and `Content = Read and Write` for branch creation)
|
||||
2. **Enter Token in OpenHands**:
|
||||
- In the Settings page, navigate to the `Integrations` tab.
|
||||
- In the Settings page, navigate to the `Git` tab.
|
||||
- Paste your token in the `GitHub Token` field.
|
||||
- Click `Save Changes` to apply the changes.
|
||||
|
||||
@@ -97,7 +97,7 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
|
||||
- `write_repository` (Write repository)
|
||||
- Set an expiration date or leave it blank for a non-expiring token.
|
||||
2. **Enter Token in OpenHands**:
|
||||
- In the Settings page, navigate to the `Integrations` tab.
|
||||
- In the Settings page, navigate to the `Git` tab.
|
||||
- Paste your token in the `GitLab Token` field.
|
||||
- Click `Save Changes` to apply the changes.
|
||||
|
||||
@@ -122,42 +122,6 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
#### BitBucket Setup (Coming soon ...)
|
||||
<AccordionGroup>
|
||||
<Accordion title="Setting Up a BitBucket Password">
|
||||
1. **Generate an App Password**:
|
||||
- On BitBucket, go to Personal Settings > App Password.
|
||||
- Create a new password with the following scopes:
|
||||
- `repository: read`
|
||||
- `repository: write`
|
||||
- `pull requests: read`
|
||||
- `pull requests: write`
|
||||
- `issues: read`
|
||||
- `issues: write`
|
||||
- App passwords are non-expiring token. OpenHands will migrate to using API tokens in the future.
|
||||
2. **Enter Token in OpenHands**:
|
||||
- In the Settings page, navigate to the `Integrations` tab.
|
||||
- Paste your token in the `BitBucket Token` field.
|
||||
- Click `Save Changes` to apply the changes.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Troubleshooting">
|
||||
Common issues and solutions:
|
||||
|
||||
- **Token Not Recognized**:
|
||||
- Ensure the token is properly saved in settings.
|
||||
- Check that the token hasn't expired.
|
||||
- Verify the token has the required scopes.
|
||||
|
||||
- **Verifying Token Works**:
|
||||
- The app will show a green checkmark if the token is valid.
|
||||
- Try accessing a repository to confirm permissions.
|
||||
- Check the browser console for any error messages.
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
#### Secrets Management
|
||||
|
||||
OpenHands provides a secrets manager that allows you to securely store and manage sensitive information that can be accessed by the agent during runtime, such as API keys. These secrets are automatically exported as environment variables in the agent's runtime environment.
|
||||
|
||||
@@ -193,9 +193,9 @@ describe("ChatInput", () => {
|
||||
|
||||
it("should handle image paste correctly", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onFilesPaste = vi.fn();
|
||||
const onImagePaste = vi.fn();
|
||||
|
||||
render(<ChatInput onSubmit={onSubmit} onFilesPaste={onFilesPaste} />);
|
||||
render(<ChatInput onSubmit={onSubmit} onImagePaste={onImagePaste} />);
|
||||
|
||||
const input = screen.getByTestId("chat-input").querySelector("textarea");
|
||||
expect(input).toBeTruthy();
|
||||
@@ -213,8 +213,8 @@ describe("ChatInput", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Verify file paste was handled
|
||||
expect(onFilesPaste).toHaveBeenCalledWith([file]);
|
||||
// Verify image paste was handled
|
||||
expect(onImagePaste).toHaveBeenCalledWith([file]);
|
||||
});
|
||||
|
||||
it("should use the default maxRows value", () => {
|
||||
|
||||
@@ -50,13 +50,13 @@ const renderRepoConnector = () => {
|
||||
|
||||
const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
id: 1,
|
||||
full_name: "rbren/polaris",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
id: 2,
|
||||
full_name: "All-Hands-AI/OpenHands",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
|
||||
@@ -94,13 +94,13 @@ describe("RepositorySelectionForm", () => {
|
||||
it("shows loading indicator when repositories are being fetched", () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
id: 1,
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
id: 2,
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
@@ -122,13 +122,13 @@ describe("RepositorySelectionForm", () => {
|
||||
it("shows dropdown when repositories are loaded", async () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
id: 1,
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
id: 2,
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
@@ -166,13 +166,13 @@ describe("RepositorySelectionForm", () => {
|
||||
it("should call the search repos API when searching a URL", async () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
id: 1,
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
id: 2,
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
@@ -181,7 +181,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
const MOCK_SEARCH_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: "3",
|
||||
id: 3,
|
||||
full_name: "kubernetes/kubernetes",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
@@ -228,7 +228,7 @@ describe("RepositorySelectionForm", () => {
|
||||
it("should call onRepoSelection when a searched repository is selected", async () => {
|
||||
const MOCK_SEARCH_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: "3",
|
||||
id: 3,
|
||||
full_name: "kubernetes/kubernetes",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
|
||||
@@ -19,10 +19,10 @@ const MOCK_TASK_1: SuggestedTask = {
|
||||
};
|
||||
|
||||
const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
{ id: "1", full_name: "repo1", git_provider: "github", is_public: true },
|
||||
{ id: "2", full_name: "repo2", git_provider: "github", is_public: true },
|
||||
{ id: "3", full_name: "repo3", git_provider: "gitlab", is_public: true },
|
||||
{ id: "4", full_name: "repo4", git_provider: "gitlab", is_public: true },
|
||||
{ id: 1, full_name: "repo1", git_provider: "github", is_public: true },
|
||||
{ id: 2, full_name: "repo2", git_provider: "github", is_public: true },
|
||||
{ id: 3, full_name: "repo3", git_provider: "gitlab", is_public: true },
|
||||
{ id: 4, full_name: "repo4", git_provider: "gitlab", is_public: true },
|
||||
];
|
||||
|
||||
const renderTaskCard = (task = MOCK_TASK_1) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { render, screen, within, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
|
||||
@@ -92,7 +92,7 @@ describe("InteractiveChatBox", () => {
|
||||
await user.type(textarea, "Hello, world!");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [file], []);
|
||||
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [file]);
|
||||
|
||||
// clear images after submission
|
||||
expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
|
||||
@@ -144,7 +144,7 @@ describe("InteractiveChatBox", () => {
|
||||
onStop={onStop}
|
||||
onChange={onChange}
|
||||
value="test message"
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
// Upload an image via the upload button - this should NOT clear the text input
|
||||
@@ -161,7 +161,7 @@ describe("InteractiveChatBox", () => {
|
||||
await user.click(submitButton);
|
||||
|
||||
// Verify onSubmit was called with the message and image
|
||||
expect(onSubmit).toHaveBeenCalledWith("test message", [file], []);
|
||||
expect(onSubmit).toHaveBeenCalledWith("test message", [file]);
|
||||
|
||||
// Verify onChange was called to clear the text input
|
||||
expect(onChange).toHaveBeenCalledWith("");
|
||||
@@ -173,7 +173,7 @@ describe("InteractiveChatBox", () => {
|
||||
onStop={onStop}
|
||||
onChange={onChange}
|
||||
value=""
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
|
||||
// Verify the text input was cleared
|
||||
|
||||
@@ -41,6 +41,19 @@ describe("UploadImageInput", () => {
|
||||
expect(onUploadMock).toHaveBeenNthCalledWith(1, files);
|
||||
});
|
||||
|
||||
it("should not upload any file that is not an image", async () => {
|
||||
render(<UploadImageInput onUpload={onUploadMock} />);
|
||||
|
||||
const file = new File(["(⌐□_□)"], "chucknorris.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
const input = screen.getByTestId("upload-image-input");
|
||||
|
||||
await user.upload(input, file);
|
||||
|
||||
expect(onUploadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should render custom labels", () => {
|
||||
const { rerender } = render(<UploadImageInput onUpload={onUploadMock} />);
|
||||
expect(screen.getByTestId("default-label")).toBeInTheDocument();
|
||||
|
||||
@@ -89,9 +89,6 @@ describe("Content", () => {
|
||||
await screen.findByTestId("gitlab-token-input");
|
||||
await screen.findByTestId("gitlab-token-help-anchor");
|
||||
|
||||
await screen.findByTestId("bitbucket-token-input");
|
||||
await screen.findByTestId("bitbucket-token-help-anchor");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
@@ -110,13 +107,6 @@ describe("Content", () => {
|
||||
expect(
|
||||
screen.queryByTestId("gitlab-token-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("bitbucket-token-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("bitbucket-token-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,7 +229,6 @@ describe("Content", () => {
|
||||
describe("Form submission", () => {
|
||||
it("should save the GitHub token", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
@@ -254,49 +243,15 @@ describe("Form submission", () => {
|
||||
expect(saveProvidersSpy).toHaveBeenCalledWith({
|
||||
github: { token: "test-token", host: "" },
|
||||
gitlab: { token: "", host: "" },
|
||||
bitbucket: { token: "", host: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should save GitLab tokens", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.type(gitlabInput, "test-token");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveProvidersSpy).toHaveBeenCalledWith({
|
||||
github: { token: "", host: "" },
|
||||
gitlab: { token: "test-token", host: "" },
|
||||
bitbucket: { token: "", host: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should save the Bitbucket token", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const bitbucketInput = await screen.findByTestId("bitbucket-token-input");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.type(bitbucketInput, "test-token");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveProvidersSpy).toHaveBeenCalledWith({
|
||||
github: { token: "", host: "" },
|
||||
github: { token: "test-token", host: "" },
|
||||
gitlab: { token: "", host: "" },
|
||||
bitbucket: { token: "test-token", host: "" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,13 +45,13 @@ const renderHomeScreen = () =>
|
||||
|
||||
const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
id: 1,
|
||||
full_name: "octocat/hello-world",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
id: 2,
|
||||
full_name: "octocat/earth",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
|
||||
3978
frontend/package-lock.json
generated
3978
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,23 +22,23 @@
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.10.0",
|
||||
"axios": "^1.9.0",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.18.1",
|
||||
"framer-motion": "^12.17.3",
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.28",
|
||||
"jose": "^6.0.11",
|
||||
"lucide-react": "^0.517.0",
|
||||
"lucide-react": "^0.514.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.255.0",
|
||||
"posthog-js": "^1.251.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
@@ -83,7 +83,7 @@
|
||||
"@babel/parser": "^7.27.1",
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.53.0",
|
||||
"@react-router/dev": "^7.6.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
@@ -92,7 +92,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/node": "^24.0.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -109,13 +109,13 @@
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.0",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"lint-staged": "^16.1.0",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.5.3",
|
||||
"stripe": "^18.2.1",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { GetFilesResponse, GetFileResponse } from "./file-service.types";
|
||||
import { getConversationUrl } from "../conversation.utils";
|
||||
import { FileUploadSuccessResponse } from "../open-hands.types";
|
||||
|
||||
export class FileService {
|
||||
/**
|
||||
@@ -36,31 +35,4 @@ export class FileService {
|
||||
|
||||
return data.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
* @param files List of files.
|
||||
* @returns list of uploaded files, list of skipped files
|
||||
*/
|
||||
static async uploadFiles(
|
||||
conversationId: string,
|
||||
files: File[],
|
||||
): Promise<FileUploadSuccessResponse> {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
const url = `${getConversationUrl(conversationId)}/upload-files`;
|
||||
const response = await openHands.post<FileUploadSuccessResponse>(
|
||||
url,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface SaveFileSuccessResponse {
|
||||
}
|
||||
|
||||
export interface FileUploadSuccessResponse {
|
||||
message: string;
|
||||
uploaded_files: string[];
|
||||
skipped_files: { name: string; reason: string }[];
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bitbucket</title><path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/></svg>
|
||||
|
Before Width: | Height: | Size: 285 B |
@@ -20,22 +20,19 @@ export function ActionSuggestions({
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
const isGitLab = providers.includes("gitlab");
|
||||
const isBitbucket = providers.includes("bitbucket");
|
||||
|
||||
const pr = isGitLab ? "merge request" : "pull request";
|
||||
const prShort = isGitLab ? "MR" : "PR";
|
||||
|
||||
const getProviderName = () => {
|
||||
if (isGitLab) return "GitLab";
|
||||
if (isBitbucket) return "Bitbucket";
|
||||
return "GitHub";
|
||||
};
|
||||
|
||||
const terms = {
|
||||
pr,
|
||||
prShort,
|
||||
pushToBranch: `Please push the changes to a remote branch on ${getProviderName()}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
|
||||
createPR: `Please push the changes to ${getProviderName()} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
|
||||
pushToBranch: `Please push the changes to a remote branch on ${
|
||||
isGitLab ? "GitLab" : "GitHub"
|
||||
}, but do NOT create a ${pr}. Please use the exact SAME branch name as the one you are currently on.`,
|
||||
createPR: `Please push the changes to ${
|
||||
isGitLab ? "GitLab" : "GitHub"
|
||||
} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
|
||||
pushToPR: `Please push the latest changes to the existing ${pr}.`,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ChatInputProps {
|
||||
onChange?: (message: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onFilesPaste?: (files: File[]) => void;
|
||||
onImagePaste?: (files: File[]) => void;
|
||||
className?: React.HTMLAttributes<HTMLDivElement>["className"];
|
||||
buttonClassName?: React.HTMLAttributes<HTMLButtonElement>["className"];
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function ChatInput({
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onFilesPaste,
|
||||
onImagePaste,
|
||||
className,
|
||||
buttonClassName,
|
||||
}: ChatInputProps) {
|
||||
@@ -45,11 +45,15 @@ export function ChatInput({
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
// Only handle paste if we have an image paste handler and there are files
|
||||
if (onFilesPaste && event.clipboardData.files.length > 0) {
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
if (onImagePaste && event.clipboardData.files.length > 0) {
|
||||
const files = Array.from(event.clipboardData.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
// Only prevent default if we found image files to handle
|
||||
event.preventDefault();
|
||||
onFilesPaste(files);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
onImagePaste(files);
|
||||
}
|
||||
}
|
||||
// For text paste, let the default behavior handle it
|
||||
};
|
||||
@@ -69,10 +73,12 @@ export function ChatInput({
|
||||
const handleDrop = (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
if (onFilesPaste && event.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (onImagePaste && event.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
if (files.length > 0) {
|
||||
onFilesPaste(files);
|
||||
onImagePaste(files);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,7 +29,6 @@ import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
|
||||
import { ErrorMessageBanner } from "./error-message-banner";
|
||||
import { shouldRenderEvent } from "./event-content-helpers/should-render-event";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
function getEntryPoint(
|
||||
@@ -70,18 +69,13 @@ export function ChatInterface() {
|
||||
);
|
||||
const params = useParams();
|
||||
const { mutate: getTrajectory } = useGetTrajectory();
|
||||
const { mutateAsync: uploadFiles } = useUploadFiles();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
const errorMessage = getErrorMessage();
|
||||
|
||||
const events = parsedEvents.filter(shouldRenderEvent);
|
||||
|
||||
const handleSendMessage = async (
|
||||
content: string,
|
||||
images: File[],
|
||||
files: File[],
|
||||
) => {
|
||||
const handleSendMessage = async (content: string, files: File[]) => {
|
||||
if (events.length === 0) {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: getEntryPoint(
|
||||
@@ -97,23 +91,11 @@ export function ChatInterface() {
|
||||
current_message_length: content.length,
|
||||
});
|
||||
}
|
||||
const promises = images.map((image) => convertImageToBase64(image));
|
||||
const promises = files.map((file) => convertImageToBase64(file));
|
||||
const imageUrls = await Promise.all(promises);
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const { skipped_files: skippedFiles, uploaded_files: uploadedFiles } =
|
||||
files.length > 0
|
||||
? await uploadFiles({ conversationId: params.conversationId!, files })
|
||||
: { skipped_files: [], uploaded_files: [] };
|
||||
|
||||
skippedFiles.forEach((f) => displayErrorToast(f.reason));
|
||||
|
||||
const filePrompt = `${t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE")}: ${uploadedFiles.join("\n\n")}`;
|
||||
const prompt =
|
||||
uploadedFiles.length > 0 ? `${content}\n\n${filePrompt}` : content;
|
||||
|
||||
send(createChatMessage(prompt, imageUrls, uploadedFiles, timestamp));
|
||||
send(createChatMessage(content, imageUrls, timestamp));
|
||||
setOptimisticUserMessage(content);
|
||||
setMessageToSend(null);
|
||||
};
|
||||
@@ -195,7 +177,7 @@ export function ChatInterface() {
|
||||
events.length > 0 &&
|
||||
!optimisticUserMessage && (
|
||||
<ActionSuggestions
|
||||
onSuggestionsClick={(value) => handleSendMessage(value, [], [])}
|
||||
onSuggestionsClick={(value) => handleSendMessage(value, [])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import i18n from "#/i18n";
|
||||
import { isUserMessage } from "#/types/core/guards";
|
||||
|
||||
export const parseMessageFromEvent = (
|
||||
event: UserMessageAction | AssistantMessageAction,
|
||||
): string => {
|
||||
const m = isUserMessage(event) ? event.args.content : event.message;
|
||||
if (!event.args.file_urls || event.args.file_urls.length === 0) {
|
||||
return m;
|
||||
}
|
||||
const delimiter = i18n.t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE");
|
||||
const parts = m.split(delimiter);
|
||||
|
||||
return parts[0];
|
||||
};
|
||||
@@ -19,8 +19,6 @@ import { MCPObservationContent } from "./mcp-observation-content";
|
||||
import { getObservationResult } from "./event-content-helpers/get-observation-result";
|
||||
import { getEventContent } from "./event-content-helpers/get-event-content";
|
||||
import { GenericEventMessage } from "./generic-event-message";
|
||||
import { FileList } from "../files/file-list";
|
||||
import { parseMessageFromEvent } from "./event-content-helpers/parse-message-from-event";
|
||||
import { LikertScale } from "../feedback/likert-scale";
|
||||
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
@@ -93,16 +91,14 @@ export function EventMessage({
|
||||
}
|
||||
|
||||
if (isUserMessage(event) || isAssistantMessage(event)) {
|
||||
const message = parseMessageFromEvent(event);
|
||||
|
||||
return (
|
||||
<ChatMessage type={event.source} message={message}>
|
||||
<ChatMessage
|
||||
type={event.source}
|
||||
message={isUserMessage(event) ? event.args.content : event.message}
|
||||
>
|
||||
{event.args.image_urls && event.args.image_urls.length > 0 && (
|
||||
<ImageCarousel size="small" images={event.args.image_urls} />
|
||||
)}
|
||||
{event.args.file_urls && event.args.file_urls.length > 0 && (
|
||||
<FileList files={event.args.file_urls} />
|
||||
)}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
);
|
||||
|
||||
@@ -3,13 +3,11 @@ import { ChatInput } from "./chat-input";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ImageCarousel } from "../images/image-carousel";
|
||||
import { UploadImageInput } from "../images/upload-image-input";
|
||||
import { FileList } from "../files/file-list";
|
||||
import { isFileImage } from "#/utils/is-file-image";
|
||||
|
||||
interface InteractiveChatBoxProps {
|
||||
isDisabled?: boolean;
|
||||
mode?: "stop" | "submit";
|
||||
onSubmit: (message: string, images: File[], files: File[]) => void;
|
||||
onSubmit: (message: string, images: File[]) => void;
|
||||
onStop: () => void;
|
||||
value?: string;
|
||||
onChange?: (message: string) => void;
|
||||
@@ -24,35 +22,21 @@ export function InteractiveChatBox({
|
||||
onChange,
|
||||
}: InteractiveChatBoxProps) {
|
||||
const [images, setImages] = React.useState<File[]>([]);
|
||||
const [files, setFiles] = React.useState<File[]>([]);
|
||||
|
||||
const handleUpload = (selectedFiles: File[]) => {
|
||||
setFiles((prevFiles) => [
|
||||
...prevFiles,
|
||||
...selectedFiles.filter((f) => !isFileImage(f)),
|
||||
]);
|
||||
setImages((prevImages) => [
|
||||
...prevImages,
|
||||
...selectedFiles.filter((f) => isFileImage(f)),
|
||||
]);
|
||||
const handleUpload = (files: File[]) => {
|
||||
setImages((prevImages) => [...prevImages, ...files]);
|
||||
};
|
||||
|
||||
const removeElementByIndex = (array: Array<File>, index: number) => {
|
||||
const newArray = [...array];
|
||||
newArray.splice(index, 1);
|
||||
return newArray;
|
||||
};
|
||||
|
||||
const handleRemoveFile = (index: number) => {
|
||||
setFiles(removeElementByIndex(files, index));
|
||||
};
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImages(removeElementByIndex(images, index));
|
||||
setImages((prevImages) => {
|
||||
const newImages = [...prevImages];
|
||||
newImages.splice(index, 1);
|
||||
return newImages;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (message: string) => {
|
||||
onSubmit(message, images, files);
|
||||
setFiles([]);
|
||||
onSubmit(message, images);
|
||||
setImages([]);
|
||||
if (message) {
|
||||
onChange?.("");
|
||||
@@ -71,12 +55,6 @@ export function InteractiveChatBox({
|
||||
onRemove={handleRemoveImage}
|
||||
/>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<FileList
|
||||
files={files.map((f) => f.name)}
|
||||
onRemove={handleRemoveFile}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
@@ -94,7 +72,7 @@ export function InteractiveChatBox({
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
value={value}
|
||||
onFilesPaste={handleUpload}
|
||||
onImagePaste={handleUpload}
|
||||
className="py-[10px]"
|
||||
buttonClassName="py-[10px]"
|
||||
/>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { FaFile } from "react-icons/fa";
|
||||
import { RemoveButton } from "#/components/shared/buttons/remove-button";
|
||||
|
||||
interface FileItemProps {
|
||||
filename: string;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function FileItem({ filename, onRemove }: FileItemProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="file-item"
|
||||
className="flex flex-row gap-x-1 items-center justify-start"
|
||||
>
|
||||
<FaFile className="h-4 w-4" />
|
||||
<code className="text-sm flex-1 text-white truncate">{filename}</code>
|
||||
{onRemove && <RemoveButton onClick={onRemove} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { FileItem } from "./file-item";
|
||||
|
||||
interface FileListProps {
|
||||
files: string[];
|
||||
onRemove?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function FileList({ files, onRemove }: FileListProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="file-list"
|
||||
className={cn("flex flex-col gap-y-1.5 justify-start")}
|
||||
>
|
||||
{files.map((f, index) => (
|
||||
<FileItem
|
||||
key={index}
|
||||
filename={f}
|
||||
onRemove={onRemove ? () => onRemove?.(index) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,9 @@ export function RepositorySelectionForm({
|
||||
}));
|
||||
|
||||
const handleRepoSelection = (key: React.Key | null) => {
|
||||
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
|
||||
const selectedRepo = allRepositories?.find(
|
||||
(repo) => repo.id.toString() === key,
|
||||
);
|
||||
|
||||
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
|
||||
setSelectedRepository(selectedRepo || null);
|
||||
|
||||
@@ -54,10 +54,6 @@ export function TaskCard({ task }: TaskCardProps) {
|
||||
const issueType =
|
||||
task.task_type === "OPEN_ISSUE" ? "issues" : "merge_requests";
|
||||
href = `https://gitlab.com/${task.repo}/-/${issueType}/${task.issue_number}`;
|
||||
} else if (task.git_provider === "bitbucket") {
|
||||
const issueType =
|
||||
task.task_type === "OPEN_ISSUE" ? "issues" : "pull-requests";
|
||||
href = `https://bitbucket.org/${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}`;
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ImageCarousel({
|
||||
key={index}
|
||||
size={size}
|
||||
src={src}
|
||||
onRemove={onRemove ? () => onRemove?.(index) : undefined}
|
||||
onRemove={onRemove && (() => onRemove(index))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -15,12 +15,7 @@ export function ImagePreview({
|
||||
return (
|
||||
<div data-testid="image-preview" className="relative w-fit shrink-0">
|
||||
<Thumbnail src={src} size={size} />
|
||||
{onRemove && (
|
||||
<RemoveButton
|
||||
onClick={onRemove}
|
||||
className="absolute right-[3px] top-[3px]"
|
||||
/>
|
||||
)}
|
||||
{onRemove && <RemoveButton onClick={onRemove} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ interface UploadImageInputProps {
|
||||
export function UploadImageInput({ onUpload, label }: UploadImageInputProps) {
|
||||
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files) {
|
||||
onUpload(Array.from(event.target.files));
|
||||
const validFiles = Array.from(event.target.files).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
onUpload(validFiles);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,6 +21,7 @@ export function UploadImageInput({ onUpload, label }: UploadImageInputProps) {
|
||||
<input
|
||||
data-testid="upload-image-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={handleUpload}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function BitbucketTokenHelpAnchor() {
|
||||
return (
|
||||
<p data-testid="bitbucket-token-help-anchor" className="text-xs">
|
||||
<Trans
|
||||
i18nKey={I18nKey.BITBUCKET$TOKEN_HELP_TEXT}
|
||||
components={[
|
||||
<a
|
||||
key="bitbucket-token-help-anchor-link"
|
||||
aria-label="Bitbucket token help link"
|
||||
href="https://bitbucket.org/account/settings/app-passwords/new?scopes=repository:write,pullrequest:write,issue:write"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
<a
|
||||
key="bitbucket-token-help-anchor-link-2"
|
||||
aria-label="Bitbucket token see more link"
|
||||
href="https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsInput } from "../settings-input";
|
||||
import { BitbucketTokenHelpAnchor } from "./bitbucket-token-help-anchor";
|
||||
import { KeyStatusIcon } from "../key-status-icon";
|
||||
|
||||
interface BitbucketTokenInputProps {
|
||||
onChange: (value: string) => void;
|
||||
onBitbucketHostChange: (value: string) => void;
|
||||
isBitbucketTokenSet: boolean;
|
||||
name: string;
|
||||
bitbucketHostSet: string | null | undefined;
|
||||
}
|
||||
|
||||
export function BitbucketTokenInput({
|
||||
onChange,
|
||||
onBitbucketHostChange,
|
||||
isBitbucketTokenSet,
|
||||
name,
|
||||
bitbucketHostSet,
|
||||
}: BitbucketTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsInput
|
||||
testId={name}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
label={t(I18nKey.BITBUCKET$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-full max-w-[680px]"
|
||||
placeholder={isBitbucketTokenSet ? "<hidden>" : "username:app_password"}
|
||||
startContent={
|
||||
isBitbucketTokenSet && (
|
||||
<KeyStatusIcon
|
||||
testId="bb-set-token-indicator"
|
||||
isSet={isBitbucketTokenSet}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
onChange={onBitbucketHostChange || (() => {})}
|
||||
name="bitbucket-host-input"
|
||||
testId="bitbucket-host-input"
|
||||
label={t(I18nKey.BITBUCKET$HOST_LABEL)}
|
||||
type="text"
|
||||
className="w-full max-w-[680px]"
|
||||
placeholder="bitbucket.org"
|
||||
defaultValue={bitbucketHostSet || undefined}
|
||||
startContent={
|
||||
bitbucketHostSet &&
|
||||
bitbucketHostSet.trim() !== "" && (
|
||||
<KeyStatusIcon testId="bb-set-host-indicator" isSet />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<BitbucketTokenHelpAnchor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
|
||||
import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
|
||||
import { useAuthUrl } from "#/hooks/use-auth-url";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
|
||||
@@ -24,11 +23,6 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
identityProvider: "gitlab",
|
||||
});
|
||||
|
||||
const bitbucketAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "bitbucket",
|
||||
});
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
if (githubAuthUrl) {
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
@@ -43,13 +37,6 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBitbucketAuth = () => {
|
||||
if (bitbucketAuthUrl) {
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = bitbucketAuthUrl;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
@@ -80,16 +67,6 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
|
||||
>
|
||||
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleBitbucketAuth}
|
||||
className="w-full"
|
||||
startContent={<BitbucketLogo width={20} height={20} />}
|
||||
>
|
||||
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
|
||||
@@ -3,20 +3,19 @@ import CloseIcon from "#/icons/close.svg?react";
|
||||
|
||||
interface RemoveButtonProps {
|
||||
onClick: () => void;
|
||||
className?: React.HTMLAttributes<HTMLDivElement>["className"];
|
||||
}
|
||||
|
||||
export function RemoveButton({ onClick, className }: RemoveButtonProps) {
|
||||
export function RemoveButton({ onClick }: RemoveButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"bg-neutral-400 rounded-full w-5 h-5 flex items-center justify-center",
|
||||
className,
|
||||
"bg-neutral-400 rounded-full w-3 h-3 flex items-center justify-center",
|
||||
"absolute right-[3px] top-[3px]",
|
||||
)}
|
||||
>
|
||||
<CloseIcon width={18} height={18} />
|
||||
<CloseIcon width={10} height={10} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
126
frontend/src/components/shared/task-form.tsx
Normal file
126
frontend/src/components/shared/task-form.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import { useNavigation } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "#/store";
|
||||
import { addFile, removeFile } from "#/state/initial-query-slice";
|
||||
import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
import { ChatInput } from "#/components/features/chat/chat-input";
|
||||
import { getRandomKey } from "#/utils/get-random-key";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { AttachImageLabel } from "../features/images/attach-image-label";
|
||||
import { ImageCarousel } from "../features/images/image-carousel";
|
||||
import { UploadImageInput } from "../features/images/upload-image-input";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { LoadingSpinner } from "./loading-spinner";
|
||||
|
||||
interface TaskFormProps {
|
||||
ref: React.RefObject<HTMLFormElement | null>;
|
||||
}
|
||||
|
||||
export function TaskForm({ ref }: TaskFormProps) {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { files } = useSelector((state: RootState) => state.initialQuery);
|
||||
|
||||
const [text, setText] = React.useState("");
|
||||
const [suggestion, setSuggestion] = React.useState(() => {
|
||||
const key = getRandomKey(SUGGESTIONS["non-repo"]);
|
||||
return { key, value: SUGGESTIONS["non-repo"][key] };
|
||||
});
|
||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
|
||||
const onRefreshSuggestion = () => {
|
||||
const suggestions = SUGGESTIONS["non-repo"];
|
||||
// remove current suggestion to avoid refreshing to the same suggestion
|
||||
const suggestionCopy = { ...suggestions };
|
||||
delete suggestionCopy[suggestion.key];
|
||||
|
||||
const key = getRandomKey(suggestionCopy);
|
||||
setSuggestion({ key, value: suggestions[key] });
|
||||
};
|
||||
|
||||
const onClickSuggestion = () => {
|
||||
setText(suggestion.value);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const q = formData.get("q")?.toString();
|
||||
createConversation({ q });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<form
|
||||
ref={ref}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col items-center gap-2"
|
||||
>
|
||||
<SuggestionBubble
|
||||
suggestion={suggestion}
|
||||
onClick={onClickSuggestion}
|
||||
onRefresh={onRefreshSuggestion}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"border border-neutral-600 px-4 rounded-lg text-[17px] leading-5 w-full transition-colors duration-200",
|
||||
inputIsFocused ? "bg-neutral-600" : "bg-tertiary",
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<div className="flex justify-center py-[17px]">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<ChatInput
|
||||
name="q"
|
||||
onSubmit={() => {
|
||||
if (typeof ref !== "function") ref?.current?.requestSubmit();
|
||||
}}
|
||||
onChange={(message) => setText(message)}
|
||||
onFocus={() => setInputIsFocused(true)}
|
||||
onBlur={() => setInputIsFocused(false)}
|
||||
onImagePaste={async (imageFiles) => {
|
||||
const promises = imageFiles.map(convertImageToBase64);
|
||||
const base64Images = await Promise.all(promises);
|
||||
base64Images.forEach((base64) => {
|
||||
dispatch(addFile(base64));
|
||||
});
|
||||
}}
|
||||
value={text}
|
||||
maxRows={15}
|
||||
showButton={!!text}
|
||||
className="text-[17px] leading-5 py-[17px]"
|
||||
buttonClassName="pb-[17px]"
|
||||
disabled={navigation.state === "submitting"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<UploadImageInput
|
||||
onUpload={async (uploadedFiles) => {
|
||||
const promises = uploadedFiles.map(convertImageToBase64);
|
||||
const base64Images = await Promise.all(promises);
|
||||
base64Images.forEach((base64) => {
|
||||
dispatch(addFile(base64));
|
||||
});
|
||||
}}
|
||||
label={<AttachImageLabel />}
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<ImageCarousel
|
||||
size="large"
|
||||
images={files}
|
||||
onRemove={(index) => dispatch(removeFile(index))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
export const useUploadFiles = () =>
|
||||
useMutation({
|
||||
mutationKey: ["upload-files"],
|
||||
mutationFn: (variables: { conversationId: string; files: File[] }) =>
|
||||
FileService.uploadFiles(variables.conversationId!, variables.files),
|
||||
onSuccess: async () => {},
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
});
|
||||
@@ -15,7 +15,7 @@ export const useAutoLogin = () => {
|
||||
// Get the stored login method
|
||||
const loginMethod = getLoginMethod();
|
||||
|
||||
// Get the auth URLs for all providers
|
||||
// Get the auth URLs for both providers
|
||||
const githubAuthUrl = useAuthUrl({
|
||||
appMode: config?.APP_MODE || null,
|
||||
identityProvider: "github",
|
||||
@@ -26,11 +26,6 @@ export const useAutoLogin = () => {
|
||||
identityProvider: "gitlab",
|
||||
});
|
||||
|
||||
const bitbucketAuthUrl = useAuthUrl({
|
||||
appMode: config?.APP_MODE || null,
|
||||
identityProvider: "bitbucket",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-login in SAAS mode
|
||||
if (config?.APP_MODE !== "saas") {
|
||||
@@ -53,14 +48,8 @@ export const useAutoLogin = () => {
|
||||
}
|
||||
|
||||
// Get the appropriate auth URL based on the stored login method
|
||||
let authUrl: string | null = null;
|
||||
if (loginMethod === LoginMethod.GITHUB) {
|
||||
authUrl = githubAuthUrl;
|
||||
} else if (loginMethod === LoginMethod.GITLAB) {
|
||||
authUrl = gitlabAuthUrl;
|
||||
} else if (loginMethod === LoginMethod.BITBUCKET) {
|
||||
authUrl = bitbucketAuthUrl;
|
||||
}
|
||||
const authUrl =
|
||||
loginMethod === LoginMethod.GITHUB ? githubAuthUrl : gitlabAuthUrl;
|
||||
|
||||
// If we have an auth URL, redirect to it
|
||||
if (authUrl) {
|
||||
@@ -79,6 +68,5 @@ export const useAutoLogin = () => {
|
||||
loginMethod,
|
||||
githubAuthUrl,
|
||||
gitlabAuthUrl,
|
||||
bitbucketAuthUrl,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -248,7 +248,6 @@ export enum I18nKey {
|
||||
INVARIANT$TRACE_EXPORTED_MESSAGE = "INVARIANT$TRACE_EXPORTED_MESSAGE",
|
||||
INVARIANT$POLICY_UPDATED_MESSAGE = "INVARIANT$POLICY_UPDATED_MESSAGE",
|
||||
INVARIANT$SETTINGS_UPDATED_MESSAGE = "INVARIANT$SETTINGS_UPDATED_MESSAGE",
|
||||
CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE = "CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE",
|
||||
CHAT_INTERFACE$DISCONNECTED = "CHAT_INTERFACE$DISCONNECTED",
|
||||
CHAT_INTERFACE$CONNECTING = "CHAT_INTERFACE$CONNECTING",
|
||||
CHAT_INTERFACE$STOPPED = "CHAT_INTERFACE$STOPPED",
|
||||
@@ -509,7 +508,6 @@ export enum I18nKey {
|
||||
SETTINGS_FORM$BASE_URL = "SETTINGS_FORM$BASE_URL",
|
||||
GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB",
|
||||
GITLAB$CONNECT_TO_GITLAB = "GITLAB$CONNECT_TO_GITLAB",
|
||||
BITBUCKET$CONNECT_TO_BITBUCKET = "BITBUCKET$CONNECT_TO_BITBUCKET",
|
||||
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
|
||||
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
|
||||
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
|
||||
@@ -526,12 +524,6 @@ export enum I18nKey {
|
||||
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
|
||||
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
|
||||
GITLAB$INSTRUCTIONS_LINK_TEXT = "GITLAB$INSTRUCTIONS_LINK_TEXT",
|
||||
BITBUCKET$TOKEN_LABEL = "BITBUCKET$TOKEN_LABEL",
|
||||
BITBUCKET$HOST_LABEL = "BITBUCKET$HOST_LABEL",
|
||||
BITBUCKET$GET_TOKEN = "BITBUCKET$GET_TOKEN",
|
||||
BITBUCKET$TOKEN_HELP_TEXT = "BITBUCKET$TOKEN_HELP_TEXT",
|
||||
BITBUCKET$TOKEN_LINK_TEXT = "BITBUCKET$TOKEN_LINK_TEXT",
|
||||
BITBUCKET$INSTRUCTIONS_LINK_TEXT = "BITBUCKET$INSTRUCTIONS_LINK_TEXT",
|
||||
GITLAB$OR_SEE = "GITLAB$OR_SEE",
|
||||
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
|
||||
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED",
|
||||
|
||||
@@ -816,20 +816,20 @@
|
||||
"uk": "{\n \"sse_servers\": [],\n \"stdio_servers\": []\n}"
|
||||
},
|
||||
"HOME$CONNECT_PROVIDER_MESSAGE": {
|
||||
"en": "To get started with suggested tasks, please connect your GitHub, GitLab, or Bitbucket account.",
|
||||
"ja": "提案されたタスクを始めるには、GitHub、GitLab、またはBitbucketアカウントを接続してください。",
|
||||
"zh-CN": "要开始使用建议的任务,请连接您的GitHub、GitLab或Bitbucket账户。",
|
||||
"zh-TW": "要開始使用建議的任務,請連接您的GitHub、GitLab或Bitbucket帳戶。",
|
||||
"ko-KR": "제안된 작업을 시작하려면 GitHub, GitLab 또는 Bitbucket 계정을 연결하세요.",
|
||||
"no": "For å komme i gang med foreslåtte oppgaver, vennligst koble til GitHub, GitLab eller Bitbucket-kontoen din.",
|
||||
"it": "Per iniziare con le attività suggerite, collega il tuo account GitHub, GitLab o Bitbucket.",
|
||||
"pt": "Para começar com tarefas sugeridas, conecte sua conta GitHub, GitLab ou Bitbucket.",
|
||||
"es": "Para comenzar con las tareas sugeridas, conecte su cuenta de GitHub, GitLab o Bitbucket.",
|
||||
"ar": "للبدء بالمهام المقترحة، يرجى ربط حساب GitHub أو GitLab أو Bitbucket الخاص بك.",
|
||||
"fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub, GitLab ou Bitbucket.",
|
||||
"tr": "Önerilen görevlerle başlamak için lütfen GitHub, GitLab veya Bitbucket hesabınızı bağlayın.",
|
||||
"de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub-, GitLab- oder Bitbucket-Konto.",
|
||||
"uk": "Щоб розпочати роботу з запропонованими завданнями, підключіть свій обліковий запис GitHub, GitLab або Bitbucket."
|
||||
"en": "To get started with suggested tasks, please connect your GitHub or GitLab account.",
|
||||
"ja": "提案されたタスクを始めるには、GitHubまたはGitLabアカウントを接続してください。",
|
||||
"zh-CN": "要开始使用建议的任务,请连接您的GitHub或GitLab账户。",
|
||||
"zh-TW": "要開始使用建議的任務,請連接您的GitHub或GitLab帳戶。",
|
||||
"ko-KR": "제안된 작업을 시작하려면 GitHub 또는 GitLab 계정을 연결하세요.",
|
||||
"no": "For å komme i gang med foreslåtte oppgaver, vennligst koble til GitHub eller GitLab-kontoen din.",
|
||||
"it": "Per iniziare con le attività suggerite, collega il tuo account GitHub o GitLab.",
|
||||
"pt": "Para começar com tarefas sugeridas, conecte sua conta GitHub ou GitLab.",
|
||||
"es": "Para comenzar con las tareas sugeridas, conecte su cuenta de GitHub o GitLab.",
|
||||
"ar": "للبدء بالمهام المقترحة، يرجى ربط حساب GitHub أو GitLab الخاص بك.",
|
||||
"fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub ou GitLab.",
|
||||
"tr": "Önerilen görevlerle başlamak için lütfen GitHub veya GitLab hesabınızı bağlayın.",
|
||||
"de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub- oder GitLab-Konto.",
|
||||
"uk": "Щоб розпочати роботу з запропонованими завданнями, підключіть свій обліковий запис GitHub або GitLab."
|
||||
},
|
||||
"HOME$LETS_START_BUILDING": {
|
||||
"en": "Let's Start Building!",
|
||||
@@ -3967,22 +3967,6 @@
|
||||
"ja": "設定を更新しました",
|
||||
"uk": "Налаштування оновлено"
|
||||
},
|
||||
"CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE":{
|
||||
"en": "NEW FILES ADDED",
|
||||
"de": "NEUE DATEIEN HINZUGEFÜGT",
|
||||
"zh-CN": "已添加新文件",
|
||||
"zh-TW": "已新增檔案",
|
||||
"ko-KR": "새 파일이 추가되었습니다",
|
||||
"no": "NYE FILER LAGT TIL",
|
||||
"it": "NUOVI FILE AGGIUNTI",
|
||||
"pt": "NOVOS ARQUIVOS ADICIONADOS",
|
||||
"es": "NUEVOS ARCHIVOS AÑADIDOS",
|
||||
"ar": "تمت إضافة ملفات جديدة",
|
||||
"fr": "NOUVEAUX FICHIERS AJOUTÉS",
|
||||
"tr": "YENİ DOSYALAR EKLENDİ",
|
||||
"ja": "新しいファイルが追加されました",
|
||||
"uk": "ДОДАНО НОВІ ФАЙЛИ"
|
||||
},
|
||||
"CHAT_INTERFACE$DISCONNECTED": {
|
||||
"en": "Disconnected",
|
||||
"ja": "切断されました",
|
||||
@@ -8143,22 +8127,6 @@
|
||||
"tr": "GitLab'a bağlan",
|
||||
"uk": "Увійти за допомогою GitLab"
|
||||
},
|
||||
"BITBUCKET$CONNECT_TO_BITBUCKET": {
|
||||
"en": "Log in with Bitbucket",
|
||||
"ja": "Bitbucketに接続",
|
||||
"zh-CN": "连接到Bitbucket",
|
||||
"zh-TW": "連接到Bitbucket",
|
||||
"ko-KR": "Bitbucket에 연결",
|
||||
"de": "Mit Bitbucket verbinden",
|
||||
"no": "Koble til Bitbucket",
|
||||
"it": "Connetti a Bitbucket",
|
||||
"pt": "Conectar ao Bitbucket",
|
||||
"es": "Conectar a Bitbucket",
|
||||
"ar": "الاتصال بـ Bitbucket",
|
||||
"fr": "Se connecter à Bitbucket",
|
||||
"tr": "Bitbucket'a bağlan",
|
||||
"uk": "Увійти за допомогою Bitbucket"
|
||||
},
|
||||
"AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER": {
|
||||
"en": "Log in to OpenHands",
|
||||
"ja": "IDプロバイダーでサインイン",
|
||||
@@ -8415,102 +8383,6 @@
|
||||
"de": "klicken Sie hier für Anweisungen",
|
||||
"uk": "натисніть тут, щоб отримати інструкції"
|
||||
},
|
||||
"BITBUCKET$TOKEN_LABEL": {
|
||||
"en": "Bitbucket Token",
|
||||
"ja": "Bitbucketトークン",
|
||||
"zh-CN": "Bitbucket令牌",
|
||||
"zh-TW": "Bitbucket權杖",
|
||||
"ko-KR": "Bitbucket 토큰",
|
||||
"no": "Bitbucket-token",
|
||||
"it": "Token Bitbucket",
|
||||
"pt": "Token do Bitbucket",
|
||||
"es": "Token de Bitbucket",
|
||||
"ar": "رمز Bitbucket",
|
||||
"fr": "Jeton Bitbucket",
|
||||
"tr": "Bitbucket Token",
|
||||
"de": "Bitbucket-Token",
|
||||
"uk": "Токен Bitbucket"
|
||||
},
|
||||
"BITBUCKET$HOST_LABEL": {
|
||||
"en": "Bitbucket Host",
|
||||
"ja": "Bitbucketホスト",
|
||||
"zh-CN": "Bitbucket主机",
|
||||
"zh-TW": "Bitbucket主機",
|
||||
"ko-KR": "Bitbucket 호스트",
|
||||
"no": "Bitbucket-vert",
|
||||
"it": "Host Bitbucket",
|
||||
"pt": "Host do Bitbucket",
|
||||
"es": "Host de Bitbucket",
|
||||
"ar": "مضيف Bitbucket",
|
||||
"fr": "Hôte Bitbucket",
|
||||
"tr": "Bitbucket Sunucu",
|
||||
"de": "Bitbucket-Host",
|
||||
"uk": "Хост Bitbucket"
|
||||
},
|
||||
"BITBUCKET$GET_TOKEN": {
|
||||
"en": "Get a Bitbucket token",
|
||||
"ja": "Bitbucketトークンを取得",
|
||||
"zh-CN": "获取Bitbucket令牌",
|
||||
"zh-TW": "獲取Bitbucket權杖",
|
||||
"ko-KR": "Bitbucket 토큰 받기",
|
||||
"no": "Få et Bitbucket-token",
|
||||
"it": "Ottieni un token Bitbucket",
|
||||
"pt": "Obter um token do Bitbucket",
|
||||
"es": "Obtener un token de Bitbucket",
|
||||
"ar": "الحصول على رمز Bitbucket",
|
||||
"fr": "Obtenir un jeton Bitbucket",
|
||||
"tr": "Bitbucket token al",
|
||||
"de": "Bitbucket-Token erhalten",
|
||||
"uk": "Отримати токен Bitbucket"
|
||||
},
|
||||
"BITBUCKET$TOKEN_HELP_TEXT": {
|
||||
"en": "Get your <0>Bitbucket app password</0> or <1>click here for instructions</1>. Enter it in the format 'username:app_password'.",
|
||||
"ja": "<0>Bitbucketアプリパスワード</0>を取得するか、<1>手順についてはここをクリック</1>。'ユーザー名:アプリパスワード'の形式で入力してください。",
|
||||
"zh-CN": "获取您的<0>Bitbucket应用密码</0>或<1>点击此处获取说明</1>。请以'用户名:应用密码'的格式输入。",
|
||||
"zh-TW": "取得您的<0>Bitbucket應用密碼</0>或<1>點擊此處獲取說明</1>。請以'用戶名:應用密碼'的格式輸入。",
|
||||
"ko-KR": "<0>Bitbucket 앱 비밀번호</0>를 받거나 <1>지침을 보려면 여기를 클릭</1>하세요. '사용자 이름:앱 비밀번호' 형식으로 입력하세요.",
|
||||
"no": "Få ditt <0>Bitbucket app-passord</0> eller <1>klikk her for instruksjoner</1>. Skriv det inn i formatet 'brukernavn:app-passord'.",
|
||||
"it": "Ottieni la tua <0>password dell'app Bitbucket</0> o <1>clicca qui per istruzioni</1>. Inseriscila nel formato 'nome utente:password dell'app'.",
|
||||
"pt": "Obtenha sua <0>senha de aplicativo do Bitbucket</0> ou <1>clique aqui para instruções</1>. Digite-a no formato 'nome de usuário:senha do aplicativo'.",
|
||||
"es": "Obtenga su <0>contraseña de aplicación de Bitbucket</0> o <1>haga clic aquí para obtener instrucciones</1>. Ingrésela en el formato 'nombre de usuario:contraseña de aplicación'.",
|
||||
"ar": "احصل على <0>كلمة مرور تطبيق Bitbucket</0> الخاصة بك أو <1>انقر هنا للحصول على تعليمات</1>. أدخلها بتنسيق 'اسم المستخدم:كلمة مرور التطبيق'.",
|
||||
"fr": "Obtenez votre <0>mot de passe d'application Bitbucket</0> ou <1>cliquez ici pour les instructions</1>. Saisissez-le au format 'nom d'utilisateur:mot de passe d'application'.",
|
||||
"tr": "<0>Bitbucket uygulama şifrenizi</0> alın veya <1>talimatlar için buraya tıklayın</1>. 'kullanıcı adı:uygulama şifresi' formatında girin.",
|
||||
"de": "Holen Sie sich Ihr <0>Bitbucket App-Passwort</0> oder <1>klicken Sie hier für Anweisungen</1>. Geben Sie es im Format 'Benutzername:App-Passwort' ein.",
|
||||
"uk": "Отримайте свій <0>пароль додатка Bitbucket</0> або <1>натисніть тут, щоб отримати інструкції</1>. Введіть його у форматі 'ім'я користувача:пароль додатка'."
|
||||
},
|
||||
"BITBUCKET$TOKEN_LINK_TEXT": {
|
||||
"en": "Bitbucket app password",
|
||||
"ja": "Bitbucketアプリパスワード",
|
||||
"zh-CN": "Bitbucket应用密码",
|
||||
"zh-TW": "Bitbucket應用密碼",
|
||||
"ko-KR": "Bitbucket 앱 비밀번호",
|
||||
"no": "Bitbucket app-passord",
|
||||
"it": "password dell'app Bitbucket",
|
||||
"pt": "senha de aplicativo do Bitbucket",
|
||||
"es": "contraseña de aplicación de Bitbucket",
|
||||
"ar": "كلمة مرور تطبيق Bitbucket",
|
||||
"fr": "mot de passe d'application Bitbucket",
|
||||
"tr": "Bitbucket uygulama şifresi",
|
||||
"de": "Bitbucket App-Passwort",
|
||||
"uk": "пароль додатка Bitbucket"
|
||||
},
|
||||
"BITBUCKET$INSTRUCTIONS_LINK_TEXT": {
|
||||
"en": "click here for instructions",
|
||||
"ja": "手順についてはここをクリック",
|
||||
"zh-CN": "点击此处获取说明",
|
||||
"zh-TW": "點擊此處獲取說明",
|
||||
"ko-KR": "지침을 보려면 여기를 클릭",
|
||||
"no": "klikk her for instruksjoner",
|
||||
"it": "clicca qui per istruzioni",
|
||||
"pt": "clique aqui para instruções",
|
||||
"es": "haga clic aquí para obtener instrucciones",
|
||||
"ar": "انقر هنا للحصول على تعليمات",
|
||||
"fr": "cliquez ici pour les instructions",
|
||||
"tr": "talimatlar için buraya tıklayın",
|
||||
"de": "klicken Sie hier für Anweisungen",
|
||||
"uk": "натисніть тут, щоб отримати інструкції"
|
||||
},
|
||||
"GITLAB$OR_SEE": {
|
||||
"en": "or see the",
|
||||
"ja": "または参照",
|
||||
|
||||
@@ -140,13 +140,13 @@ export const handlers = [
|
||||
http.get("/api/user/repositories", () => {
|
||||
const data: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
id: 1,
|
||||
full_name: "octocat/hello-world",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
id: 2,
|
||||
full_name: "octocat/earth",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
@@ -157,7 +157,7 @@ export const handlers = [
|
||||
}),
|
||||
http.get("/api/user/info", () => {
|
||||
const user: GitUser = {
|
||||
id: "1",
|
||||
id: 1,
|
||||
login: "octocat",
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
|
||||
company: "GitHub",
|
||||
|
||||
@@ -19,6 +19,7 @@ const chat = ws.link(`ws://${window?.location.host}/socket.io`);
|
||||
|
||||
export const handlers: WebSocketHandler[] = [
|
||||
chat.addEventListener("connection", (connection) => {
|
||||
// @ts-expect-error - MSW v2 type incompatibility
|
||||
const io = toSocketIo(connection);
|
||||
// @ts-expect-error - accessing private property for testing purposes
|
||||
const { url }: { url: URL } = io.client.connection;
|
||||
|
||||
@@ -31,7 +31,6 @@ export const generateAssistantMessageAction = (
|
||||
args: {
|
||||
thought: message,
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
wait_for_response: false,
|
||||
},
|
||||
});
|
||||
@@ -47,7 +46,6 @@ export const generateUserMessageAction = (
|
||||
args: {
|
||||
content: message,
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
|
||||
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
|
||||
import { BitbucketTokenInput } from "#/components/features/settings/git-settings/bitbucket-token-input";
|
||||
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
|
||||
import { InstallSlackAppAnchor } from "#/components/features/settings/git-settings/install-slack-app-anchor";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
@@ -34,24 +33,18 @@ function GitSettingsScreen() {
|
||||
React.useState(false);
|
||||
const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] =
|
||||
React.useState(false);
|
||||
const [bitbucketTokenInputHasValue, setBitbucketTokenInputHasValue] =
|
||||
React.useState(false);
|
||||
|
||||
const [githubHostInputHasValue, setGithubHostInputHasValue] =
|
||||
React.useState(false);
|
||||
const [gitlabHostInputHasValue, setGitlabHostInputHasValue] =
|
||||
React.useState(false);
|
||||
const [bitbucketHostInputHasValue, setBitbucketHostInputHasValue] =
|
||||
React.useState(false);
|
||||
|
||||
const existingGithubHost = settings?.PROVIDER_TOKENS_SET.github;
|
||||
const existingGitlabHost = settings?.PROVIDER_TOKENS_SET.gitlab;
|
||||
const existingBitbucketHost = settings?.PROVIDER_TOKENS_SET.bitbucket;
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const isGitHubTokenSet = providers.includes("github");
|
||||
const isGitLabTokenSet = providers.includes("gitlab");
|
||||
const isBitbucketTokenSet = providers.includes("bitbucket");
|
||||
|
||||
const formAction = async (formData: FormData) => {
|
||||
const disconnectButtonClicked =
|
||||
@@ -64,23 +57,15 @@ function GitSettingsScreen() {
|
||||
|
||||
const githubToken = formData.get("github-token-input")?.toString() || "";
|
||||
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
|
||||
const bitbucketToken =
|
||||
formData.get("bitbucket-token-input")?.toString() || "";
|
||||
const githubHost = formData.get("github-host-input")?.toString() || "";
|
||||
const gitlabHost = formData.get("gitlab-host-input")?.toString() || "";
|
||||
const bitbucketHost =
|
||||
formData.get("bitbucket-host-input")?.toString() || "";
|
||||
|
||||
// Create providers object with all tokens
|
||||
const providerTokens: Record<string, { token: string; host: string }> = {
|
||||
github: { token: githubToken, host: githubHost },
|
||||
gitlab: { token: gitlabToken, host: gitlabHost },
|
||||
bitbucket: { token: bitbucketToken, host: bitbucketHost },
|
||||
};
|
||||
|
||||
saveGitProviders(
|
||||
{
|
||||
providers: providerTokens,
|
||||
providers: {
|
||||
github: { token: githubToken, host: githubHost },
|
||||
gitlab: { token: gitlabToken, host: gitlabHost },
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -93,10 +78,8 @@ function GitSettingsScreen() {
|
||||
onSettled: () => {
|
||||
setGithubTokenInputHasValue(false);
|
||||
setGitlabTokenInputHasValue(false);
|
||||
setBitbucketTokenInputHasValue(false);
|
||||
setGithubHostInputHasValue(false);
|
||||
setGitlabHostInputHasValue(false);
|
||||
setBitbucketHostInputHasValue(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -105,10 +88,8 @@ function GitSettingsScreen() {
|
||||
const formIsClean =
|
||||
!githubTokenInputHasValue &&
|
||||
!gitlabTokenInputHasValue &&
|
||||
!bitbucketTokenInputHasValue &&
|
||||
!githubHostInputHasValue &&
|
||||
!gitlabHostInputHasValue &&
|
||||
!bitbucketHostInputHasValue;
|
||||
!gitlabHostInputHasValue;
|
||||
const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG;
|
||||
|
||||
return (
|
||||
@@ -135,7 +116,7 @@ function GitSettingsScreen() {
|
||||
setGithubTokenInputHasValue(!!value);
|
||||
}}
|
||||
onGitHubHostChange={(value) => {
|
||||
setGithubHostInputHasValue(!!value);
|
||||
setGitlabHostInputHasValue(!!value);
|
||||
}}
|
||||
githubHostSet={existingGithubHost}
|
||||
/>
|
||||
@@ -154,20 +135,6 @@ function GitSettingsScreen() {
|
||||
gitlabHostSet={existingGitlabHost}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isSaas && (
|
||||
<BitbucketTokenInput
|
||||
name="bitbucket-token-input"
|
||||
isBitbucketTokenSet={isBitbucketTokenSet}
|
||||
onChange={(value) => {
|
||||
setBitbucketTokenInputHasValue(!!value);
|
||||
}}
|
||||
onBitbucketHostChange={(value) => {
|
||||
setBitbucketHostInputHasValue(!!value);
|
||||
}}
|
||||
bitbucketHostSet={existingBitbucketHost}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -181,9 +148,7 @@ function GitSettingsScreen() {
|
||||
name="disconnect-tokens-button"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
isDisabled={
|
||||
!isGitHubTokenSet && !isGitLabTokenSet && !isBitbucketTokenSet
|
||||
}
|
||||
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
|
||||
>
|
||||
Disconnect Tokens
|
||||
</BrandButton>
|
||||
|
||||
@@ -3,12 +3,11 @@ import ActionType from "#/types/action-type";
|
||||
export function createChatMessage(
|
||||
message: string,
|
||||
image_urls: string[],
|
||||
file_urls: string[],
|
||||
timestamp: string,
|
||||
) {
|
||||
const event = {
|
||||
action: ActionType.MESSAGE,
|
||||
args: { content: message, image_urls, file_urls, timestamp },
|
||||
args: { content: message, image_urls, timestamp },
|
||||
};
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
|
||||
args: {
|
||||
content: string;
|
||||
image_urls: string[];
|
||||
file_urls: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +36,6 @@ export interface AssistantMessageAction
|
||||
args: {
|
||||
thought: string;
|
||||
image_urls: string[] | null;
|
||||
file_urls: string[];
|
||||
wait_for_response: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
4
frontend/src/types/git.d.ts
vendored
4
frontend/src/types/git.d.ts
vendored
@@ -7,7 +7,7 @@ interface GitHubErrorReponse {
|
||||
}
|
||||
|
||||
interface GitUser {
|
||||
id: string;
|
||||
id: number;
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
company: string | null;
|
||||
@@ -23,7 +23,7 @@ interface Branch {
|
||||
}
|
||||
|
||||
interface GitRepository {
|
||||
id: string;
|
||||
id: number;
|
||||
full_name: string;
|
||||
git_provider: Provider;
|
||||
is_public: boolean;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export const ProviderOptions = {
|
||||
github: "github",
|
||||
gitlab: "gitlab",
|
||||
bitbucket: "bitbucket",
|
||||
} as const;
|
||||
|
||||
export type Provider = keyof typeof ProviderOptions;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Generates a URL to redirect to for OAuth authentication
|
||||
* @param identityProvider The identity provider to use (e.g., "github", "gitlab", "bitbucket")
|
||||
* @param identityProvider The identity provider to use (e.g., "github", "gitlab")
|
||||
* @param requestUrl The URL of the request
|
||||
* @returns The URL to redirect to for OAuth
|
||||
*/
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Check if a file is an image.
|
||||
* @param file - The File object to check.
|
||||
* @returns True if the file is an image, false otherwise.
|
||||
*/
|
||||
export const isFileImage = (file: File): boolean =>
|
||||
file.type.startsWith("image/");
|
||||
@@ -7,12 +7,11 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
export enum LoginMethod {
|
||||
GITHUB = "github",
|
||||
GITLAB = "gitlab",
|
||||
BITBUCKET = "bitbucket",
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the login method in local storage
|
||||
* @param method The login method (github, gitlab, or bitbucket)
|
||||
* @param method The login method (github or gitlab)
|
||||
*/
|
||||
export const setLoginMethod = (method: LoginMethod): void => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, method);
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: bitbucket
|
||||
type: knowledge
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
triggers:
|
||||
- bitbucket
|
||||
---
|
||||
|
||||
You have access to an environment variable, `BITBUCKET_TOKEN`, which allows you to interact with
|
||||
the Bitbucket API.
|
||||
|
||||
<IMPORTANT>
|
||||
You can use `curl` with the `BITBUCKET_TOKEN` to interact with Bitbucket's API.
|
||||
ALWAYS use the Bitbucket API for operations instead of a web browser.
|
||||
ALWAYS use the `create_bitbucket_pr` tool to open a pull request
|
||||
</IMPORTANT>
|
||||
|
||||
If you encounter authentication issues when pushing to Bitbucket (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://x-token-auth:${BITBUCKET_TOKEN}@bitbucket.org/username/repo.git`
|
||||
|
||||
Here are some instructions for pushing, but ONLY do this if the user asks you to:
|
||||
* NEVER push directly to the `main` or `master` branch
|
||||
* Git config (username and email) is pre-set. Do not modify.
|
||||
* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing.
|
||||
* Use the `create_bitbucket_pr` tool to create a pull request, if you haven't already
|
||||
* Once you've created your own branch or a pull request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
|
||||
* Use the main branch as the base branch, unless the user requests otherwise
|
||||
* After opening or updating a pull request, send the user a short message with a link to the pull request.
|
||||
* Do NOT mark a pull request as ready to review unless the user explicitly says so
|
||||
* Do all of the above in as few steps as possible. E.g. you could push changes with one step by running the following bash commands:
|
||||
```bash
|
||||
git remote -v && git branch # to find the current org, repo and branch
|
||||
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
|
||||
```
|
||||
@@ -107,10 +107,6 @@ def initialize_repository_for_runtime(
|
||||
gitlab_token = SecretStr(os.environ['GITLAB_TOKEN'])
|
||||
provider_tokens[ProviderType.GITLAB] = ProviderToken(token=gitlab_token)
|
||||
|
||||
if 'BITBUCKET_TOKEN' in os.environ:
|
||||
bitbucket_token = SecretStr(os.environ['BITBUCKET_TOKEN'])
|
||||
provider_tokens[ProviderType.BITBUCKET] = ProviderToken(token=bitbucket_token)
|
||||
|
||||
secret_store = (
|
||||
UserSecrets(provider_tokens=provider_tokens) if provider_tokens else None
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ from openhands.events.action.action import Action, ActionSecurityRisk
|
||||
@dataclass
|
||||
class MessageAction(Action):
|
||||
content: str
|
||||
file_urls: list[str] | None = None
|
||||
image_urls: list[str] | None = None
|
||||
wait_for_response: bool = False
|
||||
action: str = ActionType.MESSAGE
|
||||
@@ -34,9 +33,6 @@ class MessageAction(Action):
|
||||
if self.image_urls:
|
||||
for url in self.image_urls:
|
||||
ret += f'\nIMAGE_URL: {url}'
|
||||
if self.file_urls:
|
||||
for url in self.file_urls:
|
||||
ret += f'\nFILE_URL: {url}'
|
||||
return ret
|
||||
|
||||
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
SuggestedTask,
|
||||
User,
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class BitbucketService(BaseGitService, GitService):
|
||||
"""Default implementation of GitService for Bitbucket integration.
|
||||
|
||||
This is an extension point in OpenHands that allows applications to customize Bitbucket
|
||||
integration behavior. Applications can substitute their own implementation by:
|
||||
1. Creating a class that inherits from GitService
|
||||
2. Implementing all required methods
|
||||
3. Setting server_config.bitbucket_service_class to the fully qualified name of the class
|
||||
|
||||
The class is instantiated via get_impl() in openhands.server.shared.py.
|
||||
"""
|
||||
|
||||
BASE_URL = 'https://api.bitbucket.org/2.0'
|
||||
token: SecretStr = SecretStr('')
|
||||
refresh = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
external_auth_id: str | None = None,
|
||||
external_auth_token: SecretStr | None = None,
|
||||
token: SecretStr | None = None,
|
||||
external_token_manager: bool = False,
|
||||
base_domain: str | None = None,
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.external_token_manager = external_token_manager
|
||||
self.external_auth_id = external_auth_id
|
||||
self.external_auth_token = external_auth_token
|
||||
self.base_domain = base_domain or 'bitbucket.org'
|
||||
|
||||
if token:
|
||||
self.token = token
|
||||
if base_domain:
|
||||
self.BASE_URL = f'https://api.{base_domain}/2.0'
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return ProviderType.BITBUCKET.value
|
||||
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
"""Get latest working token of the user."""
|
||||
return self.token
|
||||
|
||||
def _has_token_expired(self, status_code: int) -> bool:
|
||||
return status_code == 401
|
||||
|
||||
async def _get_bitbucket_headers(self) -> dict[str, str]:
|
||||
"""Get headers for Bitbucket API requests."""
|
||||
token_value = self.token.get_secret_value()
|
||||
|
||||
# Check if the token contains a colon, which indicates it's in username:password format
|
||||
if ':' in token_value:
|
||||
auth_str = base64.b64encode(token_value.encode()).decode()
|
||||
return {
|
||||
'Authorization': f'Basic {auth_str}',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'Authorization': f'Bearer {token_value}',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
params: dict | None = None,
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
) -> tuple[Any, dict]:
|
||||
"""Make a request to the Bitbucket API.
|
||||
|
||||
Args:
|
||||
url: The URL to request
|
||||
params: Optional parameters for the request
|
||||
method: The HTTP method to use
|
||||
|
||||
Returns:
|
||||
A tuple of (response_data, response_headers)
|
||||
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
bitbucket_headers = await self._get_bitbucket_headers()
|
||||
response = await self.execute_request(
|
||||
client, url, bitbucket_headers, params, method
|
||||
)
|
||||
if self.refresh and self._has_token_expired(response.status_code):
|
||||
await self.get_latest_token()
|
||||
bitbucket_headers = await self._get_bitbucket_headers()
|
||||
response = await self.execute_request(
|
||||
client=client,
|
||||
url=url,
|
||||
headers=bitbucket_headers,
|
||||
params=params,
|
||||
method=method,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json(), dict(response.headers)
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise self.handle_http_status_error(e)
|
||||
except httpx.HTTPError as e:
|
||||
raise self.handle_http_error(e)
|
||||
|
||||
async def get_user(self) -> User:
|
||||
"""Get the authenticated user's information."""
|
||||
url = f'{self.BASE_URL}/user'
|
||||
data, _ = await self._make_request(url)
|
||||
|
||||
account_id = data.get('account_id', '')
|
||||
|
||||
return User(
|
||||
id=account_id,
|
||||
login=data.get('username', ''),
|
||||
avatar_url=data.get('links', {}).get('avatar', {}).get('href', ''),
|
||||
name=data.get('display_name'),
|
||||
email=None, # Bitbucket API doesn't return email in this endpoint
|
||||
)
|
||||
|
||||
async def search_repositories(
|
||||
self,
|
||||
query: str,
|
||||
per_page: int,
|
||||
sort: str,
|
||||
order: str,
|
||||
) -> list[Repository]:
|
||||
"""Search for repositories."""
|
||||
# Bitbucket doesn't have a dedicated search endpoint like GitHub
|
||||
return []
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user using workspaces endpoint.
|
||||
|
||||
This method gets all repositories (both public and private) that the user has access to
|
||||
by iterating through their workspaces and fetching repositories from each workspace.
|
||||
This approach is more comprehensive and efficient than the previous implementation
|
||||
that made separate calls for public and private repositories.
|
||||
"""
|
||||
repositories = []
|
||||
|
||||
# Get user's workspaces
|
||||
workspaces_url = f'{self.BASE_URL}/workspaces'
|
||||
workspaces_data, _ = await self._make_request(workspaces_url)
|
||||
|
||||
for workspace in workspaces_data.get('values', []):
|
||||
workspace_slug = workspace.get('slug')
|
||||
if not workspace_slug:
|
||||
continue
|
||||
|
||||
# Get repositories for this workspace
|
||||
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
|
||||
|
||||
# Map sort parameter to Bitbucket API compatible values
|
||||
bitbucket_sort = sort
|
||||
if sort == 'pushed':
|
||||
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
|
||||
bitbucket_sort = 'updated_on'
|
||||
|
||||
params = {
|
||||
'pagelen': 100,
|
||||
'sort': bitbucket_sort,
|
||||
}
|
||||
repos_data, headers = await self._make_request(workspace_repos_url, params)
|
||||
|
||||
for repo in repos_data.get('values', []):
|
||||
uuid = repo.get('uuid', '')
|
||||
repositories.append(
|
||||
Repository(
|
||||
id=uuid,
|
||||
full_name=f'{repo.get("workspace", {}).get("slug", "")}/{repo.get("slug", "")}',
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=repo.get('is_private', True) is False,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
link_header=headers.get('Link', ''),
|
||||
pushed_at=repo.get('updated_on'),
|
||||
)
|
||||
)
|
||||
|
||||
return repositories
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories."""
|
||||
# TODO: implemented suggested tasks
|
||||
return []
|
||||
|
||||
async def get_repository_details_from_repo_name(
|
||||
self, repository: str
|
||||
) -> Repository:
|
||||
"""Gets all repository details from repository name."""
|
||||
# Extract owner and repo from the repository string (e.g., "owner/repo")
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}'
|
||||
data, _ = await self._make_request(url)
|
||||
|
||||
uuid = data.get('uuid', '')
|
||||
return Repository(
|
||||
id=uuid,
|
||||
full_name=f'{data.get("workspace", {}).get("slug", "")}/{data.get("slug", "")}',
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=data.get('is_private', True) is False,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
pushed_at=data.get('updated_on'),
|
||||
)
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository."""
|
||||
# Extract owner and repo from the repository string (e.g., "owner/repo")
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
|
||||
data, _ = await self._make_request(url)
|
||||
|
||||
branches = []
|
||||
for branch in data.get('values', []):
|
||||
branches.append(
|
||||
Branch(
|
||||
name=branch.get('name', ''),
|
||||
commit_sha=branch.get('target', {}).get('hash', ''),
|
||||
protected=False, # Bitbucket doesn't expose this in the API
|
||||
last_push_date=branch.get('target', {}).get('date', None),
|
||||
)
|
||||
)
|
||||
|
||||
return branches
|
||||
|
||||
async def create_pr(
|
||||
self,
|
||||
repo_name: str,
|
||||
source_branch: str,
|
||||
target_branch: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
draft: bool = False,
|
||||
) -> str:
|
||||
"""Creates a pull request in Bitbucket.
|
||||
|
||||
Args:
|
||||
repo_name: The repository name in the format "workspace/repo"
|
||||
source_branch: The source branch name
|
||||
target_branch: The target branch name
|
||||
title: The title of the pull request
|
||||
body: The description of the pull request
|
||||
draft: Whether to create a draft pull request
|
||||
|
||||
Returns:
|
||||
The URL of the created pull request
|
||||
"""
|
||||
# Extract owner and repo from the repository string (e.g., "owner/repo")
|
||||
parts = repo_name.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repo_name}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/pullrequests'
|
||||
|
||||
payload = {
|
||||
'title': title,
|
||||
'description': body or '',
|
||||
'source': {'branch': {'name': source_branch}},
|
||||
'destination': {'branch': {'name': target_branch}},
|
||||
'close_source_branch': False,
|
||||
'draft': draft,
|
||||
}
|
||||
|
||||
data, _ = await self._make_request(
|
||||
url=url, params=payload, method=RequestMethod.POST
|
||||
)
|
||||
|
||||
# Return the URL to the pull request
|
||||
return data.get('links', {}).get('html', {}).get('href', '')
|
||||
@@ -133,7 +133,7 @@ class GitHubService(BaseGitService, GitService):
|
||||
response, _ = await self._make_request(url)
|
||||
|
||||
return User(
|
||||
id=str(response.get('id', '')),
|
||||
id=response.get('id'),
|
||||
login=response.get('login'),
|
||||
avatar_url=response.get('avatar_url'),
|
||||
company=response.get('company'),
|
||||
@@ -229,7 +229,7 @@ class GitHubService(BaseGitService, GitService):
|
||||
# Convert to Repository objects
|
||||
return [
|
||||
Repository(
|
||||
id=str(repo.get('id')),
|
||||
id=repo.get('id'),
|
||||
full_name=repo.get('full_name'),
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
@@ -262,7 +262,7 @@ class GitHubService(BaseGitService, GitService):
|
||||
|
||||
repos = [
|
||||
Repository(
|
||||
id=str(repo.get('id')),
|
||||
id=repo.get('id'),
|
||||
full_name=repo.get('full_name'),
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
@@ -407,7 +407,7 @@ class GitHubService(BaseGitService, GitService):
|
||||
repo, _ = await self._make_request(url)
|
||||
|
||||
return Repository(
|
||||
id=str(repo.get('id')),
|
||||
id=repo.get('id'),
|
||||
full_name=repo.get('full_name'),
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
|
||||
@@ -184,12 +184,13 @@ class GitLabService(BaseGitService, GitService):
|
||||
avatar_url = response.get('avatar_url') or ''
|
||||
|
||||
return User(
|
||||
id=str(response.get('id', '')),
|
||||
login=response.get('username'),
|
||||
id=response.get('id'),
|
||||
username=response.get('username'),
|
||||
avatar_url=avatar_url,
|
||||
name=response.get('name'),
|
||||
email=response.get('email'),
|
||||
company=response.get('organization'),
|
||||
login=response.get('username'),
|
||||
)
|
||||
|
||||
async def search_repositories(
|
||||
@@ -207,7 +208,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
response, _ = await self._make_request(url, params)
|
||||
repos = [
|
||||
Repository(
|
||||
id=str(repo.get('id')),
|
||||
id=repo.get('id'),
|
||||
full_name=repo.get('path_with_namespace'),
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
@@ -258,7 +259,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
all_repos = all_repos[:MAX_REPOS]
|
||||
return [
|
||||
Repository(
|
||||
id=str(repo.get('id')),
|
||||
id=repo.get('id'),
|
||||
full_name=repo.get('path_with_namespace'),
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
@@ -408,7 +409,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
repo, _ = await self._make_request(url)
|
||||
|
||||
return Repository(
|
||||
id=str(repo.get('id')),
|
||||
id=repo.get('id'),
|
||||
full_name=repo.get('path_with_namespace'),
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
|
||||
@@ -14,7 +14,6 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.commands import CmdRunAction
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.integrations.bitbucket.bitbucket_service import BitbucketService
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.service_types import (
|
||||
@@ -109,7 +108,6 @@ class ProviderHandler:
|
||||
self.service_class_map: dict[ProviderType, type[GitService]] = {
|
||||
ProviderType.GITHUB: GithubServiceImpl,
|
||||
ProviderType.GITLAB: GitLabServiceImpl,
|
||||
ProviderType.BITBUCKET: BitbucketService,
|
||||
}
|
||||
|
||||
self.external_auth_id = external_auth_id
|
||||
|
||||
@@ -13,7 +13,6 @@ from openhands.server.types import AppMode
|
||||
class ProviderType(Enum):
|
||||
GITHUB = 'github'
|
||||
GITLAB = 'gitlab'
|
||||
BITBUCKET = 'bitbucket'
|
||||
|
||||
|
||||
class TaskType(str, Enum):
|
||||
@@ -52,16 +51,6 @@ class SuggestedTask(BaseModel):
|
||||
'ciProvider': 'GitHub',
|
||||
'requestVerb': 'pull request',
|
||||
}
|
||||
elif self.git_provider == ProviderType.BITBUCKET:
|
||||
return {
|
||||
'requestType': 'Pull Request',
|
||||
'requestTypeShort': 'PR',
|
||||
'apiName': 'Bitbucket API',
|
||||
'tokenEnvVar': 'BITBUCKET_TOKEN',
|
||||
'ciSystem': 'Bitbucket Pipelines',
|
||||
'ciProvider': 'Bitbucket',
|
||||
'requestVerb': 'pull request',
|
||||
}
|
||||
|
||||
raise ValueError(f'Provider {self.git_provider} for suggested task prompts')
|
||||
|
||||
@@ -94,7 +83,7 @@ class SuggestedTask(BaseModel):
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
id: int
|
||||
login: str
|
||||
avatar_url: str
|
||||
company: str | None = None
|
||||
@@ -110,7 +99,7 @@ class Branch(BaseModel):
|
||||
|
||||
|
||||
class Repository(BaseModel):
|
||||
id: str
|
||||
id: int
|
||||
full_name: str
|
||||
git_provider: ProviderType
|
||||
is_public: bool
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import traceback
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket.bitbucket_service import BitbucketService
|
||||
from openhands.integrations.github.github_service import GitHubService
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabService
|
||||
from openhands.integrations.provider import ProviderType
|
||||
@@ -11,52 +12,35 @@ async def validate_provider_token(
|
||||
token: SecretStr, base_domain: str | None = None
|
||||
) -> ProviderType | None:
|
||||
"""
|
||||
Determine whether a token is for GitHub, GitLab, or Bitbucket by attempting to get user info
|
||||
from the services.
|
||||
Determine whether a token is for GitHub or GitLab by attempting to get user info
|
||||
from both services.
|
||||
|
||||
Args:
|
||||
token: The token to check
|
||||
base_domain: Optional base domain for the service
|
||||
|
||||
Returns:
|
||||
'github' if it's a GitHub token
|
||||
'gitlab' if it's a GitLab token
|
||||
'bitbucket' if it's a Bitbucket token
|
||||
None if the token is invalid for all services
|
||||
None if the token is invalid for both services
|
||||
"""
|
||||
# Skip validation for empty tokens
|
||||
if token is None:
|
||||
return None
|
||||
|
||||
# Try GitHub first
|
||||
github_error = None
|
||||
try:
|
||||
github_service = GitHubService(token=token, base_domain=base_domain)
|
||||
await github_service.verify_access()
|
||||
return ProviderType.GITHUB
|
||||
except Exception as e:
|
||||
github_error = e
|
||||
logger.debug(
|
||||
f'Failed to validate Github token: {e} \n {traceback.format_exc()}'
|
||||
)
|
||||
|
||||
# Try GitLab next
|
||||
gitlab_error = None
|
||||
try:
|
||||
gitlab_service = GitLabService(token=token, base_domain=base_domain)
|
||||
await gitlab_service.get_user()
|
||||
return ProviderType.GITLAB
|
||||
except Exception as e:
|
||||
gitlab_error = e
|
||||
|
||||
# Try Bitbucket last
|
||||
bitbucket_error = None
|
||||
try:
|
||||
bitbucket_service = BitbucketService(token=token, base_domain=base_domain)
|
||||
await bitbucket_service.get_user()
|
||||
return ProviderType.BITBUCKET
|
||||
except Exception as e:
|
||||
bitbucket_error = e
|
||||
|
||||
logger.debug(
|
||||
f'Failed to validate token: {github_error} \n {gitlab_error} \n {bitbucket_error}'
|
||||
)
|
||||
logger.debug(
|
||||
f'Failed to validate GitLab token: {e} \n {traceback.format_exc()}'
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# OpenHands GitHub, GitLab & Bitbucket Issue Resolver 🙌
|
||||
# OpenHands Github & Gitlab Issue Resolver 🙌
|
||||
|
||||
Need help resolving a GitHub, GitLab, or Bitbucket issue but don't have the time to do it yourself? Let an AI agent help you out!
|
||||
Need help resolving a GitHub issue but don't have the time to do it yourself? Let an AI agent help you out!
|
||||
|
||||
This tool allows you to use open-source AI agents based on [OpenHands](https://github.com/all-hands-ai/openhands)
|
||||
to attempt to resolve GitHub, GitLab, and Bitbucket issues automatically. While it can handle multiple issues, it's primarily designed
|
||||
to attempt to resolve GitHub issues automatically. While it can handle multiple issues, it's primarily designed
|
||||
to help you resolve one issue at a time with high quality.
|
||||
|
||||
Getting started is simple - just follow the instructions below.
|
||||
@@ -74,8 +74,8 @@ If you prefer to run the resolver programmatically instead of using GitHub Actio
|
||||
pip install openhands-ai
|
||||
```
|
||||
|
||||
2. Create a GitHub, GitLab, or Bitbucket access token:
|
||||
- Create a GitHub access token
|
||||
2. Create a GitHub or GitLab access token:
|
||||
- Create a GitHub acces token
|
||||
- Visit [GitHub's token settings](https://github.com/settings/personal-access-tokens/new)
|
||||
- Create a fine-grained token with these scopes:
|
||||
- "Content"
|
||||
@@ -84,7 +84,7 @@ pip install openhands-ai
|
||||
- "Workflows"
|
||||
- If you don't have push access to the target repo, you can fork it first
|
||||
|
||||
- Create a GitLab access token
|
||||
- Create a GitLab acces token
|
||||
- Visit [GitLab's token settings](https://gitlab.com/-/user_settings/personal_access_tokens)
|
||||
- Create a fine-grained token with these scopes:
|
||||
- 'api'
|
||||
@@ -93,16 +93,6 @@ pip install openhands-ai
|
||||
- 'read_repository'
|
||||
- 'write_repository'
|
||||
|
||||
- Create a Bitbucket access token
|
||||
- Visit [Bitbucket's app passwords settings](https://bitbucket.org/account/settings/app-passwords/)
|
||||
- Create an app password with these scopes:
|
||||
- 'Repositories: Read'
|
||||
- 'Repositories: Write'
|
||||
- 'Pull requests: Read'
|
||||
- 'Pull requests: Write'
|
||||
- 'Issues: Read'
|
||||
- 'Issues: Write'
|
||||
|
||||
3. Set up environment variables:
|
||||
|
||||
```bash
|
||||
@@ -117,11 +107,6 @@ export GIT_USERNAME="your-github-username" # Optional, defaults to token owner
|
||||
export GITLAB_TOKEN="your-gitlab-token"
|
||||
export GIT_USERNAME="your-gitlab-username" # Optional, defaults to token owner
|
||||
|
||||
# Bitbucket credentials if you're using Bitbucket repo
|
||||
|
||||
export BITBUCKET_TOKEN="your-bitbucket-token"
|
||||
export GIT_USERNAME="your-bitbucket-username" # Optional, defaults to token owner
|
||||
|
||||
# LLM configuration
|
||||
|
||||
export LLM_MODEL="anthropic/claude-sonnet-4-20250514" # Recommended
|
||||
@@ -187,13 +172,13 @@ There are three ways you can upload:
|
||||
3. `ready` - create a non-draft PR that's ready for review
|
||||
|
||||
```bash
|
||||
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GIT_USERNAME --pr-type draft
|
||||
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GITHUB_OR_GITLAB_USERNAME --pr-type draft
|
||||
```
|
||||
|
||||
If you want to upload to a fork, you can do so by specifying the `fork-owner`:
|
||||
|
||||
```bash
|
||||
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GIT_USERNAME --pr-type draft --fork-owner YOUR_GIT_USERNAME
|
||||
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GITHUB_OR_GITLAB_USERNAME --pr-type draft --fork-owner YOUR_GITHUB_OR_GITLAB_USERNAME
|
||||
```
|
||||
|
||||
## Providing Custom Instructions
|
||||
@@ -202,5 +187,5 @@ You can customize how the AI agent approaches issue resolution by adding a repos
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you have any issues, please open an issue on this GitHub, GitLab, or Bitbucket repo, we're happy to help!
|
||||
If you have any issues, please open an issue on this github or gitlab repo, we're happy to help!
|
||||
Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the OpenHands Slack workspace (see [the README](/README.md) for an invite link).
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.resolver.interfaces.issue import (
|
||||
Issue,
|
||||
IssueHandlerInterface,
|
||||
ReviewThread,
|
||||
)
|
||||
from openhands.resolver.utils import extract_issue_references
|
||||
|
||||
|
||||
class BitbucketIssueHandler(IssueHandlerInterface):
|
||||
def __init__(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
token: str,
|
||||
username: str | None = None,
|
||||
base_domain: str = 'bitbucket.org',
|
||||
):
|
||||
"""Initialize a Bitbucket issue handler.
|
||||
|
||||
Args:
|
||||
owner: The workspace of the repository
|
||||
repo: The name of the repository
|
||||
token: The Bitbucket API token
|
||||
username: Optional Bitbucket username
|
||||
base_domain: The domain for Bitbucket Server (default: "bitbucket.org")
|
||||
"""
|
||||
self.owner = owner
|
||||
self.repo = repo
|
||||
self.token = token
|
||||
self.username = username
|
||||
self.base_domain = base_domain
|
||||
self.base_url = self.get_base_url()
|
||||
self.download_url = self.get_download_url()
|
||||
self.clone_url = self.get_clone_url()
|
||||
self.headers = self.get_headers()
|
||||
|
||||
def set_owner(self, owner: str) -> None:
|
||||
self.owner = owner
|
||||
|
||||
def get_headers(self) -> dict[str, str]:
|
||||
# Check if the token contains a colon, which indicates it's in username:password format
|
||||
if ':' in self.token:
|
||||
auth_str = base64.b64encode(self.token.encode()).decode()
|
||||
return {
|
||||
'Authorization': f'Basic {auth_str}',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
"""Get the base URL for the Bitbucket API."""
|
||||
return f'https://api.{self.base_domain}/2.0'
|
||||
|
||||
def get_download_url(self) -> str:
|
||||
"""Get the download URL for the repository."""
|
||||
return f'https://{self.base_domain}/{self.owner}/{self.repo}/get/master.zip'
|
||||
|
||||
def get_clone_url(self) -> str:
|
||||
"""Get the clone URL for the repository."""
|
||||
return f'https://{self.base_domain}/{self.owner}/{self.repo}.git'
|
||||
|
||||
def get_repo_url(self) -> str:
|
||||
"""Get the URL for the repository."""
|
||||
return f'https://{self.base_domain}/{self.owner}/{self.repo}'
|
||||
|
||||
def get_issue_url(self, issue_number: int) -> str:
|
||||
"""Get the URL for an issue."""
|
||||
return f'{self.get_repo_url()}/issues/{issue_number}'
|
||||
|
||||
def get_pr_url(self, pr_number: int) -> str:
|
||||
"""Get the URL for a pull request."""
|
||||
return f'{self.get_repo_url()}/pull-requests/{pr_number}'
|
||||
|
||||
async def get_issue(self, issue_number: int) -> Issue:
|
||||
"""Get an issue from Bitbucket.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number
|
||||
|
||||
Returns:
|
||||
An Issue object
|
||||
"""
|
||||
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/issues/{issue_number}'
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Create a basic Issue object with required fields
|
||||
issue = Issue(
|
||||
owner=self.owner,
|
||||
repo=self.repo,
|
||||
number=data.get('id'),
|
||||
title=data.get('title', ''),
|
||||
body=data.get('content', {}).get('raw', ''),
|
||||
)
|
||||
|
||||
return issue
|
||||
|
||||
def create_pr(
|
||||
self,
|
||||
title: str,
|
||||
body: str,
|
||||
head: str,
|
||||
base: str,
|
||||
) -> str:
|
||||
"""Create a pull request.
|
||||
|
||||
Args:
|
||||
title: The title of the pull request
|
||||
body: The body of the pull request
|
||||
head: The head branch
|
||||
base: The base branch
|
||||
|
||||
Returns:
|
||||
The URL of the created pull request
|
||||
"""
|
||||
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/pullrequests'
|
||||
|
||||
payload = {
|
||||
'title': title,
|
||||
'description': body,
|
||||
'source': {'branch': {'name': head}},
|
||||
'destination': {'branch': {'name': base}},
|
||||
'close_source_branch': False,
|
||||
}
|
||||
|
||||
response = httpx.post(url, headers=self.headers, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return data.get('links', {}).get('html', {}).get('href', '')
|
||||
|
||||
def download_issues(self) -> list[Any]:
|
||||
"""Download all issues from the repository.
|
||||
|
||||
Returns:
|
||||
A list of issues
|
||||
"""
|
||||
logger.warning('BitbucketIssueHandler.download_issues not implemented')
|
||||
return []
|
||||
|
||||
def get_issue_comments(
|
||||
self, issue_number: int, comment_id: int | None = None
|
||||
) -> list[str] | None:
|
||||
"""Get comments for an issue.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number
|
||||
comment_id: The comment ID (optional)
|
||||
|
||||
Returns:
|
||||
A list of comments
|
||||
"""
|
||||
logger.warning('BitbucketIssueHandler.get_issue_comments not implemented')
|
||||
return []
|
||||
|
||||
def get_branch_url(self, branch_name: str) -> str:
|
||||
"""Get the URL for a branch.
|
||||
|
||||
Args:
|
||||
branch_name: The branch name
|
||||
|
||||
Returns:
|
||||
The URL for the branch
|
||||
"""
|
||||
return (
|
||||
f'https://{self.base_domain}/{self.owner}/{self.repo}/branch/{branch_name}'
|
||||
)
|
||||
|
||||
def get_compare_url(self, branch_name: str) -> str:
|
||||
"""Get the URL for comparing branches.
|
||||
|
||||
Args:
|
||||
branch_name: The branch name
|
||||
|
||||
Returns:
|
||||
The URL for comparing branches
|
||||
"""
|
||||
return f'https://{self.base_domain}/{self.owner}/{self.repo}/compare/master...{branch_name}'
|
||||
|
||||
def get_authorize_url(self) -> str:
|
||||
"""Get the URL for authorization.
|
||||
|
||||
Returns:
|
||||
The URL for authorization
|
||||
"""
|
||||
return f'https://oauth2:{self.token}@{self.base_domain}/'
|
||||
|
||||
def get_pull_url(self, pr_number: int) -> str:
|
||||
"""Get the URL for a pull request.
|
||||
|
||||
Args:
|
||||
pr_number: The pull request number
|
||||
|
||||
Returns:
|
||||
The URL for the pull request
|
||||
"""
|
||||
return f'https://{self.base_domain}/{self.owner}/{self.repo}/pull-requests/{pr_number}'
|
||||
|
||||
def get_branch_name(self, base_branch_name: str) -> str:
|
||||
"""Get a unique branch name.
|
||||
|
||||
Args:
|
||||
base_branch_name: The base branch name
|
||||
|
||||
Returns:
|
||||
A unique branch name
|
||||
"""
|
||||
return f'{base_branch_name}-{self.owner}'
|
||||
|
||||
def branch_exists(self, branch_name: str) -> bool:
|
||||
"""Check if a branch exists.
|
||||
|
||||
Args:
|
||||
branch_name: The branch name
|
||||
|
||||
Returns:
|
||||
True if the branch exists, False otherwise
|
||||
"""
|
||||
logger.warning('BitbucketIssueHandler.branch_exists not implemented')
|
||||
return False
|
||||
|
||||
def get_default_branch_name(self) -> str:
|
||||
"""Get the default branch name.
|
||||
|
||||
Returns:
|
||||
The default branch name
|
||||
"""
|
||||
return 'master'
|
||||
|
||||
def create_pull_request(self, data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Create a pull request.
|
||||
|
||||
Args:
|
||||
data: The pull request data
|
||||
|
||||
Returns:
|
||||
The created pull request
|
||||
"""
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
title = data.get('title', '')
|
||||
description = data.get('description', '')
|
||||
source_branch = data.get('source_branch', '')
|
||||
target_branch = data.get('target_branch', '')
|
||||
|
||||
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/pullrequests'
|
||||
|
||||
payload = {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'source': {'branch': {'name': source_branch}},
|
||||
'destination': {'branch': {'name': target_branch}},
|
||||
'close_source_branch': False,
|
||||
}
|
||||
|
||||
response = httpx.post(url, headers=self.headers, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Ensure data is not None before accessing it
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
return {
|
||||
'html_url': data.get('links', {}).get('html', {}).get('href', ''),
|
||||
'number': data.get('id', 0),
|
||||
}
|
||||
|
||||
def request_reviewers(self, reviewer: str, pr_number: int) -> None:
|
||||
"""Request reviewers for a pull request.
|
||||
|
||||
Args:
|
||||
reviewer: The reviewer
|
||||
pr_number: The pull request number
|
||||
"""
|
||||
logger.warning('BitbucketIssueHandler.request_reviewers not implemented')
|
||||
|
||||
def send_comment_msg(self, issue_number: int, msg: str) -> None:
|
||||
"""Send a comment to an issue.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number
|
||||
msg: The message
|
||||
"""
|
||||
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/pullrequests/{issue_number}/comments'
|
||||
|
||||
payload = {'content': {'raw': msg}}
|
||||
|
||||
response = httpx.post(url, headers=self.headers, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
def get_issue_thread_comments(self, issue_number: int) -> list[str]:
|
||||
"""Get thread comments for an issue.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number
|
||||
|
||||
Returns:
|
||||
A list of thread comments
|
||||
"""
|
||||
logger.warning(
|
||||
'BitbucketIssueHandler.get_issue_thread_comments not implemented'
|
||||
)
|
||||
return []
|
||||
|
||||
def get_issue_review_comments(self, issue_number: int) -> list[str]:
|
||||
"""Get review comments for an issue.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number
|
||||
|
||||
Returns:
|
||||
A list of review comments
|
||||
"""
|
||||
logger.warning(
|
||||
'BitbucketIssueHandler.get_issue_review_comments not implemented'
|
||||
)
|
||||
return []
|
||||
|
||||
def get_issue_review_threads(self, issue_number: int) -> list[ReviewThread]:
|
||||
"""Get review threads for an issue.
|
||||
|
||||
Args:
|
||||
issue_number: The issue number
|
||||
|
||||
Returns:
|
||||
A list of review threads
|
||||
"""
|
||||
logger.warning('BitbucketIssueHandler.get_issue_review_threads not implemented')
|
||||
return []
|
||||
|
||||
def get_context_from_external_issues_references(
|
||||
self,
|
||||
closing_issues: list[str],
|
||||
closing_issue_numbers: list[int],
|
||||
issue_body: str,
|
||||
review_comments: list[str] | None,
|
||||
review_threads: list[ReviewThread],
|
||||
thread_comments: list[str] | None,
|
||||
) -> list[str]:
|
||||
"""Get context from external issue references.
|
||||
|
||||
Args:
|
||||
closing_issues: List of closing issue references
|
||||
closing_issue_numbers: List of closing issue numbers
|
||||
issue_body: The issue body
|
||||
review_comments: List of review comments
|
||||
review_threads: List of review threads
|
||||
thread_comments: List of thread comments
|
||||
|
||||
Returns:
|
||||
Context from external issue references
|
||||
"""
|
||||
new_issue_references = []
|
||||
|
||||
if issue_body:
|
||||
new_issue_references.extend(extract_issue_references(issue_body))
|
||||
|
||||
if review_comments:
|
||||
for comment in review_comments:
|
||||
new_issue_references.extend(extract_issue_references(comment))
|
||||
|
||||
if review_threads:
|
||||
for review_thread in review_threads:
|
||||
new_issue_references.extend(
|
||||
extract_issue_references(review_thread.comment)
|
||||
)
|
||||
|
||||
if thread_comments:
|
||||
for thread_comment in thread_comments:
|
||||
new_issue_references.extend(extract_issue_references(thread_comment))
|
||||
|
||||
non_duplicate_references = set(new_issue_references)
|
||||
unique_issue_references = non_duplicate_references.difference(
|
||||
closing_issue_numbers
|
||||
)
|
||||
|
||||
for issue_number in unique_issue_references:
|
||||
try:
|
||||
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/issues/{issue_number}'
|
||||
response = httpx.get(url, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
issue_data = response.json()
|
||||
issue_body = issue_data.get('content', {}).get('raw', '')
|
||||
if issue_body:
|
||||
closing_issues.append(issue_body)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f'Failed to fetch issue {issue_number}: {str(e)}')
|
||||
|
||||
return closing_issues
|
||||
|
||||
def get_converted_issues(
|
||||
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
|
||||
) -> list[Issue]:
|
||||
"""Get converted issues.
|
||||
|
||||
Args:
|
||||
issue_numbers: List of issue numbers
|
||||
comment_id: The comment ID
|
||||
|
||||
Returns:
|
||||
A list of converted issues
|
||||
"""
|
||||
if not issue_numbers:
|
||||
raise ValueError('Unspecified issue numbers')
|
||||
|
||||
all_issues = self.download_issues()
|
||||
logger.info(f'Limiting resolving to issues {issue_numbers}.')
|
||||
all_issues = [issue for issue in all_issues if issue.get('id') in issue_numbers]
|
||||
|
||||
converted_issues = []
|
||||
for issue in all_issues:
|
||||
# For PRs, body can be None
|
||||
if any([issue.get(key) is None for key in ['id', 'title']]):
|
||||
logger.warning(f'Skipping #{issue} as it is missing id or title.')
|
||||
continue
|
||||
|
||||
# Handle None body for PRs
|
||||
body = (
|
||||
issue.get('content', {}).get('raw', '')
|
||||
if issue.get('content') is not None
|
||||
else ''
|
||||
)
|
||||
|
||||
# Placeholder for PR metadata
|
||||
closing_issues: list[str] = []
|
||||
review_comments: list[str] = []
|
||||
review_threads: list[ReviewThread] = []
|
||||
thread_ids: list[str] = []
|
||||
head_branch = issue.get('source', {}).get('branch', {}).get('name', '')
|
||||
thread_comments: list[str] = []
|
||||
|
||||
issue_details = Issue(
|
||||
owner=self.owner,
|
||||
repo=self.repo,
|
||||
number=issue['id'],
|
||||
title=issue['title'],
|
||||
body=body,
|
||||
closing_issues=closing_issues,
|
||||
review_comments=review_comments,
|
||||
review_threads=review_threads,
|
||||
thread_ids=thread_ids,
|
||||
head_branch=head_branch,
|
||||
thread_comments=thread_comments,
|
||||
)
|
||||
|
||||
converted_issues.append(issue_details)
|
||||
|
||||
return converted_issues
|
||||
|
||||
def get_graphql_url(self) -> str:
|
||||
"""Get the GraphQL URL.
|
||||
|
||||
Returns:
|
||||
The GraphQL URL
|
||||
"""
|
||||
return f'https://api.{self.base_domain}/graphql'
|
||||
|
||||
def reply_to_comment(self, pr_number: int, comment_id: str, reply: str) -> None:
|
||||
"""Reply to a comment.
|
||||
|
||||
Args:
|
||||
pr_number: The pull request number
|
||||
comment_id: The comment ID
|
||||
reply: The reply message
|
||||
"""
|
||||
url = f'{self.base_url}/repositories/{self.owner}/{self.repo}/pullrequests/{pr_number}/comments/{comment_id}'
|
||||
|
||||
payload = {'content': {'raw': reply}}
|
||||
|
||||
response = httpx.post(url, headers=self.headers, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
def get_issue_references(self, body: str) -> list[int]:
|
||||
"""Extract issue references from a string.
|
||||
|
||||
Args:
|
||||
body: The string to extract issue references from
|
||||
|
||||
Returns:
|
||||
A list of issue numbers
|
||||
"""
|
||||
return extract_issue_references(body)
|
||||
|
||||
|
||||
class BitbucketPRHandler(BitbucketIssueHandler):
|
||||
"""Handler for Bitbucket pull requests, extending the issue handler."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
token: str,
|
||||
username: str | None = None,
|
||||
base_domain: str = 'bitbucket.org',
|
||||
):
|
||||
"""Initialize a Bitbucket PR handler.
|
||||
|
||||
Args:
|
||||
owner: The workspace of the repository
|
||||
repo: The name of the repository
|
||||
token: The Bitbucket API token
|
||||
username: Optional Bitbucket username
|
||||
base_domain: The domain for Bitbucket Server (default: "bitbucket.org")
|
||||
"""
|
||||
super().__init__(owner, repo, token, username, base_domain)
|
||||
@@ -121,5 +121,5 @@ class IssueHandlerInterface(ABC):
|
||||
def get_converted_issues(
|
||||
self, issue_numbers: list[int] | None = None, comment_id: int | None = None
|
||||
) -> list[Issue]:
|
||||
"""Download issues from the git provider (GitHub, GitLab, or Bitbucket)."""
|
||||
"""Download issues from Gitlab."""
|
||||
pass
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.resolver.interfaces.bitbucket import (
|
||||
BitbucketIssueHandler,
|
||||
BitbucketPRHandler,
|
||||
)
|
||||
from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler
|
||||
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler
|
||||
from openhands.resolver.interfaces.issue_definitions import (
|
||||
@@ -46,7 +42,7 @@ class IssueHandlerFactory:
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
elif self.platform == ProviderType.GITLAB:
|
||||
else: # platform == Platform.GITLAB
|
||||
return ServiceContextIssue(
|
||||
GitlabIssueHandler(
|
||||
self.owner,
|
||||
@@ -57,19 +53,6 @@ class IssueHandlerFactory:
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
elif self.platform == ProviderType.BITBUCKET:
|
||||
return ServiceContextIssue(
|
||||
BitbucketIssueHandler(
|
||||
self.owner,
|
||||
self.repo,
|
||||
self.token,
|
||||
self.username,
|
||||
self.base_domain,
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unsupported platform: {self.platform}')
|
||||
elif self.issue_type == 'pr':
|
||||
if self.platform == ProviderType.GITHUB:
|
||||
return ServiceContextPR(
|
||||
@@ -82,7 +65,7 @@ class IssueHandlerFactory:
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
elif self.platform == ProviderType.GITLAB:
|
||||
else: # platform == Platform.GITLAB
|
||||
return ServiceContextPR(
|
||||
GitlabPRHandler(
|
||||
self.owner,
|
||||
@@ -93,18 +76,5 @@ class IssueHandlerFactory:
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
elif self.platform == ProviderType.BITBUCKET:
|
||||
return ServiceContextPR(
|
||||
BitbucketPRHandler(
|
||||
self.owner,
|
||||
self.repo,
|
||||
self.token,
|
||||
self.username,
|
||||
self.base_domain,
|
||||
),
|
||||
self.llm_config,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unsupported platform: {self.platform}')
|
||||
else:
|
||||
raise ValueError(f'Invalid issue type: {self.issue_type}')
|
||||
|
||||
@@ -76,12 +76,7 @@ class IssueResolver:
|
||||
raise ValueError('Invalid repository format. Expected owner/repo')
|
||||
owner, repo = parts
|
||||
|
||||
token = (
|
||||
args.token
|
||||
or os.getenv('GITHUB_TOKEN')
|
||||
or os.getenv('GITLAB_TOKEN')
|
||||
or os.getenv('BITBUCKET_TOKEN')
|
||||
)
|
||||
token = args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN')
|
||||
username = args.username if args.username else os.getenv('GIT_USERNAME')
|
||||
if not username:
|
||||
raise ValueError('Username is required.')
|
||||
@@ -125,11 +120,7 @@ class IssueResolver:
|
||||
base_domain = args.base_domain
|
||||
if base_domain is None:
|
||||
base_domain = (
|
||||
'github.com'
|
||||
if platform == ProviderType.GITHUB
|
||||
else 'gitlab.com'
|
||||
if platform == ProviderType.GITLAB
|
||||
else 'bitbucket.org'
|
||||
'github.com' if platform == ProviderType.GITHUB else 'gitlab.com'
|
||||
)
|
||||
|
||||
self.output_dir = args.output_dir
|
||||
|
||||
@@ -116,7 +116,7 @@ def main() -> None:
|
||||
'--base-domain',
|
||||
type=str,
|
||||
default=None,
|
||||
help='Base domain for the git server (defaults to "github.com" for GitHub, "gitlab.com" for GitLab, and "bitbucket.org" for Bitbucket)',
|
||||
help='Base domain for the git server (defaults to "github.com" for GitHub and "gitlab.com" for GitLab)',
|
||||
)
|
||||
|
||||
my_args = parser.parse_args()
|
||||
|
||||
@@ -11,7 +11,6 @@ from openhands.core.config import LLMConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.resolver.interfaces.bitbucket import BitbucketIssueHandler
|
||||
from openhands.resolver.interfaces.github import GithubIssueHandler
|
||||
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler
|
||||
from openhands.resolver.interfaces.issue import Issue
|
||||
@@ -236,55 +235,40 @@ def send_pull_request(
|
||||
pr_title: str | None = None,
|
||||
base_domain: str | None = None,
|
||||
) -> str:
|
||||
"""Send a pull request to a GitHub, GitLab, or Bitbucket repository.
|
||||
"""Send a pull request to a GitHub or Gitlab repository.
|
||||
|
||||
Args:
|
||||
issue: The issue to send the pull request for
|
||||
token: The token to use for authentication
|
||||
username: The username, if provided
|
||||
token: The GitHub or Gitlab token to use for authentication
|
||||
username: The GitHub or Gitlab username, if provided
|
||||
platform: The platform of the repository.
|
||||
patch_dir: The directory containing the patches to apply
|
||||
pr_type: The type: branch (no PR created), draft or ready (regular PR created)
|
||||
fork_owner: The owner of the fork to push changes to (if different from the original repo owner)
|
||||
additional_message: The additional messages to post as a comment on the PR in json list format
|
||||
target_branch: The target branch to create the pull request against (defaults to repository default branch)
|
||||
reviewer: The username of the reviewer to assign
|
||||
reviewer: The GitHub or Gitlab username of the reviewer to assign
|
||||
pr_title: Custom title for the pull request (optional)
|
||||
base_domain: The base domain for the git server (defaults to "github.com" for GitHub, "gitlab.com" for GitLab, and "bitbucket.org" for Bitbucket)
|
||||
base_domain: The base domain for the git server (defaults to "github.com" for GitHub and "gitlab.com" for GitLab)
|
||||
"""
|
||||
if pr_type not in ['branch', 'draft', 'ready']:
|
||||
raise ValueError(f'Invalid pr_type: {pr_type}')
|
||||
|
||||
# Determine default base_domain based on platform
|
||||
if base_domain is None:
|
||||
if platform == ProviderType.GITHUB:
|
||||
base_domain = 'github.com'
|
||||
elif platform == ProviderType.GITLAB:
|
||||
base_domain = 'gitlab.com'
|
||||
else: # platform == ProviderType.BITBUCKET
|
||||
base_domain = 'bitbucket.org'
|
||||
base_domain = 'github.com' if platform == ProviderType.GITHUB else 'gitlab.com'
|
||||
|
||||
# Create the appropriate handler based on platform
|
||||
handler = None
|
||||
if platform == ProviderType.GITHUB:
|
||||
handler = ServiceContextIssue(
|
||||
GithubIssueHandler(issue.owner, issue.repo, token, username, base_domain),
|
||||
None,
|
||||
)
|
||||
elif platform == ProviderType.GITLAB:
|
||||
else: # platform == Platform.GITLAB
|
||||
handler = ServiceContextIssue(
|
||||
GitlabIssueHandler(issue.owner, issue.repo, token, username, base_domain),
|
||||
None,
|
||||
)
|
||||
elif platform == ProviderType.BITBUCKET:
|
||||
handler = ServiceContextIssue(
|
||||
BitbucketIssueHandler(
|
||||
issue.owner, issue.repo, token, username, base_domain
|
||||
),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unsupported platform: {platform}')
|
||||
|
||||
# Create a new branch with a unique name
|
||||
base_branch_name = f'openhands-fix-issue-{issue.number}'
|
||||
|
||||
@@ -17,7 +17,7 @@ from openhands.integrations.utils import validate_provider_token
|
||||
|
||||
async def identify_token(token: str, base_domain: str | None) -> ProviderType:
|
||||
"""
|
||||
Identifies whether a token belongs to GitHub, GitLab, or Bitbucket.
|
||||
Identifies whether a token belongs to GitHub or GitLab.
|
||||
Parameters:
|
||||
token (str): The personal access token to check.
|
||||
base_domain (str): Custom base domain for provider (e.g GitHub Enterprise)
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
try:
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
_DAYTONA_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DAYTONA_AVAILABLE = False
|
||||
DaytonaRuntime = None # type: ignore
|
||||
from openhands.runtime.impl.docker.docker_runtime import (
|
||||
DockerRuntime,
|
||||
)
|
||||
@@ -20,7 +27,7 @@ _DEFAULT_RUNTIME_CLASSES: dict[str, type[Runtime]] = {
|
||||
'modal': ModalRuntime,
|
||||
'runloop': RunloopRuntime,
|
||||
'local': LocalRuntime,
|
||||
'daytona': DaytonaRuntime,
|
||||
**({'daytona': DaytonaRuntime} if _DAYTONA_AVAILABLE else {}),
|
||||
'cli': CLIRuntime,
|
||||
}
|
||||
|
||||
@@ -49,7 +56,9 @@ __all__ = [
|
||||
'ModalRuntime',
|
||||
'RunloopRuntime',
|
||||
'DockerRuntime',
|
||||
'DaytonaRuntime',
|
||||
'CLIRuntime',
|
||||
'get_runtime_cls',
|
||||
]
|
||||
|
||||
if _DAYTONA_AVAILABLE:
|
||||
__all__.append('DaytonaRuntime')
|
||||
|
||||
@@ -617,7 +617,6 @@ fi
|
||||
provider_domains = {
|
||||
ProviderType.GITHUB: 'github.com',
|
||||
ProviderType.GITLAB: 'gitlab.com',
|
||||
ProviderType.BITBUCKET: 'bitbucket.org',
|
||||
}
|
||||
|
||||
domain = provider_domains[provider]
|
||||
@@ -630,22 +629,10 @@ fi
|
||||
if git_provider_tokens and provider in git_provider_tokens:
|
||||
git_token = git_provider_tokens[provider].token
|
||||
if git_token:
|
||||
token_value = git_token.get_secret_value()
|
||||
if provider == ProviderType.GITLAB:
|
||||
remote_url = (
|
||||
f'https://oauth2:{token_value}@{domain}/{repo_name}.git'
|
||||
)
|
||||
elif provider == ProviderType.BITBUCKET:
|
||||
# For Bitbucket, handle username:app_password format
|
||||
if ':' in token_value:
|
||||
# App token format: username:app_password
|
||||
remote_url = f'https://{token_value}@{domain}/{repo_name}.git'
|
||||
else:
|
||||
# Access token format: use x-token-auth
|
||||
remote_url = f'https://x-token-auth:{token_value}@{domain}/{repo_name}.git'
|
||||
remote_url = f'https://oauth2:{git_token.get_secret_value()}@{domain}/{repo_name}.git'
|
||||
else:
|
||||
# GitHub
|
||||
remote_url = f'https://{token_value}@{domain}/{repo_name}.git'
|
||||
remote_url = f'https://{git_token.get_secret_value()}@{domain}/{repo_name}.git'
|
||||
else:
|
||||
remote_url = f'https://{domain}/{repo_name}.git'
|
||||
else:
|
||||
|
||||
@@ -6,7 +6,14 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.impl.cli import CLIRuntime
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
try:
|
||||
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
|
||||
|
||||
_DAYTONA_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DAYTONA_AVAILABLE = False
|
||||
DaytonaRuntime = None # type: ignore
|
||||
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
|
||||
from openhands.runtime.impl.e2b.e2b_runtime import E2BRuntime
|
||||
from openhands.runtime.impl.local.local_runtime import LocalRuntime
|
||||
@@ -17,7 +24,6 @@ from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
|
||||
__all__ = [
|
||||
'ActionExecutionClient',
|
||||
'CLIRuntime',
|
||||
'DaytonaRuntime',
|
||||
'DockerRuntime',
|
||||
'E2BRuntime',
|
||||
'LocalRuntime',
|
||||
@@ -25,3 +31,6 @@ __all__ = [
|
||||
'RemoteRuntime',
|
||||
'RunloopRuntime',
|
||||
]
|
||||
|
||||
if _DAYTONA_AVAILABLE:
|
||||
__all__.append('DaytonaRuntime')
|
||||
|
||||
@@ -5,6 +5,7 @@ It does not implement browser functionality.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
@@ -50,6 +51,7 @@ from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.runtime.utils.bash import SubprocessBashSession
|
||||
|
||||
|
||||
class CLIRuntime(Runtime):
|
||||
@@ -119,6 +121,13 @@ class CLIRuntime(Runtime):
|
||||
self.file_editor = OHEditor(workspace_root=self._workspace_path)
|
||||
self._shell_stream_callback: Callable[[str], None] | None = None
|
||||
|
||||
# Initialize bash session
|
||||
self.bash_session = SubprocessBashSession(
|
||||
work_dir=self._workspace_path,
|
||||
username=None,
|
||||
no_change_timeout_seconds=30,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
'Initializing CLIRuntime. WARNING: NO SANDBOX IS USED. '
|
||||
'This runtime executes commands directly on the local system. '
|
||||
@@ -138,6 +147,9 @@ class CLIRuntime(Runtime):
|
||||
if not self.attach_to_existing:
|
||||
await asyncio.to_thread(self.setup_initial_env)
|
||||
|
||||
# Initialize bash session
|
||||
self.bash_session.initialize()
|
||||
|
||||
self._runtime_initialized = True
|
||||
self.set_runtime_status(RuntimeStatus.RUNTIME_STARTED)
|
||||
logger.info(f'CLIRuntime initialized with workspace at {self._workspace_path}')
|
||||
@@ -351,7 +363,7 @@ class CLIRuntime(Runtime):
|
||||
)
|
||||
|
||||
def run(self, action: CmdRunAction) -> Observation:
|
||||
"""Run a command using subprocess."""
|
||||
"""Run a command using the bash session."""
|
||||
if not self._runtime_initialized:
|
||||
return ErrorObservation(
|
||||
f'Runtime not initialized for command: {action.command}'
|
||||
@@ -369,18 +381,36 @@ class CLIRuntime(Runtime):
|
||||
)
|
||||
|
||||
try:
|
||||
effective_timeout = (
|
||||
action.timeout
|
||||
if action.timeout is not None
|
||||
else self.config.sandbox.timeout
|
||||
)
|
||||
# Set effective timeout if not already set
|
||||
if action.timeout is None:
|
||||
action.set_hard_timeout(self.config.sandbox.timeout)
|
||||
|
||||
logger.debug(
|
||||
f'Running command in CLIRuntime: "{action.command}" with effective timeout: {effective_timeout}s'
|
||||
)
|
||||
return self._execute_shell_command(
|
||||
action.command, timeout=effective_timeout
|
||||
f'Running command in CLIRuntime: "{action.command}" with effective timeout: {action.timeout}s'
|
||||
)
|
||||
|
||||
# Use the bash session to execute the command
|
||||
obs = self.bash_session.execute(action)
|
||||
|
||||
# For CLIRuntime, we need to adjust the timeout message format and working directory
|
||||
if isinstance(obs, CmdOutputObservation):
|
||||
# Fix timeout message format for CLIRuntime
|
||||
if obs.metadata.suffix and 'timed out after' in obs.metadata.suffix:
|
||||
# Extract timeout duration from the suffix
|
||||
match = re.search(
|
||||
r'timed out after ([\d.]+) seconds', obs.metadata.suffix
|
||||
)
|
||||
if match:
|
||||
timeout_duration = match.group(1)
|
||||
obs.metadata.suffix = (
|
||||
f'[The command timed out after {timeout_duration} seconds.]'
|
||||
)
|
||||
|
||||
# Fix working directory for CLIRuntime
|
||||
obs.metadata.working_dir = self._workspace_path
|
||||
|
||||
return obs
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error in CLIRuntime.run for command "{action.command}": {str(e)}'
|
||||
@@ -599,46 +629,8 @@ class CLIRuntime(Runtime):
|
||||
)
|
||||
|
||||
async def call_tool_mcp(self, action: MCPAction) -> Observation:
|
||||
"""Execute MCP action using direct client connections."""
|
||||
import sys
|
||||
|
||||
from openhands.events.observation import ErrorObservation
|
||||
|
||||
# Check if we're on Windows - MCP is disabled on Windows
|
||||
if sys.platform == 'win32':
|
||||
logger.info('MCP functionality is disabled on Windows')
|
||||
return ErrorObservation('MCP functionality is not available on Windows')
|
||||
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from openhands.mcp.utils import call_tool_mcp as call_tool_mcp_handler
|
||||
from openhands.mcp.utils import create_mcp_clients
|
||||
|
||||
# Get the updated MCP config
|
||||
updated_mcp_config = self.get_mcp_config()
|
||||
logger.debug(
|
||||
f'Creating MCP clients with servers: {updated_mcp_config.sse_servers}',
|
||||
)
|
||||
|
||||
# Create clients for this specific operation
|
||||
mcp_clients = await create_mcp_clients(
|
||||
updated_mcp_config.sse_servers,
|
||||
updated_mcp_config.shttp_servers,
|
||||
self.sid,
|
||||
)
|
||||
|
||||
if not mcp_clients:
|
||||
return ErrorObservation(
|
||||
'No MCP servers available. Please configure MCP servers in your OpenHands config.'
|
||||
)
|
||||
|
||||
# Call the tool and return the result
|
||||
result = await call_tool_mcp_handler(mcp_clients, action)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error executing MCP action: {e}')
|
||||
return ErrorObservation(f'MCP action failed: {str(e)}')
|
||||
"""Not implemented for CLI runtime."""
|
||||
return ErrorObservation('MCP functionality is not implemented in CLIRuntime')
|
||||
|
||||
@property
|
||||
def workspace_root(self) -> Path:
|
||||
@@ -775,6 +767,10 @@ class CLIRuntime(Runtime):
|
||||
raise RuntimeError(f'Error creating zip file: {str(e)}')
|
||||
|
||||
def close(self) -> None:
|
||||
# Clean up bash session
|
||||
if hasattr(self, 'bash_session'):
|
||||
self.bash_session.close()
|
||||
|
||||
self._runtime_initialized = False
|
||||
super().close()
|
||||
|
||||
@@ -807,143 +803,9 @@ class CLIRuntime(Runtime):
|
||||
def get_mcp_config(
|
||||
self, extra_stdio_servers: list[MCPStdioServerConfig] | None = None
|
||||
) -> MCPConfig:
|
||||
"""Get MCP configuration for CLI runtime."""
|
||||
import sys
|
||||
|
||||
# Check if we're on Windows - MCP is disabled on Windows
|
||||
if sys.platform == 'win32':
|
||||
logger.info('MCP is disabled on Windows, returning empty config')
|
||||
return MCPConfig(sse_servers=[], stdio_servers=[])
|
||||
|
||||
# Start with base config from OpenHands config or load from user config
|
||||
if hasattr(self.config, 'mcp') and self.config.mcp:
|
||||
updated_mcp_config = self.config.mcp.model_copy()
|
||||
else:
|
||||
updated_mcp_config = self._load_user_mcp_config()
|
||||
|
||||
# Get current stdio servers
|
||||
current_stdio_servers: list[MCPStdioServerConfig] = list(
|
||||
updated_mcp_config.stdio_servers
|
||||
)
|
||||
if extra_stdio_servers:
|
||||
current_stdio_servers.extend(extra_stdio_servers)
|
||||
|
||||
# Update the config with merged stdio servers
|
||||
updated_mcp_config.stdio_servers = current_stdio_servers
|
||||
|
||||
logger.debug(
|
||||
f'CLI Runtime MCP config: SSE servers: {len(updated_mcp_config.sse_servers)}, '
|
||||
f'SHTTP servers: {len(updated_mcp_config.shttp_servers)}, '
|
||||
f'Stdio servers: {len(updated_mcp_config.stdio_servers)}'
|
||||
)
|
||||
|
||||
return updated_mcp_config
|
||||
|
||||
def _load_user_mcp_config(self) -> MCPConfig:
|
||||
"""Load MCP config from user's configuration files and environment variables."""
|
||||
# 1. Check environment variables first
|
||||
env_config = self._load_mcp_from_env()
|
||||
if env_config:
|
||||
logger.debug('Loaded MCP config from environment variables')
|
||||
return env_config
|
||||
|
||||
# 2. Check user config file
|
||||
user_config_path = Path.home() / '.openhands' / 'config.toml'
|
||||
if user_config_path.exists():
|
||||
file_config = self._load_mcp_from_file(user_config_path)
|
||||
if file_config:
|
||||
logger.debug(f'Loaded MCP config from {user_config_path}')
|
||||
return file_config
|
||||
|
||||
# 3. Default empty config
|
||||
logger.debug('No MCP config found, using empty config')
|
||||
# TODO: Load MCP config from a local file
|
||||
return MCPConfig()
|
||||
|
||||
def _load_mcp_from_env(self) -> MCPConfig | None:
|
||||
"""Load MCP config from environment variables."""
|
||||
import json
|
||||
|
||||
try:
|
||||
sse_servers = []
|
||||
shttp_servers = []
|
||||
stdio_servers = []
|
||||
|
||||
# Support environment variables like:
|
||||
# OPENHANDS_MCP_SSE_SERVERS='[{"url":"http://localhost:3000/mcp"}]'
|
||||
# OPENHANDS_MCP_SHTTP_SERVERS='[{"url":"http://localhost:3000/mcp"}]'
|
||||
# OPENHANDS_MCP_STDIO_SERVERS='[{"name":"figma","command":"figma-mcp"}]'
|
||||
|
||||
if 'OPENHANDS_MCP_SSE_SERVERS' in os.environ:
|
||||
sse_data = json.loads(os.environ['OPENHANDS_MCP_SSE_SERVERS'])
|
||||
from openhands.core.config.mcp_config import MCPSSEServerConfig
|
||||
|
||||
sse_servers = [MCPSSEServerConfig(**server) for server in sse_data]
|
||||
|
||||
if 'OPENHANDS_MCP_SHTTP_SERVERS' in os.environ:
|
||||
shttp_data = json.loads(os.environ['OPENHANDS_MCP_SHTTP_SERVERS'])
|
||||
from openhands.core.config.mcp_config import MCPSHTTPServerConfig
|
||||
|
||||
shttp_servers = [
|
||||
MCPSHTTPServerConfig(**server) for server in shttp_data
|
||||
]
|
||||
|
||||
if 'OPENHANDS_MCP_STDIO_SERVERS' in os.environ:
|
||||
stdio_data = json.loads(os.environ['OPENHANDS_MCP_STDIO_SERVERS'])
|
||||
stdio_servers = [
|
||||
MCPStdioServerConfig(**server) for server in stdio_data
|
||||
]
|
||||
|
||||
if sse_servers or shttp_servers or stdio_servers:
|
||||
return MCPConfig(
|
||||
sse_servers=sse_servers,
|
||||
shttp_servers=shttp_servers,
|
||||
stdio_servers=stdio_servers,
|
||||
)
|
||||
|
||||
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
||||
logger.warning(
|
||||
f'Failed to parse MCP config from environment variables: {e}'
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _load_mcp_from_file(self, config_path: Path) -> MCPConfig | None:
|
||||
"""Load MCP config from a TOML configuration file using OpenHands pattern."""
|
||||
try:
|
||||
import toml
|
||||
except ImportError:
|
||||
logger.warning('toml library not available, cannot load config from file')
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as toml_contents:
|
||||
toml_config = toml.load(toml_contents)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except toml.TomlDecodeError as e:
|
||||
logger.warning(f'Cannot parse config from toml file {config_path}: {e}')
|
||||
return None
|
||||
|
||||
# Process MCP section if present (reuse OpenHands pattern)
|
||||
if 'mcp' in toml_config:
|
||||
try:
|
||||
from pydantic import ValidationError
|
||||
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
|
||||
mcp_mapping = MCPConfig.from_toml_section(toml_config['mcp'])
|
||||
# Return the base mcp config
|
||||
if 'mcp' in mcp_mapping:
|
||||
return mcp_mapping['mcp']
|
||||
except (TypeError, KeyError, ValidationError) as e:
|
||||
logger.warning(
|
||||
f'Cannot parse MCP config from toml file {config_path}: {e}'
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f'Error in MCP section in {config_path}: {e}')
|
||||
|
||||
return None
|
||||
|
||||
def subscribe_to_shell_stream(
|
||||
self, callback: Callable[[str], None] | None = None
|
||||
) -> bool:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -167,6 +168,7 @@ class BashCommandStatus(Enum):
|
||||
COMPLETED = 'completed'
|
||||
NO_CHANGE_TIMEOUT = 'no_change_timeout'
|
||||
HARD_TIMEOUT = 'hard_timeout'
|
||||
INTERRUPTED = 'interrupted'
|
||||
|
||||
|
||||
def _remove_command_prefix(command_output: str, command: str) -> str:
|
||||
@@ -654,3 +656,247 @@ class BashSession:
|
||||
logger.debug(f'SLEEPING for {self.POLL_INTERVAL} seconds for next poll')
|
||||
time.sleep(self.POLL_INTERVAL)
|
||||
raise RuntimeError('Bash session was likely interrupted...')
|
||||
|
||||
|
||||
class SubprocessBashSession(BashSession):
|
||||
"""
|
||||
A bash session implementation using individual subprocess calls
|
||||
instead of tmux, while maintaining the same interface as BashSession.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
work_dir: str,
|
||||
username: str | None = None,
|
||||
no_change_timeout_seconds: int = 30,
|
||||
max_memory_mb: int | None = None,
|
||||
allow_multiple_commands: bool = True,
|
||||
):
|
||||
# Initialize parent class attributes
|
||||
self.work_dir = work_dir
|
||||
self.username = username
|
||||
self.no_change_timeout_seconds = no_change_timeout_seconds
|
||||
self.max_memory_mb = max_memory_mb
|
||||
self.allow_multiple_commands = allow_multiple_commands
|
||||
self._initialized = False
|
||||
|
||||
# Set initial state
|
||||
self.prev_status: BashCommandStatus | None = None
|
||||
self.prev_output: str = ''
|
||||
self._closed: bool = False
|
||||
self._cwd = os.path.abspath(self.work_dir)
|
||||
self._current_process: subprocess.Popen | None = None
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize the bash session."""
|
||||
logger.debug(
|
||||
f'Initializing subprocess bash session with work dir: {self.work_dir}'
|
||||
)
|
||||
|
||||
# Set initial state
|
||||
self._initialized = True
|
||||
|
||||
logger.debug(
|
||||
f'Subprocess bash session initialized with work dir: {self.work_dir}'
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Clean up the session."""
|
||||
if self._current_process and self._current_process.poll() is None:
|
||||
self._current_process.terminate()
|
||||
try:
|
||||
self._current_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._current_process.kill()
|
||||
self._closed = True
|
||||
|
||||
def interrupt(self) -> None:
|
||||
"""Interrupt the currently running command (Ctrl+C equivalent)."""
|
||||
if self._current_process and self._current_process.poll() is None:
|
||||
logger.debug('Interrupting current command')
|
||||
self._current_process.terminate()
|
||||
self.prev_status = BashCommandStatus.INTERRUPTED
|
||||
|
||||
def get_status(self) -> BashCommandStatus | None:
|
||||
"""Get the status of the last command."""
|
||||
return self.prev_status
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if a command is currently running."""
|
||||
return (
|
||||
self._current_process is not None and self._current_process.poll() is None
|
||||
)
|
||||
|
||||
def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservation:
|
||||
"""Execute a command in the bash session using subprocess."""
|
||||
from openhands.events.observation.commands import CmdOutputMetadata
|
||||
|
||||
if not self._initialized:
|
||||
return ErrorObservation(content='Subprocess bash session not initialized')
|
||||
|
||||
command = action.command
|
||||
|
||||
# Handle interactive input (not supported in subprocess mode)
|
||||
if action.is_input:
|
||||
return ErrorObservation(
|
||||
content=f"Subprocess bash session does not support interactive input. The command '{command}' was not sent to any process."
|
||||
)
|
||||
|
||||
# Handle empty commands
|
||||
if command == '':
|
||||
return CmdOutputObservation(
|
||||
content='ERROR: No command provided.',
|
||||
command='',
|
||||
metadata=CmdOutputMetadata(),
|
||||
)
|
||||
|
||||
# Check for multiple commands based on configuration
|
||||
if not self.allow_multiple_commands:
|
||||
splited_commands = split_bash_commands(command)
|
||||
if len(splited_commands) > 1:
|
||||
return ErrorObservation(
|
||||
content=(
|
||||
f'ERROR: Cannot execute multiple commands at once.\n'
|
||||
f'Please run each command separately OR chain them into a single command via && or ;\n'
|
||||
f'Provided commands:\n{"\n".join(f"({i + 1}) {cmd}" for i, cmd in enumerate(splited_commands))}'
|
||||
)
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Prepare the command
|
||||
escaped_command = escape_bash_special_chars(command)
|
||||
logger.debug(f'EXECUTING COMMAND: {escaped_command!r}')
|
||||
|
||||
# Set effective timeout
|
||||
effective_timeout = action.timeout if action.timeout else 30.0
|
||||
|
||||
# Check if this is a background command (ends with &)
|
||||
is_background = command.strip().endswith('&')
|
||||
|
||||
# Execute the command using subprocess
|
||||
self._current_process = subprocess.Popen(
|
||||
['bash', '-c', escaped_command],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=self._cwd,
|
||||
)
|
||||
|
||||
try:
|
||||
if is_background:
|
||||
# For background commands, wait a short time to see if bash exits quickly
|
||||
# Background commands should cause bash to return immediately with exit code 0
|
||||
try:
|
||||
stdout, stderr = self._current_process.communicate(timeout=0.5)
|
||||
exit_code = self._current_process.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
# If bash doesn't exit quickly, it means the command is still running
|
||||
# This shouldn't happen for proper background commands, but handle it
|
||||
self._current_process.kill()
|
||||
stdout, stderr = self._current_process.communicate()
|
||||
exit_code = 0 # Treat as successful background launch
|
||||
else:
|
||||
stdout, stderr = self._current_process.communicate(
|
||||
timeout=effective_timeout
|
||||
)
|
||||
exit_code = self._current_process.returncode
|
||||
|
||||
# Check if process was interrupted (negative exit codes indicate signals)
|
||||
if exit_code < 0:
|
||||
self.prev_status = BashCommandStatus.INTERRUPTED
|
||||
else:
|
||||
self.prev_status = BashCommandStatus.COMPLETED
|
||||
|
||||
# Combine output and error
|
||||
combined_output = stdout
|
||||
if stderr:
|
||||
combined_output += f'\n{stderr}'
|
||||
|
||||
# Update working directory if it's a cd command
|
||||
if command.strip().startswith('cd '):
|
||||
try:
|
||||
# Try to get the new working directory
|
||||
pwd_process = subprocess.run(
|
||||
['bash', '-c', f'{escaped_command}; pwd'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=self._cwd,
|
||||
timeout=5,
|
||||
)
|
||||
if pwd_process.returncode == 0:
|
||||
new_cwd = pwd_process.stdout.strip()
|
||||
if os.path.isdir(new_cwd):
|
||||
self._cwd = new_cwd
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to update working directory: {e}')
|
||||
|
||||
# Create metadata
|
||||
metadata = CmdOutputMetadata()
|
||||
metadata.exit_code = exit_code
|
||||
metadata.working_dir = self._cwd
|
||||
|
||||
self.prev_output = ''
|
||||
|
||||
return CmdOutputObservation(
|
||||
content=combined_output.rstrip() if combined_output else '',
|
||||
command=command,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# Handle timeout
|
||||
self._current_process.kill()
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# Try to get partial output
|
||||
try:
|
||||
stdout, stderr = self._current_process.communicate(timeout=1.0)
|
||||
partial_output = stdout
|
||||
if stderr:
|
||||
partial_output += f'\n{stderr}'
|
||||
except subprocess.TimeoutExpired:
|
||||
partial_output = ''
|
||||
|
||||
metadata = CmdOutputMetadata()
|
||||
metadata.suffix = (
|
||||
f'\n[The command timed out after {elapsed_time:.1f} seconds. '
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
|
||||
self.prev_status = BashCommandStatus.HARD_TIMEOUT
|
||||
|
||||
return CmdOutputObservation(
|
||||
content=partial_output.rstrip() if partial_output else '',
|
||||
command=command,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clear current process reference
|
||||
self._current_process = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error executing command "{command}": {e}')
|
||||
return ErrorObservation(
|
||||
content=f'Error executing command "{command}": {str(e)}'
|
||||
)
|
||||
|
||||
def _ready_for_next_command(self) -> None:
|
||||
"""Reset state for next command."""
|
||||
pass
|
||||
|
||||
def _get_pane_content(self) -> str:
|
||||
"""Get current output."""
|
||||
return ''
|
||||
|
||||
@property
|
||||
def cwd(self) -> str:
|
||||
"""Get current working directory."""
|
||||
return self._cwd
|
||||
|
||||
@property
|
||||
def initialized(self) -> bool:
|
||||
"""Check if the session is initialized."""
|
||||
return self._initialized
|
||||
|
||||
@@ -111,29 +111,3 @@ def is_extension_allowed(filename: str) -> bool:
|
||||
or file_ext in (ext.lower() for ext in ALLOWED_EXTENSIONS)
|
||||
or (file_ext == '' and '.' in ALLOWED_EXTENSIONS)
|
||||
)
|
||||
|
||||
|
||||
def get_unique_filename(filename: str, folder_path: str) -> str:
|
||||
"""Returns unique filename on given folder_path. By checking if the given
|
||||
filename exists. If it doesn't, filename is simply returned.
|
||||
Otherwise, it append copy(#number) until the filename is unique.
|
||||
|
||||
Args:
|
||||
filename (str): The name of the file to check.
|
||||
folder_path (str): directory path in which file name check is performed.
|
||||
|
||||
Returns:
|
||||
string: unique filename.
|
||||
"""
|
||||
name, ext = os.path.splitext(filename)
|
||||
filename_candidate = filename
|
||||
copy_index = 0
|
||||
|
||||
while os.path.exists(os.path.join(folder_path, filename_candidate)):
|
||||
if copy_index == 0:
|
||||
filename_candidate = f'{name} copy{ext}'
|
||||
else:
|
||||
filename_candidate = f'{name} copy({copy_index}){ext}'
|
||||
copy_index += 1
|
||||
|
||||
return filename_candidate
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
)
|
||||
|
||||
|
||||
class POSTUploadFilesModel(BaseModel):
|
||||
"""
|
||||
Upload files response model
|
||||
"""
|
||||
|
||||
file_urls: list[str]
|
||||
skipped_files: list[str]
|
||||
@@ -1,7 +1,12 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pathspec import PathSpec
|
||||
from pathspec.patterns import GitWildMatchPattern
|
||||
@@ -12,15 +17,15 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
FileReadAction,
|
||||
)
|
||||
from openhands.events.action.files import FileWriteAction
|
||||
from openhands.events.observation import (
|
||||
ErrorObservation,
|
||||
FileReadObservation,
|
||||
)
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.file_config import FILES_TO_IGNORE
|
||||
from openhands.server.files import POSTUploadFilesModel
|
||||
from openhands.server.file_config import (
|
||||
FILES_TO_IGNORE,
|
||||
)
|
||||
from openhands.server.session.conversation import ServerConversation
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.utils import get_conversation, get_conversation_store
|
||||
@@ -304,37 +309,3 @@ async def get_cwd(
|
||||
cwd = os.path.join(cwd, repo_dir)
|
||||
|
||||
return cwd
|
||||
|
||||
|
||||
@app.post('/upload-files', response_model=POSTUploadFilesModel)
|
||||
async def upload_files(
|
||||
files: list[UploadFile],
|
||||
conversation: ServerConversation = Depends(get_conversation),
|
||||
):
|
||||
uploaded_files = []
|
||||
skipped_files = []
|
||||
runtime: Runtime = conversation.runtime
|
||||
|
||||
for file in files:
|
||||
file_path = os.path.join(
|
||||
runtime.config.workspace_mount_path_in_sandbox, str(file.filename)
|
||||
)
|
||||
try:
|
||||
file_content = await file.read()
|
||||
write_action = FileWriteAction(
|
||||
# TODO: DISCUSS UTF8 encoding here
|
||||
path=file_path,
|
||||
content=file_content.decode('utf-8', errors='replace'),
|
||||
)
|
||||
# TODO: DISCUSS file name unique issues
|
||||
await call_sync_from_async(runtime.run_action, write_action)
|
||||
uploaded_files.append(file_path)
|
||||
except Exception as e:
|
||||
skipped_files.append({'name': file.filename, 'reason': str(e)})
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
'uploaded_files': uploaded_files,
|
||||
'skipped_files': skipped_files,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ from fastmcp.server.dependencies import get_http_request
|
||||
from pydantic import Field
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket.bitbucket_service import BitbucketService
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
@@ -207,71 +206,3 @@ async def create_mr(
|
||||
raise ToolError(str(error))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@mcp_server.tool()
|
||||
async def create_bitbucket_pr(
|
||||
repo_name: Annotated[
|
||||
str, Field(description='Bitbucket repository (workspace/repo_slug)')
|
||||
],
|
||||
source_branch: Annotated[str, Field(description='Source branch on repo')],
|
||||
target_branch: Annotated[str, Field(description='Target branch on repo')],
|
||||
title: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description='PR Title. Start title with `DRAFT:` or `WIP:` if applicable.'
|
||||
),
|
||||
],
|
||||
description: Annotated[str | None, Field(description='PR description')],
|
||||
) -> str:
|
||||
"""Open a PR in Bitbucket"""
|
||||
|
||||
logger.info('Calling OpenHands MCP create_bitbucket_pr')
|
||||
|
||||
request = get_http_request()
|
||||
headers = request.headers
|
||||
conversation_id = headers.get('X-OpenHands-ServerConversation-ID', None)
|
||||
|
||||
provider_tokens = await get_provider_tokens(request)
|
||||
access_token = await get_access_token(request)
|
||||
user_id = await get_user_id(request)
|
||||
|
||||
bitbucket_token = (
|
||||
provider_tokens.get(ProviderType.BITBUCKET, ProviderToken())
|
||||
if provider_tokens
|
||||
else ProviderToken()
|
||||
)
|
||||
|
||||
bitbucket_service = BitbucketService(
|
||||
user_id=bitbucket_token.user_id,
|
||||
external_auth_id=user_id,
|
||||
external_auth_token=access_token,
|
||||
token=bitbucket_token.token,
|
||||
base_domain=bitbucket_token.host,
|
||||
)
|
||||
|
||||
try:
|
||||
description = await get_convo_link(
|
||||
bitbucket_service, conversation_id, description or ''
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to append convo link: {e}')
|
||||
|
||||
try:
|
||||
response = await bitbucket_service.create_pr(
|
||||
repo_name=repo_name,
|
||||
source_branch=source_branch,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
body=description,
|
||||
)
|
||||
|
||||
if conversation_id and user_id:
|
||||
await save_pr_metadata(user_id, conversation_id, response)
|
||||
|
||||
except Exception as e:
|
||||
error = f'Error creating pull request: {e}'
|
||||
logger.error(error)
|
||||
raise ToolError(str(error))
|
||||
|
||||
return response
|
||||
|
||||
@@ -330,8 +330,10 @@ class AgentSession:
|
||||
if runtime_cls == RemoteRuntime:
|
||||
# If provider tokens is passed in custom secrets, then remove provider from provider tokens
|
||||
# We prioritize provider tokens set in custom secrets
|
||||
overrided_tokens = self.override_provider_tokens_with_custom_secret(
|
||||
git_provider_tokens, custom_secrets
|
||||
provider_tokens_without_gitlab = (
|
||||
self.override_provider_tokens_with_custom_secret(
|
||||
git_provider_tokens, custom_secrets
|
||||
)
|
||||
)
|
||||
|
||||
self.runtime = runtime_cls(
|
||||
@@ -342,7 +344,7 @@ class AgentSession:
|
||||
status_callback=self._status_callback,
|
||||
headless_mode=False,
|
||||
attach_to_existing=False,
|
||||
git_provider_tokens=overrided_tokens,
|
||||
git_provider_tokens=provider_tokens_without_gitlab,
|
||||
env_vars=env_vars,
|
||||
user_id=self.user_id,
|
||||
)
|
||||
|
||||
@@ -58,7 +58,7 @@ def get_impl(cls: type[T], impl_name: str | None) -> type[T]:
|
||||
Common Use Cases:
|
||||
- Server components (ConversationManager, UserAuth, etc.)
|
||||
- Storage implementations (ConversationStore, SettingsStore, etc.)
|
||||
- Service integrations (GitHub, GitLab, Bitbucket services)
|
||||
- Service integrations (GitHub, GitLab services)
|
||||
|
||||
The implementation is cached to avoid repeated imports of the same class.
|
||||
"""
|
||||
|
||||
0
tests/runtime/__init__.py
Normal file
0
tests/runtime/__init__.py
Normal file
@@ -16,7 +16,7 @@ def test_initialize_repository_for_runtime(temp_dir, runtime_cls, run_as_openhan
|
||||
"""Test that the initialize_repository_for_runtime function works."""
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
mock_repo = Repository(
|
||||
id='1232',
|
||||
id=1232,
|
||||
full_name='All-Hands-AI/OpenHands',
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=True,
|
||||
|
||||
@@ -12,13 +12,12 @@ def assert_sandbox_config(
|
||||
base_container_image=SandboxConfig.model_fields['base_container_image'].default,
|
||||
runtime_container_image='ghcr.io/all-hands-ai/runtime:mock-nikolaik', # Default to mock version
|
||||
local_runtime_url=SandboxConfig.model_fields['local_runtime_url'].default,
|
||||
enable_auto_lint=False,
|
||||
):
|
||||
"""Helper function to assert the properties of the SandboxConfig object."""
|
||||
assert isinstance(config, SandboxConfig)
|
||||
assert config.base_container_image == base_container_image
|
||||
assert config.runtime_container_image == runtime_container_image
|
||||
assert config.enable_auto_lint is enable_auto_lint
|
||||
assert config.enable_auto_lint is False
|
||||
assert config.use_host_network is False
|
||||
assert config.timeout == 300
|
||||
assert config.local_runtime_url == local_runtime_url
|
||||
|
||||
@@ -49,7 +49,6 @@ def test_event_props_serialization_deserialization():
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'image_urls': None,
|
||||
'file_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
@@ -62,7 +61,6 @@ def test_message_action_serialization_deserialization():
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'image_urls': None,
|
||||
'file_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,685 +0,0 @@
|
||||
"""Tests for Bitbucket integration."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.integrations.service_types import ProviderType as ServiceProviderType
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.integrations.utils import validate_provider_token
|
||||
from openhands.resolver.interfaces.bitbucket import BitbucketIssueHandler
|
||||
from openhands.resolver.interfaces.issue import Issue
|
||||
from openhands.resolver.interfaces.issue_definitions import ServiceContextIssue
|
||||
from openhands.resolver.send_pull_request import send_pull_request
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.routes.secrets import check_provider_tokens
|
||||
from openhands.server.settings import POSTProviderModel
|
||||
|
||||
|
||||
# BitbucketIssueHandler Tests
|
||||
@pytest.fixture
|
||||
def bitbucket_handler():
|
||||
return BitbucketIssueHandler(
|
||||
owner='test-workspace',
|
||||
repo='test-repo',
|
||||
token='test-token',
|
||||
username='test-user',
|
||||
)
|
||||
|
||||
|
||||
def test_init():
|
||||
handler = BitbucketIssueHandler(
|
||||
owner='test-workspace',
|
||||
repo='test-repo',
|
||||
token='test-token',
|
||||
username='test-user',
|
||||
)
|
||||
|
||||
assert handler.owner == 'test-workspace'
|
||||
assert handler.repo == 'test-repo'
|
||||
assert handler.token == 'test-token'
|
||||
assert handler.username == 'test-user'
|
||||
assert handler.base_domain == 'bitbucket.org'
|
||||
assert handler.base_url == 'https://api.bitbucket.org/2.0'
|
||||
assert (
|
||||
handler.download_url
|
||||
== 'https://bitbucket.org/test-workspace/test-repo/get/master.zip'
|
||||
)
|
||||
assert handler.clone_url == 'https://bitbucket.org/test-workspace/test-repo.git'
|
||||
assert handler.headers == {
|
||||
'Authorization': 'Bearer test-token',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
|
||||
|
||||
def test_get_repo_url(bitbucket_handler):
|
||||
assert (
|
||||
bitbucket_handler.get_repo_url()
|
||||
== 'https://bitbucket.org/test-workspace/test-repo'
|
||||
)
|
||||
|
||||
|
||||
def test_get_issue_url(bitbucket_handler):
|
||||
assert (
|
||||
bitbucket_handler.get_issue_url(123)
|
||||
== 'https://bitbucket.org/test-workspace/test-repo/issues/123'
|
||||
)
|
||||
|
||||
|
||||
def test_get_pr_url(bitbucket_handler):
|
||||
assert (
|
||||
bitbucket_handler.get_pr_url(123)
|
||||
== 'https://bitbucket.org/test-workspace/test-repo/pull-requests/123'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('httpx.AsyncClient')
|
||||
async def test_get_issue(mock_client, bitbucket_handler):
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = AsyncMock()
|
||||
mock_response.json.return_value = {
|
||||
'id': 123,
|
||||
'title': 'Test Issue',
|
||||
'content': {'raw': 'Test Issue Body'},
|
||||
'links': {
|
||||
'html': {
|
||||
'href': 'https://bitbucket.org/test-workspace/test-repo/issues/123'
|
||||
}
|
||||
},
|
||||
'state': 'open',
|
||||
'reporter': {'display_name': 'Test User'},
|
||||
'assignee': [{'display_name': 'Assignee User'}],
|
||||
}
|
||||
|
||||
mock_client_instance = AsyncMock()
|
||||
mock_client_instance.get.return_value = mock_response
|
||||
mock_client.return_value.__aenter__.return_value = mock_client_instance
|
||||
|
||||
issue = await bitbucket_handler.get_issue(123)
|
||||
|
||||
assert issue.number == 123
|
||||
assert issue.title == 'Test Issue'
|
||||
assert issue.body == 'Test Issue Body'
|
||||
# We don't test for html_url, state, user, or assignees as they're not part of the Issue model
|
||||
|
||||
|
||||
@patch('httpx.post')
|
||||
def test_create_pr(mock_post, bitbucket_handler):
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {
|
||||
'links': {
|
||||
'html': {
|
||||
'href': 'https://bitbucket.org/test-workspace/test-repo/pull-requests/123'
|
||||
}
|
||||
},
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
pr_url = bitbucket_handler.create_pr(
|
||||
title='Test PR',
|
||||
body='Test PR Body',
|
||||
head='feature-branch',
|
||||
base='main',
|
||||
)
|
||||
|
||||
assert pr_url == 'https://bitbucket.org/test-workspace/test-repo/pull-requests/123'
|
||||
|
||||
expected_payload = {
|
||||
'title': 'Test PR',
|
||||
'description': 'Test PR Body',
|
||||
'source': {'branch': {'name': 'feature-branch'}},
|
||||
'destination': {'branch': {'name': 'main'}},
|
||||
'close_source_branch': False,
|
||||
}
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
'https://api.bitbucket.org/2.0/repositories/test-workspace/test-repo/pullrequests',
|
||||
headers=bitbucket_handler.headers,
|
||||
json=expected_payload,
|
||||
)
|
||||
|
||||
|
||||
# Bitbucket Send Pull Request Tests
|
||||
@patch('openhands.resolver.send_pull_request.ServiceContextIssue')
|
||||
@patch('openhands.resolver.send_pull_request.BitbucketIssueHandler')
|
||||
@patch('subprocess.run')
|
||||
def test_send_pull_request_bitbucket(
|
||||
mock_run, mock_bitbucket_handler, mock_service_context
|
||||
):
|
||||
# Mock subprocess.run to avoid actual git operations
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
# Mock the BitbucketIssueHandler instance
|
||||
mock_instance = MagicMock(spec=BitbucketIssueHandler)
|
||||
mock_bitbucket_handler.return_value = mock_instance
|
||||
|
||||
# Mock the ServiceContextIssue instance
|
||||
mock_service = MagicMock(spec=ServiceContextIssue)
|
||||
mock_service.get_branch_name.return_value = 'openhands-fix-123'
|
||||
mock_service.branch_exists.return_value = True
|
||||
mock_service.get_default_branch_name.return_value = 'main'
|
||||
mock_service.get_clone_url.return_value = (
|
||||
'https://bitbucket.org/test-workspace/test-repo.git'
|
||||
)
|
||||
mock_service.create_pull_request.return_value = {
|
||||
'html_url': 'https://bitbucket.org/test-workspace/test-repo/pull-requests/123'
|
||||
}
|
||||
# Add _strategy attribute to mock
|
||||
mock_strategy = MagicMock()
|
||||
mock_service._strategy = mock_strategy
|
||||
mock_service_context.return_value = mock_service
|
||||
|
||||
# Create a mock Issue
|
||||
mock_issue = Issue(
|
||||
number=123,
|
||||
title='Test Issue',
|
||||
owner='test-workspace',
|
||||
repo='test-repo',
|
||||
body='Test body',
|
||||
created_at='2023-01-01T00:00:00Z',
|
||||
updated_at='2023-01-01T00:00:00Z',
|
||||
closed_at=None,
|
||||
head_branch='feature-branch',
|
||||
thread_ids=None,
|
||||
)
|
||||
|
||||
# Call send_pull_request
|
||||
result = send_pull_request(
|
||||
issue=mock_issue,
|
||||
token='test-token',
|
||||
username=None,
|
||||
platform=ServiceProviderType.BITBUCKET,
|
||||
patch_dir='/tmp', # Use /tmp instead of /tmp/repo to avoid directory not found error
|
||||
pr_type='ready',
|
||||
pr_title='Test PR',
|
||||
target_branch='main',
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == 'https://bitbucket.org/test-workspace/test-repo/pull-requests/123'
|
||||
|
||||
# Verify the handler was created correctly
|
||||
mock_bitbucket_handler.assert_called_once_with(
|
||||
'test-workspace',
|
||||
'test-repo',
|
||||
'test-token',
|
||||
None,
|
||||
'bitbucket.org',
|
||||
)
|
||||
|
||||
# Verify ServiceContextIssue was created correctly
|
||||
mock_service_context.assert_called_once()
|
||||
|
||||
# Verify create_pull_request was called with the correct data
|
||||
expected_body = 'This pull request fixes #123.\n\nAutomatic fix generated by [OpenHands](https://github.com/All-Hands-AI/OpenHands/) 🙌'
|
||||
mock_service.create_pull_request.assert_called_once_with(
|
||||
{
|
||||
'title': 'Test PR',
|
||||
'description': expected_body,
|
||||
'source_branch': 'openhands-fix-123',
|
||||
'target_branch': 'main',
|
||||
'draft': False,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Bitbucket Provider Domain Tests
|
||||
class TestBitbucketProviderDomain(unittest.TestCase):
|
||||
"""Test that Bitbucket provider domain is properly handled in Runtime.clone_or_init_repo."""
|
||||
|
||||
@patch('openhands.runtime.base.Runtime.__abstractmethods__', set())
|
||||
@patch(
|
||||
'openhands.runtime.utils.edit.FileEditRuntimeMixin.__init__', return_value=None
|
||||
)
|
||||
@patch('openhands.runtime.base.ProviderHandler')
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_authenticated_git_url_bitbucket(
|
||||
self, mock_provider_handler, mock_file_edit_init, *args
|
||||
):
|
||||
"""Test that _get_authenticated_git_url correctly handles Bitbucket repositories."""
|
||||
# Mock the provider handler to return a repository with Bitbucket as the provider
|
||||
mock_repository = Repository(
|
||||
id='1',
|
||||
full_name='workspace/repo',
|
||||
git_provider=ServiceProviderType.BITBUCKET,
|
||||
is_public=True,
|
||||
)
|
||||
|
||||
mock_provider_instance = MagicMock()
|
||||
mock_provider_instance.verify_repo_provider.return_value = mock_repository
|
||||
mock_provider_handler.return_value = mock_provider_instance
|
||||
|
||||
# Create a minimal runtime instance with abstract methods patched
|
||||
config = MagicMock()
|
||||
config.get_llm_config.return_value.model = 'test_model'
|
||||
runtime = Runtime(config=config, event_stream=MagicMock(), sid='test_sid')
|
||||
|
||||
# Test with no token
|
||||
url = await runtime._get_authenticated_git_url('workspace/repo', None)
|
||||
self.assertEqual(url, 'https://bitbucket.org/workspace/repo.git')
|
||||
|
||||
# Test with username:password format token
|
||||
git_provider_tokens = {
|
||||
ProviderType.BITBUCKET: ProviderToken(
|
||||
token=SecretStr('username:app_password'), host='bitbucket.org'
|
||||
)
|
||||
}
|
||||
url = await runtime._get_authenticated_git_url(
|
||||
'workspace/repo', git_provider_tokens
|
||||
)
|
||||
# Bitbucket tokens with colon are used directly as username:password
|
||||
self.assertEqual(
|
||||
url, 'https://username:app_password@bitbucket.org/workspace/repo.git'
|
||||
)
|
||||
|
||||
# Test with email:password format token (more realistic)
|
||||
git_provider_tokens = {
|
||||
ProviderType.BITBUCKET: ProviderToken(
|
||||
token=SecretStr('user@example.com:app_password'), host='bitbucket.org'
|
||||
)
|
||||
}
|
||||
url = await runtime._get_authenticated_git_url(
|
||||
'workspace/repo', git_provider_tokens
|
||||
)
|
||||
# Email addresses in tokens are used as-is (no URL encoding in our implementation)
|
||||
self.assertEqual(
|
||||
url,
|
||||
'https://user@example.com:app_password@bitbucket.org/workspace/repo.git',
|
||||
)
|
||||
|
||||
# Test with simple token format (access token)
|
||||
git_provider_tokens = {
|
||||
ProviderType.BITBUCKET: ProviderToken(
|
||||
token=SecretStr('simple_token'), host='bitbucket.org'
|
||||
)
|
||||
}
|
||||
url = await runtime._get_authenticated_git_url(
|
||||
'workspace/repo', git_provider_tokens
|
||||
)
|
||||
# Simple tokens use x-token-auth format
|
||||
self.assertEqual(
|
||||
url, 'https://x-token-auth:simple_token@bitbucket.org/workspace/repo.git'
|
||||
)
|
||||
|
||||
@patch('openhands.runtime.base.ProviderHandler')
|
||||
@patch.object(Runtime, 'run_action')
|
||||
async def test_bitbucket_provider_domain(
|
||||
self, mock_run_action, mock_provider_handler
|
||||
):
|
||||
# Mock the provider handler to return a repository with Bitbucket as the provider
|
||||
mock_repository = Repository(
|
||||
id='1',
|
||||
full_name='test/repo',
|
||||
git_provider=ServiceProviderType.BITBUCKET,
|
||||
is_public=True,
|
||||
)
|
||||
|
||||
mock_provider_instance = MagicMock()
|
||||
mock_provider_instance.verify_repo_provider.return_value = mock_repository
|
||||
mock_provider_handler.return_value = mock_provider_instance
|
||||
|
||||
# Create a minimal runtime instance
|
||||
runtime = Runtime(config=MagicMock(), event_stream=MagicMock(), sid='test_sid')
|
||||
|
||||
# Mock the workspace_root property to avoid AttributeError
|
||||
runtime.workspace_root = '/workspace'
|
||||
|
||||
# Call clone_or_init_repo with a Bitbucket repository
|
||||
# This should now succeed with our fix
|
||||
await runtime.clone_or_init_repo(
|
||||
git_provider_tokens=None,
|
||||
selected_repository='test/repo',
|
||||
selected_branch=None,
|
||||
)
|
||||
|
||||
# Verify that run_action was called at least once (for git clone)
|
||||
self.assertTrue(mock_run_action.called)
|
||||
|
||||
# Verify that the domain used was 'bitbucket.org'
|
||||
# Extract the command from the first call to run_action
|
||||
args, _ = mock_run_action.call_args
|
||||
action = args[0]
|
||||
self.assertIn('bitbucket.org', action.command)
|
||||
|
||||
|
||||
# Provider Token Validation Tests
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_provider_token_with_bitbucket_token():
|
||||
"""
|
||||
Test that validate_provider_token correctly identifies a Bitbucket token
|
||||
and doesn't try to validate it as GitHub or GitLab.
|
||||
"""
|
||||
# Mock the service classes to avoid actual API calls
|
||||
with (
|
||||
patch('openhands.integrations.utils.GitHubService') as mock_github_service,
|
||||
patch('openhands.integrations.utils.GitLabService') as mock_gitlab_service,
|
||||
patch(
|
||||
'openhands.integrations.utils.BitbucketService'
|
||||
) as mock_bitbucket_service,
|
||||
):
|
||||
# Set up the mocks
|
||||
github_instance = AsyncMock()
|
||||
github_instance.verify_access.side_effect = Exception('Invalid GitHub token')
|
||||
mock_github_service.return_value = github_instance
|
||||
|
||||
gitlab_instance = AsyncMock()
|
||||
gitlab_instance.get_user.side_effect = Exception('Invalid GitLab token')
|
||||
mock_gitlab_service.return_value = gitlab_instance
|
||||
|
||||
bitbucket_instance = AsyncMock()
|
||||
bitbucket_instance.get_user.return_value = {'username': 'test_user'}
|
||||
mock_bitbucket_service.return_value = bitbucket_instance
|
||||
|
||||
# Test with a Bitbucket token
|
||||
token = SecretStr('username:app_password')
|
||||
result = await validate_provider_token(token)
|
||||
|
||||
# Verify that all services were tried
|
||||
mock_github_service.assert_called_once()
|
||||
mock_gitlab_service.assert_called_once()
|
||||
mock_bitbucket_service.assert_called_once()
|
||||
|
||||
# Verify that the token was identified as a Bitbucket token
|
||||
assert result == ProviderType.BITBUCKET
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_provider_tokens_with_only_bitbucket():
|
||||
"""
|
||||
Test that check_provider_tokens doesn't try to validate GitHub or GitLab tokens
|
||||
when only a Bitbucket token is provided.
|
||||
"""
|
||||
# Create a mock validate_provider_token function
|
||||
mock_validate = AsyncMock()
|
||||
mock_validate.return_value = ProviderType.BITBUCKET
|
||||
|
||||
# Create provider tokens with only Bitbucket
|
||||
provider_tokens = {
|
||||
ProviderType.BITBUCKET: ProviderToken(
|
||||
token=SecretStr('username:app_password'), host='bitbucket.org'
|
||||
),
|
||||
ProviderType.GITHUB: ProviderToken(token=SecretStr(''), host='github.com'),
|
||||
ProviderType.GITLAB: ProviderToken(token=SecretStr(''), host='gitlab.com'),
|
||||
}
|
||||
|
||||
# Create the POST model
|
||||
post_model = POSTProviderModel(provider_tokens=provider_tokens)
|
||||
|
||||
# Call check_provider_tokens with the patched validate_provider_token
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.validate_provider_token', mock_validate
|
||||
):
|
||||
result = await check_provider_tokens(post_model, None)
|
||||
|
||||
# Verify that validate_provider_token was called only once (for Bitbucket)
|
||||
assert mock_validate.call_count == 1
|
||||
|
||||
# Verify that the token passed to validate_provider_token was the Bitbucket token
|
||||
args, kwargs = mock_validate.call_args
|
||||
assert args[0].get_secret_value() == 'username:app_password'
|
||||
|
||||
# Verify that no error message was returned
|
||||
assert result == ''
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bitbucket_sort_parameter_mapping():
|
||||
"""
|
||||
Test that the Bitbucket service correctly maps sort parameters.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.bitbucket.bitbucket_service import BitbucketService
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
# Create a service instance
|
||||
service = BitbucketService(token=SecretStr('test-token'))
|
||||
|
||||
# Mock the _make_request method to avoid actual API calls
|
||||
with patch.object(service, '_make_request') as mock_request:
|
||||
# Mock workspaces response
|
||||
mock_request.side_effect = [
|
||||
# First call: workspaces
|
||||
({'values': [{'slug': 'test-workspace', 'name': 'Test Workspace'}]}, {}),
|
||||
# Second call: repositories with mapped sort parameter
|
||||
({'values': []}, {}),
|
||||
]
|
||||
|
||||
# Call get_repositories with sort='pushed'
|
||||
await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify that the second call used 'updated_on' instead of 'pushed'
|
||||
assert mock_request.call_count == 2
|
||||
|
||||
# Check the second call (repositories call)
|
||||
second_call_args = mock_request.call_args_list[1]
|
||||
url, params = second_call_args[0]
|
||||
|
||||
# Verify the sort parameter was mapped correctly
|
||||
assert params['sort'] == 'updated_on'
|
||||
assert 'repositories/test-workspace' in url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_provider_token_with_empty_tokens():
|
||||
"""
|
||||
Test that validate_provider_token handles empty tokens correctly.
|
||||
"""
|
||||
# Create a mock for each service
|
||||
with (
|
||||
patch('openhands.integrations.utils.GitHubService') as mock_github_service,
|
||||
patch('openhands.integrations.utils.GitLabService') as mock_gitlab_service,
|
||||
patch(
|
||||
'openhands.integrations.utils.BitbucketService'
|
||||
) as mock_bitbucket_service,
|
||||
):
|
||||
# Configure mocks to raise exceptions for invalid tokens
|
||||
mock_github_service.return_value.verify_access.side_effect = Exception(
|
||||
'Invalid token'
|
||||
)
|
||||
mock_gitlab_service.return_value.verify_access.side_effect = Exception(
|
||||
'Invalid token'
|
||||
)
|
||||
mock_bitbucket_service.return_value.verify_access.side_effect = Exception(
|
||||
'Invalid token'
|
||||
)
|
||||
|
||||
# Test with an empty token
|
||||
token = SecretStr('')
|
||||
result = await validate_provider_token(token)
|
||||
|
||||
# Services should be tried but fail with empty tokens
|
||||
mock_github_service.assert_called_once()
|
||||
mock_gitlab_service.assert_called_once()
|
||||
mock_bitbucket_service.assert_called_once()
|
||||
|
||||
# Result should be None for invalid tokens
|
||||
assert result is None
|
||||
|
||||
# Reset mocks for second test
|
||||
mock_github_service.reset_mock()
|
||||
mock_gitlab_service.reset_mock()
|
||||
mock_bitbucket_service.reset_mock()
|
||||
|
||||
# Test with a whitespace-only token
|
||||
token = SecretStr(' ')
|
||||
result = await validate_provider_token(token)
|
||||
|
||||
# Services should be tried but fail with whitespace tokens
|
||||
mock_github_service.assert_called_once()
|
||||
mock_gitlab_service.assert_called_once()
|
||||
mock_bitbucket_service.assert_called_once()
|
||||
|
||||
# Result should be None for invalid tokens
|
||||
assert result is None
|
||||
|
||||
|
||||
# Setup.py Bitbucket Token Tests
|
||||
@patch('openhands.core.setup.call_async_from_sync')
|
||||
@patch('openhands.core.setup.get_file_store')
|
||||
@patch('openhands.core.setup.EventStream')
|
||||
def test_initialize_repository_for_runtime_with_bitbucket_token(
|
||||
mock_event_stream, mock_get_file_store, mock_call_async_from_sync
|
||||
):
|
||||
"""Test that initialize_repository_for_runtime properly handles BITBUCKET_TOKEN."""
|
||||
from openhands.core.setup import initialize_repository_for_runtime
|
||||
from openhands.integrations.provider import ProviderType
|
||||
|
||||
# Mock runtime
|
||||
mock_runtime = MagicMock()
|
||||
mock_runtime.clone_or_init_repo = AsyncMock(return_value='test-repo')
|
||||
mock_runtime.maybe_run_setup_script = MagicMock()
|
||||
mock_runtime.maybe_setup_git_hooks = MagicMock()
|
||||
|
||||
# Mock call_async_from_sync to return the expected result
|
||||
mock_call_async_from_sync.return_value = 'test-repo'
|
||||
|
||||
# Set up environment with BITBUCKET_TOKEN
|
||||
with patch.dict(os.environ, {'BITBUCKET_TOKEN': 'username:app_password'}):
|
||||
result = initialize_repository_for_runtime(
|
||||
runtime=mock_runtime, selected_repository='all-hands-ai/test-repo'
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == 'test-repo'
|
||||
|
||||
# Verify that call_async_from_sync was called with the correct arguments
|
||||
mock_call_async_from_sync.assert_called_once()
|
||||
args, kwargs = mock_call_async_from_sync.call_args
|
||||
|
||||
# Check that the function called was clone_or_init_repo
|
||||
assert args[0] == mock_runtime.clone_or_init_repo
|
||||
|
||||
# Check that provider tokens were passed correctly
|
||||
provider_tokens = args[2] # Third argument is immutable_provider_tokens
|
||||
assert provider_tokens is not None
|
||||
assert ProviderType.BITBUCKET in provider_tokens
|
||||
assert (
|
||||
provider_tokens[ProviderType.BITBUCKET].token.get_secret_value()
|
||||
== 'username:app_password'
|
||||
)
|
||||
|
||||
# Check that the repository was passed correctly
|
||||
assert args[3] == 'all-hands-ai/test-repo' # selected_repository
|
||||
assert args[4] is None # selected_branch
|
||||
|
||||
|
||||
@patch('openhands.core.setup.call_async_from_sync')
|
||||
@patch('openhands.core.setup.get_file_store')
|
||||
@patch('openhands.core.setup.EventStream')
|
||||
def test_initialize_repository_for_runtime_with_multiple_tokens(
|
||||
mock_event_stream, mock_get_file_store, mock_call_async_from_sync
|
||||
):
|
||||
"""Test that initialize_repository_for_runtime handles multiple provider tokens including Bitbucket."""
|
||||
from openhands.core.setup import initialize_repository_for_runtime
|
||||
from openhands.integrations.provider import ProviderType
|
||||
|
||||
# Mock runtime
|
||||
mock_runtime = MagicMock()
|
||||
mock_runtime.clone_or_init_repo = AsyncMock(return_value='test-repo')
|
||||
mock_runtime.maybe_run_setup_script = MagicMock()
|
||||
mock_runtime.maybe_setup_git_hooks = MagicMock()
|
||||
|
||||
# Mock call_async_from_sync to return the expected result
|
||||
mock_call_async_from_sync.return_value = 'test-repo'
|
||||
|
||||
# Set up environment with multiple tokens
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'GITHUB_TOKEN': 'github_token_123',
|
||||
'GITLAB_TOKEN': 'gitlab_token_456',
|
||||
'BITBUCKET_TOKEN': 'username:bitbucket_app_password',
|
||||
},
|
||||
):
|
||||
result = initialize_repository_for_runtime(
|
||||
runtime=mock_runtime, selected_repository='all-hands-ai/test-repo'
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == 'test-repo'
|
||||
|
||||
# Verify that call_async_from_sync was called
|
||||
mock_call_async_from_sync.assert_called_once()
|
||||
args, kwargs = mock_call_async_from_sync.call_args
|
||||
|
||||
# Check that provider tokens were passed correctly
|
||||
provider_tokens = args[2] # Third argument is immutable_provider_tokens
|
||||
assert provider_tokens is not None
|
||||
|
||||
# Verify all three provider types are present
|
||||
assert ProviderType.GITHUB in provider_tokens
|
||||
assert ProviderType.GITLAB in provider_tokens
|
||||
assert ProviderType.BITBUCKET in provider_tokens
|
||||
|
||||
# Verify token values
|
||||
assert (
|
||||
provider_tokens[ProviderType.GITHUB].token.get_secret_value()
|
||||
== 'github_token_123'
|
||||
)
|
||||
assert (
|
||||
provider_tokens[ProviderType.GITLAB].token.get_secret_value()
|
||||
== 'gitlab_token_456'
|
||||
)
|
||||
assert (
|
||||
provider_tokens[ProviderType.BITBUCKET].token.get_secret_value()
|
||||
== 'username:bitbucket_app_password'
|
||||
)
|
||||
|
||||
|
||||
@patch('openhands.core.setup.call_async_from_sync')
|
||||
@patch('openhands.core.setup.get_file_store')
|
||||
@patch('openhands.core.setup.EventStream')
|
||||
def test_initialize_repository_for_runtime_without_bitbucket_token(
|
||||
mock_event_stream, mock_get_file_store, mock_call_async_from_sync
|
||||
):
|
||||
"""Test that initialize_repository_for_runtime works without BITBUCKET_TOKEN."""
|
||||
from openhands.core.setup import initialize_repository_for_runtime
|
||||
from openhands.integrations.provider import ProviderType
|
||||
|
||||
# Mock runtime
|
||||
mock_runtime = MagicMock()
|
||||
mock_runtime.clone_or_init_repo = AsyncMock(return_value='test-repo')
|
||||
mock_runtime.maybe_run_setup_script = MagicMock()
|
||||
mock_runtime.maybe_setup_git_hooks = MagicMock()
|
||||
|
||||
# Mock call_async_from_sync to return the expected result
|
||||
mock_call_async_from_sync.return_value = 'test-repo'
|
||||
|
||||
# Set up environment without BITBUCKET_TOKEN but with other tokens
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{'GITHUB_TOKEN': 'github_token_123', 'GITLAB_TOKEN': 'gitlab_token_456'},
|
||||
clear=False,
|
||||
):
|
||||
# Ensure BITBUCKET_TOKEN is not in environment
|
||||
if 'BITBUCKET_TOKEN' in os.environ:
|
||||
del os.environ['BITBUCKET_TOKEN']
|
||||
|
||||
result = initialize_repository_for_runtime(
|
||||
runtime=mock_runtime, selected_repository='all-hands-ai/test-repo'
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == 'test-repo'
|
||||
|
||||
# Verify that call_async_from_sync was called
|
||||
mock_call_async_from_sync.assert_called_once()
|
||||
args, kwargs = mock_call_async_from_sync.call_args
|
||||
|
||||
# Check that provider tokens were passed correctly
|
||||
provider_tokens = args[2] # Third argument is immutable_provider_tokens
|
||||
assert provider_tokens is not None
|
||||
|
||||
# Verify only GitHub and GitLab are present, not Bitbucket
|
||||
assert ProviderType.GITHUB in provider_tokens
|
||||
assert ProviderType.GITLAB in provider_tokens
|
||||
assert ProviderType.BITBUCKET not in provider_tokens
|
||||
@@ -18,7 +18,6 @@ def test_event_serialization_deserialization():
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'image_urls': None,
|
||||
'file_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
@@ -40,7 +39,6 @@ def test_array_serialization_deserialization():
|
||||
'args': {
|
||||
'content': 'This is a test.',
|
||||
'image_urls': None,
|
||||
'file_urls': None,
|
||||
'wait_for_response': False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ def runtime(temp_dir):
|
||||
|
||||
def mock_repo_and_patch(monkeypatch, provider=ProviderType.GITHUB, is_public=True):
|
||||
repo = Repository(
|
||||
id='123', full_name='owner/repo', git_provider=provider, is_public=is_public
|
||||
id=123, full_name='owner/repo', git_provider=provider, is_public=is_public
|
||||
)
|
||||
|
||||
async def mock_verify_repo_provider(*_args, **_kwargs):
|
||||
|
||||
@@ -10,7 +10,7 @@ from openhands.integrations.service_types import TaskType, User
|
||||
async def test_get_suggested_tasks():
|
||||
# Mock responses
|
||||
mock_user = User(
|
||||
id='1',
|
||||
id=1,
|
||||
login='test-user',
|
||||
avatar_url='https://example.com/avatar.jpg',
|
||||
name='Test User',
|
||||
|
||||
Reference in New Issue
Block a user